转载

解密新一代Java JIT编译器Graal

关键要点

  • 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网站 下载,其中还可以找到更详细的信息。

关于作者

解密新一代Java JIT编译器Graal 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

原文  http://www.infoq.com/cn/articles/Graal-Java-JIT-Compiler
正文到此结束
Loading...