解释器与JIT编译器
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。
在程序执行后,随着时间的推移,JIT编译器逐渐发挥作用,把越来越多的代码编译成本地机器码,可以获得更高的执行效率。
可以使用解释器执行节约内存,反之使用JIT编译器来提升效率。
HotSpot虚拟机中内置了两个JIT编译器,分别为Client Compiler(C1编译器)和Server Compiler(C2编译器)。HotSpot采用混合模式,解释器与其中一个JIT编译器搭配使用。

热点代码(Hot Spot Code)
Java 程序最初是仅仅通过解释器解释执行的,即对字节码逐条解释执行,这种方式的执行速度相对会比较慢,尤其当某个方法或代码块运行的特别频繁时,这种方式的执行效率就显得很低。于是后来在虚拟机中引入了 JIT 编译器(即时编译器),当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码认定为“Hot Spot Code”(热点代码),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,完成这项任务的正是 JIT 编译器。
编译对象
运行过程中会被即时编译器编译的“热点代码”有两类:
- 被多次调用的方法。
- 被多次调用的循环体。
两种情况,编译器都是以整个方法作为编译对象,这种编译也是虚拟机中标准的编译方式。要知道一段代码或方法是不是热点代码,是不是需要触发即时编译,需要进行Hot Spot Detection(热点探测)。
对于后一种情况,由于编译对象仍然是整个方法(而不是单独的循环体),代码可能正在解释执行的过程中直接切换到本地代码执行,所以也称为“栈上替换”(OnStackReplacement),简称OSR编译,即方法栈帧还在栈上,方法就被替换了。
热点探测
目前主要的热点探测方式有两种:基于采样和基于计数器的热点探测方法。
基于采样的热点探测
采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这段方法代码就是“热点代码”。
优点:实现简单高效,还可以很容易地获取方法调用关系。
缺点:很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
基于计数器的热点探测
采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点代码”。
优点:统计结果相对更加精确严谨。
缺点:实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。
在 HotSpot 虚拟机中使用的是基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。
方法调用计数器(Invocation Counter)
方法调用计数器用来统计方法调用的次数,在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。
当超过一定的时间限度,如果方法调用次数仍不足以触发即时编译,则方法调用器值会被减少一半,这个过程称为方法调用计数器热度的衰减,这段时间称为方法调用计数器的半衰期。
方法调用计数器触发即时编译
回边计数器(Back Edge Counter)
回边计数器用于统计一个方法中循环体代码执行的次数(准确地说,应该是回边的次数,因为并非所有的循环都是回边),在字节码中遇到控制流向后跳转的指令就称为“回边”。
建立回边计数器的目的就是为了触发OSR编译。
与方法计数器不同,回边计数器没有计数衰减热度的过程,因此统计的是该方法循环执行的绝对次数。
回边计数器触发即时编译
小结
在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阀值,当计数器的值超过了阀值,就会触发JIT编译。
触发了 JIT 编译后,在默认设置下,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成为止(编译工作在后台线程中进行)。当编译工作完成后,下一次调用该方法或代码时,就会使用已编译的版本。
JIT编译优化技术
以编译方式执行本地代码比解释方式更快,除了虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机几乎把所有对代码的优化措施集中在了JIT编译器。
下面介绍的几种优化技术,都以下面的代码作为实例。
方法内联
方法内联(Method Inlining)的重要性高于其他优化措施,它的主要目的有两个:
- 一是出去方法调用的成本(如建立栈帧等)。
- 二是为其他优化建立良好的基础,方法内联膨胀之后,便于在更大范围上采取后续的优化手段。
方法内联后的实例代码为:
冗余访问消除
冗余访问消除(Redundant Loads Elimination),假设... do stuff ...所代表的操作不会改变b.value的值,那就可以把z = b.value替换成z = y,这样就可以不再去访问对象b的属性了。
在方法内联后,接着进行冗余访问消除后的实例代码为:
公共子表达式消除
公共子表达式消除(Common Subexpression Elimination):如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,直接用钱买计算过的表达式结果代替E就可以了。
如果把
b.value看成是一个表达式,可以把这项优化看成是公共子表达式消除。
例如,int d = (c * b) * 12 + a + (a + b* c);javac编译器不会进行任何优化,但是虚拟机的JIT编译器会进行优化,将c*b与b*c是一样的表达式,而且在计算期间的值不会变化。因此,这条表达式可能被视为:int d = E * 12 + a + (a +E);。
这时JIT编译器还能进行代数简化(Algebraic Simplification),把表达式变为:int d = E * 13 + a * 2;。
复写传播
对冗余访问消除后的代码进行复写传播(Copy Propagation):因为在这段代码的逻辑中没有必要使用一个额外的变量z,它与变量y完全相等的,因此可以使用y来代替z。
复写传播后的实例代码为:
无用代码消除
无用代码消除(Dead Code Elimination):无用代码是永远不会被执行的代码,也可能是完全没有意义的代码,形象称之为“Dead Code”。如在进行复写传播后实例中的y = y。
数组边界检查消除
数组边界检查消除(Array Bounds Checking Elimination):是JIT编译器中的一项经典优化技术。为了安全,数组边界检查是不是必须在运行期一次不漏地检查则是可以“商量”的事情。
Java是一门动态安全的语言,为了安全会进行各种检查。这些安全检查也导致了相同的程序,Java要比C/C++做更多的事情(各种检查判断),这些事情就成为一种隐式开销,如果不处理好,就很可能成为一个Java语言比C/C++更慢的因素。
消除隐式开销的方式:
- 将各种安全检查尽可能把运行期检查提到编译期完成。
- 隐式异常处理:虚拟机通过注册一个异常处理器,当安全时不进行安全检查,因而也就不会额外消耗一次进行安全检查的开销。代价就是当不安全时必须转入到异常处理器进行处理(例如抛出异常),这个过程必须从
用户态转到内核态中处理,结束后再回到用户态,速度远比一次安全检查慢。