0%

JVM10-运行期优化

Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为『热点代码』。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器。

HotSpot虚拟机内的即时编译器

解释器与编译器

HotSpot虚拟机是采用解释器与编译器并存的架构。解释器和编译器各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。

编译对象与触发条件

在运行过程中被即时编译器编译的『热点代码』有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

前者很好理解,一个方法被调用得多了,方法体内代码执行的次数自然就多,他成为”热点代码”也是理所当然。而后者则是为了解决一个方法只被调用过一次或者少量的几次,但是方法体内部存在循环次数较多的循环体问题,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是”热点代码”。

判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测,目前主要有两种方式:

  • 基于采样的热点探测
  • 基于计数器的热点探测

HotSpot是使用的第二种,基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阀值,超过这个阀值,就会触发JIT编译。

方法调用计数器

这个计数器就是统计方法被调用的次数,默认阀值在Client模式下是1500次,在Server模式下是10000次,这个阀值可以通过虚拟机参数-XX:CompileThreshold来设置。
当一个方法被调用时,会检查方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器和回边计数器值之和是否超过方法调用计数器的阈值。如果已经超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是直接进入解释器按照解释方法执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会少一半,这个过程称为方法的调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾回收时顺便进行的,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

回边计数器

它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为”回边”。显然,建立回边计数器统计的目的就是为了触发OSR编译。

关于回边计数器的阈值,虽然HotSpot也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold的参数-XX:BackEdgeThreshold供用户设置,但是当前虚拟机实际上并未使用此参数,因此我们需要设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值,其计算公式如下:

  1. Client模式
    方法调用计数器阈值 × OSR比率 / 1000,其中OSR比率默认值933,如果都取默认值,Client模式下回边计数器的阈值应该是13995

  2. Server模式
    方法调用计数器阈值 × (OSR比率 - 解释器监控比率) / 100,其中OSR比率默认140,解释器监控比率默认33,如果都取默认值,Server模式下回边计数器阈值应该是10700

当解释器遇到一条回边指令时,会先查找将要执行的代码片段中是否有已经编译好的版本,如果有,它将会优先执行已编译好的代码,否则就把回边计时器的值加1,然后判断方法调用计数器与回边计数器值之和是否已经超过回边计数器的阈值。当超过阈值之后,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。

与方法计数器不同,回边计数器没有热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

编译过程

在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成的时候,都仍然按照解释方式继续执行,而编译动作则在后台的编译线程中进行。用户可以通过-XX:-BackgroundCompilation来禁止后台编译,在禁止后台编译后,一旦达到JIT的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。

对于Client Compiler(C1编译器)来说,它是一个简单快速的三段式编译,主要关注点在于局部性的优化,而放弃了许多耗时间长的全局优化手段。

对于Sever Compiler(C2编译器)来说,它则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到GNU C++编译器使用-O2参数时的优化强度,它会执行所有经典的优化动作,如无用代码消除、循环展开、常量传播、基本块重排序等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除、空值检查消除等,另外,还有可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等,下一部分将讲解上述的一部分优化手段。

Server Compiler从即时编译的标准来看,无疑是比较缓慢的,但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用Server模式的虚拟机运行。

优化技术概览

在Sun官方的Wiki上,HotSpot虚拟机设计团队列出了一个相对比较全面、在即时编译器中采用的优化技术列表,其中有不少经典编译器的优化手段,也有许多针对Java语言(准确地说是运行在Java虚拟机上得所有语言)本身进行的优化技术,下面主要看几项最有代表性的优化技术:

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

公共子表达式消除

公共子表达式消除的含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面的计算结果代替E就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。下面举例说明:

int d = (c * b) * 12 + a + (a + b * c);

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

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

还有可能进行代数简化:

int d = E * 13 + a * 2

表达式进行交换之后,再计算起来就可以节省一些时间了。

数组边界检查消除

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

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

方法内联

它是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。

1
2
3
4
5
6
7
8
9
10
public static void foo(Object obj){
if(obj!=null) {
System.out.println("do something");
}
}

public static void testInline(String[] args) {
Object = null;
foo(obj);
}

采用方法内联后大致成这样

1
2
3
4
5
6
public static void testInline(String[] args) {
Object = null;
if(obj!=null) {
System.out.println("do something");
}
}

因为有了方法内联的基础,这段代码还能被继续优化,删除不可能被执行的dead code。
只有非虚方法才能直接内联,虚方法需要运行时确定调用目标,所以虚拟机还有一套“类型继承关系分析”的技术来确定目前已加载的类中,某个接口是否有多余一种的实现,某个类是否存在子类、子类是否为抽象类等信息。

逃逸分析

逃逸分析是目前Java虚拟机中比较前沿的优化技术,他与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。逃逸分析的基本行为就是分析对象动态作用域。

当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高校的优化。如下:

  • 栈上分配:Java虚拟机中,对象在堆上分配,Java堆中的对象对于各个线程都是共享可见的。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象还是回收和整理内存都要耗费时间。如果确定一个对象不会逃逸出方法之外,那么让这个对象在栈上分配将会是一个不错的主意,对象所占用的内存空间就可以随着栈帧出栈而销毁,这样垃圾收集系统的压力将会小很多。
  • 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。
  • 标量替换:标量是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等)都不能进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解那它就称作聚合量,Java中的对象就是最典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。

关于逃逸分析的论文在1999年就已经发表,但直到Sun SDK1.6才实现了逃逸分析,而且直到现在这项优化尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是不能保证逃逸分析的性能收益必定高于它的消耗。虽然在实际测试结果中,实施逃逸分析后的程序往往能运行出不错的成绩,但是在实际的应用程序,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或因分析过程耗时但却无法有效判别出非逃逸对象而导致性能有所下降。

如果有需要,并且确认对程序运行有益,可以使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析,开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。有了逃逸分析支持之后,就可以使用参数-XX:+EliminateAllocations来开启标量替换,使用参数-XX:+EliminatLocks来开启同步消除,使用参数-XX:+PrintEliminateAllocations查看标量的替换情况。

尽管目前逃逸分析技术仍不是十分成熟,但是在今后的虚拟机中,逃逸分析技术肯定会支撑起一系列实用有效的优化技术。