Java中的即时编译器
本篇文章将会从以下几个方面讲解java即时编译器:
-
概述 -
为什么HotSpot采用解释器和编译器并存的运行架构? -
HotSpot中的分层编译工作模式 -
即时编译的触发条件 -
编译器优化技术
1 概述
Java的编译包括前端编译和后端编译,前端编译是前端编译器(如 Javac)把 *.java 文件编译成 *.class 文件的过程;后端编译指将字节码class文件转换成二进制机器码的过程,这个过程可以由即时编译(Just In Time, JIT)完成,后端编译发生在Java虚拟机中,但后端编译不是Java虚拟机必须的组成部分,《Java虚拟机规范》中也没有要求虚拟机必须包含这些编译器,但后端编译器的好坏是衡量虚拟机是否优秀的重要指标。
目前主流的两款Java虚拟机(HotSpot、OpenJ9)中,Java程序开始是通过解释器(Interpreter)解释执行,当虚拟机发现某个方法或代码块运行特别频繁,就会将这些代码认定为“热点代码”,为了提高热点代码的运行效率,在运行时,虚拟机会将这些代码编译为本地机器码,并采用各种手段对代码优化,运行时完成这个任务的就是即时编译器。
接下来我们了解下HotSpot虚拟机中即时编译器的运作过程。
2 为什么HotSpot采用解释器和编译器并存的运行架构?
并不是所有虚拟机都采用解释器和编译器并存,将解释器和编译器各自优点结合可以提高虚拟机性能。
具体地:
-
当程序需要快速启动时,可以先由解释器运行,省去编译时间; -
当程序启动后,编译器逐渐发挥作用,将更多的代码编译为本地机器码,提高运行效率; -
当程序运行环境的内存资源限制较大时,如嵌入式系统,可使用解释器以节约内存; -
当编译器对代码采用的某些激进优化措施未成功时,可退回到解释状态继续执行;
3 HotSpot中的分层编译工作模式
HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个存在已久,分别是“客户端编译器”(Client Compile)和“服务端编译器”(Client Compile),也称C1和C2,第三个是JDK10才出现的、未来计划替代C2的Graal编译器。
C1和C2的区别在于代码优化策略不同,C1关注于局部性的优化,放弃了许多耗时较长的全局优化。C2是面向服务器场景,优化复杂度很高,另外还根据解释器或C1提供的性能监控信息进行了一些不稳定的预测性激进优化。
在分层编译模式出现之前,HotSpot只是简单地将解释器与某一个编译器搭配使用,具体怎么搭配取决于自身版本和硬件性能。但由于即时编译器运行会占用程序运行时间,且编译出优化程度越高的代码,所用时间越长,同时还需要解释器替编译器收集性能监控信息,影响解释运行速度,所以为了使程序启动速度和运行效率间达到最佳平衡,HotSpot引入了分层编译工作模式。
分层编译会根据编译器优化的规模和耗时,划分出不同的编译层次:
-
第0层,程序纯解释器运行,不开启性能监控。 -
第1层,使用C1,不开启性能监控。 -
第2层,使用C1,开启部分性能监控,包括方法和回边次数统计等。 -
第3层,使用C1,开启全部性能监控。 -
第4层,使用C2。
分层编译优点:
-
C2使用高复杂度的优化算法时,C1可先用简单优化争取更多的编译时间; -
解释器运行时无须额外承担收集性能监控信息的任务;
4 即时编译的触发条件
我们可以使用参数“-Xint”强制虚拟机使用解释器执行程序,或使用参数“-Xcomp”强制虚拟机使用编译器执行程序。正常情况下我们编写的代码被执行时,由解释器启动,编译器遇到“热点代码”时被触发,对其进行优化。
“热点代码”主要包括两类:
-
被多次调用的方法 -
被多次执行的循环体
但两种情况下,编译的目标都是整个方法体,而不是单独的循环体。
要判断某段代码是否是热点代码,要进行"热点探测",主流的探测方式有两种:
-
1 基于采样的热点探测。 -
虚拟机周期性检查各线程的调用栈顶,经常出现在栈顶的方法被定义为热点方法。 -
优点:简单高效;容易获取方法调用关系。 -
缺点:很难精确确认方法热度;容易受线程阻塞等外界因素影响。 -
2 基于计数器的热点探测 -
虚拟机为每个方法建立计数器,统计方法执行次数,次数超过阈值的被定义为热点方法。 -
优点:精确。 -
缺点:麻烦。
HotSpot中采用的是第2种基于计数器的方法,HotSpot为每个方法准备了两种计数器:
-
方法调用计数器 -
统计方法被调用的次数 -
默认阈值在客户端模式下是1500,服务端模式下是10000 -
默认设置下,方法调用计数器统计的不是方法调用的绝对次数,而是执行频率,即一段时间内的调用次数。当超过一定的时间限度,调用次数仍未达到触发即时编译的阈值,则方法调用计数器数值减半,该过程称为方法调用计数器 热度的衰减。可以通过参数设置关闭热度衰减,这样方法调用计数器统计绝对次数,只要系统运行时间够长,程序大部分代码都会被编译为本地代码 -
回边计数器 -
统计回边的次数,在字节码中遇到控制流向后跳转的指令称为回边 -
默认阈值在客户端模式下是13995,服务端模式下是10700 -
回边计数器没有热度衰减,统计的就是绝对次数
回边计数器触发即时编译过程同方法调用计数器类似。
5 编译器优化技术
这里介绍几个有代表性的优化技术:
-
最重要的优化技术之一:方法内联 -
最前沿的优化技术之一:逃逸分析 -
语言无关的经典优化技术之一:公共子表达式消除 -
语言相关的经典优化技术之一:数组边界检查消除
首先说明一下,即时编译器对代码的优化是在字节码或机器码基础上,而不是java源码上,但为了直观呈现,下面一些例子直接在java源码上做了优化调整。
最重要的优化技术之一:方法内联
方法内联的作用就是去除方法调用的成本,同时它还有另一个更重要的作用,就是给其它优化手段提供基础,如果没有内联,很多其它优化就无法有效进行了,所以编译器一般把方法内联放在其它优化之前进行。
先举一个简单的方法内联的例子。
内联前的代码:
class A {
int value;
int getValue() {return value;}
}
public void foo(A a) {
int x = a.getValue();
}
内联后的代码:
public void foo(A a) {
int x = a.value;
}
通过上面的例子简单理解方法内联的话,它就是将目标方法代码复制到了方法调用处,但实现起来很难,甚至不采取特殊手段的话,按照传统编译原理的优化理论,多数方法都无法实现内联。
举个例子说明为什么无法内联。比如类B
继承了类A
,并重写了getValue()
方法,那么在编译器就无法确定foo()
传进来的对象a是父类实例还是子类实例,也就无法确定将谁的getValue()
方法内联到调用处。
为了解决这样的问题,你可以把方法都修饰成final,禁止方法被继承,当然这是不现实的,java虚拟机的解决办法是引入了名为类型继承关系分析(CHA)的技术。CHA会查询一个方法在当前程序状态下是否有多个版本,若有多个,就无法内联,需要发生方法调用;若只有一个,将其内联到调用处,这是一种激进预测性优化,因为java在运行期会不断加载新的类,不一定什么时候该方法就会有新的版本,所以必须留好退路,当CHA检查到方法有新的版本出现时,抛弃已编译的内联后的代码,退回解释状态运行。
最前沿的优化技术之一:逃逸分析
逃逸分析是比较前沿的一种优化技术,其基本原理是:分析对象动态作用域,一个对象在方法中创建后,如果被其他方法引用了,称为方法逃逸,甚至还有可能被其他线程所访问,这时称为线程逃逸,逃逸程度由高到低分别是不逃逸、方法逃逸、线程逃逸。
如果能证明一个对象不会逃逸或逃逸程度低,那么就能够采取一些优化措施,如下:
-
栈上分配。我们都知道java中对象是在堆上创建的,堆内存空间也是线程共享的,而垃圾回收堆上的内存是非常耗时耗资源的。如果确定一个对象不会发生线程逃逸,那我们就可以在栈上创建对象,对象占的内存随栈帧出栈而销毁,这样垃圾回收的压力就小多了,而且一般程序中不会逃逸出线程的对象是占很大比例的。 -
标量替换。如果方法中的一个对象不会逃逸出该方法,那么我们都不需要创建它,只需创建出它里面被该方法使用的成员变量即可。 -
同步消除。如果确定一个对象不会逃逸出线程,那肯定就不会发生多线程的读写竞争,所以对它的同步措施就可以擦除掉。
从jdk7开始,服务端编译器C2默认开启了逃逸分析,但目前分析逃逸的手段还不成熟,未来还有很大的发展空间。
语言无关的经典优化技术之一:公共子表达式消除
公共子表达式消除是一个比较经典的被广泛应用的优化技术,并且与语言无关。它的意思就是如果一个表达式之前被计算过,并且其中变量的值没有修改过,那下次越到该表达式时,就没必要重新计算了。
举个例子,未优化前代码:
int x = (a * b) + (b * a)
编译器检测到a * b
和b * a
是相同的表达式,且计算期间变量值没有变过,假设先计算出了a * b
的值为value,那上面代码就可以优化成:
int x = value + value
语言相关的经典优化技术之一:数组边界检查消除
数组边界检查消除是一项与语言相关的优化技术,读写一个数组arr时,c/c++中是通过裸指针来操作,而java是系统自动进行上下边界检查,访问arr[i]时要确保i>=0 && i<arr.length,否则就会报错。但是每次访问数组元素都进行一次边界检查的话,尤其像循环操作,系统性能开销会比较大。
为此,编译器在某些情况下会消除数组边界检查,比如在一个循环中,编译器通过数据流分析判定循环变量取值肯定在区间[0, arr.length]中,那就没必要每次都检查边界了,从而省去了很多条件判断。
类似的,java会自动进行很多检查判断,造成了许多隐式开销,编译器为了消除一些隐式开销,除了数组边界检查消除这种将运行期检查提前到编译期完成的手段外,还有一种手段—隐式异常处理。如下,优化前的判空检查是有性能开销的,优化后省去了判空检查。
优化前:
if (a != null) {
return a.value;
} else {
throw new NullPointerException();
}
优化后:
try {
return a.value;
} catch (segment_fault) {
uncommon_trap();
}
虚拟机会注册一个segment_fault信号对应的异常处理器uncommon_trap(),这是进程层面的异常处理器,如果a为空,那就要转到异常处理器中恢复中断并抛出NullPointerException异常,其中涉及到进程从用户态到内核态的切换,虽然这样切换的开销远大于判空操作,但是对于a一般不为空的情况,这样的优化是值得的。
如果你能完整地读到这,恭喜你,对Java编译的理解又上了一个台阶。这篇文章确实全是枯燥的文字,但全是干货,如果一次看不明白可以收藏下次再看。
- END -
推荐阅读: