关于JVM编译器方面的解释
一、引入JIT
1.解释器与编译器
在部分的商用虚拟机中,Java 程序最初是通过解释器( Interpreter )进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,即时编译器(Just In Time Compiler )会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。
在编译器时期,我们通过将源代码编译成.class ,配合JVM这种跨平台的抽象,屏蔽了底层计算机操作系统和硬件的区别,实现了“一次编译,到处运行” 。 而在运行时期,目前主流的JVM 都是混合模式(-Xmixed),即解释运行和编译运行配合使用。
从Java7开始,HotSpot虚拟机默认采用分层编译的方式:热点方法首先被C1编译器编译,而后热点方法中的热点再进一步被C2编译(理解为二次编译,根据前面的运行计算出更优的编译优化)。为了不干扰程序的正常运行,JIT编译时放在额外的线程中执行的,HotSpot根据实际CPU的资源,以 1:2的比例分配给C1和C2线程数。在计算机资源充足的情况,字节码的解释运行和编译运行时可以同时进行,编译执行完后的机器码会在下次调用该方法时启动,已替换原本的解释执行(意思就是已经翻译出效率更高的机器码,自然替换原来的相对低效率执行的方法)。
2.工作方式
以前有句话说:“Java是解释执行的 ” 。现在看来确实不是很准确,至于原因,在此简略解释 解释执行:将编译好的字节码一行一行地翻译为机器码执行。 编译执行:以方法为单位,将字节码一次性翻译为机器码后执行。
3.比较
- 解释器优点:当程序需要迅速启动的时候,解释器可以首先发挥作用,省去了编译的时间,立即执行。解释执行占用更小的内存空间。同时,当编译器进行的激进优化失败的时候,还可以进行逆优化来恢复到解释执行的状态。
- 编译器优点:在程序运行时,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。
4.即时编译
即时编译存在的意义在于它是提高程序性能的重要手段之一。根据“二八定律”(即:百分之二十的代码占据百分之八十的系统资源),对于大部分不常用的代码,我们无需耗时间将之编译为机器码,而是采用解释执行的方式,用到就去逐条解释运行;对于一些仅占据小部分的热点代码(可认为是反复执行的重要代码),则可将之翻译为符合机器的机器码高效执行,提高程序的效率,此为运行时的即时编译。HotSpot中内置了两个即时编译器,分别称为 Client Compiler和 Server Compiler ,或者简称为 C1 编译器和 C2 编译器。
- C1:即Client编译器,面向对启动性能有要求的客户端GUI程序,采用的优化手段比较简单,因此编译的时间较短。
- C2:即Server编译器,面向对性能峰值有要求的服务端程序,采用的优化手段复杂,因此编译时间长,但是在运行过程中性能更好。
目前的 HotSpot 编译器默认的是解释器和其中一个即时编译器配合的方式工作,具体是哪一个编译器,取决于虚拟机运行的模式,HotSpot 虚拟机会根据自身版本与计算机的硬件性能自动选择运行模式。
用户也可以使用 -client 和 -server 参数强制指定虚拟机运行在 Client 模式(C1)或者 Server 模式(C2)。配合使用的方式称为“混合模式”(Mixed Mode),用户可以使用参数 -Xint 强制虚拟机运行于 “解释模式”(Interpreted Mode),这时候编译器完全不介入工作。
另外,使用 -Xcomp 强制虚拟机运行于 “编译模式”(Compiled Mode),这时候将优先采用编译方式执行,但是解释器仍然要在编译无法进行的情况下接入执行过程。
通过虚拟机 -version 命令可以查看当前默认的运行模式。
5.认定为热点代码
在运行过程中会被即时编译的“热点代码”有两类,即:
-
被多次调用的方法
-
被多次执行的循环体
对于第一种,编译器会将整个方法作为编译对象,这也是标准的JIT 编译方式。对于第二种是由循环体出发的,但是编译器依然会以整个方法作为编译对象,因为发生在方法执行过程中,称为栈上替换。
6.JIT触发条件
判断一段代码是否是热点代码,是不是需要出发即时编译,这样的行为称为热点探测(Hot Spot Detection),探测算法有两种,分别为:
-
基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是“热点方法”。好处是实现简单、高效,很容易获取方法调用关系。缺点是很难确认方法的reduce,容易受到线程阻塞或其他外因扰乱。
-
基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。优点是统计结果精确严谨。缺点是实现麻烦,不能直接获取方法的调用关系。
HotSpot 使用的是第二种-基于计数器的热点探测,并且有两类计数器:方法调用计数器(Invocation Counter )和回边计数器(Back Edge Counter )。这两个计数器都有一个确定的阈值,超过后便会触发 JIT 编译。
-
方法调用计数器
Client 模式下默认阈值是 1500 次,在 Server 模式下是 10000次,这个阈值可以通过 -XX:CompileThreadhold 来人为设定。如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内的方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那么这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就成为此方法的统计的半衰周期( Counter Half Life Time)。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
-
回边计数器
回边计数器,作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”( 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 。
-
二、C1与C2编译器
1.C1与C2名词解释
Hotspot中内置了两种 JIT 即时编译器,分别为C1 编译器和C2 编译器,这两个编译器的编译过程是不一样的。
C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,也称为Client Compiler。
C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,也称为Server Compiler。
在 Java1.7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作,也即我们只能二选一,比如我们想要编译速度快的编译器就选择C1,想要编译效果好但较慢的编译器只能选择C2。但幸运的是,从Java1.7开始引入了分层编译,这种方式综合了C1的启动性能优势和 C2 的优化效果好的性能优势,当然我们也可以通过参数 -client 或者-server 强制指定虚拟机的即时编译模式。分层编译将 JVM 的执行状态分为了 5 个层次:
- 第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译
- 第 1 层:使用 C1 编译器,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling
- 第 2 层:使用 C1 编译器,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译
- 第 3 层:使用 C1 编译器,执行所有带 Profiling 的 C1 编译
- 第 4 层:使用 C2 编译器,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化
对于 C1 的三种状态,按执行效率从高至低:第 1 层、第 2层、第 3层。通常情况下,C2 的执行效率比 C1 高出30%以上。在 Java1.8 中,Hotspot默认开启了分层编译,如果只想开启 C2,可以使用启动参数: -XX:-TieredCompilation 关闭分层编译,如果只想用 C1,可以使用启动参数使用参数:-XX:TieredStopAtLevel=1 打开分层编译,同时指定使用C1编译器的1层编译。
2.JVM参数解释
-
-XX:-TieredCompilation (关闭分层编译)
-
-XX:+TieredCompilation (开启分层编译)
这个参数主要用于是否开启JVM的分层编译,JDK8之后默认是开启。TieredCompilation的参数,如果关闭它,会导致CodeCache变小。这个参数应该要配合另一个codeCache参数使用的,不然容易导致codeCache不够用,导致服务在长时间运行后程序整个执行效率降低
另外,jdk8默认开启了分层编译,无论开启还是关闭分层编译,配置的-client和-server都是无效的。如果关闭分层编译,JVM将直接采用C2,即配置-XX:-TieredCompilation;如果只想使用C1,在打开分层编译的同时,使用参数-XX:TieredStopAtLevel=1
-
-XX:CompileThreshold=n(指定一个方法的调用次数,以使HotSpot和JIT 编译器能编译它)
-
-Xcomp(指定JVM在第一次使用时把所有的字节码编译成本地代码. 即CompileThreshold=1)
-
-Xint(仅仅使用解释模式,不激活JIT编译器 .即CompileThreshold=0)
三、实践
使用hisdis插件查看jit编译优化后的汇编代码
1.环境搭建
将下载的插件解压得到两个dll文件放到JDK_HOME/jre/bin/client和JDK_HOME/jre/binserver目录下
2.编译生成汇编代码
将插件放置好了之后,就可以使用java命令携带参数进行编译。由字节码得到的汇编代码将输出在控制台。
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,com.lanyuan.controller.index.BackgroundController::testPrimeNumber -XX:CompileThreshold=10
参数解释
- -XX:+UnlockDiagnosticVMOptions(使用的是Product版的HotSpot。需要加上-XX:+UnlockDiagnosticVMOptions参数,且该参数必须在-XX:+PrintAssembly之前)
- -XX:+PrintAssembly(打印jit优化后的汇编代码)
- -XX:CompileCommand=compileonly,全类名::函数名(指定查看哪个类下的哪个函数)
- -XX:CompileThreshold=10(一个函数运行10次后认为其为热点代码,即触发JIT)