解密新一代 Java JIT 编译器 Graal

阅读数:3839 2018 年 7 月 26 日

关键要点

  • 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 有时也接受演讲、教学、写作和咨询活动的邀请,具体可以联系他。

查看英文原文 Getting to Know Graal, the New Java JIT Compiler

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论