JVM的运行期优化

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

HotSpot虚拟机内的即时编译器

看完这部分内容你应该会明白:

  • 为什么 HotSpot 虚拟机要使用解释器与编译器并存的架构?
  • 为什么 HotSpot 虚拟机要实现两个不同的即时编译器?
  • 程序何时使用解释器执行,何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码?

解释器与编译器

  解释器与编译器两种各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以用编译执行提升效率。同时,解释器还可以作为编译器激进优化时的一个“逃生门”,例如动态加载了新类后类继承结构出现变化、出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行。因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作,如下图所示。

图一

  HotSpot中内置了两个即时编译器,分别是 Client Compiler 和 Server Compiler,或者简称为 C1 编译器和 C2 编译器,虚拟机会根据自身版本和宿主机器的硬件性能自动选择运行模式,用户也可以使用 -client-server 参数去强制指定虚拟机运行在 Client 模式或 Server 模式,JDK1.7 后 server 模式作为默认编译策略被开启。

  无论采用的是哪个编译器,解释器和编译器搭配使用的方式在虚拟机中称为混合模式(Mixed Mode),用户可以使用 -Xint 强制虚拟机运行于解释模式(Interpreted Mode),这时编译器完全不介入工作,全部代码使用解释方式执行。也可以使用参数 -Xcomp 强制虚拟机运行于编译模式,这时将优先采用编译方式执行,但解释器仍然要在编译无法进行的情况下介入执行过程。可以使用 -version 显示出这三种模式。

哪些代码会被编译?

在运行时有两类热点代码会被即时编译器编译:

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

  前者很好理解,后者是为了解决一个方法只被调用少量的几次,但方法体内部存在循环次数较多的循环体的问题。这两种方式编译器都会以整个方法作为编译对象。

判断一段代码是不是热点代码,目前主要有两种方式:

  • 基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这些方法就是热点方法。这种方式的好处是实现简单、高效,还可以很容易地获取方法调用关系(展开调用堆栈即可),缺点是很难精确统计,容易受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  • 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法甚至是代码块建立计数器,统计方法的执行此时,如果执行次数超过一定的阈值就认为它是热点方法。这种统计方法麻烦些,且不能直接获取到方法的调用关系,但是它的结果更加准确。

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

  方法调用计数器的默认阈值在 Client 模式下是 1500 次,Server 模式下是 10000 次。当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在则使用,如果不存在,将计数器加一,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器阈值。如果超过阈值,则向即时编译器提交该方法的代码编译请求,接着继续以解释执行该方法。如果不做任何设置,方法调用计数器统计的并不是被调用的绝对次数,而是一个相对的执行频率,当超过一定的时间限制且调用次数没达到阈值,那么这个计数器就会被衰减一半。

  回边计数器作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边(Back Edge)。当解释器遇到一条回边指令时,会查找是否已经有编译好的版本,有则使用,无则将回边计数器加一,然后判断方法调用计数器与回边计数器之和是否超过回边计数器的阈值。当超过一个阈值时,会提交一个编译请求,并把回边计时器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。与方法计数器不同,回边计数器没有技术热度衰减的过程,统计的就是绝对次数

编译优化技术

  Java程序员都知道以编译方式执行本地代码比解释执行速度更快,其主要原因是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器中。大致有以下主要流程:

  1. 方法内联。方法内联的重要性高于其它优化措施,它的主要目的有两个,一是去除方法调用的成本(如建立栈帧等),二是为其他优化建立良好的基础,方法内联膨胀后可以便于在更大范围上才去后续的优化手段,从而获取更好的优化效果。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    static class B {
    int value;
    final int get() {
    return value;
    }
    }

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

    以上代码在内联后变成

    1
    2
    3
    4
    5
    6
    public void foo() {
    y = b.value;
    // ... do stuff ...
    z = b.value;
    sum = y + z;
    }
  2. 第二步是冗余访问消除。假设代码中注释掉的 do stuff 不会改变 b.value 的值,那就可以把 z = b.value 替换为 z = y,这样就可以不再访问对象 b 的局部变量了。优化后的代码如下:

1
2
3
4
5
6
public void foo() {
y = b.value;
// ... do stuff ...
z = y;
sum = y + z;
}
  1. 第三步是复写传播,因为这段代码里没有必要使用一个额外变量 z ,它与 y 是完全相等的,因此可以用 y 来代替 z,复写传播优化后的代码如下:
1
2
3
4
5
6
public void foo() {
y = b.value;
// ... do stuff ...
y = y;
sum = y + y;
}
  1. 第四步是无用代码消除,无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码,例如 y = y 就是没有意义的,消除后的代码如下:
1
2
3
4
5
public void foo() {
y = b.value;
// ... do stuff ...
sum = y + y;
}

公共子表达式消除

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

数组边界检查消除

  Java语言访问数组元素的时候系统将自动进行上下界的范围检测,这对软件开发者很友好,但是对于虚拟机来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑是一种性能负担。

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

方法内联

  方法内联是编译器最重要的优化手段之一,除了消除方法调用的成本外,它更重要的意义是为其它优化手段建立良好的基础。方法内联的优化行为看起来很简单,不过是把目标代码“复制”到发起调用的方法之中,避免真实的方法调用而已。但内联过程实际没那么简单,如果不是即时编译器做了一些特别的地方,按编译原理优化理论,大多数的Java方法都无法进行内联。

  因为Java语言中默认的实例方法都是虚方法(动态分配与虚方法),编译期做内联的时候根本无法确定应该使用哪个方法,例如 b.get() 到底执行的是父类的方法(假如父类有),还是自己的方法,只有在运行期才能确定。

  为了解决虚方法内联的问题,虚拟机团队引入了类型继承关系分析技术(CHA),它用于确定在目前已加载的类中,某个接口是否有多余一种的实现,某个类是否存在子类、子类是否为抽象类等信息。

  编译器在执行内联是,如果是非虚方法,那么直接进行内联就可以了,如果遇到虚方法,则会向 CHA 查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联属于激进优化,需要预留一个逃生门,称为守护内联。如果程序的后续执行过程,虚拟机一直没有加载到会另这个类的继承关系发生变化的类,那找个内联优化的代码就可以一直使用,否则就要抛弃已经编译的代码,退回到解释状态执行,或重新进行编译。

  如果CHA查询到多个版本的模板代码可供选择,则编译器还会尽最后一次努力,使用内联缓存来完成方法内联,工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用后,缓存记录下方法接收者的版本信息,并在每次进行方法调用的时候都比较接收者版本,如果以后进来的每次调用的方法版本都是一致的,那这个内联还可以继续用下去。如果发生方法版本不一致的情况,说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。

  所以说,在大多数情况下虚拟机进行的内联都是一种激进优化,激进优化的手段在高性能商用虚拟机中很常见,当出现异常情况时,会从逃生门回到解释状态重新执行。

逃逸分析

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

  如果能证明一个对象不会逃逸到方法或线程之外,则可能对为这个变量做一些高效的优化,但目前逃逸分析的技术仍不是很成熟:

  • 栈上分配。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存而不是堆上将会是一个很不错的主意,对象所占用的内存可以随栈帧出栈而销毁,垃圾回收的压力将减小很多。但由于实现方式比较复杂,HotSpot虚拟机暂时还没有做这项优化。
  • 同步消除。线程同步本身是一个耗时的操作,如果逃逸分析确定一个变量不会逃出线程,无法被其它线程访问,那对这个变量实施的同步措施就可以消除掉。
  • 标量替换。标量是指一个数据已经无法再分解成更小的数据来表示了,例如基本类型。如果一个数据可以继续分解,那就叫聚合量,例如Java中的对象。如果逃逸分析证明一个对象不会被外部访问,且这个对象可以被拆散的话,那程序真正执行的时候可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大概率会被虚拟机分配至物理机器的告诉寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。