JVM--解析运行期优化与JIT编译器
JVM开发团队一直在努力,缩小Java与C/C++语言在运行效率上的差距。
本篇博客,我们来谈一谈JVM(HotSpot)为了提高Java程序的运行效率,都实现了哪些激动人心的技术~
1 JIT编译器的引入
首先我们这篇文章中所说的编译器都是指JVM的组成部分之一---即时编译器(JIT),与生成Java字节码的javac编译器要区分开来。
你也许想说,为什么要引进JIT编译器?很好的问题。
我们知道,javac将程序源代码编译,转换成Java字节码,解释器对字节码进行解释执行。而虚拟机传统的解释器,就是要将字节码中的操作指令和真正的平台体系结构之间的指令做映射。比如把Java的load指令换成native code的load指令。
JIT的出现,是为了补强虚拟机边运行边解释的低性能。它会智能地对热点代码进行优化且重复利用,最终将这些代码编译为与本地平台相关的机器码。
2 解释器与编译器
2.1 并存架构
刚才说明了引入JIT编译器的好处,那么HotSpot JVM为什么不完全采用编译器模式而是采用解释器与编译器并存的架构呢?
解释器与编译器各有优势:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行。当程序运行后,随着事件的推移,JIT编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
引入编译器之后整个JVM的工作流程如下:
2.2 Client模式与Server模式
再来说一点其他的事情。
在HotSpot中还内置了两个即时编译器,分别是Client Compiler和Server Compiler,也称为C1编译器与C2编译器,在目前的HotSpot JVM中默认采用的是解释器与其中一个编译器直接配合的方式工作。我们可以使用“-client”或“-server”参数去指定解释器与具体的某个编译器配合工作。这也就是Client模式和Server模式的本质---指定了不同的JIT编译器进行工作。
网上有很多关于Java Client模式与Server模式的讲解博客,总结起来都一句话:Client版本(C1编译器)启动快,Server版本(C2编译器)运行快。至于为什么会产生这样的效果却鲜有人说明,其实就是因为两种编译器之间的差异。
因为编译器编译本地代码也是需要占用程序运行时间的。C1编译器主要是进行简单、可靠的优化,C2编译器为了编译出优化程度更高的代码主要是进行一些编译耗时较长的优化,甚至会进行激进优化。因此Client模式加载速度较快而Server模式运行起来较快。
不断追求完美是人类的天性,JVM团队为了在程序启动响应速度和与运行效率之间达到最佳平衡,设计出了分层编译。在JDK1.7的Server模式中,分层编译被作为默认编译策略开启,对于分层编译有兴趣的同学可以下去找一些资料,我在这里不再进行描述。
2.3 激进优化
上面提到了激进优化,既然是激进优化,那么这种优化即是不可靠的,是有可能优化失败的。那么为什么会存在激进优化呢?无非是为了再次提升运行时的效率。
在激进优化的时候,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,但是当激进优化失败的时候,有一种称为逆优化的技术,可以退回到解释状态继续执行(这也是选用并存架构的另一个意义)。
3 编译对象与触发条件
在了解了为什么需要引入JIT编译器之后,现在需要讨论的就是哪些代码会被JIT编译器进行编译。
会被JIT编译器编译的“热点代码”有两类:
- 被多次调用的方法
- 被多次执行的循环体
这两种情况都会使编译器以整个方法作为编译对象,不同的是,对于第二种情况,由于编译是发生在方法执行的过程中,因此会产生“栈上替换”(OSR编译)的行为,也就是方法栈帧还在栈上,方法就被替换了。
3.1 热点探测
那么如何判断一份代码是否是“热点代码”呢?主要有两种方法。
3.1.1 基于采样的热点探测
JVM周期性的检查各个线程的栈顶,如发现某个方法经常出现在栈顶,那这个方法就是“热点方法”。这个方法的劣势很明显,如果发生线程阻塞,那将会扰乱热点探测。
3.1.2 基于计数器的热点探测
HotSpot虚拟机采用这种方法。它会为每个方法建立计数器,统计方法的执行次数,如果执行次数超过了一定的阀值,就可以认为它是“热点方法”。
3.2 HotSpot中的两种计数器
“热点探测”技术给我们提供了寻找“热点方法”的途径,而计数器则是这条途径的具体实现。
HotSpot虚拟机为每个方法提供了两种计数器,这两个计数器都有一定的阀值,当计数器超过这个阀值溢出了,就会触发JIT编译。
3.2.1 方法调用计数器
这个计数器用于统计方法被调用的次数,对应“热点代码”中“被多次调用的方法”。有兴趣的同学可以查查它的默认阀值。阀值可以通过虚拟机参数-XX:CompileThreshold
进行设定。
我们重点来看一下方法调用计数器触发即时编译的整个流程。
当一个方法被调用时,会先检查方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已经被编译的版本,则将此方法的调用计数器加1,然后判断方法调用计数器与回边计数器(稍后说明)之和是否超过方法调用计数器的阀值,如果已经超过阀值,那么将会向即时编译器提交一个该方法的代码编译请求。在下次进行方法调用的时候,重复此流程。
具体流程如下图:
从图中可以看到,在向即时编译器提交编译请求之后,执行引擎并不会进行阻塞,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成,这样做很明显不会造成程序运行中的阻塞。并且,我们可以判断,即时编译由一个后台线程操作进行。
在方法调用计数器中还有两个特别重要的概念:方法调用计数器的热度衰减与半衰周期。
如果不做任何设置,方法调用计数器统计的并不是方法调用的绝对次数,而是一个相对的执行频率。也就是说,如果在一定的时间内,方法调用的次数不足以让它提交给即时编译器编译,那么这个方法的调用计数器就会被减少一半,这个过程就是方法调用计数器的热度衰减。而这段时间,就是此方法统计的半衰周期。
进行热度衰减的动作是在垃圾收集的时候顺便进行的。我们可以通过调节虚拟机参数指定是否进行热度衰减,或者调整它的半衰周期。
3.2.2 回边计数器
用于统计一个方法中循环体代码执行的次数。关于回边计数器的阀值不同的模式有不同的计算方法,不在这里进行讨论。
回边计数器触发JIT编译的流程与方法调用计数器极其类似。
当解释器遇到一条回边指令(编译原理的相关知识,可以粗略理解为循环)时,会先检查将要执行的代码片段是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已经被编译的版本,则将此方法的回边计数器加1,然后判断方法调用计数器与回边计数器之和是否超过回边调用计数器的阀值,如果已经超过阀值,那么将会向即时编译器提交一个OSR编译请求,并且会把回边计数器的值降低一些,以便继续在解释器中执行循环(说实话,对于这一步操作不是很理解,为什么方法调用计数器在提交编译请求之后不降低值呢?)。在下次进行方法调用的时候,重复此流程。
流程图如下:
回边计数器还有另外值得注意的地方:虽然编译动作是由循环体所触发,但是编译器仍然会编译整个方法,因此在回边计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态。在下次进入该方法的时候就会执行标准编译过程。
4 编译优化技术之方法内联
即时编译器在将字节码翻译成本地机器码之前,还会对字节码进行一系列的优化,因此JIT编译器产生的本地代码会比javac产生的字节码更加优秀。
JVM设计团队采用的优化手段多不胜数,《深入理解Java虚拟机》一书中列举了方法内联、冗余访问消除、复写传播、无用代码消除、公共子表达式消除、数组边界检查消除、逃逸分析等优化手段。
我在这里只说明方法内联,对其他优化方式感兴趣的同学可以下去进行额外了解。
方法内联的重要性要高于其他优化措施,它的目的有二:1.去除方法调用的成本(建立栈帧)。2.为其他优化建立良好的基础。
看一下方法内联产生的效果:
优化前的代码:
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}
内联后的代码:
public void foo() {
y = b.value;
// ...do stuff...
z = b.value;
sum = y + z;
}
方法内联看起来很简单,但按照经典编译原理的优化理论,大多数的Java方法都无法进行内联。
还记得我们在前面讲述的方法解析与分派吗?在Java中,大多数的方法都是虚方法(虚方法的定义可以参见之前博客),这就导致了不到运行期JVM根本不知道实际调用的是哪一个方法版本。那么在JIT编译期(晚期优化还是发生在运行期之前)做内联的时候也就无法确定应该使用的方法版本。
例如如果有ParentB与SubB两个具有继承关系的类,并且子类重写了父类的get方法,那么要执行父类的get方法还是执行子类的get方法,需要到运行期才能确定,JIT编译期是无法得出结论的。
4.1 守护内联与内联缓存
为了解决虚方法的内联问题,JVM设计团队引入了一种“类型继承关系分析(CHA)”的技术。它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,子类是否为抽象类等信息。
编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,这时候的内联是有稳定前提保障的。
如果遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个 “逃生门”(解释器或C1编译器),称为守护内联(Guarded Inlining)。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。(类文件可动态加载即类关系可能在运行时被修改)
如果向CHA查询出来的结果是有多个版本的目标方法可供选择,则编译器还将会进行最后一次努力,使用内联缓存(Inline Cache)来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。
所以说,在许多情况下虚拟机进行的内联都是一种激进优化,激进优化的手段在高性能的商用虚拟机中很常见,除了内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。