12.3.1 主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量(Variables)与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的^1,不会被共享,自然就不会存在竞争问题。为了获得更好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本[^2],线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据[^3]。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图12- 2所示,注意与图12-1进行对比。

image-20211126200233576

图12-2 线程、主内存、工作内存三者的交互关系(请与图12-1对比)

这里所讲的主内存、工作内存与第2章所讲的Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分[^4],而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

线程共享,但是reference本身在Java栈的局部变量表中是线程私有的。
[^2]: 有部分读者会对这段描述中的“副本”提出疑问,如“假设线程中访问一个10MB大小的对象,也会把 这10MB的内存复制一份出来吗?”,事实上并不会如此,这个对象的引用、对象中某个在线程访问到 的字段是有可能被复制的,但不会有虚拟机把整个对象复制一次。
[^3]: 根据《Java虚拟机规范》的约定,volatile变量依然有工作内存的拷贝,但是由于它特殊的操作顺序 性规定(后文会讲到),所以看起来如同直接在主内存中读写访问一般,因此这里的描述对于volatile 也并不存在例外。
[^4]: 除了实例数据,Java堆还保存了对象的其他信息,对于HotSpot虚拟机来讲,有Mark Word(存储对 象哈希码、GC标志、GC年龄、同步锁等信息)、Klass Point(指向存储类型元数据的指针)及一些用 于字节对齐补白的填充数据(如果实例数据刚好满足8字节对齐,则可以不存在补白)。

12.3 Java内存模型

《Java虚拟机规范》[^1]中曾试图定义一种“Java内存模型”[^2](Java Memory Model,JMM)来屏 蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效 果。在此之前,主流程序语言(如C和C++等)直接使用物理硬件和操作系统的内存模型。因此,由于 不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发 访问却经常出错,所以在某些场景下必须针对不同的平台来编写程序。

定义Java内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让Java的并发内存访问操作不会产生歧义;但是也必须定义得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好的执行速度。经过长时间的验证和修补,直至JDK 5(实现了JSR-133[^3])发布后,Java内存模型才终于成熟、完善起来了。

[^1]: 在《Java虚拟机规范》的第2版及之前,专门有一章“Threads and Locks”来描述内存模型,后来由于 这部分内容难以把握宽紧限度,被反复修正更新,从第3版(Java SE 7版)开始索性就被移除出规范, 独立以JSR形式维护。
[^2]: 本书中的Java内存模型都特指目前正在使用的,在JDK 1.2之后建立起来并在JDK 5中完善过的内存 模型。
[^3]: JSR-133:Java Memory Model and Thread Specification Revision(Java内存模型和线程规范修订)。

12.2 硬件的效率与一致性

在正式讲解Java虚拟机并发相关的知识之前,我们先花费一点时间去了解一下物理计算机中的并发问题。物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义。

“让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能”之间的因果关系,看 起来理所当然,实际上它们之间的关系并没有想象中那么简单,其中一个重要的复杂性的来源是绝大 多数的运算任务都不可能只靠处理器“计算”就能完成。处理器至少要与内存交互,如读取运算数据、 存储运算结果等,这个I/O操作就是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机 的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多 层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算 需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处 理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(Main Memory),这种系统称为共享内存多核系统(Shared Memory Multiprocessors System),如图12-1所示。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、 Synapse、Firefly及Dragon Protocol等。从本章开始,我们将会频繁见到“内存模型”一词,它可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型,并且与这里介绍的内存访问操作及硬件的缓存访问操作具有高度的可类比性。

image-20211126195218137

图12-1 处理器、高速缓存、主内存间的交互关系

除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序 (Instruction Reorder)优化。

第12章 Java内存模型与线程

并发处理的广泛应用是Amdahl定律代替摩尔定律[^1]成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力的最有力武器。

12.1 概述

多任务处理在现代计算机操作系统中几乎已是一项必备的功能了。在许多场景下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是计算机的运算速度与它的存储和通信子系统的速度差距太大,大量的时间都花费在磁盘I/O、网络通信或者数据库访问上。 如果不希望处理器在大部分时间里都处于等待其他资源的空闲状态,就必须使用一些手段去把处理器的运算能力“压榨”出来,否则就会造成很大的性能浪费,而让计算机同时处理几项任务则是最容易想到,也被证明是非常有效的“压榨”手段。

除了充分利用计算机处理器的能力外,一个服务端要同时对多个客户端提供服务,则是另一个更具体的并发应用场景。衡量一个服务性能的高低好坏,每秒事务处理数(Transactions Per Second, TPS)是重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,而TPS值与程序的并发能力又有非常密切的关系。对于计算量相同的任务,程序线程并发协调得越有条不紊,效率自然就会越高;反之,线程之间频繁争用数据,互相阻塞甚至死锁,将会大大降低程序的并发能力。

服务端的应用是Java语言最擅长的领域之一,这个领域的应用占了Java应用中最大的一块份额 [^2],不过如何写好并发应用程序却又是服务端程序开发的难点之一,处理好并发方面的问题通常需要更多的编码经验来支持。幸好Java语言和虚拟机提供了许多工具,把并发编程的门槛降低了不少。各种中间件服务器、各类框架也都努力地替程序员隐藏尽可能多的线程并发细节,使得程序员在编码时能更关注业务逻辑,而不是花费大部分时间去关注此服务会同时被多少人调用、如何处理数据争用、 协调硬件资源。但是无论语言、中间件和框架再如何先进,开发人员都不应期望它们能独立完成所有并发处理的事情,了解并发的内幕仍然是成为一个高级程序员不可缺少的课程。

“高效并发”是本书讲解Java虚拟机的最后一个部分,将会向读者介绍虚拟机如何实现多线程、多 线程之间由于共享和竞争数据而导致的一系列问题及解决方案。

[^1]: Amdahl定律通过系统中并行化与串行化的比重来描述多处理器系统能获得的运算加速能力,摩尔 定律则用于描述处理器晶体管数量与运行效率之间的发展关系。这两个定律的更替代表了近年来硬件 发展从追求处理器频率到追求多核心并行处理的发展过程。
[^2]: 必须以代码的总体规模来衡量,服务端应用不能与JavaCard、移动终端这些领域去比绝对数量。

11.6 本章小结

在本章中,我们学习了与提前编译和即时编译器两大后端编译器相关的知识,了解了提前编译器重新兴起的原因及其优劣势;还有与即时编译器相关的热点探测方法、编译触发条件及如何从虚拟机外部观察和分析即时编译的数据和结果;还选择了几种常见的编译器优化技术进行讲解,对Java编译器的深入了解,有助于在工作中分辨哪些代码是编译器可以帮我们处理的,哪些代码需要自己调节以便更适合编译器的优化。

11.5.5 代码优化与生成

相信读者现在已经能够基本看明白Graal理想图的中间表示了,那对应到代码上,Graal编译器是如何从字节码生成理想图?又如何在理想图基础上进行代码优化的呢?这时候就充分体现出了Graal编译器在使用Java编写时对普通Java程序员来说具有的便捷性了,在Outline视图中找到创建理想图的方法是greateGraph(),我们可以从Call Hierarchy视图中轻易地找到从JVMCI的入口方法compileMethod()到greateGraph()之间的调用关系,如图11-18所示。

greateGraph()方法的代码也很清晰,里面调用了StructuredGraph::Builder()构造器来创建理想图。这 里要关注的关键点有两个:

image-20211126192125635

图11-18 构造理想图的方法

第一是理想图本身的数据结构。它是一组不为空的节点的集合,它的节点都是用ValueNode的不同类型的子类节点来表示的。仍然以x+y表达式为例,譬如其中的加法操作,就由AddNode节点来表示, 从图11-19所示的Type Hierarchy视图中可以清楚地看到加法操作是二元算术操作节点 (BinaryArithmeticNode<OP>)的一种,而二元算术操作节点又是二元操作符(BinaryNode)的一种,以此类推直到所有操作符的共同父类ValueNode(表示可以返回数据的节点)。

第二就是如何从字节码转换到理想图。该过程被封装在BytecodeParser类中,这个解析器我们可以按照字节码解释器的思路去理解它。如果这真的是一个字节码解释器,执行一个整数加法操作,按照 《Java虚拟机规范》所定义的iadd操作码的规则,应该从栈帧中出栈两个操作数,然后相加,再将结果入栈。而从BytecodeParser::genArithmeticOp()方法上我们可以看到,其实现与规则描述没有什么差异, 如图11-20所示。

image-20211126192231390

图11-19 节点继承关系

image-20211126192300732

图11-20 字节码解析器实现的iadd操作码

其中,genIntegerAdd()方法中就只有一行代码,即调用AddNode节点的create()方法,将两个操作数作为参数传入,创建出AddNode节点,如下所示:

1
2
3
protected ValueNode genIntegerAdd(ValueNode x, ValueNode y) {
return AddNode.create(x, y, NodeView.DEFAULT);
}

每一个理想图的节点都有两个共同的主要操作,一个是规范化(Canonicalisation),另一个是生成机器码(Generation)。生成机器码顾名思义,就不必解释了,规范化则是指如何缩减理想图的规模,也即在理想图的基础上优化代码所要采取的措施。这两个操作对应了编译器两项最根本的任务:代码优化与代码翻译。

AddNode节点的规范化是实现在canonical()方法中的,机器码生成则是实现在generate()方法中的, 从AddNode的创建方法上可以看到,在节点创建时会调用canonical()方法尝试进行规范化缩减图的规模,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static ValueNode create(ValueNode x, ValueNode y, NodeView view) {
BinaryOp<Add> op = ArithmeticOpTable.forStamp(x.stamp(view)).getAdd();
Stamp stamp = op.foldStamp(x.stamp(view), y.stamp(view));
ConstantNode tryConstantFold = tryConstantFold(op, x, y, stamp, view);
if (tryConstantFold != null) {
return tryConstantFold;
}
if (x.isConstant() && !y.isConstant()) {
return canonical(null, op, y, x, view);
}
else {
return canonical(null, op, x, y, view);
}
}

从AddNode的canonical()方法中我们可以看到为了缩减理想图的规模而做的相当多的努力,即使只是两个整数相加那么简单的操作,也尝试过了常量折叠(如果两个操作数都为常量,则直接返回一个常量节点)、算术聚合(聚合树的常量子节点,譬如将(a+1)+2聚合为a+3)、符号合并(聚合树的相反符号子节点,譬如将(a-b)+b或者b+(a-b)直接合并为a)等多种优化,canonical()方法的内容较多,请读者自行参考源码,为节省版面这里就不贴出了。

对理想图的规范化并不局限于单个操作码的局部范围之内,很多的优化都是要立足于全局来进行的,这类操作在CanonicalizerPhase类中完成。仍然以上一节的公共子表达式消除为例,这就是一个全局性的优化,实现在CanonicalizerPhase::tryGlobalValueNumbering()方法中,其逻辑看起来已经非常清晰了:如果理想图中发现了可以进行消除的算术子表达式,那就找出重复的节点,然后替换、删除。具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean tryGlobalValueNumbering(Node node, NodeClass<?> nodeClass) {
if (nodeClass.valueNumberable()) {
Node newNode = node.graph().findDuplicate(node);
if (newNode != null) {
assert !(node instanceof FixedNode || newNode instanceof FixedNode);
node.replaceAtUsagesAndDelete(newNode);
COUNTER_GLOBAL_VALUE_NUMBERING_HITS.increment(debug);
debug.log("GVN applied and new node is %1s", newNode);
return true;
}
}
return false;
}

至于代码生成,Graal并不是直接由理想图转换到机器码,而是和其他编译器一样,会先生成低级中间表示(LIR,与具体机器指令集相关的中间表示),然后再由HotSpot统一后端来产生机器码。譬如涉及算术运算加法的操作,就在ArithmeticLIRGeneratorTool接口的emitAdd()方法里完成。从低级中间表示的实现类上,我们可以看到Graal编译器能够支持的目标平台,目前它只提供了三种目标平台的指令集(SPARC、x86-AMD64、ARMv8-AArch64)的低级中间表示,所以现在Graal编译器也就只能支持这几种目标平台,如图11-21所示。

image-20211126192556614

图11-21 Graal支持的目标平台生成器

为了验证代码阅读的成果,现在我们来对AddNode的代码生成做一些小改动,将原本生成加法汇编指令修改为生成减法汇编指令,即按如下方式修改AddNode::generate()方法:

1
2
3
4
5
class AddNode {
void generate(...) {
... gen.emitSub(op1, op2, false) ...// 原来这个方法是emitAdd()
}
}

然后在虚拟机运行参数中加上-XX:+PrintAssembly参数,因为从低级中间表示到真正机器码的转换是由HotSpot统一负责的,所以11.2节中用到的HSDIS插件仍然能发挥作用,帮助我们输出汇编代码。从输出的汇编中可以看到,在没有修改之前,AddNode节点输出的汇编代码如下所示:

1
2
3
4
5
6
7
8
9
0x000000010f71cda0:     nopl    0x0(%rax,%rax,1) 
0x000000010f71cda5: add %edx,%esi ;*iadd {reexecute=0 rethrow=0 return_oop=0}
; - Demo::workload@2 (line 10)
0x000000010f71cda7: mov %esi,%eax ;*ireturn {reexecute=0 rethrow=0 return_oop=0}
; - Demo::workload@3 (line 10)
0x000000010f71cda9: test %eax,-0xcba8da9(%rip) # 0x0000000102b74006
; {poll_return}
0x000000010f71cdaf: vzeroupper
0x000000010f71cdb2: retq

而被我们修改后,编译的结果已经变为:

1
2
3
4
5
6
7
8
9
0x0000000107f451a0:  nopl    0x0(%rax,%rax,1) 
0x0000000107f451a5: sub %edx,%esi ;*iadd {reexecute=0 rethrow=0 return_oop=0}
; - Demo::workload@2 (line 10)
0x0000000107f451a7: mov %esi,%eax ;*ireturn {reexecute=0 rethrow=0 return_oop=0}
; - Demo::workload@3 (line 10)
0x0000000107f451a9: test %eax,-0x1db81a9(%rip) # 0x000000010618d006
; {poll_return}
0x0000000107f451af: vzeroupper
0x0000000107f451b2: retq

我们的修改确实促使Graal编译器产生了不同的汇编代码,这也印证了我们代码分析的思路是正确的。写到这里,笔者忍不住感慨,Graal编译器的出现对学习和研究虚拟机代码编译技术实在有着不可估量的价值。在本书第2版编写时,只有C++编写的复杂无比的服务端编译器,要进行类似的实战是非常困难的,即使勉强写出来,也会因为过度烦琐而失去阅读价值。

11.5.4 代码中间表示

Graal编译器在设计之初就刻意采用了与HotSpot服务端编译器一致(略有差异但已经非常接近) 的中间表示形式,也即是被称为Sea-of-Nodes的中间表示,或者与其等价的被称为理想图(Ideal Graph,在代码中称为Structured Graph)的程序依赖图(Program Dependence Graph,PDG)形式。在11.2节即时编译器的实战中,我们已经通过可视化工具Ideal Graph Visualizer看到过在理想图上翻译和优化输入代码的整体过程,从编译器内部来看即:字节码→理想图→优化→机器码(以Mach Node Graph表示)的转变过程。在那个实战里面,我们着重分析的是理想图转换优化的整体过程,对于多数读者,尤其是不熟悉编译原理与编译器设计的读者,可能会不太容易读懂每个阶段所要做的工作。 在本节里面,我们以例子和对照Graal源码的形式,详细讲解输入代码与理想图的转化对应关系,以便读者理解Graal是如何基于理想图去优化代码的。

理想图是一种有向图,用节点来表示程序中的元素,譬如变量、操作符、方法、字段等,而用边来表示数据或者控制流。我们先从最简单的例子出发。譬如有一个表达式:x+y,在理想图中可以表示为x、y两个节点的数据流流入加法操作符,表示相加操作读取了x、y的值,流出的便则表示数据流的流向,即相加的结果会在哪里被使用,如图11-13所示。

image-20211126190533817

图11-13 构造理想图(1)

这很容易接受吧?那我们把例子稍微复杂化一些,把表达式x+y变为getX()+getY(),仍是用理想图表达其计算过程,这时候除了数据流向之外,还必须要考虑方法调用的顺序。在理想图中用另外一条边来表示方法的调用(为了便于区分,数据流笔者使用蓝色线(以虚线表示),控制流使用红色线 (以实线表示)),说明代码的执行顺序是先调用getX()方法,再调用getY()方法,如图11-14所示。

image-20211126191158391

图11-14 构造理想图(2)

以上这些简单的前置知识就已经足以支撑我们本次实战的进行了,理想图本质上就是这种将数据流图和控制流图以某种方式合并到一起,用一种边来表示数据流向,另一种边来表示控制流向的图形表示。

现在我们在代码清单11-15或者代码清单11-16所示的基础上再增加一个参数-Dgraal.Dump,要求Graal编译器把构造的理想图输出出来,加入后编译时将会产生类似如下的输出,提示了生成的理想图的存储位置:

1
2
[Use -Dgraal.LogFile=<path> to redirect Graal log output to a file.] 
Dumping IGV graphs in /home/icyfenix/develop/eclipse-workspace/A_GraalTest/graal_dumps/2019.08.18.16.51.23.073

我们可以使用mx igv命令来获得能够支持Graal编译器生成的理想图格式的新版本的Ideal Graph Visualizer工具^1,我们以下面这段简单代码的理想图的表示为例子:

1
2
3
int average(int a, int b) {
return (a + b) / 2;
}

在Ideal Graph Visualizer工具中,将显示图11-15所示的样式的理想图。

image-20211126191403447

图11-15 构造理想图(3)

与图11-11和图11-12所示相比,虽然没有了箭头,但是节点上列明了代表执行顺序的序号,仍然是蓝色线表示数据流、红色线表示控制流。从图中可以看到参数0(记作P(0))和参数1(记作P(1))是如何送入加法操作的,然后结果是如何和常量2(记作C(2))一起送入除法操作的。

再下一步我们就会开始接触真实的代码编译和优化了。前面介绍编译器优化技术时提到过公共子表达式消除,那我们来设计代码清单11-17所示的两段代码。

代码清单11-17 公共子表达式被消除的应用范围
1
2
3
4
5
6
7
8
// 以下代码的公共子表达式能够被消除
int workload(int a, int b) {
return (a + b) * (a + b);
}
// 以下代码的公共子表达式是不可以被消除的
int workload() {
return (getA() + getB()) * (getA() + getB());
}

对于第一段代码,a+b是公共子表达式,可以通过优化使其只计算一次而不会有任何的副作用。但是对于第二段代码,由于getA()和getB()方法内部所蕴含的操作是不确定的,它是否被调用、调用次数的不同都可能会产生不同返回值或者其他影响程序状态的副作用(譬如改变某个全局的状态变量), 这种代码只能内联了getA()和getB()方法之后才能考虑更进一步的优化措施,仍然保持函数调用的情况下是无法做公共子表达式消除的。我们可以从Graal生成的理想图中清晰地看到这一点,对于第一段代码,生成的理想图如图11-16所示。

image-20211126191539336

图11-16 构造理想图(4)

从图11-16所示中可以看到,参数1、2的加法操作只进行了一次,然后同时流出了两条数据流指向乘法操作的输入中。而如果是第二段代码,则生成的理想图如图11-17所示。

image-20211126191949939

图11-17 构造理想图(5)

从图中代表控制流的红色边(以实线表示)可以看出,四次方法调用全部执行了,代表数据流的蓝色边(以虚线表示)也明确看到了两个独立加法操作节点,由此看出这个版本是不会把它当作公共子表达式来消除的。

11.5.3 JVMCI编译器接口

image-20211126123752046

图11-11 手动关闭其他版本的工程

现在请读者来思考一下,如果让您来设计JVMCI编译器接口,它应该是怎样的?既然JVMCI面向的是Java语言的编译器接口,那它至少在形式上是与我们已经见过无数次的Java接口是一样的。我们来考虑即时编译器的输入是什么。答案当然是要编译的方法的字节码。既然叫字节码,顾名思义它就应该是“用一个字节数组表示的代码”。那接下来它输出什么?这也很简单,即时编译器应该输出与方法对应的二进制机器码,二进制机器码也应该是“用一个字节数组表示的代码”。这样的话,JVMCI接口就应该看起来类似于下面这种样子:

1
2
3
interface JVMCICompiler {
byte[] compileMethod(byte[] bytecode);
}

事实上JVMCI接口只比上面这个稍微复杂一点点,因为其输入除了字节码外,HotSpot还会向编译器提供各种该方法的相关信息,譬如局部变量表中变量槽的个数、操作数栈的最大深度,还有分层编译在底层收集到的统计信息等。因此JVMCI接口的核心内容实际就是代码清单11-13总所示的这些。

代码清单11-13 JVMCI接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface JVMCICompiler {
void compileMethod(CompilationRequest request);
}
interface CompilationRequest {
JavaMethod getMethod();
}
interface JavaMethod {
byte[] getCode();
int getMaxLocals();
int getMaxStackSize();
ProfilingInfo getProfilingInfo();
...
// 省略其他方法
}

我们在Eclipse中找到JVMCICompiler接口,通过继承关系分析,可以清楚地看到有一个实现类HotSpotGraalCompiler实现了JVMCI,如图11-12所示,这个就是我们要分析的代码的入口。

image-20211126123918694

图11-12 JVMCI接口的继承关系

为了后续调试方便,我们先准备一段简单的代码,并让它触发HotSpot的即时编译,以便我们跟踪观察编译器是如何工作对的。具体代码如清单11-14所示。

代码清单11-14 触发即时编译的示例代码[^1]
1
2
3
4
5
6
7
8
9
10
public class Demo {
public static void main(String[] args) {
while (true) {
workload(14, 2);
}
}
private static int workload(int a, int b) {
return a + b;
}
}

由于存在无限循环,workload()方法肯定很快就会被虚拟机发现是热点代码因而进行编译。实际上除了workload()方法以外,这段简单的代码还会导致相当多的其他方法的编译,因为一个最简单的Java 类的加载和运行也会触发数百个类的加载。为了避免干扰信息太多,笔者加入了参数-XX: CompileOnly来限制只允许workload()方法被编译。先采用以下命令,用标准的服务端编译器来运行清单11-14中所示的程序。

1
2
3
4
5
6
7
8
9
10
$ javac Demo.java 
$ java \
-XX:+PrintCompilation \
-XX:CompileOnly=Demo::workload \
Demo
...
193 1 3 Demo::workload (4 bytes)
199 2 1 Demo::workload (4 bytes)
199 1 3 Demo::workload (4 bytes) made not entrant
...

上面显示wordload()方法确实被分层编译了多次,“made not entrant”的输出就表示了方法的某个已编译版本被丢弃过。从这段信息中我们清楚看到,分层编译机制及最顶层的服务端编译都已经正常工作了,下一步就是用我们在Eclipse中的Graal编译器代替HotSpot的服务端编译器。

为简单起见,笔者加上-XX:-TieredCompilation关闭分层编译,让虚拟机只采用有一个JVMCI编译器而不是由客户端编译器和JVMCI混合分层。然后使用参数-XX:+EnableJVMCI、-XX: +UseJVMCICompiler来启用JVMCI接口和JVMCI编译器。由于这些目前尚属实验阶段的功能,需要再使用-XX:+UnlockExperimentalVMOptions参数进行解锁。最后,也是最关键的一个问题,如何让HotSpot找到Graal编译器的位置呢?

如果采用特殊版的JDK 8,那虚拟机将会自动去查找JAVA_HOME/jre/lib/jvmci目录。假如这个目录不存在,那就会从-Djvmci.class.path.append参数中搜索。它查找的目标,即Graal编译器的JAR包,刚才我们已经通过mx build命令成功编译出来,所以在JDK 8下笔者使用的启动参数如代码清单11-15所示。

代码清单11-15 JDK8的运行配置

1
2
3
4
5
6
7
8
9
-Djvmci.class.path.append=~/graal/compiler/mxbuild/dists/jdk1.8/graal.jar:~/graal/sdk/mxbuild/dists/jdk1.8/graal
-sdk.jar
-XX:+UnlockExperimentalVMOptions
-XX:+EnableJVMCI
-XX:+UseJVMCICompiler
-XX:
-TieredCompilation
-XX:+PrintCompilation
-XX:CompileOnly=Demo::workload

如果读者采用JDK 9或以上版本,那原本的Graal编译器是实现在jdk.internal.vm.compiler模块中的,我们只要用–upgrade-module-path参数指定这个模块的升级包即可,具体如代码清单11-16所示。

代码清单11-16 JDK 9或以上版本的运行配置
1
2
3
4
5
6
7
8
9
--module-path=~/graal/sdk/mxbuild/dists/jdk11/graal.jar 
--upgrade-module-path=~graal/compiler/mxbuild/dists/jdk11/jdk.internal.vm.compiler.jar
-XX:+UnlockExperimentalVMOptions
-XX:+EnableJVMCI
-XX:+UseJVMCICompiler
-XX:
-TieredCompilation
-XX:+PrintCompilation
-XX:CompileOnly=Demo::workload

通过上述参数,HotSpot就能顺利找到并应用我们编译的Graal编译器了。为了确认效果,我们对HotSpotGraalCompiler类的compileMethod()方法做一个简单改动,输出编译的方法名称和编译耗时,具体如下(黑色加粗代码是笔者在源码中额外添加的内容):

1
2
3
4
5
6
7
public CompilationRequestResult compileMethod(CompilationRequest request) {
long time = System.currentTimeMillis();
CompilationRequestResult result = compileMethod(request, true, graalRuntime.getOptions());
System.out.println("compile method:" + request.getMethod().getName());
System.out.println("time used:" + (System.currentTimeMillis() - time));
return result;
}

在Eclipse里面运行这段代码,不需要重新运行mx build,马上就可以看到类似如下所示的输出结果:

1
2
3
4
97  1           Demo::workload (4 bytes) 
……
compile method:workload
time used:4081

[^1]: 本节部分示例和图片来自于Chris Seaton的文章《Understanding How Graal Works-a Java JIT Compiler Written in Java》:https://chrisseaton.com/truffleruby/jokerconf17/。

11.5.2 构建编译调试环境

由于Graal编译器要同时支持Graal VM下的各种子项目,如Truffle、Substrate VM、Sulong等,还要支持作为HotSpot和Maxine虚拟机的即时编译器,所以只用Maven或Gradle的话,配置管理过程会相当复杂。为了降低代码管理、依赖项管理、编译和测试等环节的复杂度,Graal团队专门用Python 2写了一个名为mx的小工具来自动化做好这些事情。我们要构建Graal的调试环境,第一步要先把构建工具mx 安装好,这非常简单,进行如下操作即可:

1
2
$ git clone https://github.com/graalvm/mx.git 
$ export PATH=`pwd`/mx:$PATH

既然Graal编译器是以Java代码编写的,那第二步自然是要找一个合适的JDK来编译。考虑到Graal VM项目是基于OpenJDK 8开发的,而JVMCI接口又在JDK 9以后才会提供,所以Graal团队提供了一个带有JVMCI功能的OpenJDK 8版本,我们可以选择这个版本的JDK 8来进行编译。当读者只关注Graal 编译器在HotSpot上的应用而不想涉及Graal VM其他方面时,可直接采用JDK 9及之后的标准Open/OracleJDK。在本次实战中,笔者机器上使用的是带JVMCI的OpenJDK 8^1,对于与其他JDK版本有差别的步骤,笔者会特别说明。选择好JDK版本后,设置JAVA_HOME环境变量即可,这是编译过程中唯一需要手工处理的依赖:

1
export JAVA_HOME=/usr/lib/jvm/oraclejdk1.8.0_212-jvmci-20-b01

第三步是获取Graal编译器代码,编译器部分的代码是与整个Graal VM放在一块的,我们把Graal VM复制下来,大约有700MB,操作如下:

1
$ git clone https://github.com/graalvm/graal.git

其他目录中存放着Truffle、Substrate VM、Sulong等其他项目,这些在本次实战中不会涉及。进入compiler子目录,使用mx构建Graal编译器,操作如下:

1
2
$ cd graal/compiler 
$ mx build

由于整个构建过程需要的依赖项都可以自动处理,需要手动处理的只有OpenJDK一个,所以编译一般不会出现什么问题,大概两三分钟编译即可完成。此时其实已经可以修改、调试Graal编译器了, 但写Java代码不同于C、C++,应该没有人会直接用VIM去做Java开发调试,我们还是需要一个IDE来支持本次实战的。mx工具能够支持Eclipse、Intellij IDEA和NetBeans三种主流的Java IDE项目的创建, 由于Graal团队中使用Eclipse占多数,支持也最好,所以笔者也选择Eclipse来进行本次实战,创建Eclipse项目的操作如下:

1
2
$ cd graal/compiler 
$ mx eclipseinit

无论使用哪种IDE,都需要把IDE配置中使用的Java堆修改到2GB或以上,才能保证Graal在IDE中的编译构建能够顺利进行,譬如Eclipse默认配置(eclipse.ini文件)下的Java堆最大为1GB,这是不够的。设置完成后,在Eclipse中选择File->Open Projects from File System,再选择Graal项目的根目录,将会导入整个Graal VM,导入的工程如图11-10所示。

image-20211126123628899

图11-10 Graal VM项目(部分工程)

如果你与笔者一样采用的是JDK 8,那么要记得在Eclipse中也必须将那个带有JVMCI功能的特殊JDK 8用作Eclipse里面“Java SE-1.8”的环境配置(Windows->Preferences->Java->Install JREs->ExecutionEnvironments->Java SE-1.8),此外,还需要手工将以其他版本号结尾的工程关闭,譬如图11-11所示。 这对于采用其他版本JDK来编译的读者也是一样的。

到此为止,整个编译、调试环境就已经构建完毕,下面可以开始探索Graal工作原理的内容了。