56

解密新一代Java JIT编译器Graal

 5 years ago
source link: http://www.infoq.com/cn/articles/Graal-Java-JIT-Compiler?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

关键要点

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

关于作者

1ben-evans-1531559968514.jpg 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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK