学习JVM相关的知识,必然绕不开即时编译器,因为它太重要了。了解了它的基本原理及优化手段,在编程过程中可以让我们有种打开任督二脉的感觉。比如,很多朋友在面试当中还会遇到这样的问题:Java是基于编译执行还是基于解释执行?当你了解了Java的即时编译器,不仅能够轻松回答上述问题,还能如数家珍的讲出JVM在即时编译器上采用的优化技术,而且在实践过程中更深刻的理解代码背后的原理。本文便带大家全面的了解Java即时编译器。
即时编译器
在部分的商用虚拟机中,比如HotSpot中,Java程序先通过解释器(Interceptor)进行解释执行。这也是为什么称Java是基于解释执行的原因。但当虚拟机发现某块代码或方法运行的特别频繁,便会将其标记为“热点代码”(Hot Spot Code)。
针对热点代码,虚拟机会采用各种措施来提升其执行效率,因为执行比较频繁,如果能够提升其执行效率,性价比还是比较高的。为此,在运行时,虚拟机会把这些代码编译成与本地平台相关的机器码,并进行各层次的深度优化。而这些优化操作便是通过编译器来完成的,也称作即使编译器(Just In Time Compiler,简称 JIT 编译器)。
因此,准确的来说,像HotSpot等虚拟机,Java是基于解释执行和编译执行的。下面用一张图来解释该过程:
解释器与编译器的并存
首先,我们需要知道并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。
既然即时编译器进行了各层次的优化,那么为什么Java还使用解释器来“拖累”程序的性能呢?这是因为,解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。
Java虚拟机运行时,解释器和即时编译器能够相互协作,取长补短。无论采用解释器进行解释执行,还是采用即使编译器进行编译执行,最终字节码都需要被转换为对应平台的本地机器码指令。某些服务并不看重启动时间,而某些服务却非常看重,这就需要采用解释器与即时编译器并存来换取一个平衡点。
我们可以从解释器和编译器的编译时间开销和编译空间开销两方面进行对比。首先,看编译的时间开销。我们所说的JIT比解释器快,仅限于对“热点代码”编译之后的代码执行起来要比解释器解释执行的快。通过上图可以看出,如果是只是单次执行的代码,JIT编译比解释器要多出一步“执行编译”,因此,只执行一次时,JIT是要比解释器慢的。只执行一次的代码通常包括只被调用一次的代码(比如构造器)、没有循环的代码等,此时使用JIT显然得不偿失。
其次,再来看看编译空间方面的开销。对一般的Java方法而言,编译后代码的大小相对于字节码,膨胀比达到10倍是很正常的。只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”。这就是为什么有些JVM不会单一使用JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。
HotSpot的两种即时编译器
HotSpot虚拟机为了使用不同的应用场景,内置了两个即时编译器:Client Complier和Server Complier,简称为C1、C2编译器,分别用在客户端和服务端。Client Complier可获取更高的编译速度,Server Complier可获取更好的编译质量。
JVM Server模式与client模式最主要的差别在于:-server模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升。原因是:当虚拟机运行在-client模式时,使用的是一个代号为C1的轻量级编译器,而-server模式启动的虚拟机采用相对重量级代号为C2的编译器。C2比C1编译器编译的相对彻底,服务起来之后,性能更高。
默认情况下,使用C1还是C2编译器,要取决于虚拟机运行的模式。HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。
目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器配合的方式工作,这种配合称作混合模式(Mixed Mode)。用户可以使用参数-Xint强制虚拟机运行于 “解释模式”(Interpreted Mode),这时候编译器完全不介入工作。使用-Xcomp强制虚拟机运行于 “编译模式”(Compiled Mode),这时将优先采用编译方式执行,但是解释器仍然要在编译无法进行的情况下接入执行过程。通过虚拟机java -version命令可以查看当前默认的运行模式。在上述示例中我们不仅能够看到采用的模式为mixed mode,还能看到出采用的是Server模式。
热点探测
上面解释了JIT编译器的基本功能,那么它是如何判断热点代码的呢?判断一段代码是不是热点代码的行为,也叫热点探测(Hot Spot Detection),通常有两种方法:基于采样的热点探测和基于计数器的热点探测(HotSpot使用此方式)。
基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,会被定义为“热点方法”。实现简单、高效,很容易获取方法调用关系。但很难确认方法的reduce,容易受到线程阻塞或其他外因扰乱。
基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。统计结果精确严谨,但实现麻烦,不能直接获取方法的调用关系。
HotSpot虚拟机默认采用基于计数器的热点探测,有两种计数器:方法调用计数器和回边计数器。当计数器数值大于默认阈值或指定阈值时,方法或代码块会被编译成本地代码。
方法调用计数器,记录方法调用的次数。Client模式默认阈值是1500次,在Server模式下是10000次,可以通过 -XX:CompileThreadhold来设定。如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内的方法被调用的次数。当超过一定的时间限度,但调用次数仍然未达到阈值,那么该方法的调用计数器就会被减半,称为方法调用计数器热度的衰减(Counter Decay),这段时间称为此方法的统计半衰周期( Counter Half Life Time)。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。JIT编译交互图如下:回边计数器,统计一个方法中循环体代码执行的次数。在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),建立回边计数器统的目的是为了触发OSR编译。计数器的阈值, HotSpot提供了-XX:BackEdgeThreshold来进行设置,但当前的虚拟机实际上使用了-XX:OnStackReplacePercentage来间接调整阈值,计算公式如下:
- 在Client模式下, 公式为“方法调用计数器阈值(CompileThreshold)X OSR比率(OnStackReplacePercentage)/ 100” 。其中OSR比率默认为933,那么,回边计数器的阈值为13995。
- 在Server模式下,公式为“方法调用计数器阈值(Compile Threashold)X (OSR 比率(OnStackReplacePercentage) - 解释器监控比率(InterpreterProfilePercent))/100”。其中onStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那么Server模式虚拟机回边计数器阈值为10700。
对应的流程图如下:与方法计数器不同,回边计数器没有计数热度衰减的过程,因此统计的就是该方法循环执行的绝对次数。当计数器溢出时,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
不同模式的性能对比
了解JVM的不同编译模式,下面写一个简单的测试例子来测试一下不同编译器的性能。需要注意的是以下测试程序和场景并不够严谨,只是从大方向上带大家了解一下不同模式之间的区别。如果需要精准的测试,最好的方式应该是在严格的基准测试下测试。
public class JitTest { private static final Random random = new Random(); private static final int NUMS = 99999999; public static void main(String[] args) { long start = System.currentTimeMillis(); int count = 0; for (int i = 0; i < NUMS; i++) { count += random.nextInt(10); } System.out.println("count: " + count + ",time cost : " + (System.currentTimeMillis() - start)); } }
在测试的过程中,通过添加虚拟机参数“-XX:+PrintCompilation”来打印编译信息。
首先,来看纯解释执行模式,JVM参数添加“-Xint -XX:+PrintCompilation”,然后执行main方法,打印信息如下:
count: 449945612,time cost : 33989
花费了大概34秒。同时,控制台并未打印出编译信息,侧面证明了即时编译器没有参与工作。
下面采用编译器模式执行,修改虚拟机参数:“-Xcomp -XX:+PrintCompilation”,执行main方法,打印如下信息:其中,代码中相关消耗时间打印信息为:
count: 450031537,time cost : 10593
只用了10秒,同时会产生大量的编译信息。
最后,采用混合模式再测试一次,修改虚拟机参数为“-XX:+PrintCompilation”,执行main方法:打印了编译信息,同时发现执行同样的代码只需要不到1秒的时间。
经过上述粗略的测试,会发现在上述示例中耗时由小到大顺序为:混合模式<纯编译模式<纯解释模式。当然,如果需要更精准和更准确的测试,还需要严格的基准测试条件。
编译优化技术
即时编译器之所以快,还有另外一个原因:在编译本地代码时,虚拟机设计团队几乎把所有的优化措施都使用上了。所以,即时编译器产生的本地代码会比 javac 产生的字节码更优秀。下面看一下即时编译器在生产本地代码时都采用了哪些优化技术。
第一,语言无关的经典优化技术之一:公共子表达式消除。如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没必要花时间再对它进行计算,只需要直接使用前面计算过的表达式结果代替 E 就可以了。例子:int d = (c*b) * 12 + a + (a+ b * c) -> int d = E * 12 + a + (a+ E)。
第二,语言相关的经典优化技术之一:数组范围检查消除。在Java语言中访问数组元素的时候系统将会自动进行上下界的范围检查,超出边界会抛出异常。对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑是一种性能负担。Java 在编译期根据数据流分析可以判定范围进而消除上下界检查,节省多次的条件判断操作。
第三,最重要的优化技术之一:方法内联。简单的理解为把目标方法的代码“复制”到发起调用的方法中,消除一些无用的代码。只是实际的JVM中的内联过程很复杂,在此不分析。
第四,最前沿的优化技术之一:逃逸分析。逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到它,则可进行一些高效的优化:
- 栈上分配:将不会逃逸的局部对象分配到栈上,那对象就会随着方法的结束而自动销毁,减少垃圾收集系统的压力。
- 同步消除:如果该变量不会发生线程逃逸,也就是无法被其他线程访问,那么对这个变量的读写就不存在竞争,可以将同步措施消除掉。
- 标量替换:标量是指无法在分解的数据类型,比如原始数据类型以及reference类型。而聚合量就是可继续分解的,比如Java中的对象。标量替换如果一个对象不会被外部访问,并且对象可以被拆散的话,真正执行时可能不创建这个对象,而是直接创建它的若干个被这个方法使用到的成员变量来代替。这种方式不仅可以让对象的成员变量在栈上分配和读写,还可以为后后续进一步的优化手段创建条件。
其他更多优化项:https://wiki.openjdk.java.net/display/hotspot/performancetacticindex。
小结
通过上面的学习,想必大家已经对即时编译的运作原理、使用场景、使用流程、判断代码、优化技术项等有了更深刻的了解。当了解了这些底层的原理,在写代码、排查问题、性能调优方面均有帮助。对于本文中提到的内容,也建议大家实践、体验一下,以便加深印象。