JVM(十三):后端编译优化
在 JVM(一):源文件的转变 中我们介绍了 Java 中的前端优化,即将 Java 源代码转换为字节码文件。在本文中,我们将介绍字节码文件如何转换为本地机器码,并如何对代码进行优化,以提高性能。因为不同的虚拟机,字节码优化引擎不同,因此本文采用 JIT 来作为例子,其也是 HotSpot 中的默认编译器。
架构
我们都知道将代码转换为机器码有两种方式,而在 HotSpot 中采用了却两者全部都涉及到了,其采用了解释器和编译器并存的架构。那么其这样的目的是什么呢?
首先我们知道解释执行,可以大大提高程序启动时的效率,因为在这个时候需要执行什么代码,才对对应的源码进行翻译,将其变为机器码,因此也提高了启动时效率;
而编译执行的优点则是可以获得更高的执行效率,因为其将中间代码全部编译成了与机器相关的本地代码,并且在这一阶段,有些编译器还会对编译后的代码进行初步的优化,这也使得效率更加的优秀。
因此 Hotspot 开始执行的时候采用解释执行,获得优良的启动效率,而在代码执行过程中,对执行情况进行监控,运用以前所说的 热点代码编译技术 将热点代码编译成本地机器码,并根据执行情况进行优化,以获得两者全部的优点。
可能会有读者问道,我的代码部署在服务器上,第一次慢一点就慢一点,我只采用编译执行不行吗?
其实不然,首先因为编译器需要对代码进行优化,因此肯定是执行过程中根据执行情况进行优化更加的好,此外,在优化的过程中也有一种 激进优化 的方式,在这种情况下,就需要采用解释执行的方式通过 逆优化 的方式来退回到解释状态来执行了。
因此,两种方式并存的架构是合理且必要的,也因此目前主流的虚拟机也大多采取这种架构。
即时编译器
HotSpot 中的即时编译器有两种,分别称为 Client Complier 和 Server Complier,或者简称为 C1 和 C2,目前虚拟机一般采用解释器和一个即时编译器直接配合的方式来运行,这种模式称之为 混合模式。
既然是两者合作,那么久需要考虑一个调度的问题,即何时使用编译执行,何时采用解释执行,多少的比例可以获得最佳平衡,得到最高的效率。
在 HotSpot 中是通过 分层编译 的策略来达到最优解的。其本质的思想如下所示:
- 第0层:程序解释执行,解释器不开启性能监控,触发第一层;
- 第1层:C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,如果有必要,可以加入性能监控;
- 第2层:C2 编译,也是将字节码编译为本地代码,但其会启动一些耗时较长的优化,甚至会根据监控的信息采取一些激进的优化措施。
这种分层编译的方式可以达到一定情况的最优解:用 C1 获取更快的编译速度,用 C2 获取更好的编译质量,解释执行的时候也无需增加性能监控的任务,反而拖累了启动效率。
编译对象
因为编译的过程是一个耗时耗力的工作,因此对那么频繁执行的代码进行编译能获得更高的提升。因此如何判断那么代码是 热点代码 呢?
在 JVM 中,热点代码的判断方式有两种:
-
基于采样,周期性的检查栈顶,如果一段代码频繁出现在栈帧顶部,那么就判断其是热点代码。
- 优点:实现简单,快;
- 缺点:探测很容易收到线程阻塞的影响。例如一个方法因为线程阻塞,一直在栈顶,但其实其执行次数并不多,那么将其判定为热点代码就是不合理的。
-
基于计数器:为每个方法甚至是代码块建立计数器来统计执行次数,如果统计的次数达到了一定的条件则说明是热点代码
- 优点:结果精确
- 缺点:实现就比较麻烦了,需要维护计数器
HotSpot 中采取的是第二种方案,因为频繁执行的代码有如下两种:
- 方法的频繁执行
- 一段代码的频繁执行
因此 HotSpot 中建立了两种类型的计数器来进行判断,其执行逻辑分别为如下所示:
回边的判断方法与方法基本一致,只是在提交编译请求后,需要把回边计数器的值减小一点,保证代码以解释状态继续执行。
经典优化方案
JIT 中有太多编译的优化技术了,在这里我们就找几个比较经典的介绍一个,剩下的读者感兴趣可以 Google 一下,或者给作者留言,可以再拓展一篇文章单独介绍一下。
方法内联
方法内联应该是 Java 中最重要的几项优化技术之一,其存在的最大意义就是为其他优化手段提供了基础。其使得代码膨胀,因此也提供了更多的优化机会。
表面来看,方法内联只是将代码复制一份到调用的地方,但实现起来真的那么简单吗?
前面我们说过方法的多态调用,介绍了只有 非虚方法 可以在编译期间知道调用的是哪个版本的方法,但是像虚方法这种,是可能会存在多个版本可以选择的,那么编译器在进行方法内联的时候,该复制哪里的代码呢?
为了解决这个问题,JVM 团队引入了 类型继承关系分析 的技术。这种技术的执行逻辑如下所示:
公共子表达式消除
如果一个表达式,在两次计算过程中,其内所有变量的值并没有发生变化,那么则将其称为公共子表达式。
举个栗子:
int a = (b*c)*4+(c*b+d)+d
上面这段代码在计算 b*c
的两次中并没有变化,因此可以将其简写为int a = E * 4 + (E + d) + d
,再进一步还可以进行 代数化简 优化,将其优化为:
int a = E * 5 + 2 * d;
数组范围检查消除
Java 语言中为了保持代码的健壮和安全性,在每次数组访问的时候需要判断其是否在 0 ~ length-1 的范围内,如若不然,将抛出异常。这样做有个显而易见的好处是可以提高程序的健壮性,但这对于拥有大量数组访问的程序来说,就是一个灾难了。
因此,如果可以确保数组访问不会越界的情况下,JVM 则可以做出相应的优化,例如可以使用隐式异常处理。栗子如下:
if(object != null){
return object.value;
}else{
throw new Exception();
}
在确定如果 object 在大多数情况下不会为空后,可以做出以下优化:
try{
return object.value;
}catch(segment_fault){
exception_execute;
....
}
这样就可以减少大量的判断开销。
逃逸分析
逃逸分析可以说是目前最前沿的优化技术。其是指当分析对象作用域时,如果一个对象在被定义后,其不会外部方法和线程访问到,那么就可以说明其是不会逃逸的,即其生命周期只有在被定义的块中,因此就可以对其进行优化。
- 栈上分配,Java 对象大家都知道是分配在堆上的,但通过前面的学习,我们知道栈上的对象在管理时,是十分地影响性能的。因此我们考虑,既然其不会逃逸的话,那么直接将其分配到栈上不是更好吗。这样其可以随着线程的消亡而消亡,减少垃圾收集的压力;
- 同步消除,如果对象不会逃逸,就别谈线程不安全的访问了,也就不会被多个线程访问,因此没有必要对其进行同步,直接可以把同步消除掉;
- 标量替换,Java 中的对象分为 标量 和 聚合量 ,其中标量是不能再被拆分的变量,如 int、long 等。而聚合量中最典型的就是对象,现在如果能判断对象不会逃逸,因此结合栈上分配,将其拆分为标量然后分配到栈上是一个很好的优化方式。
前面说了那么多逃逸分析的优点,但目前逃逸分析技术还并不是十分的成熟,其能够带来的优化效果还不好说。
例如下面这种极端情况,JVM 在经过逃逸分析后,发现所有的对象都是可以逃逸出去的,那么就带来的性能消耗就十分的不值了,因为毕竟逃逸分析是一个相对高耗时的过程,耗费了大量的时间和运算资源,结果发现全部白费了。
不过虽然如此,但笔者相信逃逸分析一定是一个优化的技术发展路线。因为其经过优化后的代码大大提升了性能。
总结
在本文中,我们对后端编译的方方面面进行了分析,包括其编译器架构,分层编译的思想,如何判断一段代码值得被编译为本地代码,以及采取哪些方式来优化代码。
对这些内容的深入理解,有助于我们在工作中分出哪些代码是可以被编译器优化的,哪些是需要自己处理的,提高自身编码效率。
文章在公众号「iceWang」第一手更新,有兴趣的朋友可以关注公众号,第一时间看到笔者分享的各项知识点,谢谢!笔芯!
本系列文章主要借鉴自《深入分析 JavaWeb 技术内幕》和《深入理解 Java 虚拟机-JVM 高级特性与最佳实践》。