编译概述
编译器是一个相对复杂且专业的领域,需要一些先验理论知识。本文将简单讨论编译理论的基本概念,也会逐一讨论HotSpot VM本身涉及的许多特设的编译技术,为后面的篇章打下理论基础。
编译器简介
传统的编译方法可分为即时(Just In Time,JIT)编译和提前(Ahead Of Time,AOT)编译。JIT和AOT没有权威的定义,不过一般来说,AOT指在程序运行前完成编译,AOT编译可以生成可执行机器代码(如常见的C/C++、Rust等语言的编译),也可以提前生成较高级的字节码等中间表示(如javac将Java程序AOT编译为JVM字节码)。通常AOT编译只需由开发者编译一次,后续程序即可多次执行。
JIT指在程序启动后、执行前进行编译。所以程序每次执行时都要进行一次或多次JIT编译。JIT可以充分使用运行时收集到的数据,如receiver的类型、if分支计数等,然后进行PGO优化(
Profiling-guidedOptimization)使程序运行性能达到峰值。但是由于JIT的编译发生在程序执行过程中,需要运行时的内存、CPU资源,更重要的是JIT的编译时间也会影响程序执行时间,所以在设计JIT编译器时不能只考虑被编译程序的执行效率,编译效率(或称为JIT吞吐量)也是重要的考量标准,甚至影响整个编译器的设计架构。
AOT又叫静态编译,是指在运行前编译源代码,无须运行时开销,同时可以应用很多重量级的耗时优化,使编译后的机器代码能够快速启动,占用内存较小。但是AOT缺少程序运行时的信息,对某些程序的峰值性能优化有限。
综合上述内容可知,JIT和AOT各有千秋,在选择编译方法时需要综合考虑语言特性、类型系统等,在一些情况下,还可以使用两者的组合。
运行时代码生成
在讨论即时编译器前,首先要清楚一个重要问题:如何即时编译?要实现即时编译,需要一种动态生成可执行代码。冯·诺依曼架构将数据和指令都储存在存储器中,这种架构可以将可执行指令视作数据写入内存,然后将那片内存的数据视作指令供CPU执行,简单的示例如代码清单7-1所示:
代码清单7-1 动态代码生成技术
#include <cstdio>
#include <sys/mman.h>
#include <cstring>
// macOS + x64 + clang
int main(){
// 机器代码
constexpr unsigned char code[]={
0x55,0x48,0x89,0xe5,0x89,0x7d,0xfc,0x89,0x75,0xf8,
0x8b,0x75,0xfc,0x03,0x75,0xf8,0x89,0xf0,0x5d,0xc3,
};
constexpr int ncode = sizeof(code)/sizeof(code[0]);
// 分配内存,设置为可执行权限
void* mem = mmap(0, ncode, PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memcpy(mem,code,ncode);
// 将分配的内存地址转换为函数auto add_fun= (int(*)(int,int))mem;
// 调用加法函数
printf("%d",add_fun(3,2));
munmap(mem,ncode);
return 0;
}
代码清单7-1展示了运行时代码生成的原理:先分配一片内存,然后将其设置为可执行,接着向内存中写入机器代码,最后将内存地址强制类型转换为函数指针再调用它。代码清单7-1中的code就是需要运行时生成的机器代码,它对应加法函数,如代码清单7-2所示:
代码清单7-2 code的原始面貌
55 pushq %rbp ; 函数序幕
48 89 e5 movq %rsp, %rbp
89 7d fc movl %edi, -4(%rbp); 获取第一个参数
89 75 f8 movl %esi, -8(%rbp); 获取第二个参数
8b 75 fc movl -4(%rbp), %esi
03 75 f8 addl -8(%rbp), %esi; 两个参数相加
89 f0 movl %esi, %eax ; 结果放入eax
5d popq %rbp ; 函数收尾
c3 retq
如果直接手写二进制代码,显得太过“硬核”,同时代码也会完全不可维护、不可修改,所以在HotSpot VM中,生成机器代码依赖于宏汇编器MacroAssembler,它使用的是一种类似汇编的风格,如代码清单7-3所示:
代码清单7-3 HotSpot VM中的运行时代码生成
__ mov(rbp); // 生成55
__ mov(rsp,rbp); // 生成48 89 e5
__ mov(edi,Address(rbp,-4)); // 生成89 7d fc
...
无须硬核手写机器代码,只需要写出汇编形式,宏汇编器就可以为它生成对应的机器代码。除了即时编译器外,第5章的解释器生成也涉及动态代码生成技术,只是它是在虚拟机创建时初始化解释器的各个例程。动态代码生成的另一个常见场景是编写shellcode。
JIT编译器
高性能从来都是虚拟机绕不开的话题,为此,JVM在性能方面做了很多努力。早期虚拟机只有字节码解释器,后面实现了模板解释器,现在是模板解释器和即时编译器混合。HotSpot VM包含两个即时编译器:客户端即时编译器(C1)和服务端即时编译器(C2)。
C1面向客户端程序,需要快速响应用户请求,它编译速度快,占用资源少,产出代码性能适中。C2面向长期运行的服务端程序,允许虚拟机在编译上花更多时间以换取峰值运行性能。它使用了更多激进的优化以提高性能,包括基于类层次分析的内联、快速路径慢速路径区分、全局值编号、常量传播、指令选择、图着色寄存器分配和窥孔优化等。这些优化使得C2编译时间更长,占用资源更多,但产出代码性能极佳。
AOT编译器
即时编译本身是很快的,但是如果Java程序比较大,可能会花费更多时间在代码预热上,因为被即时编译的前提条件是方法的执行足够频繁。为了了解方法执行频率,模板解释器会进行方法调用计数和回边计数,这就会占用部分内存空间,对于一些不常使用的Java方法来说是不必要的,如果能提前编译掉这些方法,就可以省去运行时性能计数开销,所以,AOT编译器应运而生。
Java 9包含了仅Linux可用的一个实验性质的AOT编译器jaotc[1],Java 11后的jaotc支持所有操作系统。jaotc使用Graal编译器作为后端,它可以在虚拟机启动前将Java类编译成ELF格式的共享库,然后在虚拟机启动后加载共享库。虚拟机将共享库看作Code Cache的补充数据,当加载Java类时,虚拟机查找共享库看能否找到已经存在的方法,如果找到就将它们关联起来。jaotc编译产出的共享库的代码和普通JIT编译后的代码一样,加载到虚拟机后可能发生退优化、类卸载等行为。对于一些长期运行的服务端程序,它们可能经历和JIT编译器相同的生命周期。
除此之外,目前jaotc的限制较多,能编译的Java代码和使用场景也比较有限,一个更好的选择是Graal VM平台的Substrate VM。
JVMCI JIT编译器
HotSpot VM使用C++语言,所以C2也是C++写成的。使用C++没什么本质上的错误,但却有一些麻烦。C++是一门不安全的语言,这意味着C++的错误可以造成虚拟机崩溃,同时由于代码年代久远,用C++写的C2变得很难维护,很难扩展。
编译器组件和垃圾回收器等组件不同,它无须一些低级语言特性,本质是将一个byte[]转换为另一个byte[]。也许是Java比C++更安全,也许是探寻编译的本质,JEP 243提案通过了基于Java语言的JVM编译器接口JVMCI。通过JVMCI接口可以使用Java语言编写即时编译器,然后“外挂式”地植入虚拟机来代替C2编译器。
JVMCI只是一个接口,它需要一个具体的实现者。HotSpot VM自带的JVMCI实现和jaotc一样也要用到Graal编译器,需要附加虚拟机参数-XX:+
UnlockExperimentalVMOptions -XX:+UseJVMCICompiler -XX:+EnableJVMCI开启。
本文给大家讲解的内容是深入解析java虚拟机:编译概述,编译器
- 下篇文章给大家讲解的是深入解析java虚拟机:编译概述,即时编译技术;
- 觉得文章不错的朋友可以转发此文关注小编;
- 感谢大家的支持!