目前主流的商用Java虚拟机(如HotSpot)内部同时包含解释器和编译器。当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行;当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,减少解释器的中间损耗,获得更高的执行效率。解释器和编译器的交互图如下。
HotSpot虚拟机中内置了两个或三个即时编译器,其中两个编译器存在已久,分别成为客户端编译器和服务端编译器,或者简称为C1编译器和C2编译器,第三个是在JDK 10出现的,目标是代替C2的Graal编译器(目前处于实验状态)。
在分层编译的工作模式出现以前,HotSpot虚拟机通常是采用解释器和其中一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式。
无论采用的编译器是客户端编译器还是服务端编译器,解释器和编译器搭配使用的方式在虚拟机中被称为混合模式。-Xint
强制虚拟机运行于解释模式,-Xcomp
强制虚拟机运行于编译模式。
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码(将运行的特别频繁的某个方法或代码块称为热点代码)都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。
热点代码有两类,一个是被多次调用的方法还有就是被多次执行的循环体,被多次执行的循环体是为了解决一个方法只被调用过1次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样的循环体代码也被重复执行多次,所以也认为这是热点代码。
这两种情况编译的目标对象都是整个方法体。在描述热点代码的时候,提到了个词——多次执行,怎么定义多次呢?要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”,其实进行热点探测并不一定要直到方法具体被调用了多少次,目前主流的热点探测判定方式有两种:基于采样的热点探测
和基于计数器的热点探测
。
基于采样的热点探测:虚拟机周期性检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶,那该方法就是热点方法。实现简单高效,容易获取方法调用关系,缺点是很难精确的确认一个方法的热度,容易因为收到线程阻塞或别的因素影响扰乱热点探测。
基于计数器的热点探测:为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为是热点方法。统计结果相对严谨,但是为每个方法维护计数器,实现比较麻烦,而且不能直接获取到方法的调用关系。
计数器有两种,一个是方法调用计数器,另一个是回边(在循环边界往回跳转)计数器。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
方法调用计数器
当一个方法被调用时,会先检查方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已经被编译的版本,则将此方法的调用计数器加1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阀值,如果已经超过阀值,那么将会向即时编译器提交一个该方法的代码编译请求。在下次进行方法调用的时候,重复此流程。
在向即时编译器提交编译请求之后,执行引擎并不会进行阻塞,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成,这样做很明显不会造成程序运行中的阻塞。并且,我们可以判断,即时编译由一个后台线程操作进行。
在方法调用计数器中还有两个特别重要的概念:方法调用计数器的热度衰减与半衰周期。
如果不做任何设置,方法调用计数器统计的并不是方法调用的绝对次数,而是一个相对的执行频率。也就是说,如果在一定的时间内,方法调用的次数不足以让它提交给即时编译器编译,那么这个方法的调用计数器就会被减少一半,这个过程就是方法调用计数器的热度衰减。而这段时间,就是此方法统计的半衰周期。
进行热度衰减的动作是在垃圾收集的时候顺便进行的。我们可以通过调节虚拟机参数-XX:CounterHalfLifeTime
指定是否进行热度衰减,或者调整它的半衰周期。
回边计数器
当解释器遇到一条回边指令(编译原理的相关知识,可以粗略理解为循环)时,会先检查将要执行的代码片段是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已经被编译的版本,则将此方法的回边计数器加1,然后判断方法调用计数器与回边计数器之和是否超过回边调用计数器的阀值,如果已经超过阀值,那么将会向即时编译器提交一个OSR编译请求,并且会把回边计数器的值降低一些,以便继续在解释器中执行循环。在下次进行方法调用的时候,重复此流程。
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
先举个例子,优化前的原始代码如下:
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方法都无法内联,原因是:只有使用incokespecial
指令调用的私有方法、实例构造器、父类方法和使用invokestatic
指令调用的静态方法才会在编译器进行解析,其他的Java方法都可能存在多于一个版本的方法接收者,Java语言中默认的实例方法是虚方法。对虚方法,编译器静态去做内联的时候很难确定应该用哪个方法版本。
为了解决虚方法的内联问题,先是引入一种名为类型继承关系分析(CHA)的技术,这是整个应用程序范围内的类型分析技术。如果遇到虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“应用程序的全貌就是现在运行的这个样子”来进行内联,这种内联被称为守护内联(Guarded Inlining)。不过由于Java程序是动态连接的,说不准什么时候就会加载到新的类型从而改变CHA结论,因此这种内联属于激进预测性优化,必须预留好“逃生门”,即当假设条件不成立时的“退路”(Slow Path)。假如在程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
要是查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器使用内连缓存的方式缩减方法调用的开销。
逃逸分析的原理:分析对象动态作用域,当一个对象在方法里面被定义之后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸。
如果能证明一个对象不会逃逸到方法或者线程之外,或者逃逸成都比较低,则可能为这个对象实例采取不同程度的优化:栈上分配、标量替换、同步消除。
如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没发生变化,那么E这次的出现就称为公共子表达式。
数组边界检查优化尽可能把运行期检查提前到编译期完成。