JVM对代码执行的优化可分为运行时(runtime)优化和即时编译器(JIT)优化。
运行时优化主要是解释执行和动态编译通用的一些机制。比如说锁机制(如偏斜锁)、内存分配机制(如TLAB)等。除此之外,还有一些专门用于优化解释执行效率的,比如说模版解释器、内联缓存(inline cache,用于优化虚方法调用的动态绑定)
在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,本文中简称JIT编译器)。
热点代码的概念查看下文
编译器:把源程序的每一条语句都编译成机器语言,并保存成二进制文件,这样运行时计算机可以直接以机器语言来运行此程序,速度很快
解释器:只在执行程序时,才一条一条把字节码解释成机器语言给计算机来执行
Java需要将字节码逐条翻译成对应的机器指令并且执行,这就是传统的JVM的解释器的功能,正是由于解释器逐条翻译并执行这个过程的效率低,引入了JIT即时编译技术。 必须指出的是,不管是解释执行,还是编译执行,最终执行的代码单元都是可直接在真实机器上运行的机器码
其实这种思想很常见,就像编译器是读库操作,解释器是将热点机器码缓存起来。正如某位计算机大佬曾经说过的,没有任何一个计算机问题不能通过加一层来解决的~哈哈
在 HotSpot 中,解释器和 JIT 即时编译器是同时存在的,他们是 JVM 的两个组件。对于不同类型的应用程序,用户可以根据自身的特点和需求,灵活选择是基于解释器运行还是基于 JIT 编译器运行。HotSpot 为用户提供了几种运行模式供选择,可通过参数设定,分别为:解释模式、编译模式、混合模式,HotSpot 默认是混合模式,需要注意的是编译模式并不是完全通过 JIT 进行编译,只是优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。
解释器与编译器两者各有优势
解释器:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。
编译器:在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
两者的协作:在程序运行环境中内存资源限制较大时,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。当通过编译器优化时,发现并没有起到优化作用,可以通过逆优化退回到解释状态继续执行。
即时编译器并不是虚拟机必需的部分,Java虚拟机规范并没有规定Java虚拟机内必须要有即时编译器的存在,更没有限定或指导即时编译器应该如何去实现。
但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一。它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。
被多次调用的方法
方法体内的代码会被标定为“热点代码”
被多次执行的循环体
一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。
上面提到的多次是一个不具体的词语,那到底是多少次才能成为热点代码呢?
判断一段代码是否是热点代码,是否需要触发即使编译,这样的行为称为热点探测,热点探测并不一定知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种:
基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”
优点:实现简单高效,容易获取方法调用关系(将调用堆栈展开即可)
缺点:不精确,容易因为因为受到线程阻塞或别的外界因素的影响而扰乱热点探测
基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果次数超过一定的阈值就认为它是“热点方法”
优点:统计结果精确严谨
缺点:实现麻烦,需要为每个方法建立并维护计数器,不能直接获取到方法的调用关系
HotSpot使用第二种,基于计数器的热点探测方法。
确定了检测热点代码的方式,如何计算具体的次数呢? 计数器的种类(两种共同协作)
方法调用计数器:这个计数器用于统计方法被调用的次数。默认阈值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次
回边计数器:统计一个方法中循环体代码执行的次数
两个计数器的协作(这里讨论的是方法调用计数器的情况):当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器加1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。如果已经超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
当编译工作完成之后,这个方法的调用入口地址就会被系统自动改成新的,下一次调用该方法时就会使用已编译的版本。 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay
来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime
参数设置半衰周期的时间,单位是秒。
JIT的默认门限,server模式默认10000 次,client是1500次。门限大小也存在着调优的可能,可以使用-XX:CompileThreshold=N
参数调整;与此同时,该参数还可以变相起到降低预热时间的作用。
JVM会周期性的对计数的数值进行衰减操作,所以并不会所有代码的调用计数器都能达到门限值,除了可以利用-XX:CompileThreshold=N
适当调整大小,还有一个办法就是使用-XX:-UseCounterDecay
关闭计数器衰减。
JIT编译的代码是存储在Code Cache中的,需要注意的是 Code Cache 是存在大小限制的,而且不会动态调整。这意味着,如果Code Cache太小,可能只有一小部分代码可以被JIT 编译,其他的代码则没有选择,只能解释执行。所以,一个潜在的调优点就是调整其大小限制 -XX:ReservedCodeCacheSize=
。
当然,也可以调整其初始大小-XX:InitialCodeCacheSize=
注意,在相对较新版本的Java中,由于分层编译(Tiered-Compilation)的存在,Code Cache的空间需求大大增加,其本身默认大小也被提高了。
Code Cache是一块独立于 Java堆之外的内存区域。除了JIT编译的代码之外,Java所使用的本地方法代码(JNI)也会存在codeCache中。
从Java7开始,HotSpot虚拟机默认采用分层编译的方式:热点方法首先被C1编译器编译,然后 热点方法中的热点再进一步被C2编译(理解为二次编译,根据前面的运行计算出更优的编译优化)。为了不干扰程序的正常运行,JIT编译时放在额外的线程中执行的,HotSpot根据实际CPU的资源,以 1:2的比例分配给C1和C2线程数。
JVM 的编译器线程数目与我们选择的模式有关,选择client模式默认只有一个编译线程,而server模式则默认是两个,如果是当前最普遍的分层编译模式,则会根据 CPU 内核数目计算 C1 和 C2 的数值,你可以通过-XX:CICompilerCount=N
参数指定的编译线程数。
在强劲的多处理器环境中,增大编译线程数,可能更加充分的利用 CPU 资源,让预热等过程更加快速;但是,反之也可能导致编译线程争抢过多资源,尤其是当系统非常繁忙时。例如,系统部署了多个 Java 应用实例的时候,那么减小编译线程数目,则是可以考虑的。
生产实践中,也有人推荐在服务器上关闭分层编译,直接使用 server 编译器,虽然会导致稍慢的预热速度,但是可能在特定工作负载上会有微小的吞吐量提高。