关键要点
- Java 的 C2 JIT 编译器寿终正寝。
- 新的 JVMCI 编译器接口支持可插拔编译器。
- 甲骨文开发了 Graal,一个用 Java 编写的 JIT,作为潜在的编译器替代方案。
- Graal 也可以独立运行,是新平台的主要组件。
- GraalVM 是下一代 VM,支持多种语言(不仅仅是那些可编译为 JVM 字节码的语言)。
甲骨文的 Java 实现是基于开源的 OpenJDK 项目,其中包括自 Java 1.3 以来一直存在的 HotSpot 虚拟机。HotSpot 包含两个独立的 JIT 编译器,分别是 C1 和 C2(有时称为“客户端”编译器和“服务器端”编译器),现在的 Java 通常会在运行程序期间同时使用这两个 JIT 编译器。
Java 程序首先在解释模式下启动,在运行了一段时间之后,经常被调用的方法会被识别出来,并使用 JIT 编译器进行编译——先是使用 C1,如果 HotSpot 检测到这些方法有更多的调用,就使用 C2 重新编译这些方法。这种策略被称为“分层编译”,是 HotSpot 默认采用的方式。
对于大多数 Java 应用程序来说,C2 编译器是整个运行环境中最重要的一个部分,因为它为程序中最重要的部分代码生成了高度优化的机器码。
C2 非常成功,可以生成与 C++ 相媲美(甚至比 C++ 更快)的代码,这要归功于 C2 的运行时优化,而这些在 AOT(Ahead of Time)编译器(如 gcc 或 Go 编译器)中是没有的。
不过,近年来 C2 并没有带来多少重大的改进。不仅如此,C2 中的代码变得越来越难以维护和扩展,新加入的工程师很难修改使用 C++ 特定方言编写的代码。
事实上,人们(Twitter 等公司以及像 Cliff Click 这样的专家)普遍认为,在当前的基础上根本不可做出重大的改进。也就是说,任何后续的 C2 改进都是微不足道的。
在最近发布的版本中有一些改进,比如使用了更多的 JVM 内联函数(intrinsic),文档中是这样描述的这项技术的(主要用于描述 @HotSpotIntrinsicCandidate 注解):
如果 HotSpot VM 使用手写汇编或手写编译器 IR(一种旨在提升性能的编译器内联函数)替换带注解的方法,那么这个方法就是内联的。
JVM 在启动时会探测它运行在哪个处理器上,因此 JVM 可以准确地知道 CPU 支持哪些特性。它创建了一个特定于当前处理器的内联函数表,也就是说 JVM 可以充分利用硬件的能力。
这与 AOT 编译不同,后者在编译时考虑的是通用芯片,并对可用的特性做出保守的假设,因为如果 AOT 编译的二进制文件在运行时试图执行当前 CPU 不支持的指令,就会崩溃。
HotSpot 已经支持了不少内联函数——例如众所周知的 Compare-And-Swap(CAS)指令,可用于实现原子整数等功能。在几乎所有的现代处理器上,这都是通过单个硬件指令来实现的。
JVM 预先知道这些内联函数,并依赖于操作系统或 CPU 架构对特定功能的支持。因此,它们特定于平台,并非每个平台都支持所有的内联函数。
一般来说,内联函数应该被视为点修复,而不是一种通用技术。它们具有强大、轻量级和灵活的优点,但要支持多种架构,带来了潜在的高开发和维护成本。
因此,尽管在内联函数方面取得了进展,但不管怎样,C2 已经走到了生命的尽头,必须被替换掉。
甲骨文最近宣布推出第一版 GraalVM ,这是一个研究项目,可能会成为 HotSpot 的替代方案。
Java 开发人员可以认为 Graal 是由几个独立但互相关联的项目组成的——它既是 HotSpot 的新型 JIT 编译器,也是一个新的多语言虚拟机。我们使用 Graal 来称呼这个新的编译器,使用 GraalVM 来称呼这个新虚拟机。
Graal 的总体目标是重新思考如何更好地编译 Java(以及 GraalVM 支持的其他语言)。Graal 最初的出发点非常简单:
Java 的(JIT)编译器将字节码转换为机器码——在 Java 中,只不过是从一个 byte[] 到另一个 byte[] 的转换——那么如果转换代码是用 Java 编写的话会怎样呢?
事实证明,用 Java 编写编译器有如下的一些优点:
- 工程师开发新编译器的进入门槛要低得多。
- 编译器的内存安全性。
- 能够利用成熟的 Java 工具进行编译器开发。
- 更快的新编译器功能原型设计。
- 编译器可以独立于 HotSpot。
- 编译器能够自己编译自己,以生成更快的 JIT 编译版本。
Graal 使用了新的 JVM 编译器接口(JVMCI,对应 JEP 243 ),可以用在 HotSpot 中,也可以作为 GraalVM 的主要组成部分。Graal 已经发布,尽管它在 Java 10 中仍然是处于实验性阶段。要切换到新的 JIT 编译器,可以这样做:
-XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseJVMCICompiler
我们可以通过三种不同的方式运行一个简单的程序——使用常规的分层编译器,或者使用 Java 10 上的 Graal,或者使用 GraalVM 本身。
为了展示 Graal 的效果,我们使用了一个简单的例子,它可以长时间运行,这样就看到编译器的启动过程——进行简单的字符串哈希:
package kathik;
public final class StringHash {
public static void main(String[] args) {
StringHash sh = new StringHash();
sh.run();
}
void run() {
for (int i=1; i<2_000; i++) {
timeHashing(i, 'x');
}
}
void timeHashing(int length, char c) {
final StringBuilder sb = new StringBuilder();
for (int j = 0; j < length * 1_000_000; j++) {
sb.append(c);
}
final String s = sb.toString();
final long now = System.nanoTime();
final int hash = s.hashCode();
final long duration = System.nanoTime() - now;
System.out.println("Length: "+ length +" took: "+ duration +" ns");
}
}
我们可以设置 PrintCompilation 标记来执行此代码,这样就可以看到被编译的方法(它还提供了一个基线,可与 Graal 运行进行比较):
java -XX:+PrintCompilation -cp target/classes/ kathik.StringHash > out.txt
要查看 Graal 在 Java 10 上运行的效果:
java -XX:+PrintCompilation \
-XX:+UnlockExperimentalVMOptions \
-XX:+EnableJVMCI \
-XX:+UseJVMCICompiler \
-cp target/classes/ \
kathik.StringHash > out-jvmci.txt
对于 GraalVM:
java -XX:+PrintCompilation \
-cp target/classes/ \
kathik.StringHash > out-graal.txt
这些将生成三个输出文件——前 200 次调用 timeHashing() 后生成的输出看起来像这样:
$ ls -larth out*
-rw-r--r-- 1 ben staff 18K 4 Jun 13:02 out.txt
-rw-r--r-- 1 ben staff 591K 4 Jun 13:03 out-graal.txt
-rw-r--r-- 1 ben staff 367K 4 Jun 13:03 out-jvmci.txt
正如预期的那样,Graal 会产生更多的输出——这是由于 PrintCompilation 输出的不同。不过这一点也不足为奇——Graal 首先要编译 JIT 编译器,所以在 VM 启动后的前几秒内会有大量的 JIT 编译器预热动作。
让我们看一下在 Java 10 上使用 Graal 编译器的 JIT 输出(常规的 PrintCompilation 格式):
$ grep graal out-jvmci.txt | head
229 293 3 org.graalvm.compiler.hotspot.HotSpotGraalCompilerFactory::adjustCompilationLevelInternal (70 bytes)
229 294 3 org.graalvm.compiler.hotspot.HotSpotGraalCompilerFactory::checkGraalCompileOnlyFilter (95 bytes)
231 298 3 org.graalvm.compiler.hotspot.HotSpotGraalCompilerFactory::adjustCompilationLevel (9 bytes)
353 414 ! 1 org.graalvm.compiler.serviceprovider.JDK9Method::invoke (51 bytes)
354 415 1 org.graalvm.compiler.serviceprovider.JDK9Method::checkAvailability (37 bytes)
388 440 1 org.graalvm.compiler.hotspot.HotSpotForeignCallLinkageImpl::asJavaType (32 bytes)
389 441 1 org.graalvm.compiler.hotspot.word.HotSpotWordTypes::isWord (31 bytes)
389 443 1 org.graalvm.compiler.core.common.spi.ForeignCallDescriptor::getResultType (5 bytes)
390 445 1 org.graalvm.util.impl.EconomicMapImpl::getHashTableSize (43 bytes)
390 447 1 org.graalvm.util.impl.EconomicMapImpl::getRawValue (11 bytes)
像这样的小实验应该谨慎对待。例如,太多的屏幕 IO 可能会影响预热性能。不仅如此,随着时间的推移,为不断增加的字符串分配的缓冲区将会变得越来越大,以至于必须在 Humongous Region(G1 回收器为大对象保留的特殊区域)中进行分配——Java 10 和 GraalVM 默认使用了 G1 回收器。这意味着在一段时间之后,G1 垃圾回收主要由 G1 Humongous 主导,而这通常是非常规的情况。
在讨论 GraalVM 之前,我们需要注意的是,Java 10 为 Graal 编译器提供了另一种使用方式,即 Ahead-of-Time 编译器模式。
Graal(作为编译器)是一个从头开始开发的全新编译器,符合新的 JVM 接口(JVMCI)。所以,Graal 可以与 HotSpot 集成,但又不受其约束。
我们可以考虑使用 Graal 在离线模式下对所有方法进行全面编译而不执行代码,而不是使用配置驱动的方式编译热方法。这也就是“Ahead-of-Time 编译”(JEP 295)。
在 HotSpot 环境中,我们可以用它来生成共享对象 / 库(Linux 上的.so 或 Mac 上的.dylib),如下所示:
$ jaotc --output libStringHash.dylib kathik/StringHash.class
然后我们可以在以后的运行中使用已编译的代码:
$ java -XX:AOTLibrary=./libStringHash.dylib kathik.StringHash
这样用 Graal 只为了一个目的——加快启动速度,直到 HotSpot 的常规分层编译器可以接管编译工作。在完整的应用程序中,JIT 编译的实际测试基准应该能够胜过 AOT 编译,尽管具体情况要取决于实际的工作负载。
AOT 编译技术仍然是最前沿的,而且从技术上讲只支持(甚至是实验性质的)linux/x64。例如,在 Mac 上尝试编译 java.base 模块时,会出现以下错误(尽管仍会生成.dylib 文件):
$ jaotc --output libjava.base.dylib --module java.base
Error: Failed compilation: sun.reflect.misc.Trampoline.invoke(Ljava/lang/reflect/Method;Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;: org.graalvm.compiler.java.BytecodeParser$BytecodeParserError: java.lang.Error: Trampoline must not be defined by the bootstrap classloader
at parsing java.base@10/sun.reflect.misc.Trampoline.invoke(MethodUtil.java:70)
Error: Failed compilation: sun.reflect.misc.Trampoline.<clinit>()V: org.graalvm.compiler.java.BytecodeParser$BytecodeParserError: java.lang.NoClassDefFoundError: Could not initialize class sun.reflect.misc.Trampoline
at parsing java.base@10/sun.reflect.misc.Trampoline.<clinit>(MethodUtil.java:50)
我们可以使用编译器指令文件来控制这些错误,从 AOT 编译中排除掉某些方法(有关详细信息,请参阅 JEP 295 )。
尽管存在编译器错误,我们仍然可以尝试将 AOT 编译的基本模块代码和用户代码一起运行,如下所示:
java -XX:+PrintCompilation \
-XX:AOTLibrary=./libStringHash.dylib,libjava.base.dylib \
kathik.StringHash
打开 PrintCompilation 标记,就可以看到 JIT 的编译情况——现在几乎没有。现在只有一些初始引导程序要用到的核心方法需要进行 JIT 编译:
111 1 n 0 java.lang.Object::hashCode (native)
115 2 n 0 java.lang.Module::addExportsToAllUnnamed0 (native) (static)
因此,我们可以得出结论,这个简单的 Java 应用程序现在是在几乎 100%的 AOT 编译模式下运行。
现在回到 GraalVM,让我们看一下该平台提供的重磅功能——能够将多种语言完整地嵌入到运行在 GraalVM 上的 Java 应用程序中。
这可以被认为是 JSR 223(Java 平台的脚本)的等效或替代方案,不过 Graal 比之前的 HotSpot 走得更深入更远。
该功能依赖于 GraalVM 和 Graal SDK——GraalVM 默认的类路径中包含了 Graal SDK,但在 IDE 中需要显式指定,例如:
<dependency>
<groupId>org.graalvm</groupId>
<artifactId>graal-sdk</artifactId>
<version>1.0.0-rc1</version>
</dependency>
最简单的例子是 Hello World——让我们使用 GraalVM 默认提供的 Javascript 实现:
import org.graalvm.polyglot.Context;
public class HelloPolyglot {
public static void main(String[] args) {
System.out.println("Hello World: Java!");
Context context = Context.create();
context.eval("js", "print('Hello World: JavaScript!');");
}
}
这在 GraalVM 上可以按预期运行,但尝试在 Java 10 上运行时,即使使用了 Graal SDK,仍然会产生这个(不足为奇的)错误:
$ java -cp target/classes:$HOME/.m2/repository/org/graalvm/graal-sdk/1.0.0-rc1/graal-sdk-1.0.0-rc1.jar kathik.HelloPolyglot
Hello Java!
Exception in thread "main" java.lang.IllegalStateException: No language and polyglot implementation was found on the classpath. Make sure the truffle-api.jar is on the classpath.
at org.graalvm.polyglot.Engine$PolyglotInvalid.noPolyglotImplementationFound(Engine.java:548)
at org.graalvm.polyglot.Engine$PolyglotInvalid.buildEngine(Engine.java:538)
at org.graalvm.polyglot.Engine$Builder.build(Engine.java:367)
at org.graalvm.polyglot.Context$Builder.build(Context.java:528)
at org.graalvm.polyglot.Context.create(Context.java:294)
at kathik.HelloPolyglot.main(HelloPolyglot.java:8)
自 Java 6 以来,随着 Scripting API 的引入,已经支持多语言。随着 Nashorn(基于 invokedynamic 的 JavaScript 实现)的出现,Java 8 对多语言的支持有了显著增强。
GraalVM 的与众不同之处在于,Java 生态系统现在明确提供了 SDK 和支持工具,用于实现多语言,并让它们成为运行在底层 VM 之上的平等且可互操作的公民。
完成这一步的关键在于一个叫作 Truffle 的组件和一个简单的 VM——SubstrateVM(能够执行 JVM 字节码)。
Truffle 为创建新语言实现提供了 SDK 和工具。一般过程如下:
- 从语法开始
- 应用解析器生成器(例如 Coco/R )
- 使用 Maven 构建解释器和简单的语言运行时
- 在 GraalVM 上运行生成的语言实现
- 等待 Graal(在 JIT 模式下)启动,自动增强新语言的性能
- 在 AOT 模式下使用 Graal 将解释器编译为本机启动器(可选)
GraalVM 默认支持 JVM 字节码、JavaScript 和 LLVM。如果我们尝试向下面这样调用另一种语言,比如 Ruby:
context.eval("ruby", "puts \"Hello World: Ruby\"");
GraalVM 会抛出一个运行时异常:
Exception in thread "main" java.lang.IllegalStateException: A language with id 'ruby' is not installed. Installed languages are: [js, llvm].
at com.oracle.truffle.api.vm.PolyglotEngineImpl.requirePublicLanguage(PolyglotEngineImpl.java:559)
at com.oracle.truffle.api.vm.PolyglotContextImpl.requirePublicLanguage(PolyglotContextImpl.java:738)
at com.oracle.truffle.api.vm.PolyglotContextImpl.eval(PolyglotContextImpl.java:715)
at org.graalvm.polyglot.Context.eval(Context.java:311)
at org.graalvm.polyglot.Context.eval(Context.java:336)
at kathik.HelloPolyglot.main(HelloPolyglot.java:10)
要使用(当前为测试版)Truffle 版本的 Ruby(或其他语言),需要下载并安装它。对于 Graal 版本的 RC1(很快会推出 RC2),可以通过以下方式安装:
gu -v install -c org.graalvm.ruby
要注意,如果 GraalVM 是在系统级别安装的,则需要 sudo。如果使用的是 GraalVM 的非 OSS EE 版本(目前 Mac 上只有这个版本可用),则可以更进一步——可以将 Truffle 解释器转为本机代码。
为语言重建本机镜像(启动程序)可以提高它的性能,但这需要使用命令行工具,比如(假设 GraalVM 是安装在系统级别,因此需要 root 权限):
$ cd $JAVA_HOME
$ sudo jre/lib/svm/bin/rebuild-images ruby
这个工具还处于开发阶段,所以需要进行一些手动操作,开发团队希望在后续让这个流程变得更加顺畅。
如果在重建本机组件时遇到任何问题,请不要担心——即使不重建本机镜像仍然可以正常使用它。
让我们看一个更复杂的多语言示例:
Context context = Context.newBuilder().allowAllAccess(true).build();
Value sayHello = context.eval("ruby",
"class HelloWorld\n" +
" def hello(name)\n" +
" \"Hello #{name}\"\n" +
" end\n" +
"end\n" +
"hi = HelloWorld.new\n" +
"hi.hello(\"Ruby\")\n");
String rubySays = sayHello.as(String.class);
Value jsFunc = context.eval("js",
"function(x) print('Hello World: JavaScript with '+ x +'!');");
jsFunc.execute(rubySays);
这段代码有点难以阅读,它同时用到了 TruffleRuby 和 JavaScript。首先,我们调用了一段 Ruby 代码:
class HelloWorld
def hello(name)
"Hello #{name}"
end
end
hi = HelloWorld.new
hi.hello("Ruby")
这将创建一个新的 Ruby 类,并为这个类定义了一个方法,然后实例化了一个 Ruby 对象,最后调用它的 hello() 方法。这个方法返回一个(Ruby)字符串,该字符串在 Java 运行时中被强制转换为 Java 字符串。
然后我们创建了一个简单的 JavaScript 匿名函数,如下所示:
function(x) print('Hello World: JavaScript with '+ x +'!');
我们通过 execute() 调用这个函数,并将 Ruby 返回的结果传给函数,该函数在 JS 运行时中将其打印出来。
请注意,我们在创建 Context 对象时,需要放开该对象的访问权限。这样做是为了 Ruby——JS 没有这个问题——所以在创建对象时稍微复杂了一些。这是由当前的 Ruby 实现限制造成的,这个限制将来可能会被移除。
让我们看一个最终的多语言示例:
Value sayHello = context.eval("ruby",
"class HelloWorld\n" +
" def hello(name)\n" +
" \"Hello Ruby: #{name}\"\n" +
" end\n" +
"end\n" +
"hi = HelloWorld.new\n" +
"hi");
Value jsFunc = context.eval("js",
"function(x) print('Hello World: JS with '+ x.hello('Cross-call') +'!');");
jsFunc.execute(sayHello);
在这个版本中,我们返回一个实际的 Ruby 对象,而不仅仅是一个字符串。这次我们没有将它强制转换为任何 Java 类型,而是将其直接传给这个 JS 函数:
function(x) print('Hello World: JS with '+ x.hello('Cross-call') +'!');
它输出了预期的内容:
Hello World: Java!
Hello World: JS with Hello Ruby: Cross-call!
这说明 JS 运行时可以调用处于其他运行时中的对象的方法,并进行无缝类型转换(至少可以进行简单类型转换)。
对于这种可跨多种具有不同语义和类型系统的语言的可互换能力,JVM 工程师已经讨论了很长一段时间(至少 10 年),而随着 GraalVM 的到来,它向主流迈出了非常重要的一步。
让我们使用这一小段打印 Ruby 对象的 JS 代码演示这些外部对象是如何在 GraalVM 中表示的:
function(x) print('Hello World: JS with '+ x +'!');
输出如下(或类似这样的):
Hello World: JS with foreign {is_a?: DynamicObject@540a903b<Method>, extend: DynamicObject@238acd0b<Method>, protected_methods: DynamicObject@34e20e6b<Method>, public_methods: DynamicObject@15ac59c2<Method>, ...}!
这些输出显示了外部对象被表示为一系列 DynamicObject 对象,在大多数情况下,它将语义操作委托给对象的主运行时。
在结束本文之前,我们应该谈谈基准和许可。我们必须搞清楚的是,尽管 Graal 和 GraalVM 有着巨大的前景,但目前仍处于早期阶段 / 实验技术阶段。
它尚未针对通用场景进行优化,并且尚需时日才能与 HotSpot/C2 平起平坐。微基准通常也会产生误导——在某些情况下它们可以指明方向,但对于性能分析来说,只有最终的用户级基准才算数。
我们可以这样想,C2 已经最大限度地提升了局部性能,并且即将寿终正寝。Graal 让我们有机会突破局部最大化,并转到一个更好的新领域——并且有可能会重新构思我们对 VM 设计和编译器的许多想法。但它仍然不够成熟,并且不太可能在几年内完全成为主流。
这意味着现在进行的任何性能测试都应该进行谨慎分析。性能测试的比较(特别是 HotSpot/C2 与 GraalVM)是苹果与橙子之间的比较——一个成熟的生产级运行时与一个还处于早期阶段的实验性产品。
还需要指出的是,GraalVM 的许可制度可能与迄今为止看到的有所不同。甲骨文在收购 Sun 公司时,HotSpot 已经是非常成熟的产品,并被冠以自由软件许可。他们很少在 HotSpot 核心产品之上增加价值和进行变现——例如 UnlockCommercialFeatures 开关。随着这些功能的退出(比如开源Mission Control ),可以说,该模型并没有取得巨大的商业成功。
Graal 与众不同——它起源于甲骨文 Research 项目,现在正朝着生产产品的方向发展。甲骨文已投入大量资金让 Graal 成为现实——该项目所需的人才和团队不足,而且他们都不便宜。因为使用了不同的底层技术,甲骨文可以自由地使用不同的商业许可模型,并尝试基于更广泛的客户群为 GraalVM 变现——包括那些目前不为 HotSpot 运行付费的客户。甲骨文甚至可以将 GraalVM 的某些功能定向提供给甲骨文云客户使用。
目前,甲骨文正在发布一个基于 GPL 许可的社区版本(CE),它可以免费用于开发和生产用途,以及一个企业版(EE),它可以免费用于开发和评估。这两个版本都可以从甲骨文的 GraalVM 网站下载,其中还可以找到更详细的信息。
关于作者
Ben Evans 是 JVM 性能优化公司 jClarity 的联合创始人。他是 LJC(伦敦 JUG)的组织者,也是 JCP 执行委员会的成员,帮助定义 Java 生态系统的标准。Ben 是 Java Champion、3 次 JavaOne Rockstar 演讲者,“The Well-Grounded Java Developer”、新版“Java in a Nutshell”和“Optimizing Java”的作者。他是 Java 平台、性能、架构、并发、初创公司和相关主题的演讲常客。Ben 有时也接受演讲、教学、写作和咨询活动的邀请,具体可以联系他。
评论 1 条评论