概述:
部分商用虚拟机中,Java程序最初是通过解释器对.class文件进行解释执行的,当虚拟机发现某个方法或代码块运行地特别频繁的时候,就会把这些代码认定为热点代码Hot Spot Code(这也是我们使用的虚拟机HotSpot名称的由来)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器叫做即时编译器(Just In Time Compiler,即JIT编译器)。JIT编译器并不是虚拟机必需的部分,Java虚拟机规范并没有要求要有JIT编译器的存在,更没有限定或指导JIT编译器应该如何去实现。但是,JIT编译器性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一。
解释器和编译器其实各有优势:
1、当程序需要迅速启动和执行的时候,解释器可以先发挥作用,省去编译的时间,立即执行
2、在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率
我们使用的HotSpot中内置了两个JIT编译器,即C1编译器和C2编译器,默认采用的是解释器和一个编辑器配合的方式进行工作。HotSpot在启动的时候会根据自身版本以及宿主机器的硬件性能自动选择运行模式,比如会检测宿主机器是否为服务器、比如J2SE会检测主机是否有至少2个CPU和至少2GB的内存。
1、如果是,则虚拟机会以Server模式运行,该模式与C2编译器共同运行,更注重编译的质量,启动速度慢,但是运行效率高,适合用在服务器环境下,针对生产环境进行了优化
2、如果不是,则虚拟机会以Client模式运行,该模式与C1编译器共同运行,更注重编译的速度,启动速度快,更适合用在客户端的版本下,针对GUI进行了优化
编译对象与触发条件:
之前讲过,Sun使用的虚拟机之所以被叫做"HotSpot",就是因为运行过程中会检测热点代码,那么运行过程中,会被即时编译器编译的"热点代码"有两类,即:
- 被多次调用的方法
- 被多次执行的循环体
前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,他成为"热点代码"也是理所当然。
而后者则是为了解决一个方法只被调用过一次或者少量的几次,但是方法体内部存在循环次数较多的循环体问题,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是"热点代码"。
后一种情况,编译器依然会以整个方法作为编译对象,称为栈上替换(简称OSR编译)。
那上面的问题描述中,所谓"多次"都不是一个具体、严谨的用语,那么多少次才算"多次"?还有,虚拟机如何统计一个方法或一段代码被执行过多少次呢?
判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为"热点探测",其实热点探测并不一定要知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种:
- 基于采样的热点探测:虚拟机会周期性检查各个线程的栈顶,如果某个方法经常出现在栈顶,那这个方法就是热点方法。
- 基于计数器的热点探测:虚拟机为每个方法建立计数器,统计执行次数,如果次数超过阈值就认为是热点方法。
HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,它为每个方法准备了两类计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译,分别看一下:
1、方法调用计数器
顾名思义,这个计数器就是用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server模式下是10000次。这个阈值可以通过参数-XX:CompileThreshold来人为设定。当一个方法被调用时,会检查方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器和回边计数器值之和是否超过方法调用计数器的阈值。如果已经超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
如果这个参数不做任何设置,那么方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会少一半,这个过程称为方法的调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作实在虚拟机进行垃圾回收时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。
那如果参数不设置的话,执行引擎并不会同步等待编译请求完成,而是直接进入解释器按照解释方法执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。
2、回边计数器
它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为"回边"。显然,建立回边技术其统计的目的就是为了触发OSR编译。关于回边计数器的阈值,虽然HotSpot也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前虚拟机实际上并未使用此参数,因此我们需要设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段中是否有已经编译好的版本,如果有,它将会优先执行已编译好的代码,否则就把回边计时器的值加1,然后判断方法调用计数器与回边计数器值之和是否已经超过回边计数器的阈值。当超过阈值之后,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
与方法计数器不同,回边计数器没有热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
编译过程:
在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成的时候,都仍然按照解释方式继续执行,而编译动作则在后台的编译线程中进行。用户可以通过-XX:-BackgroundCompilation来禁止后台编译,在禁止后台编译后,一旦达到JIT的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。
对于Client Compiler(C1编译器)来说,它是一个简单快速的三段式编译,主要关注点在于局部性的优化,而放弃了许多耗时间长的全局优化手段。
对于Sever Compiler(C2编译器)来说,它则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度,它会执行所有经典的优化动作,如无用代码消除、循环展开、常量传播、基本块重排序等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除等,另外,还有可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等,下一部分将讲解上述的一部分优化手段。
Server Compiler从即时编译的标准来看,无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用Server模式的虚拟机运行。