在上文JVM系列之函数调用入门已经简单介绍了JVM是如何调用方法的:JVM先调用call_stub()方法将_call_stub_entry转换成CallStub这个函数指针类型,再通过该函数指针来完成方法的调用。既然call_stub()方法都将_call_stub_entry转换成了函数指针类型,那它肯定指向了被调用方法的入口,这样JVM才能将这个函数指针当成函数一样调用嘛,那_call_stub_entry到底指向哪里呢?接下来我们就结合_call_stub_entry的初始化来看看它到底指向到哪里~
_call_stub_entry初始化
在上文的中,已经介绍了_call_stub_entry是通过方法generate_call_stub
完成初始化的,接下来我们通过分析方法generate_call_stub
的源码来解开它的神秘面纱~
generate_call_stub实现
注:generate_call_stub()的最主要作用就是生成机器指令,代码较多,本文将会分几部分依次分析。另外,本文的源码都是针对x86 32位linux平台。
-
pc函数
在开始初始化时,首先调用pc函数获取当前入口的内存地址,pc函数的声明如下:
注:pc函数声明在assembler.hpp中
从源码可以看出,pc函数直接返回了变量_code_pos:
在JVM启动的过程中,会生成很多的例程,比如函数调用、函数返回、异常调用等。
注:例程即一段固定的机器指令,可以实现一种特定的功能逻辑
JVM所有的例程都在一段连续内存中,例程与例程之间的内存空间是连续的,第一个例程生成时,__ pc()返回0,如果第一个例程占用内存大小为20字节,那当JVM生成第二个例程时,第二个例程执行__ pc()时返回20,内存分配如下图所示:
每一个例程,在通过generate()函数初始化的时候,总是会在开始处执行address start = __ pc()
,然后在函数末尾返回start,从pc函数的声明可以知道,start的值其实就是_code_pos(偏移量)。当JVM生成新的例程时,_code_pos会指向上一个例程的最后一个字节的位置,这也正是下一个例程的起始位置。但是为什么第一步需要调用pc函数保存_code_pos呢?当generate()函数主体逻辑执行完之后,_code_pos的值会不断自增,直到达到当前例程的最后一个字节的位置,如果不在第一步就保存_code_pos,那么最后返回的值就会变成当前例程的末端位置,这时候,JVM再通过函数指针执行这段机器指令时就会因为位置不对而报错。注:JVM例程的内存分配都在堆上,当JVM初始化的时候,会初始化一块堆内存,例程会写入堆中比较靠前的位置。
-
入参定义
在开始介绍入参之前,我们来回想一下函数的堆栈模型,函数的堆栈空间可以分为3部分:
堆栈变量区:
主要用于保存方法的局部变量,如果没有局部变量,编译器不会为其分配该区域;入参区:
如果当前方法调用了其他方法并且传递了参数,这些入参会保存在调用者的堆栈中(压栈);ip和bp区:
ip:代码段寄存器,用于恢复调用者方法的代码位置;
bp:堆栈栈基寄存器,用于恢复调用方法的堆栈位置。
根据堆栈模型,一个简易的方法调用模型图就出来了:
从栈模型图可以看出:在32位平台,第一个入参相对于被调用函数栈底的偏移位置是8个字节,因此第一个入参的位置可以标记为8(%ebp),第二个入参相对于被调用函数栈底的偏移位置是12个字节,可以标记为12(%ebp),以此类推,第三个入参位置可以标记为16(%ebp),第n个入参位置可以标记为(n + 1) * 4(%ebp),寻址公式就这样出来了~
有了这个公式,我们再来看generate_call_stub函数,从上文相关内容就知道CallStub函数其实最终指向generate_call_stub函数,generate_call_stub函数可谓是CallStub的函数体啊。JVM在通过call_helper函数中调用CallStub函数时,传入了以下8个参数,那这8个参数在栈中的位置也就随之可以计算出来:
入参 | 位置 | 对应源码 |
---|---|---|
(address) & link,连接器 | 8(%ebp) | |
result_val_address,返回地址 | 12(%ebp) | Address result (rbp, 3 * wordSize) |
result_type,返回类型 | 16(%ebp) | Address result_type (rbp, 4 * wordSize) |
method(),java方法对象 | 20(%ebp) | Address method (rbp, 5 * wordSize) |
entry_point,java方法调用入口 | 24(%ebp) | Address entry_point (rbp, 6 * wordSize) |
parameters(),java方法入参 | 28(%ebp) | Address parameters (rbp, 7 * wordSize) |
size_of_parameters(),java方法入参数量 | 32(%ebp) | Address parameter_size(rbp, 8 * wordSize) |
CHECK,当前线程 | 36(%ebp) | Address thread (rbp, 9 * wordSize) |
看到这里可能会有点小怪异,link定义在哪里的?为什么没有定义?其实没有定义主要是因为没有用到。另外,代码里还有另外4个参数定义:
const Address mxcsr_save (rbp, -4 * wordSize);
const Address saved_rbx (rbp, -3 * wordSize);
const Address saved_rsi (rbp, -2 * wordSize);
const Address saved_rdi (rbp, -1 * wordSize);
参数mxcsr_save、saved_rbx、saved_rsi、saved_rdi这4个参数相对于rbp的偏移量都为负,说明这4个参数的位置在CallStub函数堆栈内部,不在调用函数call_helper内部。那这4个变量主要用来干什么呢?它们其实是用于保存调用者信息的。
-
保存调用者堆栈
接下来开始进入正题了,generate_call_stub函数的执行逻辑是从这里开始:
这一行代码是干嘛的呢?我们来看下x86平台该方法实现:
注:x86平台enter函数实现位于assembly_x86.cpp文件
哦豁,这俩汇编指令大家肯定都比较熟,就不赘述了,为了帮助大家理解,还是画个对比图:
-
堆栈的动态分配
JVM之所以可以在物理机器上为类型变量分配内存完全是因为机器指令支持分配堆栈空间。就拿c语言来说,编译器在编译时会根据被调用函数中的变量声明,计算出被调用函数需要多大的堆栈空间,物理机器在执行该程序时将按照编译器计算出来的大小为被调用函数分配堆栈空间。但是因为Java程序不能直接被编译成机器指令(忽略掉JIT编译),所以Java编译器自然也无法计算出需要多大的堆栈空间,那怎么办?JVM为了能够调用Java函数,做了这样的改造:以CallStub函数为桥梁,同时将被调用函数入参放在CallStub函数的堆栈中,再通过CallStub函数调用Java函数,这样不就解决问题了么~接下来的问题就转移到了如何依靠物理机器的指令对CallStub函数的堆栈进行扩展,放下被调用函数堆栈呢?我们来看看这段代码:
这段代码就是JVM在计算被调用的Java函数的入参。parameter_size在代码开始就已经被定义了,接下来我们分别看看这端代码主要干了什么事情:
__ movptr(rcx, parameter_size);
__ shlptr(rcx, Interpreter::logStackElementSize)
这两行代码最终会生成这样两条机器指令:
movl 0x20(%ebp), %ecx;
shl $0x2, %ecx
第一条机器指令会将ebp栈基地址向高地址方向偏移32位处的数据(parameter_size变量的值)传送到ecx寄存器中。第二条机器指令会将ecx寄存器中的值左移2位,相当于将ecx寄存器中的值乘以4。在32位平台上,每一个入参都需要占用32位内存,也就是4个字节,那么总的入参需要的内存大小 = 4 * parameter_size,这也就是为什么要执行第二条指令的理由。执行完这两条指令后,被调用函数的入参所需要的堆栈空间就计算好了,但是,CallStub函数还需要保存调用者的现场,我们来看看是怎么保存调用者现场的呢?__ addptr(rcx, locals_count_in_bytes)
这行代码主要用于保存调用者信息:rdi,rsi,rbx,mxcsr这四个值,它最终会生成这样一条机器指令:
add $0x10, %ecx
在32位平台上,这四个变量占16个字节,所以ecx寄存器还需要再加16个字节。到这里为止,被调用的Java函数入参所占用的堆栈空间就计算好了,堆栈大小 = Java函数入参个数 * 4 + 4 * 4。完成了入参空间计算后,接下来就该动态分配堆栈内存了,我们来看看是如何分配内存的~__ subptr(rsp, rcx);
这行代码完成了内存的分配,它最终会生成这样一条机器指令:sub %ecx, %esp
,在这条指令执行之前,JVM计算出来的堆栈空间大小保存在ecx寄存器中,现在只需要直接将esp减去ecx的值就可以完成空间的分配了。当然,为了加速内存寻址和回收,机器在分配内存的时候会做内存对齐操作,当然JVM也保持了这一个原则,在分配完内存后,会执行该代码:__ andptr(rsp, -(StackAlignmentInBytes))
,它最终会生成这样的机器指令and $0xfffffff0, %esp
,该机器指令会让堆栈按16位对齐。
-
调用者信息保存
堆栈空间分配完之后就该执行被调用的Java方法了,在转交CPU的控制权之前,调用者需要保存自己的寄存器数据,主要包括edi,esi,ebx。注:edi,esi,ebx的作用到底是什么?
有计算机基础的童鞋都知道,其实在内存中,所有的都是一串二进制数表示,不管是数据,还是指令,那机器如何识别当前这串二进制数是指令呢还是数据呢?这就依赖CS:IP寄存器了,在CS:IP寄存器中自然就是指令了,CPU会执行它。然后我们再来看Java函数的调用,每次在执行函数的时候,CS:IP寄存器的指令都会先从调用函数的指令跳转到被调用函数的指令,CPU执行完被调用函数的指令后,肯定是需要恢复到调用函数的指令的,那如何恢复CS:IP的值呢?这就依赖edi和esi寄存器了,它俩一般被用来存储目的偏移地址和源偏移地址,当然,JVM中它俩干的就更多了,比如在Java的函数调用过程中用esi来做字节码寻址啊~至于ebx嘛,它就是一个通用寄存器,经常被用来作为一段数据的基地址,当然,在JVM中,它的这种作用表现的就更明显了,一般都会使用它来存放Java函数中即将被执行的字节码指令的基地址,然后通过jmp指令就可以跳转到指定字节码位置解释执行了。接下来我们来看看源码是如何实现的:
这三条指定最终会转化成以下机器指令:
mov %edi, -0x4(%ebp);
mov %esi, -0x8(%ebp);
mov %ebx, -0xc(%ebp)
这仨货执行完后,堆栈会变成这样子:
-
参数入栈
在经过前几个步骤,CallStub函数为调用者分配的堆栈空间还剩下具体的入参数据需要填充了。那么,接下来,JVM就要将即将被调用的Java函数的入参复制到堆栈中,既然要做入参数据复制,CallStub函数至少都需要知道:被调用的Java入参数量有多少;
被复制的入参数据集合在哪里~
这俩货其实在进入CallStub函数之前就已经被JVM计算得到了,并且通过入参parameter_size和parameters传递给了CallStub指向的函数。
由于Java函数的入参个数不可能都是一样的,CallStub为了兼容所有的情况,采取了循环的方式进行处理:
源码的执行流程如下:
注:部分代码被转成机器指令
mov 0x20(%ebp), %ecx
,将32(%ebp)处的parameter_size给ecx寄存器;校验parameter_size是否为0,如果为0直接跳过入参压栈;
mov 0x1c(%ebp), %edx
,将28(%ebp)处的parameters给edx寄存器,
parameters其实保存的是Java函数的第一个入参指针;xor %ebx, %ebx
,将ebx设置为0;
到这里,相关寄存器初始化就结束了,接下来就到重头戏了:循环将Java入参压栈:
这段代码最终会生成如下机器指令:
mov -0x4(%edx, %ecx, 4), %eax
mov %eax, (%esp, %ebx, 4)
inc %ebx
dec %ecx
jne 0xb370b696
从汇编代码可以看出,将参数压栈采取的循环模式是跳转的模式,并且每次对循环因子ecx做的是减法,至于寻址的公式 = 第一个入参位置 + (N - 1) * 4。看不懂机器指令没有关系,还是来个简单的示意图吧,分分钟就能懂:
-
函数调用
在经过了堆栈保存,堆栈动态分配,现场保存和Java函数入参压栈这一系列步骤之后,JVM终于可以开始函数调用(entry_point)了。看过上文JVM系列之函数调用入门可以知道,entry_point其实就是一个指向被调用函数首地址的指针,对于CPU来说,只要能拿到被调用函数的入口地址,那调用肯定就没有任何问题了。当然,调用的指令也很简单,就是call:
源码执行的流程如下:注:部分代码被转成机器指令
mov 0x14(%ebp), %ebx
,将method首地址给ebx寄存器;mov 0x18(%ebp), %eax
,将entry_point给eax寄存器;mov %esp, %esi
,将当前栈顶保存到esi寄存器中;call *%eax
,调用函数
到这里为止,CallStub函数的大部分入参都被放入到寄存器中保存了,各个入参的去向可总结如下表所示:
入参 | 位置 |
---|---|
result_val_address | 仍在堆栈12(%ebp) |
result_type | 仍在堆栈16(%ebp) |
method() | 放入ebx寄存器,原本在堆栈20(%ebp) |
entry_point | 放入eax寄存器,原本在堆栈24(%ebp) |
parameters() | 放入edx寄存器,原本在堆栈28(%ebp) |
size_of_parameters() | 放入ecx寄存器,原本在堆栈32(%ebp) |
CHECK | 仍在堆栈36(%ebp) |
从去向表可以看出,就method/entry_point/parameters/size_of_parameters这四个参数被放入了寄存器,为什么呢?这样做最主要的原因是entry_point的调用是用call来完成的,call一般都会配合push/move等指令一起使用,栈帧ebp也是会被改变的,如果不放在寄存器,在调用完entry_point再回到CallStub函数是无法通过ebp变址寻址到method/entry_point等参数的,所以,JVM用寄存器把它们暂存起来。
注:针对执行
call *%eax
之前,各物理寄存器都保存了什么,我们也来做一个总结:
寄存器 | 保存内容 |
---|---|
ebx | Java函数对应的method对象 |
eax | entry_point |
edx | parameters首地址 |
ecx | Java函数入参数量 |
esi | CallStub栈顶 |
-
获取返回值
CallStub调用entry_point使用的call指令,在调用完成之后,CallStub还会拿回CPU的控制权的。调用完entry_point后,会有返回值,我们通过源码来看看CallStub是如何处理返回值的:
从源码可以看出,JVM将result和result_type分别存储到了edi和esi寄存器中,调用者在获取被调用函数的result和result_type时直接从这两个寄存器获取即可。