上篇日志总结CPU调用函数时的栈内存变化过程,用的C程序解释成汇编来描述,目的是想说明,JVM在执行Java程序时,函数调用的过程和C程序函数调用的过程是相同的,C作为静态编译型的语言,在程序执行先需要编译成CPU能直接执行的二进制码,JVM执行Java程序时也需要先将其解释成字节码,或者说字节码指令集更准确,通常指令集是计算机硬件才有的东西,在开发语言上包装一套指令集,好处是可以统一规范,这样“统一的接口”让开发者用更加接近人类语言来调用机器指令,想想如果让你用汇编来写程序,movl、pop、inc、shl左移右移等,程序可读性没那么高,效率也没那么高。
JVM字节码指令
字节码指令作为中间语言,作用是帮助Java语言实现一些如栈操作,入栈出栈,传参读参,读写局部变化和函数调用等,例如最简单的控制指令,for循环、foreach循环、do…while循环、if…else和switch等。运算指令集有算术、逻辑、比较和位运算。数据交换指令集,用来操作栈内存、Java堆等,使用数据交换指令来实现数据在这些内存区域里面的交换,或者说传递,函数调用的指令集也在数据交换指令集中。
因为JVM本身就是用C和C++共同编写的解释性虚拟机,所以在执行Java程序时,最终是JVM交由C语言来运行,也就是说,JVM是一边将字节码指令翻译成C程序,一边执行,通过C来调用执行机器指令。这就是JVM中模板解释器的实现思想,为每一个机器指令编写一段实现对应功能的汇编代码,在JVM初始化时,就会将汇编代码翻译成机器指令,加载到内存中,当JVM执行某一条Java字节码指令时,就可以从内存中直接执行对应的汇编代码,直接跳到该指令的内存地址就可以调用执行。
函数指针直接触发机器指令
按照上面说的思路,JVM的模板解释器为每一个机器指令编写一段实现对应功能的汇编代码,在运行某一字节码指令时,可以直接执行对应的机器指令,具体的实现方式是怎样的,来看一个例子:
#include <stdio.h>
#include <stdlib.h>
const unsigned char run[] =
{
0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x40, 0x53, 0x56, 0x57,
0x8d, 0x7d, 0xc0, 0xb9, 0x10, 0x00, 0x00, 0x00, 0xb8,
0xcc, 0xcc, 0xcc, 0xcc, 0xf3, 0xab,
0x8b, 0x45, 0x08, 0x03, 0x45, 0x0c,
0x5f, 0x5e, 0x5b, 0x8b, 0xe5, 0x5d, 0xc3
};
int main(int argc, char const* argv[])
{
int a = 4;
int b = 5;
int (*add)(int, int); // 定义函数指针
add = (void*) run; // 函数指针add指向run机器码
int result = add(a, b);
printf("%d + %d = %d", a, b, result);
return 0;
}
首先定义一个字符数组run,里面是一个函数的十六进制表示方式,这些字符组成机器指令,作用是对传入的两个数a和b进行求和,并返回结果。接着往下,main()函数里,还记得C直接操作机器指令的方式吗,就是用函数指针,通过函数指针变量(一个指针),存放某一段机器指令,在C程序编译阶段,C函数指针直接指向了某一段机器指令的首地址,实现直接调用该机器指令。
所以在main()函数里,定义了一个函数指针add,下一行存放了run字符数组的首地址,最后通过add(a, b)来调用,程序执行到int result = add(a, b);时,就会直接将run数组里的一片连续内存区代码拿出来执行:
两种触发方式
上面的例子,通过函数指针直接触发机器指令,方式是先定义一个函数指针,函数指针就是一个指针变量,和其他普通变量如int,float,char等一样,存放的是一个值,指针变量存放的就是首地址,声明和调用正如上面的例子:
int (*add)(int, int); // 声明
arr = (void*) run; //存放某一片地址区域
int i = arr(a, b); // 调用
还有一种方式,就是先声明其是一种类型,有点像面向对象中的类,首先通过typedef定义一种类型,一种函数指针类型,例如:
typedef (*addType)(int, int);
该语句声明了一种函数指针类型addType,是用户自定义的一种数据类型,然后就可以通过该类型是声明一个变量来用:
addType add = (void*) run;
int i = arr(a, b);
无论是上面哪种声明,在调用时也有两种方式触发机器指令,第一张就是上面都用到的,直接int i = arr(a, b);看似最简洁明了,但是最好还是使用第二种方式:int i = (*add)(a, b);因为这样调用可以让别人一看就知道你使用了函数指针,而不是一个普通函数的显式调用。
call_stub函数指针
JVM中实现函数指针调用机器指令,用的是call_stub,它也是一个函数指针,函数原型如下:
static CallStub call_stub()
{
return (CallStub)(castable_address(_call_stub_entry));
}
函数的调用结果最终会被类型转换成CallStub,CallStub是一个自定义类型,函数指针类型,结构如下:
typedef void (*CallStub) {
address link,
intptr_t* result,
BasicType result_type,
methodOopDesc* method,
address entry_point,
intptr_t* parameters,
int size_of_parameters,
TRAPS
};
可以看到一共有8个参数,link表示的是连接器,result是函数返回值的地址,result_type顾名思义就是函数的返回类型,method表示Java方法对象,entry_point这个参数很重要,表示的是JVM调用Java方法的事先定义好的入口,前面说到模板解释器,在JVM初始化时会将一些方法调用的“入口”代码编译成机器指令,加载到内存中,在JVM调用方法时,需要先调用这些入口指令,例如Java程序的主函数必须通过call_stub函数指针来执行。parameters指的是Java方法的参数集合,下一个size_of_parameters自然就是参数的数量了。
castable_address()
CallStub结构搞清楚后,回到call_stub的调用语句看:
return (CallStub)(castable_address(_call_stub_entry));
里面的参数是castable_address,也是一个函数,结构是这样的:
inline address_word castable_address(address_x)
{
return address_word(x);
}
返回类型是address_word,顾名思义是一个地址类型,自定义的地址类型,很容易就能查到它经过了哪些包装:
typedef uintptr_t address_word;
typedef unsigned int uintptr_t;
可以看到,address_word的最终原型是无符号整型类型unsigned int。最后,就只剩下_call_stub_entry这个参数了它的原封类型也是unsigned int,这些很容易查到它的封装:
static address _call_stub_entry;
到这里把call_stub函数指针里的三个类型都搞明白了,JVM通过call_stub函数指针调用目标函数,call_stub函数相当于一个接口,里面又调用了castable_address()函数,传入原封类型是unsigned int的参数_address_stub_entry,标识的就是一个函数的地址。