导读: Java 是最广泛使用的编程语言之一近日, Oracle 发布了 Java 的最新版本, Java10 在这个版本中, Oracle 引入 109 项新特性, 其中最引人注目的就是 Java 的新 Jit 编译器 Graal 在这个编译器中, 我们可以使用 Java 来做 Java 的 Jit 编译器本文作者详细介绍了该特性, 十分值得一读
Introduction
对于大部分应用开发者来说, 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) 这篇文章将介绍 Graal 在动态编译上的应用有关静态编译, 可查阅 JEP 295 或 Substrate VM
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 均需重新编译 HotSpotJEP 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 亦是通过此机制部署编译后的语言解释器
Graal v.s. C2
前面提到, JIT Compiler 并不依赖于底层语言特性, 它仅仅是一种代码形式到另一种代码形式的转换因此, 理论上任意 C2 中以 C++ 实现的优化均可以在 Graal 中通过 Java 实现, 反之亦然事实上, 许多 C2 中实现的优化均被移植到 Graal 中, 如近期由其他开发者贡献的 String.compareTo intrinsic 的移植当然, 局限于 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 的 PEA 则在此基础上引入了控制流信息, 将所有的堆分配操作虚拟化, 并仅在对象确定逃逸的分支 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
Using Graal
在 Java 10 (Linux/x64, macOS/x64)中, 默认情况下 HotSpot 仍使用 C2, 但通过向 java 命令添加 - XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 参数便可将 C2 替换成 Graal
Oracle Labs GraalVM 是由 Oracle Labs 直接发布的 JDK 版本它基于 Java 8, 并且囊括了 Graal enterprise 如果对源代码感兴趣, 可直接签出 Graal 社区版的 GitHub repo 源代码的编译需借助 mx 工具及 labsjdk(注: 请下载页面最下方的 labsjdk, 直接使用 GraalVM 可能会导致编译问题)
在 graal/compiler 目录下使用 mx eclipseinit,mx intellijinit 或 mx netbeansinit 可分别生成 Eclipse,IntelliJ 或 NetBeans 的工程配置文件
来源: http://developer.51cto.com/art/201803/568607.htm