Jvm笔记总结(十三):HotSpot中的JIT编译器——Java运行期优化

PS : 本文乃学习整理参考而来 ,目录参考 [ Jvm系列目录 ]


        在主流商用虚拟机中,HotSpotJ9可以采用混合模式(解释器与编译器配搭使用),而JRockit内部没有解释器,采用纯编译模式。
        Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块运行的特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成为本地平台相关的机器码,并进行优化,而完成这个任务的编译器称为及时编译器Just In Time Compiler简称JIT)。

        即时编译器并不是虚拟机的必须部分,因为在Java虚拟机规范当中没有规定必须要有即时编译器的存在,更没有限定或指导及时编译器应该如何去实现。但现在即时编译器编译的性能好坏代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一。如下几个问题:

1.为何HotSpot虚拟机要使用解释器编译器并存的架构。
2.为何HotSpot虚拟机要实现两个不同的即时编译器
3.程序何时使用解释器执行,何时使用编译器执行.
4.哪些程序代码会被编译为本地代码,如何编译为本地代码。


解释器与编译器

解释器优势:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行。同时解释器还可作为编译器激进优化时的一个“逃生门”,当激进优化假设不成立时可退回到解释状态继续执行。

编译器优势编译之后得到优化后的本地代码执行效率高,但优化耗时较长,且占用内存资源较大


        HotSpot虚拟机中内置了两个即时编译器,分别为Client CompilerServer Compiler或者简称为C1编译器和C2编译器。
程序选用哪个编译器,取决于虚拟机运行模式(现一般都是server模式)。无论采用的编译器是Client Compile还是Server Compile,解释器与编译器搭配使用的方式称为“混合模式(Mixed Mode)”,用户可以使用参数“-Xint”强制虚拟机运行于解释模式(Interpreted Mode),这时编译器完全不介入工作。也可使用参数“-Xcomp”强制运行与“编译模式(Compiled Mode)”,这时将优先采用编译方式执行程序,但解释器仍然在编译无法进行时介入执行过程。

        为了程序启动响应速度运行效率之间达到最佳平衡HotSpot虚拟机还会逐渐启用分层编译(Tiered Compilation)的策略,在JDK1.7的Server模式中分层编译作为默认策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次:

第0层:程序解释执行,解释器不开启性能监控,可触发第一层编译。
第1层:也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化
第2层:也称为C2编译,也是将字节码编译为本地代码,但会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠激进优化

分层编译中,Client CompilerServer Compiler将会同时工作,用Client Compiler获取更高的编译速度,用Server Compiler来获取更好的编译质量。


编译对象与触发条件

当被确定为“热点代码”时会触发即时编译,有以下两种运行情况确定为“热点代码”:

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

判断一段代码是不是热点代码的主要方式有两种

1.基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性的检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是“热点方法”。
2.基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定阈值就认为是“热点方法”。

HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,因此他为每个方法准备两类计数器方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

多次
方法调用计数器:该计数器用于统计方法被调用的次数,他的默认阈值Client模式下是1500次,在Server模式下是10000次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来设定

        当一个方法被调用时,会先检查该方法是否存在JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在,则将此方法的调用计数器加1,然后判断方法调用计数器回边计数器值之和是否超过调用计数器的阈值如果超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。如果不做任何设置,执行引擎并不会同步等待编译请求完成而是继续进入解释器按照解释方式执行字节码。当编译工作完成之后,这个方法的调用入口地址就被系统自动改成新的,下一次调用该方法时就会使用已编译的版本。如果不做任何设置,方法调用计数器统计不是方法被调用的绝对次数当超过一定的时间限度,如果方法的调用次数依然不足以让他提交给即时编译器,那这个方法的调用计数器就会被减少一半这称为热度衰减,这段时间就称为半衰周期可以使用虚拟机参数关闭热度衰减,或者设置半衰周期的时间。

回边计数器:与方法调用计数器不同,回边计数器没有热度衰减,因此这个计数器统计的就是该方法循环执行的绝对次数


编译优化技术(JIT编译)

Java程序员有一个共识,以编译方式执行本地代码比解释方式更快,之所有有这样的共识,出去虚拟机解释执行字节码额外消耗时间的原因,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在即时编译器之中。以下是HotSpot的即时编译器在生成代码时采用的部分代码优化技术

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

1.公共子表达式消除:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那E的这次出现就成为了公共子表达式
例:int d = ( c * b ) * 12 + a + ( a + b * c);
当这段代码经过虚拟机即时编译器后,他将进行如下优化:减少了栈上操作。
int d = E * 12 + a + ( a + E);
有肯还会进行另一种优化:代数化简
int d = E * 13 + a * 2;

2.数组边界检查消除:在Java语言中访问数组元素foo[i]的时候系统将会自动进行上下界的返回检查,即检查i必须满足 i>= 0 && i < foo.length否则将抛出一个运行时异常:ArrayIndexOutOfBoundsException。这对于开发者来说是一件好事但对于虚拟机来说每次数组元素的读写有一次隐含的条件判定操作,对于有大量数组访问的程序代码无疑是一种性能负担为了安全数组边界检查是必须做的,但数组边界检查是不是必须在运行期间一次不漏的检查则是不一定的。数组下标是一个常量,如foo[3],在编译期根据数据流分析来确定foo.length的值,并判断没有越界执行的时候就无需判断了。又如在数组访问发生在循环之中,如果编译期只要通过数据流分析可以判断循环变量的取值范围永远在[0,foo.length)中,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断

3.方法内联减少方法调用成本,为其他优化手段建立基础。
如下代码:

 static class B{
        int value;
        final int getValue(){
            return value;
        }
    }

    public void foo(){
        B b = new B();
        int x = b.getValue();
    }

进行方法内联优化后得到如下代码:

 public void foo(){
        B b = new B();
        int x = b.value;
    }

        方法内联优化行是把目标方法的代码“复制”到发生调用的方法之中,避免发生真实的方法调用可减少栈帧入出栈)。且只有使用invokespecial指令调用的私有方法、实例构造器、父类方法以及使用invokestatic指令进行调用的静态方法才是在编译期进行解析的(以及final),除了上述4中方法非虚方法)之外,其他的Java方法调用(虚方法)都要在运行进行时进行方法接受者多态选择默认的实例方法都是虚方法
        因为对于虚方法,编译器内联时无法确定应该使用哪个方法版本。但Java提倡使用面向对象的方式编程,而Java对象的方法默认就是虚方法,因此出现了与上述内联优化的选择出现了“矛盾”。(可使用final将对象方法进行非虚化也是一种方法)。
        为了解决虚方法的内联问题,虚拟机设计团队相处了很多办法。其中引入了一种名为“类型继承关系分析(Class Hierarchy Analysis,CHA)” 的技术,这是一种基于整个应用程序的类型分析技术,用于确定在已加载的类中某个接口是否有多余一种实现某个类是否存在子类子类是否为抽象类等。
        编译器在进行内联时,如果是非虚方法,就直接进行内联这种内联是安全的。如果是虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果查询只有一个版本也可以进行内联。不过这种内联属于激进优化为什么一个版本还属于激进优化?可能是由于Class文件来源不同可能来源于自定义加载器(运行时来源于网络,而不在本地),导致不在一个编译器中无法进行肯定),需要预留一个“逃生门”,称为守护内联如果在程序后续的执行中,虚拟机一直没有加载到令这个方法的接受者的继承关系发生变化的类,那这个内联优化会一直使用如果加载了导致继承关系发生变化的新类则抛弃已编译的代码退回到解释状态执行,或者重新编译。如果向CHA查询结果是有多个目标方法可供使用,编译器还会进行最后一次努力,使用内联缓存(Inline Cache)的方法来进行内联优化。其大概原理:在方法调用前,内联缓存为空,当第一次调用发生后缓存记录下接收者的版本信息,并且每次调用方法时比较接收者信息如果一致,则沿用内联优化如果不同,则会取消内联查找虚方法表进行方法分派
        虚拟机的内联优化一般都是激进型的,如果出现问题,会使用逃生门回到解释状态执行

4.逃逸分析(Escape Analysis):或称为对象的逃逸分析。逃逸分析是Java虚拟机中较前沿的优化技术,他与类型继承关系分析(CHA)一样,并不是代码直接的优化手段,而是为其他优化手段提供依据分析技术
        逃逸分析的基本行为就是分析对象的作用域:当一个对象在方法中被定义以后,他可能被外部方法引用,例如作为调用参数传递到其他方法中称为方法逃逸。甚至还有可能被外部线程访问到譬如赋值给类变量可以在其他线程中访问的实例变量,称为线程逃逸。如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可以为这个变量进行一些高效的优化如:

1.栈上分配(Stack Allocation)虚拟机的垃圾收集系统(主要针对堆),无论是筛选可回收对象,还是回收整理内存都需要耗费时间如果确定一个对象不会逃逸出方法之外,那就可以让对象在栈上分配内存,对象所占用的内存空间就可以随栈帧出栈而销毁。而在一般应用中,不会逃逸的局部对象所占比例很大,如果能使用栈上分配,垃圾收集系 统压力将会小很多。
2.同步消除线程同步本身是一个相对耗时的过程,如果逃逸分析能确定一个变量不会逃逸出线程,无法被其他线程访 问,那这个变量的读写肯定不会有竞争对这个变量实施的同步措施可以消除掉
3.标量替换:标量是指一个数据已经无法再分解成更小的数据来表示,如Java中的原始数据类型(int、long…)都不 能在进一步分解,他们就可以称为标量。相对的,如果一个数据可以继续分解,那它就乘坐聚合量,Java中的对象就是 典型的聚合量。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候可能不创建这个对象,而改为直接创建他的若干个被这个方法使用的成员变量来代替


对于上文提出的4个问题:
1.为何HotSpot虚拟机要使用解释器编译器并存的架构?
解:解释执行用于需要迅速启动和执行时,消耗时间短。且作为编译器激进优化的“逃生门”。而编译器是为了更好的优化代码,提高执行效率,但优化时间一般会比较长。

2.为何HotSpot虚拟机要实现两个不同的即时编译器
解:C1和C2编译器从优化的程度上是不一样的,C1进行简单、可靠的优化,耗时短。C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。C1同时提供运行时性能监控信息

3.程序何时使用解释器执行,何时使用编译器执行
解:一般在代码触发编译条件以前会使用解释执行,随着运行的时间增长,当触发编译条件时,即时编译器会对代码进行编译,而触发编译器的条件是代码执行的次数够多(达到阈值)。解释执行也在编译器进行激进优化不成立的时候,作为逃生门对代码进行解释执行。

4.哪些程序代码会被编译为本地代码,如何编译为本地代码
解:触发了即时编译条件的代码会被编译为本地代码。随着时间的增长,大部分代码会被编译为本地代码,增加执行效率。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐