jit编译器知识总结

788 阅读8分钟

背景

我们知道,一个java程序要先被编译成class文件,然后再被虚拟机解释执行。解释执行的速度一般不如编译执行快,为了优化java程序的运行速度,一些商用虚拟机会采用这么一种策略:在解释执行的过程中,如果发现有“热点代码”,则会将其编译成本地代码。这样一来,这些热点代码就可以规避掉解释执行的缺点,一定程度上提升java运行效率。在jvm中负责这块任务的角色就是jit编译器,即 just in time compiler(即时编译器)。

值得注意的是:jvm规范中并没有规定必须要存在jit编译器,更没有规定即时编译器该如何实现,但是即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。

解释器(Interceptor)和编译器(Compiler)

按照背景所述,既然经过编译之后效率更高,为啥java不直接采用编译执行呢?采用解释器和编译器配合的方式岂不是多此一举?

这里需要纠正一下,jit是即时编译,也就是运行时编译,既然是在运行时,那相较于提前编译/静态编译,jvm就能拿到更多的信息,也就能进行更好的优化。因此只要jit编译器不拉跨,其即时编译出的本地代码性能应该超过预先编译好的情况。因此,在常见jvm中,解释器和编译器一般是同时存在,相互配合工作的。这种组合在虚拟机中也叫混合编译模式,即mixed mode。用户可以通过-Xint参数强制虚拟机仅使用解释器,即解释模式Intercepter mode。也可以使用-XComp强制使得虚拟机仅使用编译器,即编译模式Compiler mode(编译无法进行时,还是需要解释器介入的)。

C1编译器和C2编译器:

常见的hotspot虚拟机中内置了两个编译器,即client compiler和server compiler,分别称为c1编译器和c2编译器。在jdk1.7之后,默认开启了分层编译的策略:所谓分层,就是由c1编译器优化代码,但是仅仅是一些简单的,可靠的优化。而c2编译器则会进行一些复杂的,更为激进的优化。

何为热点?

热点代码会被jit编译成本地代码,所谓的热点包括两种:

1.  被多次调用的方法
2.  被多次执行的循环体

多热才算热?

在回答这个问题之前,想要搞清楚jvm是怎么探测热点的:

方法一:周期性的采样,虚拟机会周期性的检查线程的栈顶,如果发现某些方法总是出现在栈顶,那么这个方法可能就是热点方法

方法二:为每个方法建立一个计数器,每调用一次,计数器就加1

jvm一般采用第二种方法去做热点探测,jvm提供了一个参数-XX:CompileThreshold,这个参数就表示计数器的阈值,当超过这个阈值时,就算热点方法。在client模式下,这个参数的默认值为1500,在server模式下,这个参数的默认值则为10000。

方法计数器和回边计数器

按照上面的结论,jvm通过计数器方式探测热点。同时,我们也知道所谓的热点有两种:被多次调用的方法 + 被多次执行的循环体。

jvm针对这两种热点所采用的计数器是不一样的,对于方法热点,采用方法计数器进行计数。对于循环体热点,则采用回边计数器(Back Edge)计数。

热度衰减

仅仅使用计数器统计调用次数有点小问题,假如我们将热点的阈值设置为1000,有一个方法在某段时间内疯狂调用了900次,但是剩下的几周有可能总共只调用了101次,虽然触发了阈值,但是貌似不能说这个方法是热点方法。

对于上面这种情况,jvm有一种称为热度衰减的机制。也就是说,如果一段时间内没有发生调用,那么热度会自动衰减一半,比如900->450。这样可以大大降低上述情况出现的概率。

OSR编译

方法计数器对应的方法热点比较简单,方法调用次数累积到一定程度就会触发jit编译。一旦编译之后,下次再调用此方法就变成编译执行了。

但是回边计数器对应的循环体热点就不一样了,假如循环10万次,而热点阈值设置为1万,那jvm要做到在第10001次的时候就要使用编译执行,这就相当于飞机还在飞着,要把发动机给换掉。因此这种编译形象的称为栈上替换(On Stack Replacement),就是方法栈还在执行呢,方法就被替换了。

如何验证?

一般情况下,jit过程对我们是透明的,开发者不需要关心这些过程,安安心心的写好crud就行了。但是考虑这么一种场景:我们需要对应用中的热点方法进行预热,想要得知这些热点方法有没有被jit编译成本地代码。此时,可以使用-XX:+PrintCompilation来要求虚拟机将被jit编译成本地代码的方法名打印出来。其中输出结果中 %表示由回边计数器触发的OSR编译。

jit用了哪些编译优化手段?

jit用了很多优化手段去提升编译成本地代码的质量,其中代表性的手段有:

  1. 公共子表达式消除(语言无关的经典优化手段之一)
  2. 数组范围检查消除(语言相关的经典优化手段之一)
  3. 方法内联(最重要的优化技术之一)
  4. 逃逸分析(最前沿的优化技术之一)

下面一个个来分析:

公共子表达式消除

意思是说,如果一个表达式我们已经计算过了,那么当后续再出现这个表达式的时候,就不用再计算一遍了。如果这种表达式是在方法内部的,那么就称为局部公共表达式消除。相反则称之为全局公共表达式消除。

注意哦,这个过程是发生在jit中的,前期java代码编译成字节码时可没有这种优化,后续不再赘述。

数组范围检查消除

一般情况下,java访问数组指定位置i的元素时,编译器都会编译上自动判断是否越界的代码。这对于程序员来说当然是好事,但是对于压根不可能出现越界的情况,每次都判断是否越界显然多了一些开销。数组范围检查消除这项优化就是做这种工作的,一旦发现不可能出现越界情况,则会消除越界判断的代码。

方法内联

调用方法是有开销的,要建立调用栈,如果能把被调用方法的代码“复制”到调用方法代码中的话,那就相当于只调用了一个方法,就完成了两个方法做的事情。

来看一个例子:

通过方法内联优化以后:

逃逸分析

很前沿的jit优化技术,它不是直接优化代码的手段,而是为其他优化手段提供分析基础。所谓的逃逸:就是如果一个对象在方法中被定义了,然后却作为参数传给其它方法了,或者是赋给某个类的静态变量了,总之它的生命周期超出了定义或是生成它的方法的生命周期,那么这个对象就是逃逸了。

怎么利用这个技术呢?或者说通过这个技术可以干嘛呢?

  1. 栈上分配:对象一般是在堆上分配的,堆空间对于各个线程是共享的,同时也是垃圾回收最多作用的地方,如果能够通过分析得出某个对象不可能逃逸,那么完全可以将其分配到栈中,这样它的周期就在方法周期之内,方法调用完事之后,这个对象也消失了,减少了不少gc的压力。

  2. 锁消除/同步消除:如果一个对象不可能逃逸,那么这个对象就不可能成为共享变量,就不可能出现多线程竞争此对象的场景。那么此对象上的锁也就没什么意义。

  3. 标量替换:所谓标量就是不可再拆分的类型,在java中可以简单理解为基本数据类型。与之相对的是聚合量,可以理解中java中的对象。如果某个对象不可能逃逸,jit编译器会尝试不创建对象,而直接用对象拆分之后成员变量的基本类型。从而获得更高的执行效率。