第十一章 后端编译与优化

152 阅读20分钟

1.概述

编译器无论在何时、任何状态下把 Class 文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以被视为整个编译过程的后端。在 Java 中,提前编译早已在应用,但是即时编译才是占绝对主流的编译形式。由于提前编译逐渐被主流 JDK 支持,所以再只提"运行期"和"即时编译"就显得不够全面了。

无论是提前编译还是即时编译都不是 JVM 的必须组成部分,但是后端编译器性能和代码优化质量确实衡量一款商用虚拟机是否优秀的关键指标之一。

本章内容以 HotSpot 虚拟机为例子进行讲解。

2.即时编译器

目前主流的两款商用 Java 虚拟机(HotSpot、OpenJ9),Java 程序最初都是通过解释器进行解释执行。当虚拟机发现某个方法或者代码块运行特别频繁,就会把这些代码认定为热点代码,为了提高热点代码的执行效率,再运行时,虚拟机会把这些代码编译成本地机器码,并进行各种手段的代码优化,运行时完成这个任务的后端编译器被称为即时编译器。

2.1 解释器与编译器

尽管不是所有的 Java 虚拟机都采用了解释器与编译器并存的运行架构,但是目前主流的商用 Java 虚拟机内部都同时包含解释器与编译器。解释器与编译器各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译时间,立即运行。当程序随着时间推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境的内存资源限制较大时,可以用解释执行节约内存,反之可以使用编译执行来提升效率。同时解释器还能作为编译器激进优化的逃生门,当出现罕见陷阱时可以通过逆优化退回到解释状态继续执行。

解释器与编译器的交互图:

image.png

HotSpot 虚拟机中内置了两个即时编译器"客户端编译器"C1,"服务的编译"C2,在 JDK10 出现了 Graal 编译器,它的长期目标是代替 C2。

在分层编译的工作模式出现以前, HotSpot 通常采用解释器与其中一个编译器直接搭配的方式工作,程序使用哪个编译器只取决于虚拟机的运行模式,HotSpot 会根据自身版本和宿主机的硬件条件自动选择运行模式,用户也可以用参数强制指定客户端模式或者服务端模式。

无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式都被称为混合模式,也可以通过参数强制指定运行解释模式,另外也可也强制运行编译模式,这时候优先采用编译方式,但是解释器任然要在编译无法进行的情况下介入执行过程。

由于即时编译编译本地代码需要占用程序运行时间,并且通常编译出优化程度越高的代码,所花时间便会越长,同时还需要解释器替编译器收集性能监控信息,对解释器执行速度也会有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡, HotSpot 在编译子系统中加入了分层编译的功能。

分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

  • 第 0 层:纯解释执行,并且解释器不开启性能监控功能。
  • 第 1 层:使用客户端编译器将字节码编译成本地代码来运行,进行简单可靠的稳定优化,不开启性能监控。
  • 第 2 层:仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控。
  • 第 3 层:仍然使用客户端编译器执行,开启全部性能监控,除了第 2 层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
  • 第 4 层:使用服务端编译器将字节码编译为本地代码,相比客户端编译器,服务端编译器回启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,解释器、客户端编译器、服务端编译就会同时工作,热点代码都可能会被多长编译,用客户端编译器获取更快的编译速度,用服务端编译器来获取更好的编译质量,字解释执行的时候也无需额外承担收集性能监控的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可以先采用简单的优化来为它争取更多的编译时间。

2.2 编译对象与触发条件

运行期会被即时编译编译的目标代码是热点代码,热点代码主要有两类:

  • 被多次调用的方法。
  • 被多次执行的循环体。 对于这两种情况,编译的目标对象都是整个方法体,而不会是单个循环体。对于被多次执行的循环体的编译发生在方法执行过程中,因此被形象的称为栈上替换,即方法的栈帧还在栈上,方法就被替换了。

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为热点探测,进行热点探测并不一定要知道方法具体被调用了多少次,目前主流的热点探测判定方式有两种:

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

J9 采用了基于采样的热点探测,而 HotSpot 采用基于计数器的热点探测。HotSpot 为每个方法准备了两类计数器,方法调用计数器和回边计数器(回边:在循环边界往回跳转)。在虚拟机运行参数确定的前提下,这两个计数器都有一个阈值,计数器阈值一旦溢出,就会触发即时编译。

对于方法调用计数器,默认阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次,这个阈值可以通过参选进行设置。当方法被调用时,虚拟机会先检查方法是否存在被即时编译过的版本,如果存在,则优先使用即时编译后的本地代码执行。如果不存在,则将方法的计数器值加一,然后判断方法调用计数器与回边计数器的值是否超过阈值,超过的话就向即时编译器提交一个该方法的编译请求。

默认情况下,执行引擎不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译完成。当编译完成后,下一次调用该方法时就会使用已编译版本。

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

方法编译流程图: image.png

回边计数器,它的作用是统计一个方法中循环体代码执行的次数,它的目的是为了触发栈上的替换编译。

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

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

回边编译流程图: image.png

2.3 编译过程

在默认情况下,无论是方法调用产生的标准编译请求,还是栈上替换的编译请求,虚拟机在编译器还没有完成编译之前,都仍然将会按照解释方式继续执行代码,而编译动作则在后台编译程序中进行。可以通过虚拟机参数来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求后会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。

客户端编译器的编译过程大致分为三个阶段:

  • 第一阶段,将字节码构造成一种高级的中间表示(与目标机器指令无关的中间表示)。
  • 第二阶段,将高级的中间表示转换为低级的中间表示(与目标机器指令集相关的中间表示)。
  • 第三阶段,产生机器码。

3 提前编译器

提前编译很早就出现在 java 的技术体系中。但是提前编译与 java 的平台中立性"一次编译,到处运行"相冲突,导致提前编译一直没有什么进展。直到 使用提前编译的 Android 打败了使用即时编译的 Dalivk 提前编译再次出现在大家的视野里。

3.1 提前编译的优劣势

提前编译有两条路:第一条是类似传统的 C、C++,在程序运行之前把程序代码编译成机器码,这是静态编译。另一条是把原本即使编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码时直接把他们加载进来使用。

第一种编译方式最大的优点是不会占用程序运行时间和计算资源。相应的第二种编译方式,在即使在有分层编译的支持下,对运行的时间和资源占用依然存在,且不会消失。

4 编译器优化技术

4.1 优化技术概览

首先来看一个编译器优化的例子:

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

第一步优化,去掉方法调用:

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

第二步优化,消除冗余访问:

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

第三步优化,进行复写传播:

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

第四步优化,消除无用代码:

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

下面我们将会学习四项有代表性的优化技术:方法内联,逃逸分析,公关子表达式消除,数组边界检查消除。

4.2 方法内联

方法内联是编译器最重要的优化手段,它除了消除方法调用成本之外,更重要的是它为其他优化手段提供了良好的基础。比如下面的例子,如果不进行方法内联优化,是没有办法识别到"Dead Code"的:

public static void foo(Object obj) { 
    if (obj != null) { 
        System.out.println("do something"); 
        } 
    } 
public static void testInline(String[] args) { 
    Object obj = null; 
    foo(obj); 
}

方法内联的行为很简单,就是把目标方法的代码复制到发起调用的方法内,从而消除真实的方法调用。但是实际上 Java 虚拟机再进行内联的过程又很复杂,因为大多数 Java 方法都无法进行内联。

在第八章讲解方法解析和分派调用的时候就已经解释过:只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法和使用 invokestatic 指令调用静态方法才会在编译器进行解析。除了这四种方法之外,最多再出去被 final 修饰的方法,它被 invokevirtual 指令调用,但是在 Java 语言规范中明确说了它不属于虚方法。其他 Java 方法调用都必须在运行时进行方法接收者的多态选择,它们都有可能存在多于一个版本的方法接收者,简言之,Java 语言中默认的实例方法是虚方法。

由于虚方法在编译时很难确定应使用哪个方法版本,并且 Java 提倡使用面向对象的方式进行编程,而 Java 对象的方法默认就是虚方法。所以 Java 方法很难进行内联优化。而 C 和 C++ 默认的方法时非虚方法,如果需要用到多态,就用 virtual 关键字来修饰,但是 Java 选择了在虚拟机中解决这个问题。

为了解决虚方法内联问题,Java 引入了类型继承关系分析技术(CHA)。它用来确定目前已经家中的类中,某个接口是否有多于一种的实现,某个类是否存在子类,某个子类是否覆盖了父类的某个虚方法信息。这样编译器在进行内联时就会分情况处理:对于非虚方法直接内联。对于只有一个目标版本的虚方法,可以假设应用程序就是现在这样运行的来进行内联,这种内联称为守护内联,这种属于激进优化,因为在程序运行过程中,可能会加载新的类型进来。这时就必须预留逃生门,当类继承关系发生变化时,回退到解释状态执行或者重新编译。

对于确实有多个目标版本的虚方法,编译器还可以使用内联缓存来缩减方法调用的开销。它的工作原理大致为:方法调用前内联缓存为空,当发生方法调用后,将调用版本缓存起来。后续调用时进行先判断被调用的目标方法的版本,如果每次都是调用同一个版本,它就是一种单态内联缓存。如果调用的版本不一样,就说明程序用到了虚方法的多态特性,这时候会退化成多态内联缓存,它的开销相当于真正查找虚方法表来进行方法分派。

4.3 逃逸分析

逃逸分析是比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。

逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里被定义后,它可能被外部方法引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,比如赋值其他线程中可以访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸、线程逃逸,称为对象由低到高的不同逃逸程度。

根据对象的逃逸程度,可以采用不同的优化:

  • 栈上分配:在 Java 虚拟机中,对象都是分配在堆上。但是对堆上对象进行回收需要耗费大量资源。如果对象不会逃逸到线程之外,将这个对象分配到栈上,对象所占用的内存空间可以随着栈帧出栈而销毁。在应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占比率很大,如果能使用栈上分配,那么大量对象会随着方法结束而自动销毁,垃圾收集子系统的压力会下降很多。栈上分配可以支持方法逃逸,但是不能支持线程逃逸。
  • 标量替换:所谓标量是指一个已经无法再分解成更小的数据,比如 Java 虚拟机中的原始数据类型(int, long等),相对的如果一个对象可以继续分解,它就称为聚合量。Java 中的对象就是典型的聚合量。如果把一个对象拆散,根据程序的访问情况,将它用到的成员变量恢复为原始类型来访问,这个过程称为标量替换。如果一个对象不会被方法外访问,并且这个对象是可以拆散的,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建若干个被这个方法使用的成员变量来代替。除了可以让对象的成员变量再栈上分配和读写之外,还可以为后续进一步优化创造条件。标量替换可以视为栈上替换的一种特例,实现更简单,但它不允许对象逃逸出方法范围内。
  • 同步消除:线程同步是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全的消除。

逃逸分析目前还不够成熟,还有很大的改进余地。不成熟的原因是逃逸分析的计算成本很高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。试想逃逸分析结束后发现几乎没有不逃逸的对象,那么逃逸分析就是白白浪费时间了。

下面是模拟逃逸分析的例子:

// 原始代码
public int test(int x) { 
    int xx = x + 2; 
    Point p = new Point(xx, 42); 
    return p.getX(); 
}

// 进行方法内联
public int test(int x) { 
    int xx = x + 2; 
    Point p = point_memory_alloc(); // 在堆中分配P 对象的示意方法 
    p.x = xx; // Point 构造函数被内联后的样子 
    p.y = 42; 
    return p.x; // Point::getX()被内联后的样子 
}
// 标量替换
public int test(int x) { 
    int xx = x + 2; 
    int px = xx; 
    int py = 42; 
    return px; 
}
// 消除无用代码
public int test(int x) { 
    return x + 2; 
}

4.4 公共子表达式消除

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

下面是公共子表达式消除的例子:

// 原始表达式
int d = (c * b) * 12 + a + (a + b * c);
// c * b 与 b * c 相同,进行替换
int d = E * 12 + a + (a + E);
// 编译器还可能进行代数简化优化,得到最终表达式
int d = E * 13 + a + a;

4.5 数组边界检查消除

在 Java 中对数组进行访问时,会自动去进行数组上下界的范围检查,比如访问:foo[i] ,每次访问时都会进行 i>=0&&i<foo.length 判断。这对于开发者很友好,但是对于有大量数组访问的程序,这比定是性能负担。

如果我们能在编译期根据数据流分析来确定 foo.length 的值,并判断 i 不会越界,执行的时候,就可以消除这个判断。

当我们把视角放高,我们可以把数组越界、空指针异常、算术错误异常等检查都提前到编译期检查,从而消除这些隐式的开销。

除了将这些检查提前到编译期完成之外,还有一种思路:隐式异常处理。

下面的例子使用 Java 伪代码来表示虚拟机访问 foo.value 的过程:

// 模拟的原始访问过程,会多一个判断 foo != null
if (foo != null) { 
    return foo.value; 
} else { 
    throw new NullPointException(); 
}

// 隐式优化后的伪代码,减少一个判断
try { 
    return foo.value; 
} catch (segment_fault) { 
    uncommon_trap(); 
}

当 foo 很少为空时,这个优化会减少大量的 foo != null 判断,如果 foo 为空,会进入进程态进行异常处理,花销很大。但是 Java 虚拟机会根据运行期收集到的性能监控信息自动选择最合适的方案。