11.5 实战:深入理解Graal编译器

在本书刚开始介绍HotSpot即时编译器的时候曾经说过,从JDK 10起,HotSpot就同时拥有三款不同的即时编译器。此前我们已经介绍了经典的客户端编译器和服务端编译器,在本节,我们将把目光聚焦到HotSpot即时编译器以及提前编译器共同的最新成果——Graal编译器身上。

11.5.1 历史背景

在第1章展望Java技术的未来时,我们就听说过Graal虚拟机以及Graal编译器仍在实验室中尚未商用,但未来其有望代替或成为HotSpot下一代技术基础。Graal编译器最初是在Maxine虚拟机^1中作为C1X编译器[^2]的下一代编译器而设计的,所以它理所当然地使用于Java语言来编写。2012年,Graal编译器从Maxine虚拟机项目中分离,成为一个独立发展的Java编译器项目[^3],Oracle Labs希望它最终能够成为一款高编译效率、高输出质量、支持提前编译和即时编译,同时支持应用于包括HotSpot在内的不同虚拟机的编译器。由于这个编译器使用Java编写,代码清晰,又继承了许多来自HotSpot的服务端编译器的高质量优化技术,所以无论是科技企业还是高校研究院,都愿意在它上面研究和开发新编译技术。HotSpot服务端编译器的创造者Cliff Click自己就对Graal编译器十分推崇,并且公开表示再也不会用C、C++去编写虚拟机和编译器了。Twitter的Java虚拟机团队也曾公开说过C2目前犹如一潭死水, 亟待一个替代品,因为在它上面开发、改进实在太困难了。

Graal编译器在JDK 9时以Jaotc提前编译工具的形式首次加入到官方的JDK中,从JDK 10起,Graal 编译器可以替换服务端编译器,成为HotSpot分层编译中最顶层的即时编译器。这种可替换的即时编译器架构的实现,得益于HotSpot编译器接口的出现。

早期的Graal曾经同C1及C2一样,与HotSpot的协作是紧耦合的,这意味着每次编译Graal均需重新编译整个HotSpot。JDK 9时发布的JEP 243:Java虚拟机编译器接口(Java-Level JVM Compiler Interface,JVMCI)使得Graal可以从HotSpot的代码中分离出来。JVMCI主要提供如下三种功能:

  • 响应HotSpot的编译请求,并将该请求分发给Java实现的即时编译器。
  • 允许编译器访问HotSpot中与即时编译相关的数据结构,包括类、字段、方法及其性能监控数据 等,并提供了一组这些数据结构在Java语言层面的抽象表示。
  • 提供HotSpot代码缓存(Code Cache)的Java端抽象表示,允许编译器部署编译完成的二进制机器 码。

综合利用上述三项功能,我们就可以把一个在HotSpot虚拟机外部的、用Java语言实现的即时编译器(不局限于Graal)集成到HotSpot中,响应HotSpot发出的最顶层的编译请求,并将编译后的二进制代码部署到HotSpot的代码缓存中。此外,单独使用上述第三项功能,又可以绕开HotSpot的即时编译系统,让该编译器直接为应用的类库编译出二进制机器码,将该编译器当作一个提前编译器去使用 (如Jaotc)。

Graal和JVMCI的出现,为不直接从事Java虚拟机和编译器开发,但对Java虚拟机技术充满好奇心的读者们提供一条窥探和尝试编译器技术的良好途径,现在我们就将开始基于Graal来实战HotSpot虚拟机的即时编译与代码优化过程。

[^2]: C1X是Maxine虚拟机照着HotSpot C1编译器实现的编译器。
[^3]: 相关资料:https://jaxenter.com/oracle-championing-cause-for-graal-to-be-part-of-openjdk-104172.html。

11.4.5 数组边界检查消除

数组边界检查消除(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的过程为:

1
2
3
4
5
6
if (foo != null) {
return foo.value;
}
else{
throw new NullPointException();
}

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

1
2
3
4
5
6
try {
return foo.value;
}
catch (segment_fault) {
uncommon_trap();
}

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

与语言相关的其他消除操作还有不少,如自动装箱消除(Autobox Elimination)、安全点消除 (Safepoint Elimination)、消除反射(Dereflection)等,这里就不再一一介绍了。

11.4.4 公共子表达式消除

公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E 的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。下面举个简单的例子来说明它的优化过程,假设存在如下代码:

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

如果这段代码交给Javac编译器则不会进行任何优化,那生成的代码将如代码清单11-12所示,是完全遵照Java源码的写法直译而成的。

代码清单11-12 未作任何优化的字节码
1
2
3
4
5
6
7
8
9
10
11
12
13
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

当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到cb与bc是一样的表达式,而且在计算期间b与c的值是不变的。

因此这条表达式就可能被视为:

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

这时候,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化 ——代数化简(Algebraic Simplification),在E本来就有乘法运算的前提下,把表达式变为:

1
int d = E * 13 + a + a;

表达式进行变换之后,再计算起来就可以节省一些时间了。如果读者还对其他的经典编译优化技术感兴趣,可以参考《编译原理》(俗称龙书)中的相关章节。

11.4.3 逃逸分析

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

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

如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:

  • 栈上分配[^1](Stack Allocations):在Java虚拟机中,Java堆上分配创建对象的内存空间几乎是 Java程序员都知道的常识,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引 用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回 收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。如果确定一个对 象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存 空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所 占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收 集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
  • 标量替换(Scalar Replacement):若一个数据已经无法再分解成更小的数据来表示了,Java虚拟 机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据 就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java 中的对象就是典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量 恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部 访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创 建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上 (栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可 以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考 虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
  • 同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析 能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。

关于逃逸分析的研究论文早在1999年就已经发表,但直到JDK 6,HotSpot才开始支持初步的逃逸分析,而且到现在这项优化技术尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。如果要百分之百准确地判断一个对象是否会逃逸,需要进行一系列复杂的数据流敏感的过程间分析,才能确定程序各个分支执行时对此对象的影响。前面介绍即时编译、提前编译优劣势时提到了过程间分析这种大压力的分析算法正是即时编译的弱项。可以试想一下,如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象, 那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析。

C和C++语言里面原生就支持了栈上分配(不使用new操作符即可),而C#也支持值类型,可以很自然地做到标量替换(但并不会对引用类型做这种优化)。在灵活运用栈内存方面,确实是Java的一个弱项。在现在仍处于实验阶段的Valhalla项目里,设计了新的inline关键字用于定义Java的内联类型, 目的是实现与C#中值类型相对标的功能。有了这个标识与约束,以后逃逸分析做起来就会简单很多。

下面笔者将通过一系列Java伪代码的变化过程来模拟逃逸分析是如何工作的,向读者展示逃逸分析能够实现的效果。初始代码如下所示:

1
2
3
4
5
6
// 完全未优化的代码
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}

此处笔者省略了Point类的代码,这就是一个包含x和y坐标的POJO类型,读者应该很容易想象它的样子。

第一步,将Point的构造函数和getX()方法进行内联优化:

1
2
3
4
5
6
7
// 步骤1:构造函数内联后的样子
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()被内联后的样子
}

第二步,经过逃逸分析,发现在整个test()方法的范围内Point对象实例不会发生任何程度的逃逸, 这样可以对它进行标量替换优化,把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从而避免Point对象实例被实际创建,优化后的结果如下所示:

1
2
3
4
5
6
// 步骤2:标量替换后的样子
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42 return px;
}

第三步,通过数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果,如下所示:

1
2
3
4
// 步骤3:做无效代码消除后的样子
public int test(int x) {
return x + 2;
}

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

尽管目前逃逸分析技术仍在发展之中,未完全成熟,但它是即时编译器优化技术的一个重要前进方向,在日后的Java虚拟机中,逃逸分析技术肯定会支撑起一系列更实用、有效的优化技术。

[^1]: 由于复杂度等原因,HotSpot中目前暂时还没有做这项优化,但一些其他的虚拟机(如Excelsior JET)使用了这项优化。
[^2]: 从JDK 6 Update 23开始,服务端编译器中开始才默认开启逃逸分析。

11.4.2 方法内联

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

代码清单11-11 未作任何优化的字节码
1
2
3
4
5
6
7
8
9
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方法都无法进行内联。

无法内联的原因其实在第8章中讲解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虚拟机中比比皆是,极为常见。除了方法内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。

11.4 编译器优化技术

经过前面对即时编译、提前编译的讲解,读者应该已经建立起一个认知:编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。在本章之前的内容里出现过许多优化措施的专业名词, 有一些是编译原理中的基础知识,譬如方法内联,只要是计算机专业毕业的读者至少都有初步的概念;但也有一些专业性比较强的名词,譬如逃逸分析,可能不少读者只听名字很难想象出来这个优化会做什么事情。本节将介绍几种HotSpot虚拟机的即时编译器在生成代码时采用的代码优化技术,以小见大,见微知著,让读者对编译器代码优化有整体理解。

11.4.1 优化技术概览

OpenJDK的官方Wiki上,HotSpot虚拟机设计团队列出了一个相对比较全面的、即时编译器中采用的优化技术列表^1,如表11-1所示,其中有不少经典编译器的优化手段,也有许多针对Java语言,或者说针对运行在Java虚拟机上的所有语言进行的优化。本节先对这些技术进行概览,在后面几节中, 将挑选若干最重要或最典型的优化,与读者一起看看优化前后的代码发生了怎样的变化。

表11-1 即时编译器优化技术一览

上述的优化技术看起来很多,而且名字看起来大多显得有点“高深莫测”,实际上要实现这些优化确实有不小的难度,但大部分优化技术理解起来都并不困难,为了消除读者对这些优化技术的陌生感,笔者举一个最简单的例子:通过大家熟悉的Java代码变化来展示其中几种优化技术是如何发挥作用的。不过首先需要明确一点,即时编译器对这些代码优化变换是建立在代码的中间表示或者是机器码之上的,绝不是直接在Java源码上去做的,这里只是笔者为了方便讲解,使用了Java语言的语法来表示这些优化技术所发挥的作用。

第一步,从原始代码开始,如代码清单11-6所示[^2]。

代码清单11-6 优化前的原始代码
1
2
3
4
5
6
7
8
9
10
11
12
static class B {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}

代码清单11-6所示的内容已经非常简化了,但是仍有不少优化的空间。首先,第一个要进行的优化是方法内联,它的主要目的有两个:一是去除方法调用的成本(如查找方法版本、建立栈帧等); 二是为其他优化建立良好的基础。方法内联膨胀之后可以便于在更大范围上进行后续的优化手段,可以获取更好的优化效果。因此各种编译器一般都会把内联优化放在优化序列最靠前的位置。内联后的代码如代码清单11-7所示。

代码清单11-7 内联后的代码
1
2
3
4
5
6
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看作一个表达式,那么也可以把这项优化看作一种公共子表达式消除(Common Subexpression Elimination),优化后的代码如代码清单11-8所示。

代码清单11-8 冗余存储消除的代码
1
2
3
4
5
6
public void foo() {
y = b.value;
// ...do stuff...
z = y;
sum = y + z;
}

第三步进行复写传播(Copy Propagation),因为这段程序的逻辑之中没有必要使用一个额外的变量z,它与变量y是完全相等的,因此我们可以使用y来代替z。复写传播之后的程序如代码清单11-9所示。

代码清单11-9 复写传播的代码
1
2
3
4
5
6
public void foo() {
y = b.value;
// ...do stuff...
y = y;
sum = y + y;
}

第四步进行无用代码消除(Dead Code Elimination),无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码。因此它又被很形象地称为“Dead Code”,在代码清单11-9中,“y=y”是没有意义的,把它消除后的程序如代码清单11-10所示。

代码清单11-10 进行无用代码消除的代码
1
2
3
4
5
public void foo() {
y = b.value;
// ...do stuff...
sum = y + y;
}

经过四次优化之后,代码清单11-10所示代码与代码清单11-6所示代码所达到的效果是一致的,但是前者比后者省略了许多语句,体现在字节码和机器码指令上的差距会更大,执行效率的差距也会更高。编译器的这些优化技术实现起来也许确实复杂,但是要理解它们的行为,对于一个初学者来说都是没有什么困难的,完全不需要有任何的恐惧心理。

接下来,笔者挑选了四项有代表性的优化技术,与大家一起观察它们是如何运作的。它们分别是:

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

[^2]: 本示例原型来自Oracle官方对编译器技术的介绍材料: http://download.oracle.com/docs/cd/E13150_01/jrockit_jvm/jrockit/geninfo/diagnos/underst_jit.html。

11.3.2 实战:Jaotc的提前编译

JDK 9引入了用于支持对Class文件和模块进行提前编译的工具Jaotc,以减少程序的启动时间和到 达全速性能的预热时间,但由于这项功能必须针对特定物理机器和目标虚拟机的运行参数来使用,加 之限制太多,Java开发人员对此了解、使用普遍比较少,本节我们将用Jaotc来编译Java SE的基础库
[^1]: (java.base模块),以改善本机Java环境的执行效率。

我们首先通过一段测试代码(什么代码都可以,最简单的HelloWorld都可以,内容笔者就不贴了)来演示Jaotc的基本使用过程,操作如下:

1
2
3
4
$ javac HelloWorld.java 
$ java HelloWorld
Hello World!
$ jaotc --output libHelloWorld.so HelloWorld.class

通过以上命令,就生成了一个名为libHelloWorld.so的库,我们可以使用Linux的ldd命令来确认这是否是一个静态链接库,使用mn命令来确认其中是否包含了HelloWorld的构造函数和main()方法的入口信息,操作如下:

1
2
3
4
5
6
$ ldd libHelloWorld.so statically linked 
$ nm libHelloWorld.so
……
0000000000002a20 t HelloWorld.()V
0000000000002b20 t HelloWorld.main([Ljava/lang/String;)V
……

现在我们就可以使用这个静态链接库而不是Class文件来输出HelloWorld了:

1
2
java -XX:AOTLibrary=./libHelloWorld.so HelloWorld 
Hello World!

提前编译一个HelloWorld只具备演示价值,下一步我们来做更有实用意义的事情:把java.base模块编译成类似的静态链接库。java.base包含的代码数量庞大,虽然其中绝大部分内容现在都能被Jaotc的提前编译所支持了,但总还有那么几个“刺头”会导致编译异常。因此我们要建立一个编译命令文件来排除这些目前还不支持提前编译的方法,笔者将此文件取名为java.base-list.txt,其具体内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# jaotc: java.lang.StackOverflowError 
exclude sun.util.resources.LocaleNames.getContents()[[Ljava/lang/Object;
exclude sun.util.resources.TimeZoneNames.getContents()[[Ljava/lang/Object;
exclude sun.util.resources.cldr.LocaleNames.getContents()[[Ljava/lang/Object;
exclude sun.util.resources..*.LocaleNames_.*.getContents\(\)\[\[Ljava/lang/Object;
exclude sun.util.resources..*.LocaleNames_.*_.*.getContents\(\)\[\[Ljava/lang/Object;
exclude sun.util.resources..*.TimeZoneNames_.*.getContents\(\)\[\[Ljava/lang/Object;
exclude sun.util.resources..*.TimeZoneNames_.*_.*.getContents\(\)\[\[Ljava/lang/Object;
# java.lang.Error: Trampoline must not be defined by the bootstrap classloader
exclude sun.reflect.misc.Trampoline.<clinit>()V
exclude sun.reflect.misc.Trampoline.invoke(Ljava/lang/reflect/Method;Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
# JVM asserts exclude com.sun.crypto.provider.AESWrapCipher.engineUnwrap([BLjava/lang/String;I)Ljava/security/Key;
exclude sun.security.ssl.*
exclude sun.net.RegisteredDomain.<clinit>()V
# Huge methods exclude jdk.internal.module.SystemModules.descriptors()[Ljava/lang/module/ModuleDescriptor;

然后我们就可以开始进行提前编译了,使用的命令如下所示:

1
2
3
jaotc -J-XX:+UseCompressedOops -J-XX:+UseG1GC -J-Xmx4g 
--compile-for-tiered --info --compile-commands java.base-list.txt
--output libjava.base-coop.so --module java.base

上面Jaotc用了-J参数传递与目标虚拟机相关的运行时参数,这些运行时信息与编译的结果是直接相关的,编译后的静态链接库只能支持运行在相同参数的虚拟机之上,如果需要支持多种虚拟机运行参数(譬如采用不同垃圾收集器、是否开启压缩指针等)的话,可以花点时间为每一种可能用到的参数组合编译出对应的静态链接库。此外,由于Jaotc是基于Graal编译器开发的,所以现在ZGC和Shenandoah收集器还不支持Graal编译器,自然它们在Jaotc上也是无法使用的。事实上,目前Jaotc只支持G1和Parallel(PS+PS Old)两种垃圾收集器。使用Jaotc编译java.base模块的输出结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ jaotc -J-XX:+UseCompressedOops -J-XX:+UseG1GC -J-Xmx4g --compile-for-tiered --info --compile-commands java.base-list.txt --output libjava.base-coop.so --module java.base 
Compiling libjava.base-coop.so...
6177 classes found (335 ms)
55845 methods total, 49575 methods to compile (1037 ms)
Compiling with 4 threads
……
49575 methods compiled, 0 methods failed (138821 ms)
Parsing compiled code (906 ms)
Processing metadata (10867 ms)
Preparing stubs binary (0 ms)
Preparing compiled binary (103 ms)
Creating binary: libjava.base-coop.o (2719 ms)
Creating shared library: libjava.base-coop.so (5812 ms)
Total time: 163609 ms

在笔者的i7-8750H、32GB内存的笔记本上,编译JDK 11的java.base大约花了三分钟的时间,生成的libjava.base-coop.o库大小为366MB。JDK 9刚刚发布时,笔者做过相同的编译,当时耗时高达十分钟。编译完成后,我们就可以使用提前编译版本的java.base模块来运行Java程序了,方法与前面运行HelloWorld是一样的,用-XX:AOTLibrary来指定链接库位置即可,譬如:

1
2
java -XX:AOTLibrary=java_base/libjava.base-coop.so,./libHelloWorld.so HelloWorld 
Hello World!

我们还可以使用-XX:+PrintAOT参数来确认哪些方法使用了提前编译的版本,从输出信息中可以看到,如果不使用提前编译版本的java.base模块,就只有HelloWord的构造函数和main()方法是提前编译版本的:

1
2
3
4
5
$ java -XX:+PrintAOT -XX:AOTLibrary=./libHelloWorld.so HelloWorld 
11 1 loaded ./libHelloWorld.so aot library
105 1 aot[ 1] HelloWorld.()V
105 2 aot[ 1] HelloWorld.main([Ljava/lang/String;)V
Hello World!

但如果加入libjava.base-coop.so,那使用到的几乎所有的标准Java SE API都是被提前编译好的,输出如下:

1
2
3
4
5
6
7
8
9
10
java -XX:AOTLibrary=java_base/libjava.base-coop.so,./libHelloWorld.so HelloWorld 
Hello World!
13 1 loaded java_base/libjava.base-coop.so aot library
13 2 loaded ./libHelloWorld.so aot library
[Found [Z in java_base/libjava.base-coop.so]
…… // 省略其他输出
[Found [J in java_base/libjava.base-coop.so]
31 1 aot[ 1] java.lang.Object.()V
31 2 aot[ 1] java.lang.Object.finalize()V
…… // 省略其他输出

目前状态的Jaotc还有许多需要完善的地方,仍难以直接编译SpringBoot、MyBatis这些常见的第三方工具库,甚至在众多Java标准模块中,能比较顺利编译的也只有java.base模块而已。不过随着Graal编译器的逐渐成熟,相信Jaotc前途还是可期的。

此外,本书虽然选择Jaotc来进行实战,但同样有发展潜力的Substrate VM也不应被忽视。Jaotc做的提前编译属于本节开头所说的“第二条分支”,即做即时编译的缓存;而Substrate VM则是选择的“第一条分支”,做的是传统的静态提前编译,关于Substrate VM的实战,建议读者自己去尝试一下。

[^1]: 本实战就源于JEP 295:Ahead-of-Time Compilation:https://openjdk.java.net/jeps/295。

11.3.1 提前编译的优劣得失

本节希望同时向读者展示出一枚硬币的两面,解释清楚提前编译相对于即时编译有什么优势,又有什么不足,还有即时编译器有没有办法得到这些优势,需要付出哪些努力等。

现在提前编译产品和对其的研究有着两条明显的分支,一条分支是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器其他Java进程使用)时直接把它加载进来使用。

我们先来说第一条,这是传统的提前编译应用形式,它在Java中存在的价值直指即时编译的最大弱点:即时编译要占用程序运行时间和运算资源。即使现在先进的即时编译器已经足够快,以至于能够容忍相当高的优化复杂度了(譬如Azul公司基于LLVM的Falcon JIT,就能够以相当于Clang-O3的优化级别进行即时编译;又譬如OpenJ9的即时编译器Testarossa,它的静态版本同时也作为C、C++语言的提前编译器使用,优化的复杂度自然也支持得非常高);即使现在先进的即时编译器架构有了分层编译的支持,可以先用快速但低质量的即时编译器为高质量的即时编译器争取出更多编译时间,但是,无论如何,即时编译消耗的时间都是原本可用于程序运行的时间,消耗的运算资源都是原本可用于程序运行的资源,这个约束从未减弱,更不会消失,始终是悬在即时编译头顶的达摩克利斯之剑。

这里举个更具体的例子来帮助读者理解这种约束:在编译过程中最耗时的优化措施之一是通过“过程间分析”(Inter-Procedural Analysis,IPA,也经常被称为全程序分析,即Whole Program Analysis)来获得诸如某个程序点上某个变量的值是否一定为常量、某段代码块是否永远不可能被使用、在某个点调用的某个虚方法是否只能有单一版本等的分析结论。这些信息对生成高质量的优化代码有着极为巨大的价值,但是要精确(譬如对流敏感、对路径敏感、对上下文敏感、对字段敏感)得到这些信息, 必须在全程序范围内做大量极耗时的计算工作,目前所有常见的Java虚拟机对过程间分析的支持都相当有限,要么借助大规模的方法内联来打通方法间的隔阂,以过程内分析(Intra-Procedural Analysis, 只考虑过程内部语句,不考虑过程调用的分析)来模拟过程间分析的部分效果;要么借助可假设的激进优化,不求得到精确的结果,只求按照最可能的状况来优化,有问题再退回来解析执行。但如果是在程序运行之前进行的静态编译,这些耗时的优化就可以放心大胆地进行了,譬如Graal VM中的Substrate VM,在创建本地镜像的时候,就会采取许多原本在HotSpot即时编译中并不会做的全程序优化措施^1以获得更好的运行时性能,反正做镜像阶段慢一点并没有什么大影响。同理,这也是ART打败Dalvik的主要武器之一,连副作用也是相似的。在Android 5.0和6.0版本,安装一个稍微大一点的Android应用都是按分钟来计时的,以至于从Android 7.0版本起重新启用了解释执行和即时编译(但这已与Dalvik无关,它彻底凉透了),等空闲时系统再在后台自动进行提前编译。

关于提前编译的第二条路径,本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(Dynamic AOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)。在目前的Java技术体系里,这条路径的提前编译已经完全被主流的商用JDK支持。在商业应用中,这条路径最早出现在JDK 6版本的IBM J9虚拟机上,那时候在它的CDS(Class Data Sharing)功能的缓存中就有一块是即时编译缓存^2。不过这个缓存和CDS缓存一样是虚拟机运行时自动生成的,直接来源于J9的即时编译器,而且为了进程兼容性,很多激进优化都不能肆意运用,所以编译输出的代码质量反而要低于即时编译器。 真正引起业界普遍关注的是OpenJDK/OracleJDK 9中所带的Jaotc提前编译器,这是一个基于Graal编译器实现的新工具,目的是让用户可以针对目标机器,为应用程序进行提前编译。HotSpot运行时可以直接加载这些编译的结果,实现加快程序启动速度,减少程序达到全速运行状态所需时间的目的。这里面确实有比较大的优化价值,试想一下,各种Java应用最起码会用到Java的标准类库,如java.base等模块,如果能够将这个类库提前编译好,并进行比较高质量的优化,显然能够节约不少应用运行时的编译成本。关于这点,我们将在下一节做一个简单的实战练习,而在此要说明的是,这的确是很好的想法,但实际应用起来并不是那么容易,原因是这种提前编译方式不仅要和目标机器相关,甚至还必须与HotSpot虚拟机的运行时参数绑定。譬如虚拟机运行时采用了不同的垃圾收集器,这原本就需要即时编译子系统的配合(典型的如生成内存屏障代码,见第3章相关介绍)才能正确工作,要做提前编译的话,自然也要把这些配合的工作平移过去。至于前面提到过的提前编译破坏平台中立性、字节膨胀等缺点当然还存在,这里就不重复了。尽管还有许多困难,但提前编译无疑已经成为一种极限榨取性能 (启动、响应速度)的手段,且被官方JDK关注,相信日后会更加灵活、更加容易使用,就如已经相当成熟的CDS(AppCDS需要用户参与)功能那样,几乎不需要用户介入,可自动完成。

最后,我们还要思考一个问题:提前编译的代码输出质量,一定会比即时编译更高吗?提前编译因为没有执行时间和资源限制的压力,能够毫无顾忌地使用重负载的优化手段,这当然是一个极大的优势,但即时编译难道就没有能与其竞争的强项了吗?当然是有的,尽管即时编译在时间和运算资源方面的劣势是无法忽视的,但其依然有自己的优势。接下来便要开始即时编译器的绝地反击了,笔者将简要介绍三种即时编译器相对于提前编译器的天然优势。

首先,是性能分析制导优化(Profile-Guided Optimization,PGO)。上一节介绍HotSpot的即时编译器时就多次提及在解释器或者客户端编译器运行过程中,会不断收集性能监控信息,譬如某个程序点抽象类通常会是什么实际类型、条件判断通常会走哪条分支、方法调用通常会选择哪个版本、循环通常会进行多少次等,这些数据一般在静态分析时是无法得到的,或者不可能存在确定且唯一的解, 最多只能依照一些启发性的条件去进行猜测。但在动态运行时却能看出它们具有非常明显的偏好性。 如果一个条件分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热的代码集中放到一起,集中优化和分配更好的资源(分支预测、寄存器、缓存等)给它。

其次,是激进预测性优化(Aggressive Speculative Optimization),这也已经成为很多即时编译优化措施的基础。静态优化无论如何都必须保证优化后所有的程序外部可见影响(不仅仅是执行结果) 与优化前是等效的,不然优化之后会导致程序报错或者结果不对,若出现这种情况,则速度再快也是没有价值的。然而,相对于提前编译来说,即时编译的策略就可以不必这样保守,如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见分支上,大不了退回到低级编译器甚至解释器上去执行,并不会出现无法挽救的后果。只要出错概率足够低,这样的优化往往能够大幅度降低目标程序的复杂度, 输出运行速度非常高的代码。譬如在Java语言中,默认方法都是虚方法调用,部分C、C++程序员(甚至一些老旧教材)会说虚方法是不能内联的,但如果Java虚拟机真的遇到虚方法就去查虚表而不做内联的话,Java技术可能就已经因性能问题而被淘汰很多年了。实际上虚拟机会通过类继承关系分析等一系列激进的猜测去做去虚拟化(Devitalization),以保证绝大部分有内联价值的虚方法都可以顺利内联。内联是最基础的一项优化措施,本章稍后还会对专门的Java虚拟机具体如何做虚方法内联进行详细讲解。

最后,是链接时优化(Link-Time Optimization,LTO),Java语言天生就是动态链接的,一个个Class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码,这类事情在Java程序员眼里看起来毫无违和之处。但如果类似的场景出现在使用提前编译的语言和程序上,譬如C、C++的程序要调用某个动态链接库的某个方法,就会出现很明显的边界隔阂,还难以优化。这是因为主程序与动态链接库的代码在它们编译时是完全独立的,两者各自编译、优化自己的代码。这些代码的作者、编译的时间,以及编译器甚至很可能都是不同的,当出现跨链接库边界的调用时,那些理论上应该要做的优化——譬如做对调用方法的内联,就会执行起来相当的困难。如果刚才说的虚方法内联让C、C++程序员理解还算比较能够接受的话(其实C++编译器也可以通过一些技巧来做到虚方法内联),那这种跨越动态链接库的方法内联在他们眼里可能就近乎于离经叛道了(但实际上依然是可行的)。

经过以上的讨论,读者应该能够理解提前编译器的价值与优势所在了,但忽略具体的应用场景就说它是万能的银弹,那肯定是有失偏颇的,提前编译有它的应用场景,也有它的弱项与不足,相信未来很长一段时间内,即时编译和提前编译都会是Java后端编译技术的共同主角。

第11章 后端编译与优化

从计算机程序出现的第一天起,对效率的追逐就是程序员天生的坚定信仰,这个过程犹如一场没有终点、永不停歇的F1方程式竞赛,程序员是车手,技术平台则是在赛道上飞驰的赛车。

11.1 概述

如果我们把字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR)的话, 那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。如果读者阅读过本书的第2版,可能会发现本章的标题已经从“运行期编译与优化”悄然改成了“后端编译与优化”,这是因为在2012年的Java世界里,虽然提前编译(Ahead Of Time,AOT)早已有所应用,但相对而言,即时编译(Just In Time, JIT)才是占绝对主流的编译形式。不过,最近几年编译技术发展出现了一些微妙的变化,提前编译不仅逐渐被主流JDK所支持,而且在Java编译技术的前沿研究中又重新成了一个热门的话题,所以再继续只提“运行期”和“即时编译”就显得不够全面了,在本章中它们两者都是主角。

无论是提前编译器抑或即时编译器,都不是Java虚拟机必需的组成部分,《Java虚拟机规范》中从来没有规定过虚拟机内部必须要包含这些编译器,更没有限定或指导这些编译器应该如何去实现。但是,后端编译器编译性能的好坏、代码优化质量的高低却是衡量一款商用虚拟机优秀与否的关键指标之一,它们也是商业Java虚拟机中的核心,是最能体现技术水平与价值的功能。在本章中,我们将走进Java虚拟机的内部,探索后端编译器的运作过程和原理。

既然《Java虚拟机规范》没有具体的约束规则去限制后端编译器应该如何实现,那这部分功能就完全是与虚拟机具体实现相关的内容,如无特殊说明,本章中所提及的即时编译器都是特指HotSpot虚拟机内置的即时编译器,虚拟机也是特指HotSpot虚拟机。不过,本章虽然有大量的内容涉及了特定的虚拟机和编译器的实现层面,但主流Java虚拟机中后端编译器的行为会有很多相似相通之处,因此对其他虚拟机来说也具备一定的类比参考价值。

11.3 提前编译器

提前编译在Java技术体系中并不是新事物。1996年JDK 1.0发布,Java有了正式的运行环境,第一个可以使用外挂即时编译器的Java版本是1996年7月发布的JDK 1.0.2,而Java提前编译器的诞生并没有比这晚多少。仅几个月后,IBM公司就推出了第一款用于Java语言的提前编译器(IBM High Performance Compiler for Java)。在1998年,GNU组织公布了著名的GCC家族(GNU Compiler Collection)的新成员GNU Compiler for Java(GCJ,2018年从GCC家族中除名),这也是一款Java的提前编译器^1,而且曾经被广泛应用。在OpenJDK流行起来之前,各种Linux发行版带的Java实现通常就是GCJ。

但是提前编译很快又在Java世界里沉寂了下来,因为当时Java的一个核心优势是平台中立性,其宣传口号是“一次编译,到处运行”,这与平台相关的提前编译在理念上就是直接冲突的。GCJ出现之后在长达15年的时间里,提前编译这条故事线上基本就再没有什么大的新闻和进展了。类似的状况一直持续至2013年,直到在Android的世界里,剑走偏锋使用提前编译的ART(Android Runtime)横空出世。ART一诞生马上就把使用即时编译的Dalvik虚拟机按在地上使劲蹂躏,仅经过Android 4.4一个版本的短暂交锋之后,ART就迅速终结了Dalvik的性命[^2],把它从Android系统里扫地出门。

尽管Android并不能直接等同于Java,但两者毕竟有着深厚渊源,提前编译在Android上的革命与崛起也震撼到了Java世界。在某些领域、某些人眼里,只要能获得更好的执行性能,什么平台中立性、 字节膨胀[^3]、动态扩展[^4],一切皆可舍弃,唯一的问题就只有“提前编译真的会是获得更高性能的银弹吗?”

[^2]: ART干掉Dalvik之后,到Android 7.0时其内部也加入了解释执行和即时编译,这是后话。
[^3]: 指提前编译的本地二进制码的体积会明显大于字节码的体积。
[^4]: 指提前编译通常要求程序是封闭的,不能在外部动态加载新的字节码。