即时编译编译器:JIT

最后更新:2021-01-06

1. JIT

JIT是(just in time)的缩写, 也就是即时编译编译器。使用即时编译器技术,能够加速Java程序的执行速度。

Java编译器通常通过javac将程序源代码编译,转换成java字节码,JVM通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。很显然,经过解释执行,其执行速度必然会比可执行的二进制字节码程序慢很多。为了提高执行速度,引入了JIT技术。

JIT编译狭义来说是当某段代码即将第一次被执行时进行编译,因而叫“即时编译”。JIT技术,将字节码编译成平台相关的原生机器码,并进行各个层次的优化,这些机器码会被缓存起来,以备下次使用。

如果JIT对每条字节码都进行编译,缓存(缓存的指令是有限的),会增加开销,因此JIT只对热点代码进行即时编译,如循环高频度使用的方法,会将整个方法编译成本地机器码,然后直接运行机器码。

JIT编译器 VS 解释器

解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。

解释器的执行代码过程:

字节码 -> [ 解释器 解释执行机器码 ] -> 执行结果

JIT编译器的执行代码过程:

字节码 -> [ 编译器 编译 ] -> 与机器相关的机器码-> [ 执行 ] -> 执行结果

说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。对只执行一次的代码做JIT编译再执行,可以说是得不偿失。对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。只有对频繁执行的代码,JIT编译才能保证有正面的收益。

对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的,会有比较大的空间开销,所以从空间角度考虑,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”。这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。

2. 热点代码

程序中的代码只有是热点代码时,才会编译为本地代码。运行过程中会被即时编译器编译的“热点代码”有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

两种情况,编译器都是以整个方法作为编译对象。 这种编译方法因为编译发生在方法执行过程之中,因此形象的称之为栈上替换(On Stack Replacement,OSR),即方法栈帧还在栈上,方法就被替换了。

在HotSpot虚拟机中使用的基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

2.1. 方法调用计数器

顾名思义,这个计数器用于统计方法被调用的次数。当一个方法被调用时,会首先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在,则将此方法的调用计数器加 1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。如果超过阈值,将会向即时编译器提交一个该方法的代码编译请求。

如果不做任何设置,执行引擎不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译完成后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。

2.2. 回边计数器

回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为「回边」(Back Edge)。建立回边计数器统计的目的是为了触发 OSR 编译。

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否已经有编译好的版本,如果有,它将优先执行已编译的代码,否则就把回边计数器值加 1,然后判断方法调用计数器和回边计数器值之和是否超过计数器的阈值。当超过阈值时,将会提交一个 OSR 编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。

3. 编译器分类

在HotSpot虚拟机中,内置了两种JIT编译器,分别为C1编译器C2编译器,通常情况下,C2的执行效率比 C1高出30%以上。

C1编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,也称为Client Compiler,例如,GUI应用对界面启动速度就有一定要求。

C2编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,也称为Server Compiler,例如,服务器上长期运行的Java应用对稳定运行就有一定的要求。

在Java7之前,需要根据程序的特性来选择对应的JIT编译器,虚拟机默认采用解释器和其中一个编译器配合工作。Java7 引入了分层编译,这种方式综合了C1的启动性能优势和C2的峰值性能优势。

分层编译将JVM的执行状态分为了5个层次:

  • 第0层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
  • 第1层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;
  • 第2层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;
  • 第3层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;
  • 第4层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

4. HotSpot虚拟机的优化技术

JIT编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。常见的优化技术有如下几种:

公共子表达式消除

如果一个表达式已经进行过计算,并且在下次用到之前依赖的变量没有变化,即表达式的计算结果不会发生变化,则在下次使用这个表达式时直接使用计算的结果。

数组边界检查消除

在Java中访问数组时,会自动进行边界检查来防止数组下标越界。但是对于某些情况并不需要每次访问都去检查,如在一个循环中遍历数组元素,如果虚拟机能够确定下标不会发生越界并且优化确实能够提高运行速度,则虚拟机会去除每次访问的下标检查。

方法内联

方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用时产生的上下文切换。

逃逸分析

方法中定义的一个对象,如果会被其他方法访问则称为方法逃逸,如果会被其他线程访问则称为线程逃逸。对于不能逃逸的对象采用了栈上分配、同步消除、标量替换等方法进行优化。

5. 编译阈值

即时编译JIT只在代码段执行足够次数才会进行优化,在执行过程中不断收集各种数据,作为优化的决策,所以在优化完成之前,例子中的User对象还是在堆上进行分配。

那么一段代码需要执行多少次才会触发JIT优化呢?通常这个值由-XX:CompileThreshold参数进行设置: 1、使用client编译器时,默认为1500; 2、使用server编译器时,默认为10000; 意味着如果方法调用次数或循环次数达到这个阈值就会触发标准编译,更改CompileThreshold标志的值,将使编译器提早(或延迟)编译。

除了标准编译,还有一个叫做OSR(On Stack Replacement)栈上替换的编译,如上述例子中的main方法,只执行一次,远远达不到阈值,但是方法体中执行了多次循环,OSR编译就是只编译该循环代码,然后将其替换,下次循环时就执行编译好的代码,不过触发OSR编译也需要一个阈值,可以通过以下公式得到。

6. 参考资料

http://www.dalong.me/developer/java/jit/

https://www.jianshu.com/p/20bd2e9b1f03

Edgar

Edgar
一个略懂Java的小菜比