即时编译,是一项用来提升应用程序运行效率的技术。通常代码会先被JVM解释执行,之后反复执行的热点代码则会被即时编译成为机器码,直接运行在底层硬件之上。
分层编译模式
HotSpot虚拟机包含多个即时编译器C1、C2和Graal。
Graal是一个实验性质的即时编译器,可以通过参数-XX:+UnlockExperimentalVMOption -XX:+UseJVMCICompiler启用,并且替换C2。
在Java7以前,需要根据程序的特性选择对应的即时编译器。对于执行时间较短的,或者对启动性能有要求的程序,采用编译效率较快的C1,对应参数-client。
对于执行时间较长的,或者对峰值性能有要求的程序,采用生成代码执行效率较快的C2,对应参数-server。
Java7引入了分层编译(对应参数-XX:+TieredCompilation)的概念,综合了C1的启动性能优势和C2的峰值性能优势。分层编译将JVM的执行状态分为了五个层次:
- 解释执行(带profiling);
- 执行不带profiling的C1代码;
- 执行仅带方法调用次数以及循环回边执行次数profiling的C1代码;
- 执行带所有profiling的C1代码;
- 执行C2代码。
通常情况下,C2代码的执行效率要比C1代码的高出30%以上。对于C1代码的三种状态,按执行效率2层>3层>4层。2层的性能比3层的稍微高一些,3层的性能比4层高出30%。profiling越多,其额外的性能开销越大。profiling是指在程序执行过程中,收集能够反映程序执行状态的数据。所收集的数据称之为程序的profile。
在5个层次的执行状态中,2层和5层为终止状态。当一个方法被终止状态编译过后,如果编译后的代码并没有失效,那么JVM不会再次发出该方法的编译请求。
Java8默认开启了分层编译。不管是开启还是关闭分层编译,原本用来选择即时编译器的参数-client和-server都是无效的。当关闭分层编译的情况下,JVM将直接采用C2。如果希望只是用C1,那么可以在打开分层编译的情况下使用参数-XX:TieredStopAtLecel=1。在这种情况下,JVM会在解释执行之后直接由2层的C1进行编译。
即时编译的触发
JVM是根据方法的调用次数以及循环回边的执行次数来触发即时编译的。JVM在1层、3层、4层执行状态时进行profiling,其中就包含方法的调用次数和循环回边的执行次数。(循环回边是控制流图中的概念,可以简单理解为往回跳转的指令)。
在即时编译过程中,会识别循环的头部和尾部。循环尾部到循环头部的控制流边就是真正意义上的循环回边。C1将在这个位置插入增加循环回边计数器的代码。
解释执行和C1代码中增加循环回边计数器的位置并不相同,但并不会对程序造成影响。
JVM不会对这些计数器进行同步操作,因此收集而来的执行次数也并非精确值。不管如何,即时编译的触发并不需要非常精确的数值。只要该数值足够大,就能说明对应的方法包含热点代码。
在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数-XX:CompileThreshold指定的阈值时(使用C1时,该值为1500;使用C2时,该值为10000),便会触发即时编译。
当启用分层编译时,JVM将不再采用由参数-XX:CompileThreshold指定的阈值(该参数失效),而是使用另一套阈值大小动态调整的系统:在比较阈值时,JVM会将阈值与某个系数s相乘。该系数与当前待编译的方法数目成正相关,与编译线程的数目成负相关。
1 | 系数的计算方法为: |
在64位JVM中,默认情况下编译线程的总数目是根据处理器数量来调整的。
对应参数-XX:+CICompilerCountPerCPU,默认为true;当通过参数-XX:+CICompilerCount=N强制设定总编译线程数目时,CICompilerCountPerCPU将被设置为false。
JVM会将编译线程按照1:2的比例分配给C1和C2(至少各1个)。
对于四核及以上的机器,总的编译线程数目为:n=log2N * log2(log2N) * 3 / 2;其中N为CPU核心数目。
当启用分层编译时,即时编译触发条件:
1 | 当方法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数, |
OSR编译
决定一个方法是否为热点代码的因素有两个:方法的调用次数、循环回边的执行次数。即时编译便是根据这两个计数器的和来触发的。
除了以方法为单位的即时编译之外,JVM还存在着另一种以循环为单位的即时编译,On-Stack-Replacement(OSR)编译,循环回边计数器便是用来触发这种类型的编译的。
OSR是一种技术,指的是在程序执行过程中,动态的替换掉Java方法栈帧,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。去优化(deoptimization)(从执行机器码切换回解释执行)采用的技术也可以称之为OSR。
在不启用分层编译的情况下,触发OSR编译的阈值是由参数-XX:CompileThreshold指定的阈值的倍数(使用C1时,该值为1500;使用C2时,该值为10000)。
(OnStackReplacePercentage - InterpreterProfilePercentage)/100
其中-XX:InterpreterProfilePercentage的默认值为33,当使用C1时-XX:OnStackReplacePercentage为933,当使用C2时为140。那么默认情况下,C1的OSR编译的阈值为13500,C2的为10700。
《深入理解Java虚拟机-第2版》p335区别
在启用分层编译的情况下,触发OSR编译的阈值则是由参数-XX:TierXBackEdgeThreshold指定的阈值乘以系数。
OSR编译在正常的应用程序中并不多见。它只在基准测试时比较常见。
总结
从Java8开始,JVM默认采用分层编译的方式。它将执行分为五个层次,分别为1层解释执行,2层执行没有profiling的C1代码,3层执行部分profiling的C1代码,4层执行全部profiling的C1代码,和5层执行C2代码。
通常情况下,方法会首先被解释执行,然后被4层的C1编译,最后被5层的C2编译。
即时编译是由方法调用计数器和循环回边计数器触发的。在使用分层编译的情况下,触发编译的阈值是根据当前待编译的方法数目动态调整的。
OSR是一种能够在非方法入口处 进行解释执行和编译后代码之间切换的技术。OSR编译可以用来解决单次调用方法包含热循环的性能优化问题。
使用参数-XX:+PrintCompilation可以打印项目中的即时编译情况。