文章目录
这里说的编译优化指的是JIT(Just In Time 即时编译)优化。即时编译器会对正在运行的服务进行一系列的优化,包括字节码解析过程中的分析,根据编译过程中代码的一些中间形式来做局部优化,还会根据程序依赖图进行全局优化,最后才会生成机器码。
代码的执行方式
JVM对代码的执行分为解释执行和编译执行。
解释执行
依赖解释器,对字节码逐条解释为机器码执行,性能不如C++这类编译型语言。
编译执行
依赖编译器,将热点代码编译成本地机器码执行,可以获取更高的性能。
JVM中集成了两种编译器,Client端编译器(以下称Client)和Server端编译器(以下称Server)。
- Client注重启动速度和局部的优化,
- Server则更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会变慢。两种编译器有着不同的应用场景,在虚拟机中同时发挥作用。
通常解释器和编译器在JVM中共同协作。
当程序运行时,解释器首先发挥作用,代码可以直接执行。随着时间推移,即时编译器逐渐发挥作用,把越来越多的代码编译优化成本地代码,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。
即时编译的触发条件
默认情况下,JVM根据方法的调用次数以及回边函数执行次数来触发即时编译。当方法的调用次数和循环回边的次数的和,超过由参数-XX:CompileThreshold指定的阈值时就会触发即时编译。
- 函数调用次数,顾名思义就是一个函数被调用了多少次。
- 回边函数,可以简单理解成为一个循环结构,回边函数的执行次数就是该循环结构的循环次数。
开启分层编译的情况下,-XX:CompileThreshold参数设置的阈值将会失效,触发编译会由以下的条件来判断:
方法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数。
方法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时。
分层编译参考:https://mp.weixin.qq.com/s/7PH8o1tbjLsM4-nOnjbwLw
编译优化
即时编译器会对程序做一些优化最后才会生成机器码。
中间表达形式
在编译原理中,通常把编译器分为前端和后端,前端编译经过词法分析、语法分析、语义分析生成中间表达形式(Intermediate Representation,以下称为IR),后端会对IR进行优化,生成目标代码。
Java字节码是一种IR,但是Java字节码结构复杂,不适合做全局的分析优化。因此现代编译器一般采用图结构的IR,静态单赋值(Static Single Assignment,SSA)IR是目前比较常用的一种。
这种IR的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用。
基于SSA IR的特点对冗余赋值检测和死代码删除有很大帮助
冗余赋值检测
如下伪代码:
{
x = 1;
x = 2;
y = x;
}
我们很容易看出来x = 1的赋值是多余的,但是编译器不容易看出来。
如果借助了SSA IR,编译器则可以很容易识别冗余赋值。
上面代码的SSA IR形式的伪代码可以表示为:
{
x_1 = 1;
x_2 = 2;
y_1 = x_2;
}
由于SSA IR中每个变量只能赋值一次,所以代码中的x在SSA IR中会分成x_1、x_2两个变量来赋值,这样编译器就可以很容易通过扫描这些变量来发现x_1的赋值后并没有使用,赋值是冗余的。
死代码删除
如下代码:
public void fun{
int x = 3;
int y = 1
if(2 > 1){
x = 1;
} else{
y = 2;
}
add(x,y)
}
SSA IR伪代码:
x_1 = 3;
y_1 = 1
if true:
x_2 = 1;
else
y_2 = 2;
add(x,y)
编译器通过执行字节码可以发现 b_2 赋值后不会被使用,else分支不会被执行。经过死代码删除后就可以得到代码:
public void fun{
int x = 3;
int y = 1;
add(x,y)
}
方法内联
是指在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。JIT大部分的优化都是在内联的基础上进行的,方法内联是即时编译器中非常重要的一环。
简单来说,就是用被调用的方法体替换方法调用,避免了创建栈帧、入栈、出栈等消耗。
如下代码:
public int add(int a, int b , int c, int d){
return add(a, b) + add(c, d);
}
public int add(int a, int b){
return a + b;
}
内联之后:
public int add(int a, int b , int c, int d){
return a + b + c + d;
}
内联优化的条件
一般来说,内联的方法越多,生成代码的执行效率越高。但是对于即时编译器来说,内联的方法越多,编译时间也就越长,程序达到峰值性能的时刻也就比较晚。
- 方法字节码大小小于
-XX:MaxTrivialSize(默认值:6)
则直接内联;
- 如果方法调用次数超过
-XX:InlineFrequencyCount(默认值:100)
则认为是热点方法;
- 如果热点方法的字节码大小小于
-XX:FreqInlineSize(默认值325)
则可以内联;
- 如果方法调用找不到唯一的实现,则不能内联;
逃逸分析
当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
Java虚拟机的即时编译器会对新建的对象进行逃逸分析,判断对象是否逃逸出线程或者方法。
即时编译器判断对象是否逃逸的依据有两种:
- 对象是否被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。
- 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。
逃逸分析通常是在方法内联的基础上进行的,即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。
如下代码:
public class Example{
public void fun1() {
Foo foo = new Foo();
Bar bar1 = foo.getBar();
}
}
public class Bar {}
public class Foo {
public Bar getBar() {
Bar bar = new Bar();
return bar;
}
}
上面的例子bar对象在getBar方法中创建,并将其作为返回值返回到fun1中的bar1,因此bar对象是逃逸的。
再看如下代码:
pulbic class Example{
public static void main(String[] args) {
example();
}
public static void example() {
Foo foo = new Foo();
Bar bar = new Bar();
bar.setFoo(foo);
}
class Foo {}
class Bar {
private Foo foo;
public void setFoo(Foo foo) {
this.foo = foo;
}
}
}
在这个例子中,创建了两个对象foo和bar,其中一个作为另一个方法的参数提供。setFoo()接收Foo对象的引用。如果Bar对象在堆上,则对Foo的引用将逃逸。但是在这种情况下,编译器可以通过逃逸分析确定Bar对象本身不会对逃逸出example()的调用。这意味着对Foo的引用也不能逃逸。因此,编译器可以安全地在栈上分配两个对象。
锁消除
锁消除就是在逃逸分析的基础上进行的。
如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没就有意义。因为线程并不能获得该锁对象。在这种情况下,即时编译器会消除对该不逃逸锁对象的加锁、解锁操作。
栈上分配
Java的对象一般是在堆上分配的,而堆是对所有对象可见的。
如果逃逸分析能够证明某些新建的对象不逃逸,那么JVM完全可以将其分配至栈上,并且在new语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。
⚠️注意:对于常见的对象(聚合量)并不是直接把该对象的内存分配到栈上,而是将其替换为“标量”再分配到栈上。
循环转换
最重要的两种转换就是循环展开和循环分离。
循环展开
循环展开是一种循环转换技术,它试图以牺牲程序二进制码大小为代价来优化程序的执行速度,是一种用空间换时间的优化手段。
简而言之,有些循环可以写成一些重复独立的代码。
如下代码:
public void loopRolling(){
for(int i = 0;i<200;i++){
delete(i);
}
}
循环展开后代码:
public void loopRolling(){
for(int i = 0;i<200;i+=5){
delete(i);
delete(i+1);
delete(i+2);
delete(i+3);
delete(i+4);
}
}
循环分离
循环分离也是循环转换的一种手段。它把循环中一次或多次的特殊迭代分离出来,在循环外执行。
如下代码:
int a = 10;
for(int i = 0;i<10;i++){
b[i] = x[i] + x[a];
a = i;
}
可以看出这段代码除了第一次循环a = 10以外,其他的情况a都等于i-1。所以可以把特殊情况分离出去,变成下面这段代码:
b[0] = x[0] + 10;
for(int i = 1;i<10;i++){
b[i] = x[i] + x[i-1];
}
这种等效的转换消除了在循环中对a变量的需求,从而减少了开销。
窥孔优化与寄存器分配
窥孔优化和寄存器分配是优化的最后一步,这之后程序就会转换成机器码保存在codeCache中。
窥孔优化
窥孔优化就是将编译器所生成的中间代码(或目标代码)中相邻指令,将其中的某些组合替换为效率更高的指令组,常见的比如强度削减、常数合并等。
强度削减
如下代码:
y1=x1*3 经过强度削减后得到 y1=(x1<<1)+x1
编译器使用移位和加法削减乘法的强度,使用更高效率的指令组。
寄存器分配
它是通过把频繁使用的变量保存在寄存器中,CPU访问寄存器的速度比内存快得多,可以提升程序的运行速度。
除了以上优化优化手段以外还有更加激进的“分支预测”等等。
总结
JVM真是深不可测,小到其内存模型,大到机器指令,编译原理等等。哎~且行且珍惜~