虚拟机的实现通常可以划分为3部分:运行时(Run-Time)、编译优化(JIT)和垃圾回收。已经有较多的书籍和文章介绍了运行时,本书不再介绍。垃圾回收是本书的重点,后面会详细介绍。关于JIT的相关介绍并不多,同时JIT也非常复杂,特别是编译优化的相关知识。本节在Linux/AArch64平台的基础上,通过一个简单的例子演示JIT的基本概念。
首先从一个简单的C代码例子出发,如下所示:
#include <stdio.h> int add(int a, int b){ return a + b; } int main(){ printf("%d\n",add(4,5)); return 0; }
该代码片段的功能非常简单,其中函数add实现加法功能。这个add例子和1.4.1节中Java的add功能完全相关,都是完成两个整数的加法计算并返回结果。本节构造C的add函数就是为了让读者可以方便地理解在编译优化时Java的函数(字节码片段)可以被一个C/C++的函数替代。当然,这里省略了JVM构造这个C语言的add函数的过程,这本质上就是编译优化要做的工作。
使用gcc进行编译,这里先使用O2的编译优化级别,命令如下:
gcc -O2 -o test test.c
编译后使用objdump命令查看add函数的反汇编代码:
0000000000400650 <add>: 400650: 0b010000 add w0, w0, w1 400654: d65f03c0 ret
注意
在AArch64平台中有31个通用寄存器,其中x0~x7用于传递参数和返回值。w0~w7是x0~x7的低32位,用于传递32位的参数,当函数的参数个数超过8个时,通过栈传递。
在这个例子中,add的两个参数通过w0和w1传入,通过add指令完成加法,结果存放在寄存器w0中,通过ret返回函数的执行结果。
假设JVM识别Java的add函数为热点,现在也知道add函数对应的汇编代码,那么还有一个问题,就是如何让JVM替换原来的add函数而执行编译后的代码。下面通过一个例子演示C/C++代码直接执行编译后代码的过程。首先将编译后的代码作为输入数据,表示待执行的函数,然后通过mmap函数将数据加载到内存区,并设置内存区可以执行(PROT_EXEC),最后再通过函数调用执行相关代码。代码示例如下:
#include<stdio.h> #include<memory.h> #include<sys/mman.h> typedef int (* add_func)(int a, int b); int main() { char code[] = { 0x00,0x00,0x01,0x0b, //0x0b010000, 等价于指令 add w0, w0, w1 0xc0,0x03,0x5f,0xd6 //0xd65f03c0, 等价于指令 ret }; //参考objdump对add函数的反汇编代码 void * code_cache = mmap(NULL, sizeof(code), PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); memcpy(code_cache, code, sizeof(code)); add_func p_add = (add_func)code_cache; printf(“%d\n”, p_add(4,5)); return 0; }
示例中通过一个函数调用完成汇编代码的执行。实际上除了使用函数调用以外,还可以直接通过jmp完成相关的调用(函数调用的本质是通过call指令完成控制流的转移)。JVM执行编译后的代码原理和示例介绍基本类似,通过识别热点代码(例如Java中的add函数),并对热点代码进行编译优化,产生目标机器代码(类似于此处C代码中add函数的反汇编代码),然后执行目标机器代码。
在add函数的编译过程中直接使用了O2的编译优化级别,gcc默认的编译优化级别为O0。下面是使用默认编译优化级别产生的目标文件反汇编的结果。
0000000000400624 <add>: 400624: d10043ff sub sp, sp, #0x10 400628: b9000fe0 str w0, [sp, #12] 40062c: b9000be1 str w1, [sp, #8] 400630: b9400fe1 ldr w1, [sp, #12] 400634: b9400be0 ldr w0, [sp, #8] 400638: 0b000020 add w0, w1, w0 40063c: 910043ff add sp, sp, #0x10 400640: d65f03c0 ret
比较O2和O0的编译优化结果可以发现,O2的代码质量远高于O0的代码质量(指令明显少了很多)。那么O2采用的编译优化会更加复杂,编译耗时也更多。JVM中C1和C2编译器的目的也是生成不同指令的编译代码,可以简单理解为gcc不同编译级别产生的代码。当然JVM中C1和C2采用了不同的技术,使用的IR和编译优化手段都不相同。