深入理解java虚拟机----第十一章晚期优化

11.1 概述

    在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码” (Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文中简称 JIT 编译器)。

        即时编译器并不是虚拟机必需的部分,Java 虚拟机规范并没有规定 Java 虚拟机内必需要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一,它也是虚拟机内中最核心且最能体现虚拟机技术水平的部分。在本章中,我们将走进虚拟机的内部,探索即时编译器的运作过程。

       由于Java虚拟机规范没有具体的约束规则去限制即时编译器应该如何实现,所以这部分功能完全是与虚拟机具体实现(Implementation Specific)相关的内容,如无特殊说明,本章提及的编译器、即时编译器都是指 HotSpot 虚拟机内部的即时编译器,虚拟机也是特指 HotSpot 虚拟机。不过,本章的大部分内容是描述即时编译器的行为,涉及编译器实现层面的内容较少,而主流虚拟机中即时编译器的行为又有很多相似和想通之处,因此,对其他虚拟机来说也具有较高的参考意义。

11.2 HotSpot 虚拟机内的即时编译器

问题

为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?

为何 HotSpot 虚拟机要实现两个不同的即时编译器?

程序何时使用解释器执行?何时使用编译器执行?

哪些程序代码会被编译为本地代码?如何编译为本地代码?

如何从外部观察即时编译器的编译过程和编译结果?

11.2.1 解释器和编译器

HotSpot虚拟机采用解释器与编译器并存的架构,解释器与编译器两者各有优势:

1   当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行

2    在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率

3    当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率

4 解释器还可以作为编译器激进优化的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立时,可以通过逆优化退回到解释状态继续执行

11.2.2 编译对象与触发条件

在运行过程中会被即时编译器编译的 “热点代码” 有两类,即:

  被多次调用的方法。

  被多次执行的循环体。

判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种(注:还有其他热点代码的探测方式,如基于“踪迹”(Trace)的热点探测再最近相当流行,像 Firefox 中的 TraceMonkey 和 Dalvik 中新的 JIT 编译器都用了这种热点探测方式),分别如下。
基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是 “热点方法”。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是 “热点方法”。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。

在HotSpot虚拟机中使用的是第二种,因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都由一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。

方法调用计数器:用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server模式在是10000次,可通过-XX: CompileThreshold来设定。

回边计数器:用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。

11.2.3 编译过程

 在默认设置下,无论是方法调用产生的即时编译请求,还是 OSR 编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。(书上将了很多我感觉知道这一点就行了 详细的可以看书)。

11.2.4 查看及分析即时编译结果

不总结这一点了。

11.3 编译优化技术

11.3.1 优化技术概览

static class B{  
   int value;  
    final int get() {  
        return value;  
    }  
}  
public void foo() {  
    y = b.get();  
    // ...do stuff...  
    z = b.get();  
    sum = y + z;  
}  

首先需要明确的是,这些代码优化变换是建立在代码的某种中间表示或机器码之上的,绝不是建立在Java 源码之上的,为了展示方便,笔者使用了 Java 语言的语法来表示这些优化技术所发挥的作用。


        代码清单11-6 的代码已经非常简单了,但是仍然有许多优化的余地。第一步进行方法内联(Method Inlining),方法内联的重要性要高于其他优化措施,它的主要目的有两个,一是去除方法调用的成本(如建立栈帧等)。二是为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。因此,各种编译器一般都会把内联优化放在优化序列的最靠前位置。

public void foo() {  
    y = b.value;  
    // ...do stuff...  
    z = b.value;  
    sum = y + z;  
}  

第二步进行冗余访问消除(Redundant Loads Elimination),假设代码中间注释掉的 “do stuff...” 所代表的操作不会改变 b.value 的值,那就可以把 “z = b.value” 替换为 “z = y”,因为上一句 “y = b.value” 已经保证了变量 y 与 b.value 是一致的,这样就可以不再去访问对象 b 的局部变量了。如果把 b.value 看做是一个表达式,那也可以把这项优化看成是公共子表达式消除。

public void foo() {  
    y = b.value;  
    // ...do stuff...  
    z = y;  
    sum = y + z;  
}  

 第三步我们进行复写传播(Copy Propagation),因为在这段程序的逻辑中并没有必要使用一个额外的变量 “z”,它与变量 “y” 是完全相等的,因此可以使用 “y” 来代替 “z”。

public void foo() {  
    y = b.value;  
    // ...do stuff...  
    y = y;  
    sum = y + y;  
}  

 第四步我们进行无用代码消除(Dead Code Elimination)。无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码,因此,它又形象地称为 “Dead Code”,“y = y” 是没有意义的。

public void foo() {  
    y = b.value;  
    // ...do stuff...  
    sum = y + y;  
}  

11.3.2 公共子表达式消除

公共子表达式消除是一个普遍应用与各种编译器的经典优化技术,它的含义是:

1  如果一个表达式E已经计算过了,并且从先前的计算到现在E中的所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。

2  对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果替代E就可以了。

3  如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。

iload_2     // b  
imul        // 计算b * c  
bipush 12   // 推入12  
imul        // 计算(c * b)*12  
iload_1     // a  
iadd        // 计算(c * b)*12+a  
iload_1     // a  
iload_2     // b  
iload_3     // c  
imul        // 计算b * c  
iadd        // 计算a+b * c  
iadd        // 计算(c * b)*12+a+(a+b * c)  
istore 4  

当这段代码进入到虚拟机即时编译器后,它将进行如下优化:编译器检测到 “c*b” 与 “b*c” 是一样的表达式,而且在计算期间 b 与 c 的值是不变的。因此,这条表达式就可能被视为:

int d = E * 12 + a + (a + E);  

 这时,编译器还可能(取决于那种虚拟机的编译器以及具体的上下文而定)进行另外一种优化:代数化简(Algebraic Simplification),把表达式变为:

int d = E * 13 + a * 2;   书中推荐去读紫龙树 我也没读过。。。

11.3.3 数组边界检查消除

       数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。我们知道 Java 语言是一门动态安全的语言,对数组的读写访问也不像 C、C++ 那样在本质上是裸指针操作。如果有一个数组 foo[],在 Java 语言中访问数组元素 foo[i] 的时候系统将会自动进行上下界的范围检查,即检查 i 必须满足 i >= 0 && i < foo.length 这个条件,否则将会抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException。这对软件开发者来说是一件很好的事情,即使程序员没有专门编写防御代码,也可以避免大部分的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担。

无论如何,为了安全,数组边界检查肯定是必须做的,但数组边界是不是必须在运行期间一次不漏地检查则是可以 “商量” 的事情。例如下面这个简单的情况:数组下标是一个常量,如 foo[3],只要在编译期根据数组流分析来确定 foo.length 的值,并判断下标 “3” 没有越界,执行的时候就无须判断了。更加常见的情况是数组访问发生在循环之中,并且使用循环遍历来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0, foo.length)之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。

if (foo != null) {  
    return foo.value;  
else {  
    throw new NullPointException();  
}  

 在使用隐式异常优化之后,虚拟机会把上面伪代码所表示的访问过程变为如下伪代码。

try {  
     return foo.value;  
} catch (segment_fault) {  
    uncommon_trap();  
}  

11.3.4 方法内联

方法内联是编译器最重要的优化手段之一,除了消除方法调用成本之外,更重要的意义是为其他优化手段建立良好的基础。方法内联的优化行为只不过是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上java虚拟机中的内联过程远远没有那么简单,因为java中的方法大多数是虚方法,虚方法在编译期做内联的时候根本无法确定应该使用哪个方法版本

对此java虚拟机设计团队想了很多办法,首先是引入了一种名为“类型继承关系分析”(Class Hierarchy Analysis, CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多余一种的实现,某个类是否存在子类、子类是否为抽象类等信息

编译器在进行内联:
* 非虚方法:直接进行内联,这时候的内联是有稳定前提保障的
* 虚方法:向CHA查询此方法只在当前程序下是否有多个目标版本可供选择
* 只有一个:可以进行内联,不过这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联(Guarded Inlining)。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联游湖的代码就可以一直使用下去。否则,就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译
* 有多个版本:编译器还将进行最后一次努力,使用内联缓存(Inline Cache)来完成方法内联。工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。

11.3.4 逃逸分析

逃逸分析(Escape Analysis)是目前java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。其基本行为是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,如作为调用参数传递到其他方法中,称为方法逃逸;被外部线程访问到,称为线程逃逸

如果能证明一个对象不会逃逸到方法或线程之外,则可能为这个变量进行一些高效的优化:

栈上分配(Stack Allocation):将对象在栈上分配内存,这样就可以使对象所占内存空间随栈帧出栈而销毁,减小垃圾收集系统的压力。

同步消除(Synchronization Elimination):对象无法被其他线程访问,这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以消除掉。

标量替换(Scalar Replacement):标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。

11.4 java与c/c++的编译器对比

 大多数程序员都认为 C/C++ 会比 Java 语言块,甚至觉得从 Java 语言诞生以来 “执行速度缓慢” 的帽子就应当扣在它的头顶,这种观点的出现是由于 Java 刚出现的时候即时编译技术还不成熟,主要靠解释器执行的 Java 语言性能确实比较低下。但目前即时编译技术已经十分成熟,Java 语言有可能在速度上与 C/C++ 一争高下吗?要想知道这个问题的答案,让我们从两者的编译器谈起。

        Java 与 C/C++ 的编译器对比实际上代表了最经典的即时编译器与静态编译器的对比,很大程度上也决定了 Java 与 C/C++ 的性能对比的结果,因为无论是 C/C++ 还是 Java 代码,最终编译之后被机器执行的都是本地机器码,哪种语言的性能更高,除了它们自身的 API 库实现得好坏以外,其余的比较就成了一场 “拼编译器” 和 “拼输出代码质量” 的游戏。当然,这种比较也是剔除了开发效率的片面对比,语言间孰优孰劣、谁块谁慢的问题都是很难有结果的争论,下面我们就回到正题,看看这两种语言的编译器各有何种优势。

        Java 虚拟机的即时编译器与 C/C++ 的静态优化编译器相比,可能会由于下列这些原因而导致输出的本地代码有一些劣势(下面列举的也包括一些虚拟机执行子系统的性能劣势):

        第一,因为即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。如果编译速度不能达到要求,那用户将在启动程序或程序的某部分察觉到重大延迟,这点使得即时编译器不敢随便引入大规模的优化技术,而编译的时间成本在静态优化编译器中并不是主要的关注点。

        第二,Java 语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。从实现层面上看,这就意味着虚拟机必须频繁地进行动态检查,如实例方法访问时检测空指针、数组元素访问时检测上下文范围、类型转换时检测继承关系等。对于这类程序代码没有明确写出的检查行为,尽管编译器会努力进行优化,但是总体上仍然要消耗不少的运行时间。

        第三,Java 语言中虽然没有 virtual 关键字,但是使用虚方法的频率却远远大于 C/C++ 语言,这意味着运行时对方法接收者进行多态选择的频率要远远大于 C/C++ 语言,也意味着即时编译在进行一些优化(如前面提到的方法内联)时的难度要远大于 C/C++ 的静态优化编译器。

        第四,Java 语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,这使得很多全局的优化都难以进行,因为编译器无法看见程序的全貌,许多全局的优化措施都只能以激进优化的方式来完成,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化。

        第五,Java 语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配。而 C/C++ 的对象则有多种内存分配方式,既可能在堆上分配,又可能在栈上分配,如果可以在栈上分配线程私有的对象,将减轻内存回收的压力。另外,C/C++ 中主要由用户程序代码来回收分配的内存,这就不存在无用对象筛选的过程,因此效率上(仅指运行效率,排除了开发效率)也比垃圾收集机制要高。

        上面说了一大堆 Java 语言相对 C/C++ 的劣势,不是说 Java 就真的不如 C/C++ 了,相信读者也注意到了,Java 语言的这些性能上的劣势都是为了换取开发效率上的优势而付出的代价,动态安全、动态扩展、垃圾回收这些 “拖后腿” 的特性都是为 Java 语言的开发效率做出了很大贡献。

11.5 本章小结

在本章中,我们着重了解了虚拟机的热点探测方法、HotSpot的即时编译器、编译触发条件,以及如何从虚拟机外部观察和分析JIT编译的数据和结果,还选择了几种常见的编译期优化技术进行讲解。对java编译器的深入了解,有助于在工作中分辨哪些代码是编译器可以帮我们处理的,哪些代码需要自己调节以便更适合编译器的优化。

猜你喜欢

转载自blog.csdn.net/qq_40182703/article/details/81278424