即时编译器
目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器
(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认
定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代
码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称
为即时编译器。
解释器与编译器
尽管并不是所有的Java虚拟机都采用解释器与编译器并存的运行架构,但目前主流的商用Java虚拟
机,譬如HotSpot、OpenJ9等,内部都同时包含解释器与编译器[1],解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。
同时,解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许,
HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率
选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设
不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通
过逆优化(Deoptimization)退回到解释状态继续执行,因此在整个Java虚拟机执行架构里,解释器与
编译器经常是相辅相成地配合工作。

HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称
为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和
C2编译器(部分资料和JDK源码中C2也叫Opto编译器),第三个是在JDK 10时才出现的、长期目标
是代替C2的Graal编译器。
在分层编译(Tiered Compilation)的工作模式出现以前,HotSpot虚拟机通常是采用解释器与其中
一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式,HotSpot虚拟机
会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。
解释器与编译器搭配使用的方式在虚拟机
中被称为“混合模式”(Mixed Mode),用户也可以使用参数“-Xint”强制虚拟机运行于“解释模
式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也
可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方
式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。
由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花
费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信
息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,
HotSpot虚拟机在编译子系统中加入了分层编译的功能[2],如果不开启分层编译策略,而虚拟机又运行在服务端模式,服务端编译器需要性能监控信息提供编译
依据,则是由解释器收集性能监控信息供服务端编译器使用。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包
括:
·第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
·第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启
性能监控功能。
·第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
·第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如
分支跳转、虚方法调用版本等全部的统计信息。
·第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启
用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多
次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行
的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客
户端编译器可先采用简单优化来为它争取更多的编译时间。
编译对象与触发条件
热点代码主要有两类,包括:
·被多次调用的方法。
·被多次执行的循环体。
前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代码”是理所当然的。而后者则是为了解决当一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码” [1]。(还有一个不太上台面但其实是Java虚拟机必须支持循环体触发编译的理由,是诸多跑分软件的测试用力通常都属于第二种,如果不去支持跑分会显得成绩很不好看。)
对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。第一种情况,由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。(个人理解第二种需要全部编译,然后从中间指令跳转到编译后的方法,而不是直接从一开始就替换的原因类比,比如十个方法调一个公共的方法,我如果要改逻辑,我只需要在这个方法改一个地方就行,而不需要去十个方法调用的地方都改)
要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(HotSpot Code Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种[2],分别是:
·基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
·基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。
这两种探测手段在商用Java虚拟机中都有使用到,譬如J9用过第一种采样热点探测,而在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。
当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
我们首先来看看方法调用计数器。顾名思义,这个计数器就是用于统计方法被调用的次数,它的默认阈值在客户端模式下是1500次,在服务端模式下是10000次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。
当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。
当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了。在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

回边计数器,它的作用是统计一个方法中循环体代码执行的次数[3],在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,很显然建立回边计数器统计的目的是为了触发栈上的替换编译。(回边就是指令跳转到之前的某条指令就是回边)
·虚拟机运行在服务端模式下,回边计数器阈值的计算公式为:方法调用计数器阈值(-XX:CompileThreshold)乘以(OSR比率(-XX:OnStackReplacePercentage)减去解释器监控比率(-XX:InterpreterProfilePercentage)的差值)除以100。其中-XX:OnStack ReplacePercentage默认值为140,-XX:InterpreterProfilePercentage默认值为33,如果都取默认值,那服务端模式虚拟机回边计数器的阈值为10700。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。

与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
从理论上了解过编译对象和编译触发条件后,我们还可以从HotSpot虚拟机的源码中简单观察一下这两个计数器,在MehtodOop.hpp(一个methodOop对象代表了一个Java方法)中,定义了Java方法在虚拟机中的内存布局,如下所示:
在这段注释所描述的方法内存布局里,每一行表示占用32个比特,从中我们可以清楚看到方法调用计数器和回边计数器所在的位置和数据宽度,另外还有from_compiled_entry和from_interpreted_entry两个方法入口所处的位置。
编译过程
在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器
还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。
用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即
时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始
执行编译器输出的本地代码。
那在后台执行编译的过程中,编译器具体会做什么事情呢?服务端编译器和客户端编译器的编译
过程是有所差别的。对于客户端编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点
在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。
在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。

而服务端编译器则是专门面向服务端的典型应用场景,并为服务端的性能配置针对性调整过的编译器,也是一个能容忍很高优化复杂度的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度。它会执行大部分经典的优化动作,如:无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(CommonSubexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic BlockReordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range CheckElimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等。
服务端编译采用的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如
RISC)上的大寄存器集合。以即时编译的标准来看,服务端编译器无疑是比较缓慢的,但它的编译速
度依然远远超过传统的静态优化编译器,而且它相对于客户端编译器编译输出的代码质量有很大提
高,可以大幅减少本地代码的执行时间,从而抵消掉额外的编译时间开销,所以也有很多非服务端的
应用选择使用服务端模式的HotSpot虚拟机来运行。
无用代码消除(Dead Code Elimination): 这指的是编译器会识别和删除程序中没有任何实际效果的代码,这些代码不会对程序运行结果产生影响。例如,如果有一段代码赋值了一个变量,但是这个变量后续没有被使用到,编译器会将这段赋值代码删除,以减少程序的冗余。
循环展开(Loop Unrolling): 循环是程序中常见的结构,循环展开就是将循环体内的代码复制多次,减少循环控制的开销。这可以减少每次迭代中的跳转次数,从而提高程序的运行速度。
循环表达式外提(Loop Expression Hoisting): 在循环中,如果某个表达式的值在每次迭代中都是一样的,编译器会将其移出循环,这样可以避免重复计算,提高效率。
消除公共子表达式(Common Subexpression Elimination): 当程序中多次出现相同的表达式时,编译器会计算并保存结果,然后在需要时直接使用已经计算好的结果,而不是重新计算,以节省时间和计算资源。
常量传播(Constant Propagation): 如果某个变量的值在编译时可以确定不变,编译器会用该常量替换变量的引用,这样可以减少内存的使用和运行时的计算开销。
基本块重排序(Basic Block Reordering): 基本块是一组连续的指令序列,这些指令没有分支和跳转。编译器会重新排列这些基本块的顺序,以优化指令的执行顺序,减少分支预测错误,从而提高程序的性能。
范围检查消除(Range Check Elimination): 在某些语言中,例如Java,会对数组或集合进行范围检查以确保访问的合法性。编译器在某些情况下可以静态分析并消除这些检查,因为它们在编译时就可以确定是安全的。
空值检查消除(Null Check Elimination): 类似于范围检查消除,编译器可以分析代码中的空值检查,以确定哪些情况下可以安全地省略这些检查,从而提高程序的运行效率。
守护内联(Guarded Inlining)
在编译优化中,内联(Inlining)是一种将函数调用处用函数体直接替换的技术。内联可以减少函数调用的开销,例如参数传递、栈帧管理等,从而提高程序的执行速度。但并非所有函数都适合内联,因为有时内联可能会导致代码体积增大,造成缓存未命中等问题。
守护内联考虑到了这些问题。它是一种策略,只有在一定条件下才会选择内联某个函数,而不是无条件地内联。这些条件可能包括:
函数体大小:只有当函数体很小,内联才有利于减少调用开销。
调用频率:只有当函数被频繁调用时,内联才有可能带来显著的性能提升。
内联后代码复杂性:内联后的代码复杂度不应过高,避免影响缓存效率和指令流水线。
守护内联通过评估这些条件来决定是否将函数内联,以达到最佳的性能优化效果。
分支频率预测
比如if else 程序会根据以往执行信息找到执行概率比较大的一个分支,然后对此分支就行优化,比如生成只走此分支的优化代码,当如果条件不符合,那么就会退回到低级编译器甚至解释器上去执行。
实战:查看及分析即时编译结果
public static final int NUM = 15000;
public static int doubleValue(int i) {
// 这个空循环用于后面演示JIT代码优化过程
for(int j=0; j<100000; j++);
return i * 2;
}
public static long calcSum() {
long sum = 0;
for (int i = 1; i <= 100; i++) {
sum += doubleValue(i);
}
return sum;
}
public static void main(String[] args) {
for (int i = 0; i < NUM; i++) {
calcSum();
}
}使用参数-XX:+PrintCompilation要求虚拟机在即时编译时将被编译成本地代码的方法名称打
印出来
VM option '+PrintCompilation'
310 1 java.lang.String::charAt (33 bytes)
329 2 org.fenixsoft.jit.Test::calcSum (26 bytes)
329 3 org.fenixsoft.jit.Test::doubleValue (4 bytes)
332 1% org.fenixsoft.jit.Test::main @ 5 (20 bytes)310 1 java.lang.String::charAt (33 bytes)
310表示编译后的方法编号。1表示这是该方法被编译的第一次。java.lang.String::charAt表示被编译的方法的全限定名。(33 bytes)表示该方法的字节码长度。
带有“%”的输出说明是由回边计数器触发的栈上替换编译
方法编号: 每次 JIT 编译器编译一个方法时,都会给予一个唯一的编号,这有助于跟踪和分析。
编译次数: 指示该方法被 JIT 编译的次数。数字越高,代表这个方法被多次编译,通常是因为编译器在优化和改进编译后的代码。
方法的全限定名和字节码长度: 显示了被编译的方法的信息,包括类名、方法名和方法的字节码长度。
加上参数-XX:+PrintInlining要求虚拟机输出方法内联信息
VM option '+PrintCompilation'
VM option '+PrintInlining'
273 1 java.lang.String::charAt (33 bytes)
291 2 org.fenixsoft.jit.Test::calcSum (26 bytes)
@ 9 org.fenixsoft.jit.Test::doubleValue inline (hot)
294 3 org.fenixsoft.jit.Test::doubleValue (4 bytes)
295 1% org.fenixsoft.jit.Test::main @ 5 (20 bytes)
@ 5 org.fenixsoft.jit.Test::calcSum inline (hot)
@ 9 org.fenixsoft.jit.Test::doubleValue inline (hot)方法编号和编译次数: 说明了每个方法被 JIT 编译的次数及其唯一编号,这有助于跟踪和分析编译的效果和频率。
被内联编译的方法: 内联是 JIT 编译器的一种优化技术,它将一个方法的代码直接插入到调用它的地方,减少了方法调用的开销。在日志中,
@后面的行号表示这是在该行处触发的内联编译,而(hot)表示该方法是一个经常被执行的热点方法,适合进行内联优化。
doubleValue()方法已被内联编译到calcSum()方法中,而
calcSum()方法又被内联编译到main()方法里面,所以虚拟机再次执行main()方法的时候(举例而已,
main()方法当然不会运行两次),calcSum()和doubleValue()方法是不会再被实际调用的,没有任何方法
分派的开销,它们的代码逻辑都被直接内联到main()方法里面了。
经过即使编译,doubleValue()方法空循环的开销被消除了,许多语言安全保障措施和GC安全点的轮询操作也被一起消
除了,因为编译器判断到即使不做这些保障措施,程序也能得到相同的结果,不会有可观察到的副作
用产生,虚拟机的运行安全也不会受到威胁