GraalVM(Oracle Labs)是一个高性能的、支持多种编程语言的执行环境。它既可以在传统的OpenJDK上运行,也可以通过AOT(Ahead-Of-Time)编译成可执行文件单独运行,甚至可以继承至数据库中运行。
除此之外,它还移除了编程语言之间的边界,并且支持通过即时编译技术,将混杂了不同的编程语言的代码编译到同一段二进制码之中,从而实现不同语言之间的无缝切换。
GraalVM的基石Graal编译器
Graal编译器是一个用Java编写的即时编译器,它从Java9u开始便被集成自JDK中,作为实验性质的即时编译器。
Graal编译器可以通过JVM参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler启用。当启用时,它将替换掉HotSpot中的C2编译器,并响应原本由C2负责的编译请求。
Graal和JVM的交互
即时编译器是JVM中相对独立的模块,它主要负责接收Java字节码,并生成可以直接运行的二进制码。
具体来说,即时编译器与JVM的交互可以分为如下三个方面:
- 响应编译请求;
- 获取编译所需的元数据(如类、方法、字段)和反映程序执行状态的profile;
- 将生成的二进制码部署至代码缓存(code cache)里。
即时编译器通过这三个功能组成了一个响应编译请求、获取编译所需的数据,完成编译并部署的完整编译周期。
传统情况下即时编译器是与JVM紧耦合的。对即时编译器的更改需要重新编译整个JVM。这对于开发相对活泼的Graal来说显然是不可接受的。
为了让JVM与Graal解耦合,引入了JVM编译器接口(JVM Compiler Interface, JVMCI),将上述三个功能抽象成一个Java层面的接口。这样在Graal所依赖的JVMCI版本不变的情况下,仅需要替换Graal编译器相关的jar包(Java9以后的jmod文件),便可完成对Graal的升级。
JVMCI的作用并不局限于完成由JVM发出的编译请求。实际上,Java程序可以直接调用Graal,编译并部署指定方法。
Graal的单元测试便是基于这项技术。为了测试某项优化是否起作用,原本需要反复运行某一测试方法,直至Graal收到由JVM发出针对该方法的编译请求,而现在可以直接指定编译该方法,并进行测试。(Truffle语言实现的框架也基于这项技术)
Graal和C2的区别
Graal和C2最为明显的一个区别是:Graal是用Java写的,而C2是用C++写的。相对来说,Graal更加模块化,也更容易开发维护。
在充分预热的情况下,Java程序中的热点代码早已经通过即时编译转换为二进制码,在执行速度上并不亚于静态编译的C++程序。
即便是解释执行Graal,也仅是会减慢编译效率,而并不影响编译结果的性能。
如果C2和Graal采用相同的优化手段,那么他们的编译结果是一样的。所以,程序达到稳定状态(即不再触发新的即时编译)的性能-峰值性能,也是一样的。
由于Java语言容易开发维护的优势,可以很方便的将C2的新优化移植到Graal中。反之则不然,如Graal中被证实有效的部分逃逸分析(partial escape analysis)至今未被移植到C2中。
Graal和C2另一个优化上的分歧则是方法内联算法。相对来说,Graal的内联算法对新语法、新语言更加友好,如Java8的Lambda表达式以及Scala语言。
峰值性能,对Java程序来说,Graal的优势并不明显;对Scala来说,Graal的心更难优势达到了10%。大规模使用Scala的Twitter在生产环境中部署了Graal编译器,取得了11%的性能提升。
Graal的实现
Graal编译器将编译过程分为前端和后端两大部分。前端用于实现平台无关的优化(如方法内联),以及小部分平台相关的优化;而后端则负责大部分的平台相关优化(如寄存器分配),以及机器码的生成。
Graal和C2都采用了Sea-of-Nodes IR。严格来讲,这里指的是Graal的前端,而后端采用的是另一种非Sea-of-Nodes的IR。通常将前端的IR称之为High-level IR或者HIR;后端的IR则称之为Low-level IR或者LIR。
Graal的前端是由一个个单独的优化阶段(optimization phase)构成的。可以将每个优化阶段想象成一个图算法:它会接收一个规则的图,遍历图上的节点并作出优化,并且返回另一个规则的图。前端中的编译阶段除了少数几个关键的之外,其余均可以通过配置选项来开启或关闭。

Graal和C2都采用了激进的投机性优化阶段(speculative optimization)。
通常这些优化都基于某种假设(assumption)。当假设出错的情况下,JVM会借助去优化(deoptimization)这项机制,从执行即时编译器生成的机器码切换回解释执行,在必要情况下,它甚至会废弃这份机器码,并在重新收集程序profile之后,再进行编译。
例:类层次分析。在进行虚方法内联时(或者其他与类层次相关的优化),可能会发现某个接口仅有一个实现。
在即时编译过程中,可以假设在之后的执行过程中仍旧只有这一个实现,并根据这个假设进行编译优化。当之后加载了接口的另一实现时,便会废弃这份机器码。
Graal与C2相比会更加激进。它从设计上便十分青睐这种基于假设的优化手段。在编译过程中,Graal支持自定义假设,并且直接与去优化节点相关联。
当对应的去优化被触发时,JVM将负责记录对应的自定义假设。而Graal在第二次编译同一方法时,便会知道该自定义假设有误,从而不再对该方法使用相同的激进优化。
JVM的另一个能够大幅度提升性能的特性是intrinsic方法。在Graal中,实现高性能的intrinsic方法也相对比较简单。Graal提供了一种替换方法调用的机制,在解析Java字节码时会将匹配到的方法调用,替换成对另一个内部方法的调用,或者直接替换为特殊节点。
例:可以把比较两个byte数组的方法java.util.Arrays.equals(byte[], byte[])替换成一个特殊节点,用来代表整个数组比较的逻辑。这样,当前编译方法所对应的图将被简化,因而其使用于其他优化的可能性也将提升。
总结
Graal是一个用Java写就的、并能够将Java字节码转换成二进制码的即时编译器。它通过JVMCI与JVM交互,响应由后者发出的编译请求、完成编译并部署编译结果。
对Java程序而言,Graal编译结果的性能略优于OpenJDK中的C2;对Scala程序而言,它的性能优势可达到10%(企业版甚至可以达到20%!)。这背后离不开Graal所采用的激进优化方法。
在Java10、11中,可以通过虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler来启用。
在刚开始运行的过程中,Graal编译器本身需要被即时编译(默认情况下,用C1编译Graal。调用Graal编译Graal并不会造成无穷递归,因为JVM中有解释执行器,能够执行Graal代码。),会抢占原本可用于编译应用代码的计算资源。因此,Graal编译器的启动性能会较差。