Java - JIT即时编译器
前言
我们知道,Java有着“一次编译,处处运行”的特性,会将编译阶段分成两部分:
- 前端编译:由
javac
编译成字节码,该过程会进行词法分析、语法分析、语义分析。 - 后端编译:由
解释器
将字节码解释为机器码来执行。
而后端编译又可以根据执行的方式来分为两种:
- 解释执行:一行一行解释成机器码再执行,每次调用时都需要重新逐条解释执行。
- 编译执行JIT(Just In Time):将执行的比较多的热点代码编译优化成本地代码,提高执行效率。(好比加了缓存)
热点代码:
当方法或者代码块在一定时间内的调用次数超过JVM规定的阈值,则会被编译,存入codeCache中。
codeCache:
代码缓存区,主要存放JIT所编译的代码,同时还有Java所使用的本地方法代码也会存储在codecache中,例如
Object.wait()
,Object.notify()
等被native
修饰的都是所谓的本地方法。
Java编译的整个过程为:
一. JVM编译器
JVM有两种编译器:
- Client Compiler(C1编译模式):着重于启动速度和局部优化。
- Server Compiler(C2编译模式):着重于全局优化。性能更好,但是启动速度慢。
对于HotSpot而言,一共有三种编译模式:
- 混合模式(Mixed Mode):C1和C2编译模式混合起来使用(默认的模式),若希望单独使用C1编译模式或者C2编译模式,分别使用
-client
,-server
参数来打开。 - 解释模式(Interpreted Mode):
- 编译模式(Compiled Mode):
输入命令java -vsersion
可以查看当前的编译模式:
1.1 Client Compiler
HotSpot VM中的Client Compiler为C1编译器,启动速度快,主要做三件事情:
- 局部优化,例如字节码上的基础优化、方法内联、常量传播。
- 将字节码构造成高级中间表示(HIR)。
- 将HIR转换成低级中间表示(LIR),在LIR基础上进行局部优化,生成机器码。
1.2 Server Compiler
主要关注一些编译比较耗时的全局优化,适用于长时间运行的后台程序,其性能一般比Client Compiler要高30%以上,Server Compiler有两种。
1.2.1 C2 Compiler
C2是Hotspot的默认Server编译器,C2使用一种控制流和数据流相结合的图数据结构(Ideal Graph,后面简称IG),其表示当前程序的数据流向和指令间的依赖关系。而C2则通过这种图结构来优化步骤。
1.2.2 Graal Compiler
启用方式:
-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
与C2编译器相比而言,Graal有这么几个关键特性:
- 由Java编写,对于
lambda
表达式、stream
流的使用要更加友好。 - 会做虚函数的内联、部分逃逸分析等更深层次的优化。
- 对于代码的分支预测和选择优化要更好。
总结就是:
Java执行过程有两步:
- 前端编译:由
javac
编译成字节码。 - 后端编译:由
解释器
将字节码解释为机器码来执行。
后端编译有两种执行方式:
- 解释执行:一行一行解释成机器码再执行,每次调用时都需要重新逐条解释执行。
- 编译执行JIT(Just In Time):将热点代码编译优化成
codeCache
。
JVM虚拟机有两种编译器:Client / Server Compiler
比较项 | Client Compiler | Server Compiler |
---|---|---|
启动速度 | 快 | 慢 |
包含的编译器 | C1 | C2,Graal |
性能 | 一般 | 高30% |
主要的工作 | 字节码优化、方法内联。将字节码转HIR,再转LIR | 主要关注一些编译耗时较长的全局优化,甚至会还会根据程序运行的信息进行一些不可靠的激进优化 |
优化范围 | 局部优化 | 全局优化 |
二. JIT和编译优化
JIT的触发时机:JVM根据某个方法的调用次数和循环回边的执行次数来触发。当两者的次数和超过-XX:CompileThreshold
指定的阈值,则触发JIT编译。
C1的默认阈值为1500.
C2的默认阈值为10000.
JIT在进行编译的时候,会做一些优化操作。
2.1 中间表达形式(IR)
首先,编译的阶段由上文我们知道分为了:
- 前端:通过词法分析、语法分析、语义分析生成中间表达形式(IR),例如字节码。
- 后端:对IR进行优化,生成目标代码。
那么这些IR优化一般有哪些内容呢?
- 识别冗余赋值。
- 删除无用代码。
2.2 方法内联
方法内联的含义:在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。 并且JIT大部分的优化都是在内联的基础上进行的。
以get/set
为例,区别如下:
- 无方法内联:调用时,程序执行时需要保存当前方法的执行位置,创建并压入用于getter/setter的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。
- 有方法内联:程序执行到对应的位置时,直接进行字段访问即可。
方法内联的优劣势:
- 优势:内联的方法越多,生成代码的执行效率越高。
- 劣势:内联的方法越多,JIT编译时间也就越长
2.3 逃逸分析
逃逸分析可以分析在程序的哪些地方可以访问到指针,JIT会对新建的对象进行逃逸分析,判断对象是否逃逸出线程或者方法。 判断对象是否逃逸的依据有两种:
- 对象是否存入堆中,若存入,则其他线程便能获得这个对象的引用。
- 对象是否被传入未知代码(未被内联的代码)中,此时可认为方法调用的调用者以及参数是逃逸的。
2.4 Loop Transformations
JIT编译器会对循环做一些转换,其中最重要的是循环展开和循环分离。
循环展开:以牺牲程序二进制码大小为代价来优化程序的执行速度,通过减少或消除控制程序循环的指令,来减少计算开销(空间换时间)。
例如:
public void test(){
for(int i = 0;i<100;i++){
run(i);
}
}
经过循环展开后得到:
public void test(){
for(int i = 0;i<100;i+=5){
run(i);
run(i+1);
run(i+2);
run(i+3);
run(i+4);
}
}
效果:减少循环次数
循环分离:把循环中一次或多次的特殊迭代分离出来,在循环外执行。
例如:
原本的代码如下:
int a = 10;
for(int i = 0;i<10;i++){
b[i] = x[i] + x[a];
a = i;
}
经过循环分离后:(将特殊情况第一次a=10
分离了出来)
b[0] = x[0] + 10;
for(int i = 1;i<10;i++){
b[i] = x[i] + x[i-1];
}
2.5 窥孔优化与寄存器分配
窥孔优化作为最后一步JIT优化。将编译器所生成的中间代码的某些组合替换为效率更高的指令组。
例如:
y=x*3
变成:(移位的效率更高)
y=(x<<1)+x
寄存器分配(C2):把频繁使用的变量保存在寄存器中,CPU访问寄存器的速度比内存快得多,就可以提升程序的运行速度。
在窥孔优化和寄存器分配结束后,程序就会被转换成机器码保存到codecache中了。
参考:(详细介绍的请看第一篇文章)