This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
<audio id="audio" title="34 | Graal用Java编译Java" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/60/f3/6003fa06d5a4a1509ccddada9ec037f3.mp3"></audio>
最后这三篇文章我将介绍Oracle Labs的GraalVM项目。
GraalVM是一个高性能的、支持多种编程语言的执行环境。它既可以在传统的OpenJDK上运行也可以通过AOTAhead-Of-Time编译成可执行文件单独运行甚至可以集成至数据库中运行。
除此之外,它还移除了编程语言之间的边界,并且支持通过即时编译技术,将混杂了不同的编程语言的代码编译到同一段二进制码之中,从而实现不同语言之间的无缝切换。
今天这一篇我们就来讲讲GraalVM的基石Graal编译器。
在之前的篇章中特别是介绍即时编译技术的第二部分我们反反复复提到了Graal编译器。这是一个用Java写就的即时编译器它从Java 9u开始便被集成自JDK中作为实验性质的即时编译器。
Graal编译器可以通过Java虚拟机参数`-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler`启用。当启用时它将替换掉HotSpot中的C2编译器并响应原本由C2负责的编译请求。
在今天的文章中我将详细跟你介绍一下Graal与Java虚拟机的交互、Graal和C2的区别以及Graal的实现细节。
## Graal和Java虚拟机的交互
我们知道即时编译器是Java虚拟机中相对独立的模块它主要负责接收Java字节码并生成可以直接运行的二进制码。
具体来说即时编译器与Java虚拟机的交互可以分为如下三个方面。
1. 响应编译请求;
1. 获取编译所需的元数据如类、方法、字段和反映程序执行状态的profile
1. 将生成的二进制码部署至代码缓存code cache里。
即时编译器通过这三个功能组成了一个响应编译请求、获取编译所需的数据,完成编译并部署的完整编译周期。
传统情况下即时编译器是与Java虚拟机紧耦合的。也就是说对即时编译器的更改需要重新编译整个Java虚拟机。这对于开发相对活跃的Graal来说显然是不可接受的。
为了让Java虚拟机与Graal解耦合我们引入了[Java虚拟机编译器接口](http://openjdk.java.net/jeps/243)JVM Compiler InterfaceJVMCI将上述三个功能抽象成一个Java层面的接口。这样一来在Graal所依赖的JVMCI版本不变的情况下我们仅需要替换Graal编译器相关的jar包Java 9以后的jmod文件便可完成对Graal的升级。
JVMCI的作用并不局限于完成由Java虚拟机发出的编译请求。实际上Java程序可以直接调用Graal编译并部署指定方法。
Graal的单元测试便是基于这项技术。为了测试某项优化是否起作用原本我们需要反复运行某一测试方法直至Graal收到由Java虚拟机发出针对该方法的编译请求而现在我们可以直接指定编译该方法并进行测试。我们下一篇将介绍的Truffle语言实现框架同样也是基于这项技术的。
## Graal和C2的区别
Graal和C2最为明显的一个区别是Graal是用Java写的而C2是用C++写的。相对来说Graal更加模块化也更容易开发与维护毕竟连C2的作者Cliff Click大神都不想重蹈用C++开发Java虚拟机的覆辙。
许多开发者会觉得用C++写的C2肯定要比Graal快。实际上在充分预热的情况下Java程序中的热点代码早已经通过即时编译转换为二进制码在执行速度上并不亚于静态编译的C++程序。
再者即便是解释执行Graal也仅是会减慢编译效率而并不影响编译结果的性能。
换句话说如果C2和Graal采用相同的优化手段那么它们的编译结果是一样的。所以程序达到稳定状态即不再触发新的即时编译的性能也就是峰值性能将也是一样的。
由于Java语言容易开发维护的优势我们可以很方便地将C2的新优化移植到Graal中。反之则不然比如在Graal中被证实有效的部分逃逸分析partial escape analysis至今未被移植到C2中。
Graal和C2另一个优化上的分歧则是方法内联算法。相对来说Graal的内联算法对新语法、新语言更加友好例如Java 8的lambda表达式以及Scala语言。
我们曾统计过数十个Java或Scala程序的峰值性能。总体而言Graal编译结果的性能要优于C2。对于Java程序来说Graal的优势并不明显对于Scala程序来说Graal的性能优势达到了10%。
大规模使用Scala的Twitter便在他们的生产环境中部署了Graal编译器并取得了11%的性能提升。([Slides](https://downloads.ctfassets.net/oxjq45e8ilak/6eh2A72b4IyWsWOIcig4K0/cbb664566fe86672d92ddfb210623920/Chris_Thalinger_Twitter_s_quest_for_a_wholly_Graal_runtime.pdf), [Video](https://youtu.be/G-vlQaPMAxg?t=20m15s)该数据基于GraalVM社区版。
## Graal的实现
Graal编译器将编译过程分为前端和后端两大部分。前端用于实现平台无关的优化如方法内联以及小部分平台相关的优化而后端则负责大部分的平台相关优化如寄存器分配以及机器码的生成。
在介绍即时编译技术时我曾提到过Graal和C2都采用了Sea-of-Nodes IR。严格来说这里指的是Graal的前端而后端采用的是另一种非Sea-of-Nodes的IR。通常我们将前端的IR称之为High-level IR或者HIR后端的IR则称之为Low-level IR或者LIR。
Graal的前端是由一个个单独的优化阶段optimization phase构成的。我们可以将每个优化阶段想象成一个图算法它会接收一个规则的图遍历图上的节点并做出优化并且返回另一个规则的图。前端中的编译阶段除了少数几个关键的之外其余均可以通过配置选项来开启或关闭。
<img src="https://static001.geekbang.org/resource/image/d9/b8/d9772c569c25eabb7c2e7af53878e3b8.png" alt="" />
Graal编译器前端的优化阶段局部
>
<p>感兴趣的同学可以阅读Graal repo里配置这些编译优化阶段的源文件<br />
[HighTier.java](https://github.com/oracle/graal/blob/master/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/phases/HighTier.java)[MidTier.java](https://github.com/oracle/graal/blob/master/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/phases/MidTier.java),以及[LowTier.java](https://github.com/oracle/graal/blob/master/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/phases/LowTier.java)。</p>
我们知道Graal和C2都采用了激进的投机性优化手段speculative optimization
通常这些优化都基于某种假设assumption。当假设出错的情况下Java虚拟机会借助去优化deoptimization这项机制从执行即时编译器生成的机器码切换回解释执行在必要情况下它甚至会废弃这份机器码并在重新收集程序profile之后再进行编译。
举个以前讲过的例子,类层次分析。在进行虚方法内联时(或者其他与类层次相关的优化),我们可能会发现某个接口仅有一个实现。
在即时编译过程中,我们可以假设在之后的执行过程中仍旧只有这一个实现,并根据这个假设进行编译优化。当之后加载了接口的另一实现时,我们便会废弃这份机器码。
Graal与C2相比会更加激进。它从设计上便十分青睐这种基于假设的优化手段。在编译过程中Graal支持自定义假设并且直接与去优化节点相关联。
当对应的去优化被触发时Java虚拟机将负责记录对应的自定义假设。而Graal在第二次编译同一方法时便会知道该自定义假设有误从而不再对该方法使用相同的激进优化。
Java虚拟机的另一个能够大幅度提升性能的特性是intrinsic方法我在之前的篇章中已经详细介绍过了。在Graal中实现高性能的intrinsic方法也相对比较简单。Graal提供了一种替换方法调用的机制在解析Java字节码时会将匹配到的方法调用替换成对另一个内部方法的调用或者直接替换为特殊节点。
举例来说我们可以把比较两个byte数组的方法`java.util.Arrays.equals(byte[],byte[])`替换成一个特殊节点,用来代表整个数组比较的逻辑。这样一来,当前编译方法所对应的图将被简化,因而其适用于其他优化的可能性也将提升。
## 总结与实践
Graal是一个用Java写就的、并能够将Java字节码转换成二进制码的即时编译器。它通过JVMCI与Java虚拟机交互响应由后者发出的编译请求、完成编译并部署编译结果。
对Java程序而言Graal编译结果的性能略优于OpenJDK中的C2对Scala程序而言它的性能优势可达到10%企业版甚至可以达到20%。这背后离不开Graal所采用的激进优化方式。
今天的实践环节你可以尝试使用附带Graal编译器的JDK。在Java 1011中你可以通过添加虚拟机参数`-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler`来启用,或者下载我们部署在[Oracle OTN](https://www.oracle.com/technetwork/oracle-labs/program-languages/downloads/index.html)上的基于Java 8的版本。
>
在刚开始运行的过程中Graal编译器本身需要被即时编译会抢占原本可用于编译应用代码的计算资源。因此目前Graal编译器的启动性能会较差。最后一篇我会介绍解决方案。

View File

@@ -0,0 +1,207 @@
<audio id="audio" title="35 | Truffle语言实现框架" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2b/05/2bf0b4760e1aac3dc495918fe4dd0a05.mp3"></audio>
今天我们来聊聊GraalVM中的语言实现框架Truffle。
我们知道,实现一门新编程语言的传统做法是实现一个编译器,也就是把用该语言编写的程序转换成可直接在硬件上运行的机器码。
通常来说,编译器分为前端和后端:前端负责词法分析、语法分析、类型检查和中间代码生成,后端负责编译优化和目标代码生成。
不过,许多编译器教程只涉及了前端中的词法分析和语法分析,并没有真正生成可以运行的目标代码,更谈不上编译优化,因此在生产环境中并不实用。
另一种比较取巧的做法则是将新语言编译成某种已知语言或者已知的中间形式例如将Scala、Kotlin编译成Java字节码。
这样做的好处是可以直接享用Java虚拟机自带的各项优化包括即时编译、自动内存管理等等。因此这种做法对所生成的Java字节码的优化程度要求不高。
不过,不管是附带编译优化的编译器,还是生成中间形式并依赖于其他运行时的即时编译优化的编译器,它们所针对的都是[编译型语言](https://en.wikipedia.org/wiki/Compiled_language),在运行之前都需要这一额外的编译步骤。
与编译型语言相对应的则是[解释型语言](https://en.wikipedia.org/wiki/Interpreted_language)例如JavaScript、Ruby、Python等。对于这些语言来说它们无须额外的编译步骤而是依赖于解释执行器进行解析并执行。
为了让该解释执行器能够高效地运行大型程序,语言实现开发人员通常会将其包装在虚拟机里,并实现诸如即时编译、垃圾回收等其他组件。这些组件对语言设计 本身并无太大贡献,仅仅是为了实用性而不得不进行的工程实现。
在理想情况下我们希望在不同的语言实现中复用这些组件。也就是说每当开发一门新语言时我们只需要实现它的解释执行器便能够直接复用即时编译、垃圾回收等组件从而达到高性能的效果。这也是Truffle项目的目标。接下来我们就来讲讲这个项目。
## Truffle项目简介
Truffle是一个用Java写就的语言实现框架。基于Truffle的语言实现仅需用Java实现词法分析、语法分析以及针对语法分析所生成的抽象语法树Abstract Syntax TreeAST的解释执行器便可以享用由Truffle提供的各项运行时优化。
就一个完整的Truffle语言实现而言由于实现本身以及其所依赖的Truffle框架部分都是用Java实现的因此它可以运行在任何Java虚拟机之上。
当然如果Truffle运行在附带了Graal编译器的Java虚拟机之上那么它将调用Graal编译器所提供的API主动触发对Truffle语言的即时编译将对AST的解释执行转换为执行即时编译后的机器码。
在这种情况下Graal编译器相当于一个提供了即时编译功能的库宿主虚拟机本身仍可使用C2作为其唯一的即时编译器或者分层编译模式下的4层编译器。
<img src="https://static001.geekbang.org/resource/image/20/30/20c7a514f226689536fafc6886a08e30.png" alt="" />
我们团队实现并且开源了多个Truffle语言例如[JavaScript](https://github.com/graalvm/graaljs)[Ruby](https://github.com/oracle/truffleruby)[R](https://github.com/oracle/fastr)[Python](https://github.com/graalvm/graalpython)以及可用来解释执行LLVM bitcode的[Sulong](https://github.com/oracle/graal/tree/master/sulong)。关于Sulong项目任何能够编译为LLVM bitcode的编程语言例如C/C++,都能够在这上面运行。
下图展示了运行在GraalVM EE上的Java虚拟机语言以及除Python外Truffle语言的峰值性能指标2017年数据
<img src="https://static001.geekbang.org/resource/image/0a/44/0aa87b77b2d6eb0147d4a2b342b0d644.png" alt="" />
这里我采用的基线是每个语言较有竞争力的语言实现。
- 对于Java虚拟机语言Java、Scala我比较的是使用C2的HotSpot虚拟机和使用Graal的HotSpot虚拟机。
- 对于Ruby我比较的是运行在HotSpot虚拟机之上的JRuby和Truffle Ruby。
- 对于R我比较的是GNU R和基于Truffle的FastR。
- 对于C/C++我比较的是利用LLVM编译器生成的二进制文件和基于Truffle的Sulong。
- 对于JavaScript我比较的是Google的V8和Graal.js。
针对每种语言,我们运行了上百个基准测试,求出各个基准测试**峰值性能**的加速比并且汇总成图中所示的几何平均值Geo. mean
简单地说明一下当GraalVM的加速比为1时代表使用其他语言实现和使用GraalVM的性能相当。当GraalVM加速比超过1时则代表GraalVM的性能较好反之则说明GraalVM的性能较差。
我们可以看到Java跑在Graal上和跑在C2上的执行效率类似而Scala跑在Graal上的执行效率则是跑在C2上的1.2倍。
对于Ruby或者R这类解释型语言经由Graal编译器加速的Truffle语言解释器的性能十分优越分别达到对应基线的4.1x和4.5x。这里便可以看出使用专业即时编译器的Truffle框架的优势所在。
不过对于同样拥有专业即时编译器的V8来说基于Truffle的Graal.js仍处于追赶者的位置。考虑到我们团队中负责Graal.js的工程师仅有个位数能够达到如此性能已属不易。现在Graal.js已经开源出来我相信借助社区的贡献它的性能能够得到进一步的提升。
Sulong与传统的C/C++相比,由于两者最终都将编译为机器码,因此原则上后者定义了前者的性能上限。
不过Sulong将C/C++代码放在托管环境中运行所有代码中的内存访问都会在托管环境的监控之下。无论是会触发Segfault的异常访问还是读取敏感数据的恶意访问都能够被Sulong拦截下来并作出相应处理。
## Partial Evaluation
如果要理解Truffle的原理我们需要先了解Partial Evaluation这一个概念。
假设有一段程序`P`,它将一系列输入`I`转换成输出`O`(即`P: I -&gt; O`)。而这些输入又可以进一步划分为编译时已知的常量`IS`,和编译时未知的`ID`
那么,我们可以将程序`P: I -&gt; O`转换为等价的另一段程序`P': ID -&gt; O`。这个新程序`P'`便是`P`的特化Specialization而从`P`转换到`P'`的这个过程便是所谓的Partial Evaluation。
回到Truffle这边我们可以将Truffle语言的解释执行器当成`P`将某段用Truffle语言写就的程序当作`IS`并通过Partial Evaluation特化为`P'`。由于Truffle语言的解释执行器是用Java写的因此我们可以利用Graal编译器将`P'`编译为二进制码。
下面我将用一个具体例子来讲解。
假设有一门语言X只支持读取整数参数和整数加法。这两种操作分别对应下面这段代码中的AST节点`Arg``Add`
```
abstract class Node {
abstract int execute(int[] args);
}
class Arg extends Node {
final int index;
Arg(int i) { this.index = i; }
int execute(int[] args) {
return args[index];
}
}
class Add extends Node {
final Node left, right;
Add(Node left, Node right) {
this.left = left;
this.right = right;
}
int execute(int[] args) {
return left.execute(args) +
right.execute(args);
}
}
static int interpret(Node node, int[] args) {
return node.execute(args);
}
```
所谓AST节点的解释执行便是调用这些AST节点的`execute`方法而一段程序的解释执行则是调用这段程序的AST根节点的`execute`方法。
我们可以看到,`Arg`节点和`Add`节点均实现了`execute`方法接收一个用来指代程序输入的int数组参数并返回计算结果。其中`Arg`节点将返回int数组的第`i`个参数(`i`是硬编码在程序之中的常量);而`Add`节点将分别调用左右两个节点的`execute`方法, 并将所返回的值相加后再返回。
下面我们将利用语言X实现一段程序计算三个输入参数之和`arg0 + arg1 + arg2`。这段程序解析生成的AST如下述代码所示
```
// Sample program: arg0 + arg1 + arg2
sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
```
这段程序对应的解释执行则是`interpret(sample, args)`,其中`args`为代表传入参数的int数组。由于`sample`是编译时常量因此我们可以将其通过Partial Evaluation特化为下面这段代码所示的`interpret0`方法:
```
static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
static int interpret0(int[] args) {
return sample.execute(args);
}
```
Truffle的Partial Evaluator会不断进行方法内联直至遇到被``@TruffleBoundary`注解的方法)。因此,上面这段代码的`interpret0`方法,在内联了对`Add.execute`方法的调用之后,会转换成下述代码:
```
static final Node sample = new Add(new Add(new Arg(0), new Arg(1)), new Arg(2));
static int interpret0(int[] args) {
return sample.left.execute(args) + sample.right.execute(args);
}
```
同样,我们可以进一步内联对`Add.execute`方法的调用以及对`Arg.execute`方法的调用,最终将`interpret0`转换成下述代码:
```
static int interpret0(int[] args) {
return args[0] + args[1] + args[2];
}
```
至此我们已成功地将一段Truffle语言代码的解释执行转换为上述Java代码。接下来我们便可以让Graal编译器将这段Java代码编译为机器码从而实现Truffle语言的即时编译。
## 节点重写
Truffle的另一项关键优化是节点重写node rewriting
在动态语言中,许多变量的类型是在运行过程中方能确定的。以加法符号`+`为例,它既可以表示整数加法,还可以表示浮点数加法,甚至可以表示字符串加法。
如果是静态语言,我们可以通过推断加法的两个操作数的具体类型,来确定该加法的类型。但对于动态语言来说,我们需要在运行时动态确定操作数的具体类型,并据此选择对应的加法操作。这种在运行时选择语义的节点,会十分不利于即时编译,从而严重影响到程序的性能。
Truffle语言解释器会收集每个AST节点所代表的操作的类型并且在即时编译时作出针对所收集得到的类型profile的特化specialization
还是以加法操作为例如果所收集的类型profile显示这是一个整数加法操作那么在即时编译时我们会将对应的AST节点当成整数加法如果是一个字符串加法操作那么我们会将对应的AST节点当成字符串加法。
当然,如果该加法操作既有可能是整数加法也可能是字符串加法,那么我们只好在运行过程中判断具体的操作类型,并选择相应的加法操作。
<img src="https://static001.geekbang.org/resource/image/54/8f/543ee374164fd43f2773043c675b568f.png" alt="" />
这种基于类型profile的优化与我们以前介绍过的Java虚拟机中解释执行器以及三层C1编译代码十分类似它们背后的核心都是基于假设的投机性优化以及在假设失败时的去优化。
在即时编译过后如果运行过程中发现AST节点的实际类型和所假设的类型不同Truffle会主动调用Graal编译器提供的去优化API返回至解释执行AST节点的状态并且重新收集AST节点的类型信息。之后Truffle会再次利用Graal编译器进行新一轮的即时编译。
当然如果能够在第一次编译时便已达到稳定状态不再触发去优化以及重新编译那么这会极大地减短程序到达峰值性能的时间。为此我们统计了各个Truffle语言的方法在进行过多少次方法调用后其AST节点的类型会固定下来。
据统计在JavaScript方法和Ruby方法中80%会在5次方法调用后稳定下来90%会在7次调用后稳定下来99%会在19次方法调用之后稳定下来。
R语言的方法则比较特殊即便是不进行任何调用有50%的方法已经稳定下来了。这背后的原因也不难推测这是因为R语言主要用于数值统计几乎所有的操作都是浮点数类型的。
## Polyglot
在开发过程中,我们通常会为工程项目选定一门语言,但问题也会接踵而至:一是这门语言没有实现我们可能需要用到的库,二是这门语言并不适用于某类问题。
Truffle语言实现框架则支持Polyglot允许在同一段代码中混用不同的编程语言从而使得开发人员能够自由地选择合适的语言来实现子组件。
与其他Polyglot框架不同的是Truffle语言之间能够共用对象。也就是说在不对某个语言中的对象进行复制或者序列化反序列化的情况下Truffle可以无缝地将该对象传递给另一门语言。因此Truffle的Polyglot在切换语言时性能开销非常小甚至经常能够达到零开销。
Truffle的Polyglot特性是通过Polyglot API来实现的。每个实现了Polyglot API的Truffle语言其对象都能够被其他Truffle语言通过Polyglot API解析。实际上当通过Polyglot API解析外来对象时我们并不需要了解对方语言便能够识别其数据结构访问其中的数据并进行进一步的计算。
## 总结与实践
今天我介绍了GraalVM中的Truffle项目。
Truffle是一个语言实现框架允许语言开发者在仅实现词法解析、语法解析以及AST解释器的情况下达到极佳的性能。目前Oracle Labs已经实现并维护了JavaScript、Ruby、R、Python以及可用于解析LLVM bitcode的Sulong。后者将支持在GraalVM上运行C/C++代码。
Truffle背后所依赖的技术是Partial Evaluation以及节点重写。Partial Evaluation指的是将所要编译的目标程序解析生成的抽象语法树当做编译时常量特化该Truffle语言的解释器从而得到指代这段程序解释执行过程的Java代码。然后我们可以借助Graal编译器将这段Java代码即时编译为机器码。
节点重写则是收集AST节点的类型根据所收集的类型profile进行的特化并在节点类型不匹配时进行去优化并重新收集、编译的一项技术。
Truffle的Polyglot特性支持在一段代码中混用多种不同的语言。与其他Polyglot框架相比它支持在不同的Truffle语言中复用内存中存储的同一个对象。
今天的实践环节请你试用GraalVM中附带的各项语言实现。你可以运行我们官网上的各个[示例程序](https://www.graalvm.org/docs/examples/)。

View File

@@ -0,0 +1,106 @@
<audio id="audio" title="36 | SubstrateVMAOT编译框架" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/60/b4/600d2b337171381b5f16347bc8abc5b4.mp3"></audio>
今天我们来聊聊GraalVM中的Ahead-Of-TimeAOT编译框架SubstrateVM。
先来介绍一下AOT编译所谓AOT编译是与即时编译相对立的一个概念。我们知道即时编译指的是在程序的运行过程中将字节码转换为可在硬件上直接运行的机器码并部署至托管环境中的过程。
而AOT编译指的则是在**程序运行之前**,便将字节码转换为机器码的过程。它的成果可以是需要链接至托管环境中的动态共享库,也可以是独立运行的可执行文件。
狭义的AOT编译针对的目标代码需要与即时编译的一致也就是针对那些原本可以被即时编译的代码。不过我们也可以简单地将AOT编译理解为类似于GCC的静态编译器。
AOT编译的优点显而易见我们无须在运行过程中耗费CPU资源来进行即时编译而程序也能够在启动伊始就达到理想的性能。
然而与即时编译相比AOT编译无法得知程序运行时的信息因此也无法进行基于类层次分析的完全虚方法内联或者基于程序profile的投机性优化并非硬性限制我们可以通过限制运行范围或者利用上一次运行的程序profile来绕开这两个限制。这两者都会影响程序的峰值性能。
Java 9引入了实验性AOT编译工具[jaotc](http://openjdk.java.net/jeps/295)。它借助了Graal编译器将所输入的Java类文件转换为机器码并存放至生成的动态共享库之中。
在启动过程中Java虚拟机将加载参数`-XX:AOTLibrary`所指定的动态共享库,并部署其中的机器码。这些机器码的作用机理和即时编译生成的机器码作用机理一样,都是在方法调用时切入,并能够去优化至解释执行。
由于Java虚拟机可能通过Java agent或者C agent改动所加载的字节码或者这份AOT编译生成的机器码针对的是旧版本的Java类因此它需要额外的验证机制来保证即将链接的机器码的语义与对应的Java类的语义是一致的。
jaotc使用的机制便是类指纹class fingerprinting。它会在动态共享库中保存被AOT编译的Java类的摘要信息。在运行过程中Java虚拟机负责将该摘要信息与已加载的Java类相比较一旦不匹配则直接舍弃这份AOT编译的机器码。
jaotc的一大应用便是编译java.base module也就是Java核心类库中最为基础的类。这些类很有可能会被应用程序所调用但调用频率未必高到能够触发即时编译。
因此如果Java虚拟机能够使用AOT编译技术将它们提前编译为机器码那么将避免在执行即时编译生成的机器码时因为“不小心”调用到这些基础类而需要切换至解释执行的性能惩罚。
不过今天要介绍的主角并非jaotc而是同样使用了Graal编译器的AOT编译框架SubstrateVM。
## SubstrateVM的设计与实现
SubstrateVM的设计初衷是提供一个高启动性能、低内存开销并且能够无缝衔接C代码的Java运行时。它与jaotc的区别主要有两处。
第一SubstrateVM脱离了HotSpot虚拟机并拥有独立的运行时包含异常处理同步线程管理内存管理垃圾回收和JNI等组件。
第二SubstrateVM要求目标程序是封闭的即不能动态加载其他类库等。基于这个假设SubstrateVM将探索整个编译空间并通过静态分析推算出所有虚方法调用的目标方法。最终SubstrateVM会将所有可能执行到的方法都纳入编译范围之中从而免于实现额外的解释执行器。
>
有关SubstrateVM的其他限制你可以参考[这篇文档](https://github.com/oracle/graal/blob/master/substratevm/LIMITATIONS.md)。
从执行时间上来划分SubstrateVM可分为两部分native image generator以及SubstrateVM运行时。后者SubstrateVM运行时便是前面提到的精简运行时经过AOT编译的目标程序将跑在该运行时之上。
native image generator则包含了真正的AOT编译逻辑。它本身是一个Java程序将使用Graal编译器将Java类文件编译为可执行文件或者动态链接库。
在进行编译之前native image generator将采用指针分析points-to analysis从用户提供的程序入口出发探索所有可达的代码。在探索的同时它还将执行初始化代码并在最终生成可执行文件时将已初始化的堆保存至一个堆快照之中。这样一来SubstrateVM将直接从目标程序开始运行而无须重复进行Java虚拟机的初始化。
SubstrateVM主要用于Java虚拟机语言的AOT编译例如Java、Scala以及Kotlin。Truffle语言实现本质上就是Java程序而且它所有用到的类都是编译时已知的因此也适合在SubstrateVM上运行。不过它并不会AOT编译用Truffle语言写就的程序。
## SubstrateVM的启动时间与内存开销
SubstrateVM的启动时间和内存开销非常少。我们曾比较过用C和用Java两种语言写就的Hello World程序。C程序的执行时间在10ms以下内存开销在500KB以下。在HotSpot虚拟机上运行的Java程序则需要40ms内存开销为24MB。
使用SubstrateVM的Java程序的执行时间则与C程序持平内存开销在850KB左右。这得益于SubstrateVM所保存的堆快照以及无须额外初始化直接执行目标代码的特性。
同样我们还比较了用JavaScript编写的Hello World程序。这里的测试对象是Google的V8以及基于Truffle的Graal.js。这两个执行引擎都涉及了大量的解析代码以及执行代码因此可以当作大型应用程序来看待。
V8的执行效率非常高能够与C程序的Hello World相媲美但是它使用了约18MB的内存。运行在HotSpot虚拟机上的Graal.js则需要650ms方能执行完这段JavaScript的Hello World程序而且内存开销在120MB左右。
运行在SubstrateVM上的Graal.js无论是执行时间还是内存开销都十分优越分别为10ms以下以及4.2MB。我们可以看到它在运行时间与V8持平的情况下内存开销远小于V8。
由于SubstrateVM的轻量特性它十分适合于嵌入至其他系统之中。Oracle Labs的另一个团队便是将Truffle语言实现嵌入至Oracle数据库之中这样就可以在数据库中运行任意语言的预储程序stored procedure。如果你感兴趣的话可以搜索Oracle Database Multilingual EngineMLE或者参阅这个[网址](https://www.oracle.com/technetwork/database/multilingual-engine/overview/index.html)。我们团队也在与MySQL合作开发MySQL MLE详情可留意我们在今年Oracle Code One的[讲座](https://oracle.rainfocus.com/widget/oracle/oow18/catalogcodeone18?search=MySQL%20JavaScript)。
## Metropolis项目
去年OpenJDK推出了[Metropolis项目](http://openjdk.java.net/projects/metropolis/)他们希望可以实现“Java-on-Java”的远大目标。
我们知道目前HotSpot虚拟机的绝大部分代码都是用C++写的。这也造就了一个非常有趣的现象那便是对Java语言本身的贡献需要精通C++。此外随着HotSpot项目日渐庞大维护难度也逐渐上升。
由于上述种种原因使用Java来开发Java虚拟机的呼声越来越高。Oracle的架构师John Rose便提出了使用Java开发Java虚拟机的四大好处
1. 能够完全控制编译Java虚拟机时所使用的优化技术
1. 能够与C++语言的更新解耦合;
1. 能够减轻开发人员以及维护人员的负担;
1. 能够以更为敏捷的方式实现Java的新功能。
当然Metropolis项目并非第一个提出Java-on-Java概念的项目。实际上[JikesRVM项目](https://www.jikesrvm.org/)和[Maxine VM项目](https://github.com/beehive-lab/Maxine-VM)都已用Java完整地实现了一套Java虚拟机后者的即时编译器C1X便是Graal编译器的前身
然而Java-on-Java技术通常会干扰应用程序的垃圾回收、即时编译优化从而严重影响Java虚拟机的启动性能。
举例来说目前使用了Graal编译器的HotSpot虚拟机会在即时编译过程中生成大量的Java对象这些Java对象同样会占据应用程序的堆空间从而使得垃圾回收更加频繁。
另外Graal编译器本身也会触发即时编译并与应用程序的即时编译竞争编译线程的CPU资源。这将造成应用程序从解释执行切换至即时编译生成的机器码的时间大大地增长从而降低应用程序的启动性能。
Metropolis项目的第一个子项目便是探索部署已AOT编译的Graal编译器的可能性。这个子项目将借助SubstrateVM技术把整个Graal编译器AOT编译为机器码。
这样一来在运行过程中Graal编译器不再需要被即时编译因此也不会再占据可用于即时编译应用程序的CPU资源使用Graal编译器的HotSpot虚拟机的启动性能将得到大幅度地提升。
此外由于SubstrateVM编译得到的Graal编译器将使用独立的堆空间因此Graal编译器在即时编译过程中生成的Java对象将不再干扰应用程序所使用的堆空间。
目前Metropolis项目仍处于前期验证阶段如果你感兴趣的话可以关注之后的发展情况。
## 总结与实践
今天我介绍了GraalVM中的AOT编译框架SubstrateVM。
SubstrateVM的设计初衷是提供一个高启动性能、低内存开销和能够无缝衔接C代码的Java运行时。它是一个独立的运行时拥有自己的内存管理等组件。
SubstrateVM要求所要AOT编译的目标程序是封闭的即不能动态加载其他类库等。在进行AOT编译时它会探索所有可能运行到的方法并全部纳入编译范围之内。
SubstrateVM的启动时间和内存开销都非常少这主要得益于在AOT编译时便已保存了已初始化好的堆快照并支持从程序入口直接开始运行。作为对比HotSpot虚拟机在执行main方法前需要执行一系列的初始化操作因此启动时间和内存开销都要远大于运行在SubstrateVM上的程序。
Metropolis项目将运用SubstrateVM项目逐步地将HotSpot虚拟机中的C++代码替换成Java代码从而提升HotSpot虚拟机的可维护性也加快新Java功能的开发效率。
今天的实践环节,请你参考我们官网的[SubstrateVM教程](https://www.graalvm.org/docs/examples/java-kotlin-aot/)AOT编译一段Java-Kotlin代码。