新的Java JIT编译器 Graal 简介
1. 概述
在本教程中,我们将深入了解名为 Graal 的新 Java 即时 (JIT) 编译器。
我们将了解Graal 项目是什么并描述它的一部分,即高性能动态 JIT 编译器。
2. 什么是JIT编译器?
我们先解释一下 JIT 编译器是做什么的。
当我们编译我们的 Java 程序(例如,使用 javac命令)时,我们最终会将我们的源代码编译成我们代码的二进制表示形式——一个 JVM 字节码。这个字节码比我们的源代码更简单、更紧凑,但我们计算机中的常规处理器无法执行它。
为了能够运行 Java 程序,JVM 会解释字节码。由于解释器通常比在真实处理器上执行的本机代码慢很多,因此 JVM 可以运行另一个编译器,该编译器现在会将我们的字节码编译成可以由处理器运行的机器码。这种所谓的即时编译器比javac编译器复杂得多,它运行复杂的优化以生成高质量的机器代码。
3. 更详细地了解 JIT 编译器
Oracle 的 JDK 实现基于开源 OpenJDK 项目。这包括从 Java 1.3 版开始可用的HotSpot 虚拟机。它包含两个传统的 JIT 编译器:客户端编译器,也称为 C1 和服务器编译器,称为 opto 或 C2。
C1 旨在运行更快并生成优化程度较低的代码,而另一方面,C2 需要更多时间来运行,但会生成更好的优化代码。客户端编译器更适合桌面应用程序,因为我们不希望 JIT 编译有长时间的停顿。服务器编译器更适合需要花费更多时间进行编译的长时间运行的服务器应用程序。
3.1. 分层编译
今天,Java 安装在正常程序执行期间使用这两种 JIT 编译器。
正如我们在上一节中提到的,由javac编译的 Java 程序以解释模式开始执行。JVM 跟踪每个经常调用的方法并编译它们。为此,它使用 C1 进行编译。但是,HotSpot 仍然关注这些方法的未来调用。如果调用次数增加,JVM 将再次重新编译这些方法,但这次使用 C2。
这是 HotSpot 使用的默认策略,称为分层编译。
3.2. 服务器编译器
现在让我们稍微关注一下 C2,因为它是两者中最复杂的。C2 已经过极大的优化,可以生成可以与 C++ 竞争甚至更快的代码。服务器编译器本身是用特定的 C++ 方言编写的。
但是,它带来了一些问题。由于 C++ 中可能存在分段错误,它可能会导致 VM 崩溃。此外,在过去几年中,编译器没有实现重大改进。C2 中的代码已经变得难以维护,因此我们不能指望当前设计有新的重大改进。考虑到这一点,新的 JIT 编译器正在名为 GraalVM 的项目中创建。
4. GraalVM 项目
GraalVM 项目是 Oracle 创建的一个研究项目。我们可以将 Graal 视为几个相互关联的项目:一个基于 HotSpot 的新 JIT 编译器和一个新的多语言虚拟机。它提供了一个全面的生态系统,支持大量语言(Java 和其他基于 JVM 的语言;JavaScript、Ruby、Python、R、C/C++ 和其他基于 LLVM 的语言)。 我们当然会关注 Java。
4.1. Grail - 用 Java 编写的 JIT 编译器
**Graal 是一个高性能的 JIT 编译器。**它接受 JVM 字节码并生成机器码。
用 Java 编写编译器有几个关键优势。首先,安全性,这意味着没有崩溃,而是出现异常,并且没有真正的内存泄漏。此外,我们将拥有良好的 IDE 支持,我们将能够使用调试器或分析器或其他方便的工具。此外,编译器可以独立于 HotSpot,它可以生成更快的 JIT 编译版本。
Graal 编译器的创建考虑了这些优点。 它使用新的 JVM 编译器接口 – JVMCI 与 VM 通信。要启用新的 JIT 编译器,我们需要在从命令行运行 Java 时设置以下选项:
-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler
这意味着我们可以通过三种不同的方式运行一个简单的程序:使用常规分层编译器、使用 Java 10 上的 JVMCI 版本的 Graal 或使用 GraalVM 本身。
4.2. JVM 编译器接口
JVMCI 是自 JDK 9 以来 OpenJDK 的一部分,因此我们可以使用任何标准的 OpenJDK 或 Oracle JDK 来运行 Graal。
JVMCI 实际上允许我们做的是排除标准分层编译并插入我们全新的编译器(即 Graal),而无需更改 JVM 中的任何内容。
界面非常简单。当 Graal 编译一个方法时,它会将该方法的字节码作为输入传递给 JVMCI’。作为输出,我们将获得编译后的机器代码。输入和输出都只是字节数组:
interface JVMCICompiler {
byte[] compileMethod(byte[] bytecode);
}
在实际场景中,我们通常需要更多信息,例如局部变量的数量、堆栈大小以及从解释器中的分析收集的信息,以便我们知道代码在实践中是如何运行的。
本质上,当调用 JVMCICompiler 接口的compileMethod() 时 ,我们需要传递一个 CompilationRequest 对象。然后它将返回我们要编译的 Java 方法,在该方法中,我们将找到我们需要的所有信息。
4.3. Graal 例子
Graal 本身由 VM 执行,因此当它变热时,它首先会被解释和 JIT 编译。让我们看一个例子,它也可以在 GraalVM 的官方网站 上找到:
public class CountUppercase {
static final int ITERATIONS = Math.max(Integer.getInteger("iterations", 1), 1);
public static void main(String[] args) {
String sentence = String.join(" ", args);
for (int iter = 0; iter < ITERATIONS; iter++) {
if (ITERATIONS != 1) {
System.out.println("-- iteration " + (iter + 1) + " --");
}
long total = 0, start = System.currentTimeMillis(), last = start;
for (int i = 1; i < 10_000_000; i++) {
total += sentence
.chars()
.filter(Character::isUpperCase)
.count();
if (i % 1_000_000 == 0) {
long now = System.currentTimeMillis();
System.out.printf("%d (%d ms)%n", i / 1_000_000, now - last);
last = now;
}
}
System.out.printf("total: %d (%d ms)%n", total, System.currentTimeMillis() - start);
}
}
}
现在,我们将编译并运行它:
javac CountUppercase.java
java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler
这将导致类似于以下的输出:
1 (1581 ms)
2 (480 ms)
3 (364 ms)
4 (231 ms)
5 (196 ms)
6 (121 ms)
7 (116 ms)
8 (116 ms)
9 (116 ms)
total: 59999994 (3436 ms)
我们可以看到一开始需要更多的时间。预热时间取决于各种因素,例如应用程序中的多线程代码量或 VM 使用的线程数。如果内核数量较少,则预热时间可能会更长。
如果我们想查看 Graal 编译的统计信息,我们需要在执行程序时添加以下标志:
-Dgraal.PrintCompilation=true
这将显示与编译方法相关的数据、花费的时间、处理的字节码(也包括内联方法)、生成的机器代码的大小以及编译期间分配的内存量。执行的输出占用了相当多的篇幅,这里就不展示了。
4.4. 与顶级编译器比较
现在让我们将上述结果与使用顶级编译器编译的同一程序的执行进行比较。为此,我们需要告诉 VM 不要使用 JVMCI 编译器:
java -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:-UseJVMCICompiler
1 (510 ms)
2 (375 ms)
3 (365 ms)
4 (368 ms)
5 (348 ms)
6 (370 ms)
7 (353 ms)
8 (348 ms)
9 (369 ms)
total: 59999994 (4004 ms)
我们可以看到,各个时间之间的差异较小。它还导致更短的初始时间。
4.5. Graal 背后的数据结构
正如我们之前所说,Graal 基本上是将一个字节数组转换为另一个字节数组。在本节中,我们将重点介绍此过程背后的内容。以下示例依赖于 Chris Seaton 在 JokerConf 2017 上的演讲 。
一般来说,基本编译器的工作是对我们的程序进行操作。这意味着它必须用适当的数据结构对其进行符号化。 Graal 为此目的使用了一个图,即所谓的程序依赖图。
在一个简单的场景中,我们想要添加两个局部变量,即x + y,我们将有一个节点用于加载每个变量,另一个节点用于添加它们。除此之外,我们还有两条边表示数据流:
数据流边缘以蓝色显示。他们指出,当加载局部变量时,结果将进入加法运算。
现在让我们介绍另一种类型的边,即描述控制流的边。为此,我们将通过调用方法来检索我们的变量而不是直接读取它们来扩展我们的示例。当我们这样做时,我们需要跟踪方法调用顺序。我们将用红色箭头表示这个顺序:
在这里,我们可以看到节点实际上并没有改变,但是我们添加了控制流边。
4.6. 实际图表
我们可以使用 IdealGraphVisualiser 检查真实的 Graal 图。要运行它,我们使用 mx igv命令。我们还需要通过设置 -Dgraal.Dump标志来配置 JVM。
让我们看一个简单的例子:
int average(int a, int b) {
return (a + b) / 2;
}
这有一个非常简单的数据流:
在上图中,我们可以清楚地看到我们的方法。参数 P(0) 和 P(1) 流入加法运算,该加法运算进入常数 C(2) 的除法运算。最后,返回结果。
我们现在将前面的示例更改为适用于数字数组:
int average(int[] values) {
int sum = 0;
for (int n = 0; n < values.length; n++) {
sum += values[n];
}
return sum / values.length;
}
我们可以看到添加循环导致我们得到更复杂的图表:
我们可以在这里注意到的是:
- 开始和结束循环节点
- 表示数组读数和数组长度读数的节点
- 数据和控制流边缘,就像以前一样。
这种数据结构有时被称为 sea-of-nodes 或 soup-of-nodes。我们需要提到的是,C2 编译器使用了类似的数据结构,所以它并不是什么新东西,是专为 Graal 创新的。
值得注意的是,Graal 通过修改上述数据结构来优化和编译我们的程序。我们可以看到为什么用 Java 编写 Graal JIT 编译器实际上是一个不错的选择:图只不过是一组对象,其中引用将它们连接为边。该结构与面向对象的语言(在本例中为 Java)完美兼容。
4.7. 提前编译器模式
还有一点很重要,我们也可以在 Java 10 的 Ahead-of-Time 编译器模式下使用 Graal 编译器。正如我们已经说过的,Graal 编译器是从头开始编写的。它符合新的干净接口 JVMCI,这使我们能够将它与 HotSpot 集成。这并不意味着编译器绑定到它。
使用编译器的一种方法是使用配置文件驱动的方法仅编译热方法,但我们也可以使用 Graal 在离线模式下对所有方法进行总编译,而无需执行代码。这就是所谓的“Ahead-of-Time Compilation”,JEP 295, 但这里我们不会深入探讨 AOT 编译技术。
我们以这种方式使用 Graal 的主要原因是加快启动时间,直到 HotSpot 中的常规分层编译方法可以接管。