Java编译器中的优化技术

一、JIT技术

        Java中的热点代码主要有两类,包括:1、被多次调用的方法。 2、被多次执行的循环体。
前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为 热点代
是理所当然的。而后者则是为了解决当一个方法只被调用过一次或少量的几次,但是方法体内部存在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“ 热点代码”
        对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体。第一种情况,由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机 中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一 部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index BCI )。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“ 栈上替换 On Stack Replacement OSR ),即方法的栈帧还在栈上,方法就被替换了。
        读者可能还会有疑问,在上面的描述里,无论是“多次执行的方法 ,还是 多次执行的代码块 , 所谓“ 多次 只定性不定量,并不是一个具体严谨的用语,那到底多少次才算 多次 呢?还有一个问题,就是Java 虚拟机是如何统计某个方法或某段代码被执行过多少次的呢?解决了这两个问题,也就解答了即时编译被触发的条件。
        要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“ 热点探测 Hot
Spot Code Detection ),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种 ,分别是:
        基于采样的热点探测(Sample Based Hot Spot Code Detection )。采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“ 热点方法” 。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
        基于计数器的热点探测(Counter Based Hot Spot Code Detection )。采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“ 热点方法 。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。
这两种探测手段在商用 Java 虚拟机中都有使用到,譬如 J9 用过第一种采样热点探测,而在 HotSpot 虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数, HotSpot 为每个方法准备了两类计数器:方法调用计数器(Invocation Counter )和回边计数器( Back Edge Counter 回边 的意思就是指在循环边界往回跳转)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
        我们首先来看看方法调用计数器。顾名思义,这个计数器就是用于统计方法被调用的次数,它的默认阈值在客户端模式下是1500 次,在服务端模式下是 10000 次,这个阈值可以通过虚拟机参数 -XX :CompileThreshold来人为设定。当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。
        如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了,整个即时编译的交互过程如图11-3 所示。
        在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay ),而这段时间就称为此方法统计的半衰周期( Counter Half Life Time ),进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX - UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。

        现在我们再来看看另外一个计数器—— 回边计数器,它的作用是统计一个方法中循环体代码执行 的次数 ,在字节码中遇到控制流向后跳转的指令就称为 回边( Back Edge ,很显然建立回边计数 器统计的目的是为了触发栈上的替换编译。

        关于回边计数器的阈值,虽然HotSpot 虚拟机也提供了一个类似于方法调用计数器阈值 -XX
CompileThreshold 的参数 -XX BackEdgeThreshold 供用户设置,但是当前的 HotSpot 虚拟机实际上并未使用此参数,我们必须设置另外一个参数-XX OnStackReplacePercentage 来间接调整回边计数器的阈值,其计算公式有如下两种。
        虚拟机运行在客户端模式下,回边计数器阈值计算公式为:方法调用计数器阈值(-XX
CompileThreshold )乘以 OSR 比率( -XX OnStackReplacePercentage )除以 100 。其中 -XX
OnStackReplacePercentage 默认值为 933 ,如果都取默认值,那客户端模式虚拟机的回边计数器的阈值为13995。
        虚拟机运行在服务端模式下,回边计数器阈值的计算公式为:方法调用计数器阈值(-XX
CompileThreshold )乘以( OSR 比率( -XX OnStackReplacePercentage )减去解释器监控比率( -XX : InterpreterProfilePercentage)的差值)除以 100 。其中 -XX OnStack ReplacePercentage 默认值为 140 - XX: InterpreterProfilePercentage 默认值为 33 ,如果都取默认值,那服务端模式虚拟机回边计数器的阈值为10700
        当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如图11-4 所示。

二、方法内联

在前面的讲解中,我们多次提到方法内联,说它是编译器最重要的优化手段,甚至都可以不加
之一 。内联被业内戏称为优化之母,因为除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础,代码清单11-11 所示的简单例子就揭示了内联对其他优化手段的巨大价值:没有内联,多数其他优化都无法有效进行。例子里testInline() 方法的内部全部是无用的代码,但如果不做内联,后续即使进行了无用代码消除的优化,也无法发现任何“Dead Code” 的存在。如果分开来看,foo() testInline() 两个方法里面的操作都有可能是有意义的。代码清单11-11 未作任何优化的字节码

        方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“
到发起调用的方法之中,避免发生真实的方法调用而已。但实际上 Java 虚拟机中的内联过程却远没有想象中容易,甚至如果不是即时编译器做了一些特殊的努力,按照经典编译原理的优化理论,大多数的Java方法都无法进行内联。
        在java中只有使用invokespecial指令调用的私有方法、实例构造器、父类方法和使用invokestatic 指令调用的静态方法才会在编译期进行解析。除了上述四种方法之外(最多再除去被final 修饰的方法这种特殊情况,尽管它使用invokevirtual 指令调用,但也是非虚方法,《 Java 语言规范》中明确说明了这点),其他的 Java 方法调用都必须在运行时进行方法接收者的多态选择,它们都有可能存在多于一个版本的方法接收者,简而言之,Java 语言中默认的实例方法是虚方法。
        对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本,以将代码清单11-7中所示 b.get() 直接内联为 b.value 为例,如果不依赖上下文,是无法确定 b 的实际类型是什么的。假如有ParentB SubB 是两个具有继承关系的父子类型,并且子类重写了父类的 get() 方法,那么 b.get() 是执行父类的get() 方法还是子类的 get() 方法,这应该是根据实际类型动态分派的,而实际类型必须在实际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。
        更糟糕的情况是,由于Java 提倡使用面向对象的方式进行编程,而 Java 对象的方法默认就是虚方法,可以说Java 间接鼓励了程序员使用大量的虚方法来实现程序逻辑。根据上面的分析可知,内联与虚方法之间会产生“ 矛盾 ,那是不是为了提高执行性能,就应该默认给每个方法都使用 final 关键字去修饰呢?C C++ 语言的确是这样做的,默认的方法是非虚方法,如果需要用到多态,就用 virtual 关键字来修饰,但Java 选择了在虚拟机中解决这个问题。
        为了解决虚方法的内联问题,Java 虚拟机首先引入了一种名为类型继承关系分析( Class Hierarchy Analysis, CHA )的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。这样,编译器在进行内联时就会分不同情况采取不同的处理:如果是非虚方法,那么直接进行内联就可以了,这种的内联是有百分百安全保障的;如果遇到虚方法,则会向CHA 查询此方法在当前程序状态下是否真的有多个目标版本可供选择,如果查询到只有一个版本,那就可以假设“ 应用程序的全貌就是现在运行的这个样子” 来进行内联,这种内联被称为守护内联( Guarded Inlining )。不过由于Java 程序是动态连接的,说不准什么时候就会加载到新的类型从而改变 CHA 结论,因此这种内联属于激进预测性优化,必须预留好“ 逃生门 ,即当假设条件不成立时的 退路 Slow Path )。假如在程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
        假如向CHA 查询出来的结果是该方法确实有多个版本的目标方法可供选择,那即时编译器还将进行最后一次努力,使用内联缓存(Inline Cache )的方式来缩减方法调用的开销。这种状态下方法调用是真正发生了的,但是比起直接查虚方法表还是要快一些。内联缓存是一个建立在目标方法正常入口之前的缓存,它的工作原理大致为:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者的版本。如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存(Monomorphic Inline Cache)。通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。但如果真的出现方法接收者不一致的情况,就说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存(Megamorphic Inline Cache ),其开销相当于真正查找虚方法表来进行方法分派。
  
        所以说,在多数情况下Java 虚拟机进行的方法内联都是一种激进优化。事实上,激进优化的应用在高性能的Java 虚拟机中比比皆是,极为常见。除了方法内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“ 移除 ,如果真的出现了小概率事件,这时才会从 逃生门 回到解释状态重新执行。

三、逃逸分析

        逃逸分析(Escape Analysis )是目前 Java 虚拟机中比较前沿的优化技术,它与类型继承关系分析一 样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
        逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
        
        如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:
        1、栈上分配( Stack Allocations ):在 Java 虚拟机中, Java 堆上分配创建对象的内存空间几乎是 Java程序员都知道的常识, Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
        2、标量替换(Scalar Replacement ):若一个数据已经无法再分解成更小的数据来表示了, Java 虚拟机中的原始数据类型(int long 等数值类型及 reference 类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate ), Java中的对象就是典型的聚合量。如果把一个Java 对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
        3、同步消除(Synchronization Elimination ):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。
        关于逃逸分析的研究论文早在1999 年就已经发表,但直到 JDK 6 HotSpot才开始支持初步的逃逸分析,而且到现在这项优化技术尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。如果要百分之百准确地判断一个对象是否会逃逸,需要进行一系列复杂的数据流敏感的过程间分析,才能确定程序各个分支执行时对此对象的影响。前面介绍即时编译、提前编译优劣势时提到了过程间分析这种大压力的分析算法正是即时编译的弱项。可以试想一下,如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象,那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析。
        C和 C++ 语言里面原生就支持了栈上分配(不使用 new 操作符即可),而 C# 也支持值类型,可以很自然地做到标量替换(但并不会对引用类型做这种优化)。在灵活运用栈内存方面,确实是Java 的一个弱项。在现在仍处于实验阶段的Valhalla 项目里,设计了新的 inline 关键字用于定义 Java 的内联类型,目的是实现与C# 中值类型相对标的功能。有了这个标识与约束,以后逃逸分析做起来就会简单很多。
        下面笔者将通过一系列Java 伪代码的变化过程来模拟逃逸分析是如何工作的,向读者展示逃逸分析能够实现的效果。初始代码如下所示:
        此处笔者省略了Point 类的代码,这就是一个包含 x y 坐标的 POJO 类型,读者应该很容易想象它的样子。
第一步,将 Point 的构造函数和 getX() 方法进行内联优化:
        第二步,经过逃逸分析,发现在整个test() 方法的范围内 Point 对象实例不会发生任何程度的逃逸, 这样可以对它进行标量替换优化,把其内部的x y 直接置换出来,分解为 test() 方法内的局部变量,从而避免Point 对象实例被实际创建,优化后的结果如下所示:
        第三步,通过数据流分析,发现py 的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果,如下所示:

 

        从测试结果来看,实施逃逸分析后的程序在MicroBenchmarks 中往往能得到不错的成绩,但是在实际的应用程序中,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或分析过程耗时但却无法有效判别出非逃逸对象而导致性能(即时编译的收益)下降,所以曾经在很长的一段时间里,即使是服务端编译器,也默认不开启逃逸分析 [2] ,甚至在某些版本(如 JDK 6 Update 18 )中还曾经完全禁止了这项优化,一直到JDK 7 时这项优化才成为服务端编译器默认开启的选项。如果有需要,或者确认对程序运行有益,用户也可以使用参数-XX +DoEscapeAnalysis 来手动开启逃逸分析, 开启之后可以通过参数-XX +PrintEscapeAnalysis 来查看分析结果。有了逃逸分析支持之后,用户可以使用参数-XX +EliminateAllocations 来开启标量替换,使用 +XX +EliminateLocks 来开启同步消除,使用参数-XX +PrintEliminateAllocations 查看标量的替换情况。
        尽管目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进方向,在日后的Java 虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。

四、公共子表达式消除

        公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直 接用前面计算过的表达式结果代替E 。如果这种优化仅限于程序基本块内,便可称为局部公共子表达 式消除(Local Common Subexpression Elimination ),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination )。下面举个简单的例子来说明它 的优化过程,假设存在如下代码:
        如果这段代码交给Javac 编译器则不会进行任何优化,那生成的代码将如代码清单 11-12 所示,是完全遵照Java 源码的写法直译而成的。
代码清单 11-12 未作任何优化的字节码
        当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到c*b b*c 是一样的表达式,而且在计算期间b c 的值是不变的。
因此这条表达式就可能被视为:
        这时候,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化 ——代数化简( Algebraic Simplification ),在 E 本来就有乘法运算的前提下,把表达式变为:

 五、数组边界检查消除

        数组边界检查消除(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) 之内,那么在循环中就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。  
        把这个数组边界检查的例子放在更高的视角来看,大量的安全检查使编写Java 程序比编写 C
C++ 程序容易了很多,比如:数组越界会得到 ArrayIndexOutOfBoundsException 异常;空指针访问会得到NullPointException 异常;除数为零会得到 ArithmeticException 异常 …… C C++ 程序中出现类似的问题,一个不小心就会出现Segment Fault 信号或者 Windows 编程中常见的 “XXX 内存不能为Read/Write”之类的提示,处理不好程序就直接崩溃退出了。但这些安全检查也导致出现相同的程序, 从而使Java C C++ 要做更多的事情(各种检查判断),这些事情就会导致一些隐式开销,如果不处 理好它们,就很可能成为一项“Java 语言天生就比较慢 的原罪。为了消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提前到编译期完成的思路之外,还有一种避开的处理思路—— 隐式异常处理,Java 中空指针检查和算术运算中除数为零的检查都采用了这种方案。举个例子,程序中访问一个对象(假设对象叫foo )的某个属性(假设属性叫 value ),那以 Java 伪代码来表示虚拟机访问foo.value 的过程为:

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

        虚拟机会注册一个Segment Fault 信号的异常处理器(伪代码中的 uncommon_trap() ,务必注意这里 是指进程层面的异常处理器,并非真的Java try-catch 语句的异常处理器),这样当 foo 不为空的时 候,对value 的访问是不会有任何额外对 foo 判空的开销的,而代价就是当 foo 真的为空时,必须转到异 常处理器中恢复中断并抛出NullPointException 异常。进入异常处理器的过程涉及进程从用户态转到内 核态中处理的过程,结束后会再回到用户态,速度远比一次判空检查要慢得多。当foo 极少为空的时 候,隐式异常优化是值得的,但假如foo 经常为空,这样的优化反而会让程序更慢。幸好 HotSpot 虚拟机足够聪明,它会根据运行期收集到的性能监控信息自动选择最合适的方案。

猜你喜欢

转载自blog.csdn.net/weixin_55229531/article/details/131945220