引言
对于大部分应用开发者来说, Java 编译器指的是 JDK 自带的 javac 指令. 这一指令可将 Java 源程序编译成. class 文件, 其中包含的代码格式我们称之为 Java bytecode(Java 字节码). 这种代码格式无法直接运行, 但可以被不同平台 JVM 中的 interpreter 解释执行. 由于 interpreter 效率低下, JVM 中的 JIT compiler(即时编译器)会在运行时有选择性地将运行次数较多的方法编译成二进制代码, 直接运行在底层硬件上. Oracle 的 HotSpot VM 便附带两个用 C++ 实现的 JIT compiler:C1 及 C2.
与 interpreter,GC 等 JVM 的其他子系统相比, JIT compiler 并不依赖于诸如直接内存访问的底层语言特性. 它可以看成一个输入 Java bytecode 输出二进制码的黑盒, 其实现方式取决于开发者对开发效率, 可维护性等的要求. Graal 是一个以 Java 为主要编程语言, 面向 Java bytecode 的编译器. 与用 C++ 实现的 C1 及 C2 相比, 它的模块化更加明显, 也更加容易维护. Graal 既可以作为动态编译器, 在运行时编译热点方法; 亦可以作为静态编译器, 实现 AOT 编译. 在 Java 10 中, Graal 作为试验性 JIT compiler 一同发布( JEP 317 http://openjdk.java.net/jeps/317 ). 这篇文章将介绍 Graal 在动态编译上的应用. 有关静态编译, 可查阅 JEP 295 http://openjdk.java.net/jeps/295 或 Substrate VM https://github.com/graalvm/graal/tree/master/substratevm .
分层编译( tiered compilation )
在介绍 Graal 前, 我们先了解 HotSpot 中的 tiered compilation. 前面提到, HotSpot 集成了两个 JIT compiler - C1 及 C2(或称为 Client 及 Server). 两者的区别在于, 前者没有应用激进的优化技术, 因为这些优化往往伴随着耗时较长的代码分析. 因此, C1 的编译速度较快, 而 C2 所编译的方法运行速度较快. 在 Java 7 前, 用户需根据自己的应用场景选择合适的 JIT compiler. 举例来说, 针对偏好高启动性能的 GUI 用户端程序则使用 C1, 针对偏好高峰值性能的服务器端程序则使用 C2.
Java 7 引入了 tiered compilation 的概念, 综合了 C1 的高启动性能及 C2 的高峰值性能. 这两个 JIT compiler 以及 interpreter 将 HotSpot 的执行方式划分为五个级别:
level 0:interpreter 解释执行
level 1:C1 编译, 无 profiling
level 2:C1 编译, 仅方法及循环 back-edge 执行次数的 profiling
level 3:C1 编译, 除 level 2 中的 profiling 外还包括 branch(针对分支跳转字节码)及 receiver type(针对成员方法调用或类检测, 如 checkcast,instnaceof,aastore 字节码)的 profiling
level 4:C2 编译
其中, 1 级和 4 级为接受状态 - 除非已编译的方法被 invalidated(通常在 deoptimization 中触发), 否则 HotSpot 不会再发出该方法的编译请求.
上图列举了 4 种编译模式(非全部). 通常情况下, 一个方法先被解释执行(level 0), 然后被 C1 编译(level 3), 再然后被得到 profile 数据的 C2 编译(level 4). 如果编译对象非常简单, 虚拟机认为通过 C1 编译或通过 C2 编译并无区别, 便会直接由 C1 编译且不插入 profiling 代码(level 1). 在 C1 忙碌的情况下, interpreter 会触发 profiling, 而后方法会直接被 C2 编译; 在 C2 忙碌的情况下, 方法则会先由 C1 编译并保持较少的 profiling(level 2), 以获取较高的执行效率(与 3 级相比高 30%).
Graal 可替换 C2 成为 HotSpot 的顶层 JIT compiler, 即上述 level 4. 与 C2 相比, Graal 采用更加激进的优化方式, 因此当程序达到稳定状态后, 其执行效率 (峰值性能) 将更有优势.
早期的 Graal 同 C1 及 C2 一样, 与 HotSpot 是紧耦合的. 这意味着每次编译 Graal 均需重新编译 HotSpot. JEP 243 http://openjdk.java.net/jeps/243 将 Graal 中依赖于 HotSpot 的代码分离出来, 形成 Java-Level JVM Compiler Interface(JVMCI). 该接口主要提供如下三种功能:
响应 HotSpot 的编译请求, 并分发给 Java-Level JIT compiler
允许 Java-Level JIT compiler 访问 HotSpot 中与 JIT compilation 相关的数据结构, 包括类, 字段, 方法及其 profiling 数据等, 并提供这些数据结构在 Java 层面的抽象
提供 HotSpot codecache 的 Java 抽象, 允许 Java-Level JIT compiler 部署编译完成的二进制代码
综合利用这三种功能, 我们可以将 Java-Level 编译器 (不局限于 Graal) 集成至 HotSpot 中, 响应 HotSpot 发出的 level 4 的编译请求并将编译后的二进制代码部署到 HotSpot 的 codecache 中. 此外, 单独利用上述第三种功能可以绕开 HotSpot 的编译系统 - Java-Level 编译器将作为上层应用的类库直接部署编译后的二进制代码. Graal 自身的单元测试便是依赖于直接部署而非等待 HotSpot 发出编译请求; Truffle https://github.com/oracle/graal/tree/master/truffle 亦是通过此机制部署编译后的语言解释器.
Graal v.s. C2
前面提到, JIT Compiler 并不依赖于底层语言特性, 它仅仅是一种代码形式到另一种代码形式的转换. 因此, 理论上任意 C2 中以 C++ 实现的优化均可以在 Graal 中通过 Java 实现, 反之亦然. 事实上, 许多 C2 中实现的优化均被移植到 Graal 中, 如近期由其他开发者贡献的 String.compareTointrinsic 的移植. 当然, 局限于 C++ 的开发 / 维护难度(个人猜测), 许多 Graal 中被证明有效的优化并没有被成功移植到 C2 上, 这其中就包含 Graal 的 inlining 算法及 partial escape analysis(PEA).
Inlining 是指在编译时识别 callsite 的目标方法, 将其方法体纳入编译范围并用其返回结果替换原 callsite. 最简单直观的例子便是 Java 中常见的 getter/setter 方法 - inlining 可以将一个方法中调用 getter/setter 的 callsite 优化成单一内存访问指令. Inlining 被业内戏称为优化之母, 其原因在于它能引发更多优化. 然而在实践中我们往往受制于编译单元大小或编译时间的限制, 无法无限制地递归 inline. 因此, inlining 的算法及策略很大程度上决定了编译器的优劣, 尤其是在使用 Java 8 的 stream API 或使用 Scala 语言的场景下. 这两种场景对应的 Java bytecode 包含大量的多层单方法调用.
Graal 拥有两个 inliner 实现. 社区版的 inliner 采用的是深度优先的搜索方式, 在分析某一方法时, 一旦遇到不值得 inline 的 callsite 时便回溯至该方法的调用者. Graal 允许自定义策略以判断某一 callsite 值不值得 inline. 默认情况下, Graal 会采取一种相对贪婪的策略, 根据 callsite 的目标方法的大小做出相应的决定. Graal enterprise 的 inliner 则对所有 callsite 进行加权排序, 其加权算法取决于目标方法的大小以及可能引发的优化. 当目标方法被 inline 后, 其包含的 callsite 同样会进入该加权队列中. 这两种搜索方式都较为适合拥有多层单方法调用的应用场景.
Escape analysis(逃逸分析, EA)是一类识别对象动态范围的程序分析. 编译器中常见的应用有两类: 如果对象仅被单一线程访问, 则可去除针对该对象的锁操作; 如果对象为堆分配且仅被单一方法访问 (inlining 的重要性再次体现), 则可将该对象转化成栈分配. 后者通常伴随着 scalar replacement, 即将对对象字段的访问替换成对虚拟局部操作数的访问, 从而进一步将对象由栈分配转换成虚拟分配. 这不仅节省了原本用于存放对象 header 的内存空间, 而且可以在 register allocator 的帮助下将(部分) 对象字段存放在寄存器中, 在节省内存的同时提高执行效率(内存访问转换成寄存器访问).
Java 中常见的 for-each loop 是 EA 的一大目标客户. 我们知道 for-each loop 会调用被遍历对象的 iterator 方法, 返回一个实现 interface Iterator 的对象, 并利用其 hasNext 及 next 接口进行遍历. Java collections 中的容器类 (如 ArrayList) 通常会构造一个新的 Iterator 实例, 其生命周期局限于该 for-each loop 中. 如若 Iterator 实例的构造函数以及 hasNext,next 方法调用 (连同它们方法体中以 this 为 receiver 的方法调用, 如 checkForComodification()) 都被 inline,EA 会认为该实例没有逃逸, 并采取栈分配及 scalar replacement.
理想情况下, Foo.bar 会被优化成如下代码:
HotSpot 的 C2 便已应用控制流无关的 EA 实现 scalar replacement. 而 Graal 的 http://www.ssw.uni-linz.ac.at/Research/Papers/Stadler14/Stadler2014-CGO-PEA.pdf 则在此基础上引入了控制流信息, 将所有的堆分配操作虚拟化, 并仅在对象确定逃逸的分支 materialize. 与 C2 的 EA 相比, PEA 分析效率较低, 但能够在对象没有逃逸的分支上实现 scalar replacement. 如下例所示, 如果 then-branch 的执行概率为 1%, 那么被 PEA 优化后的代码在 99% 的情况下并不会执行堆分配, 而 C2 的 EA 则 100% 会执行堆分配. 另一个典型的例子是渲染引擎 Sunflow - 在运行 DaCapo benchmark suite 所附带的默认 workload 时, Graal 的 PEA 判定约 27% 的堆分配 (共占 700M) 可被虚拟化. 该数字远超 C2 的 EA.
使用 Graal
在 Java 10 (Linux/x64, macOS/x64)中, 默认情况下 HotSpot 仍使用 C2, 但通过向 java 命令添加 - XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 参数便可将 C2 替换成 Graal.
Oracle Labs GraalVM http://www.oracle.com/technetwork/oracle-labs/program-languages/overview/index.html 是由 Oracle Labs 直接发布的 JDK 版本. 它基于 Java 8, 并且囊括了 Graal enterprise. 如果对源代码感兴趣, 可直接签出 Graal 社区版的 GitHub repo https://github.com/oracle/graal . 源代码的编译需借助 https://github.com/graalvm/mx 工具及 labsjdk http://www.oracle.com/technetwork/oracle-labs/program-languages/downloads/index.html (注: 请下载页面最下方的 labsjdk, 直接使用 GraalVM 可能会导致编译问题).
在 graal/compiler 目录下使用 mx eclipseinit,mx intellijinit 或 mx netbeansinit 可分别生成 Eclipse,IntelliJ 或 NetBeans 的工程配置文件.
参考链 接
- Graal publications https://github.com/oracle/graal/blob/master/docs/Publications.md
- Graal tutorial https://www.youtube.com/watch?v=5_Y3kc--eTI , slides http://lafo.ssw.uni-linz.ac.at/papers/2017_PLDI_GraalTutorial.pdf
- Chris Seaton: Understanding How Graal Works - a Java JIT Compiler Written in Java http://chrisseaton.com/truffleruby/jokerconf17/
- Upcoming: advanced topics in Graal compiler
Debugging compiled code
Deoptimization & Java-level assumption - SpeculationLog
Graal method substitution & Snippet
Implementing JVM intrinsics
作者介绍: 郑雨迪 , 现于 Oracle Labs 任职高级研究员, 是 Graal 编译器组的核心开发者之一. 他的研究方向包括动态编译及程序分析. 在加入 Oracle Labs 前, 郑雨迪于瑞士卢加诺大学攻读并获得博士学位. 他即将在 QCon 北京 2018 https://2018.qconbeijing.com/schedule?utm_source=infoq&utm_campaign=full&utm_medium=article&utm_term=0411 现场分享 GraalVM 及其生态系统 https://2018.qconbeijing.com/presentation/405?utm_source=infoq&utm_campaign=full&utm_medium=article&utm_term=0411 , 敬请关注.
来源: http://www.tuicool.com/articles/uEN3UrR