You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

21 KiB

13 | Java JIT编译器动手修改Graal编译器

你好,我是宫文学。

在前面的4讲当中我们已经解析了OpenJDK中的Java编译器它是把Java源代码编译成字节码然后交给JVM运行。

用过Java的人都知道在JVM中除了可以解释执行字节码以外还可以通过即时编译JIT技术生成机器码来执行程序这使得Java的性能很高甚至跟C++差不多。反之,如果不能达到很高的性能,一定会大大影响一门语言的流行。

但是,对很多同学来说,对于编译器中后端的了解,还是比较模糊的。比如说,你已经了解了中间代码、优化算法、指令选择等理论概念,那这些知识在实际的编译器中是如何落地的呢?

所以从今天开始我会花4讲的时间来带你了解Java的JIT编译器的组成部分和工作流程、它的IR的设计、一些重要的优化算法以及生成目标代码的过程等知识点。在这个过程中你还可以印证关于编译器中后端的一些知识点。

今天这一讲呢我首先会带你理解JIT编译的基本原理然后我会带你进入Graal编译器的代码内部一起去修改它、运行它、调试它让你获得第一手的实践经验消除你对JIT编译器的神秘感。

认识Java的JIT编译器

我们先来探究一下JIT编译器的原理。

第5讲我讲过程序运行的原理把一个指令指针指向一个内存地址CPU就可以读取其中的内容并作为指令来执行。

所以Java后端的编译器只要生成机器码就行了。如果是在运行前一次性生成就叫做提前编译AOT如果是在运行时按需生成机器码就叫做即时编译JIT。Java以及基于JVM的语言都受益于JVM的JIT编译器。

在JDK的源代码中你能找到src/hotspot目录这是JVM的运行时它们都是用C++编写的其中就包括JIT编译器。标准JDK中的虚拟机呢就叫做HotSpot。

实际上HotSpot带了两个JIT编译器一个叫做C1,又叫做客户端编译器,它的编译速度快,但优化程度低。另一个叫做C2,又叫做服务端编译器,它的编译速度比较慢,但优化程度更高。这两个编译器在实际的编译过程中,是被结合起来使用的。而字节码解释器,我们可以叫做是C0,它的运行速度是最慢的。

在运行过程中HotSpot首先会用C0解释执行接着HotSpot会用C1快速编译生成机器码从而让运行效率提升。而对于运行频率高的热点HotSpot代码则用C2深化编译得到运行效率更高的代码这叫做分层编译Tiered Compilation

图1分层编译

由于C2会做一些激进优化比如说它会根据程序运行的统计信息认为某些程序分支根本不会被执行从而根本不为这个分支生成代码。不过有时做出这种激进优化的假设其实并不成立那这个时候就要做一个逆优化Deoptimization退回到使用C1的代码或退回到用解释器执行。

触发即时编译,需要检测热点代码。一般是以方法为单位虚拟机会看看该方法的运行频次是否很高如果运行特别频繁那么就会被认定为是热点代码从而就会被触发即时编译。甚至如果一个方法里有一个循环块是热点代码比如循环1.5万次以上),这个时候也会触发编译器去做即时编译,在这个方法还没运行完毕的时候,就被替换成了机器码的版本。由于这个时候,该方法的栈帧还在栈上,所以我们把这个技术叫做栈上替换On-stack ReplacementOSR。栈上替换的技术难点在于让本地变量等数据无缝地迁移让运行过程可以正确地衔接。

Graal用Java编写的JIT编译器

如果想深入地研究Java所采用的JIT编译技术我们必须去看它的源码。可是对于大多数Java程序员来说如果去阅读C++编写的编译器代码,肯定会有些不适应。

一个好消息是Oracle公司推出了一个完全用Java语言编写的JIT编译器Graal并且也有开放源代码的社区版你可以下载安装并使用。

用Java开发一款编译器的优点是很明显的。

  1. 首先Java是内存安全的而C++程序的很多Bug都与内存管理有关比如可能不当地使用了指针之类的。
  2. 第二与Java配套的各种工具比如IDE更友好、更丰富。
  3. 第三Java的性能并不低所以能够满足对编译速度的需求。
  4. 最后用Java编译甚至还能节省内存的占用因为Java采用的是动态内存管理技术一些对象没用了其内存就会被回收。而用C++编写的话,可能会由于程序员的疏忽,导致一些内存没有被及时释放。

从Java9开始你就可以用Graal来替换JDK中的JIT编译器。这里有一个JVMCIJVM Compiler Interface接口标准符合这个接口标准的JIT编译器都可以被用于JVM。

Oracle公司还专门推出了一款JVM叫做GraalVM。它除了用Graal作为即时编译器以外还提供了一个很创新的功能在一个虚拟机上支持多种语言并且支持它们之间的互操作。你知道传统的JVM上已经能够支持多种语言比如Scala、Clojure等。而新的GraalVM会更进一步它通过一个Truffle框架可以支持JavaScript、Ruby、R、Python等需要解释执行的语言。

再进一步它还通过一个Sulong框架支持LLVM IR从而支持那些能够生成LLVM IR的语言如C、C++、Rust等。想想看在Java的虚拟机上运行C语言还是有点开脑洞的

图2GraalVM的架构

最后GraalVM还支持AOT编译这就让Java可以编译成本地代码让程序能更快地启动并投入高速运行。我听说最近的一些互联网公司已经在用Graal做AOT编译来生成本地镜像提高应用的启动时间从而能够更好地符合云原生技术的要求。

修改并运行Graal

那接下来我就带你一起动手修改一下Graal编译器在这个过程中你就能对Graal的程序结构熟悉起来消除对它的陌生感有助于后面深入探索其内部的实现机制。

在本课程中我采用了Graal的20.0.1版本的源代码。你可以参考Graal中的文档来做编译工作。

首先,下载源代码(指定了代码的分支):

git clone -b vm-20.0.1 https://github.com/oracle/graal.git 

接着下载GraalVM的构建工具mx它是用Python2.7编写的你需要有正确的Python环境

git clone https://github.com/graalvm/mx.git
export PATH=$PWD/mx:$PATH

你需要在自己的机器上设置好JDK8或11的环境。我这里是在macOS上采用JDK8。

export PATH="/Library/Java/JavaVirtualMachines/openjdk1.8.0_252-jvmci-20.1-b02-fastdebug/Contents/Home/bin:$PATH"
export JAVA_HOME=/Library/Java/JavaVirtualMachines/openjdk1.8.0_252-jvmci-20.1-b02-fastdebug/Contents/Home

好了现在你就可以编译Graal了。你可以在Graal源代码的compiler子目录中运行mx build

mx build

编译完毕以后你可以写一个小小的测试程序来测试Graal编译器的功能。

javac Foo.java    //编译Foo.java
mx vm Foo         //运行Foo.java相当于执行java Foo

“mx vm”命令在第一次运行的时候会打包出一个新的GraalVM它所需要的HotSpot VM是从JDK中拷贝过来的然后它会把Graal编译器等其他模块也添加进去。

Foo.java的源代码如下。在这个示例程序中main方法会无限次地调用add方法所以add方法就成为了热点代码这样会逼迫JIT编译器把add方法做即时编译。

public class Foo{
    public static void main(String args[]){
        int i = 0;
        while(true){
           if(i%1000==0){
                System.out.println(i);
                try{
                    Thread.sleep(100);  //暂停100ms
                }catch(Exception e){}
            }
            
            i++;
            add(i,i+1);   
        }
    }


    public static int add(int x, int y){
        return x + y;
    }

由于我们现在已经有了Graal的源代码所以我们可以在源代码中打印一点信息来显示JIT是什么时候被触发的。

org.graalvm.compiler.hotspot.HotspotGraalCompiler.compileMethod()方法是即时编译功能的入口你可以在里面添加一行输出功能然后用“mx build”命令重新构建。

public CompilationRequestResult compileMethod(CompilationRequest request) {
    //打印被编译的方法名和字节码  
    System.out.println("Begin to compile method: " + request.getMethod().getName() + "\nbytecode: " + java.util.Arrays.toString(request.getMethod().getCode()));
        
    return compileMethod(request, true, graalRuntime.getOptions());
}

你在compiler目录里打出“mx ideinit”命令就可以为Eclipse、IntelliJ Idea等编译器生成配置信息了。你可以参照文档来做好IDE的配置。

注意我用Eclipse和IntelliJ Idea都试了一下。Idea的使用体验更好一些。但用mx ideinit命令为Idea生成的配置文件只是针对JDK8的如果要改为JDK11还需要手工修改不少配置信息。
在使用Idea的时候你要注意安装python插件文档中建议的其他插件可装可不装。
在使用Eclipse时我曾经发现有一些报错信息是因为IDE不能理解一些注解。你如果也遇到了类似情况稍微修改一下头注释就能正常使用了。

mx ideinit

然后,你可以运行下面的命令来执行示例程序:

mx vm \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+EnableJVMCI \
  -XX:+UseJVMCICompiler \
  -XX:-TieredCompilation \
  -XX:CompileOnly=Foo.add \
Foo  

你会看到,命令中包含了很多不同的参数,它们分别代表了不同的含义。

  • -XX:+UnlockExperimentalVMOptions启用试验特性。
  • -XX:+EnableJVMCI启用JVMCI功能。
  • -XX:+UseJVMCICompiler 使用JVMCI编译器也就是Graal。
  • -XX:-TieredCompilation :禁用分层编译。
  • -XX:CompileOnly=Foo.add只编译add方法就行了。

当程序运行以后根据打印的信息你就能判断出JIT编译器是否真的被调用了。实际上它是在add方法执行了15000次以后才被调用的。这个时候JVM会认为add方法是一个热点。因为JIT是在另一个线程启动执行的所以输出信息要晚一点。

好了通过这个实验你就能直观地了解到JVM是如何判断热点并启动JIT机制的了。

另外,在这个实验中,你还可以通过“-XX:CompileThreshold”参数来修改热点检测的门槛。比如说你可以在“-XX:CompileThreshold=50”也就是让JVM在被add方法执行了50次之后就开始做即时编译。你还可以使用“-Xcomp”参数让方法在第一次被调用的时候就开始做编译。不过这样编译的效果会差一些因为让方法多运行一段时间再编译JVM会收集一些运行时的信息这些信息会有助于更好地做代码优化。这也是AOT编译的效果有时会比JIT差的原因因为AOT缺少了运行时的一些信息。

好了接下来我们再来看看JIT编译后的机器码是什么样子的。

JIT所做的工作本质上就是把字节码的Byte数组翻译成机器码的Byte数组在翻译过程中编译器要参考一些元数据信息符号表等再加上运行时收集的一些信息用于帮助做优化

前面的这个示例程序,它在运行时就已经打印出了字节码:[26, 27, 96, -84]。如果我们转换成16进制就是[1a, 1b, 60, ac]。它对应的字节码是:[iload_0, iload_1, iadd, ireturn]。

我们暂时忽略掉这中间的编译过程先来看看JIT编译后生成的机器码。

Graal编译完毕以后是在org.graalvm.compiler.hotspot.CompilationTask的performCompilation方法中,把编译完毕的机器码安装到缓存区,用于后续执行。在这里,你可以加一点代码,打印编译后的结果。

...
installMethod(debug, result);   //result是编译结果
System.out.println("Machine code: " + java.util.Arrays.toString(result.getTargetCode()));
...

打印输出的机器码数组如下:

我们光看这些机器码数组当然是看不出来有什么含义的但JDK可以把机器码反编译成汇编码然后打印输出就会更方便被我们解读。这就需要一个反汇编工具hsdis

运行“mx hsdis”命令你可以下载一个动态库在macOS上是hsdis-amd64.dylib在Linux上以so结尾在Windows上以dll结尾。这个动态库会被拷贝到JDK的lib目录下这样我们就可以通过命令行参数让JVM输出编译生成的汇编码。

sudo -E mx hsdis #用sudo是为了有权限把动态库拷贝到系统的JDK的lib目录

由于我使用mx命令来运行示例程序所以使用的JDK实际上是GraalVM从系统JDK中拷贝过来的版本因此我需要手工把hsdis.dylib拷贝到graal-vm-20.0.1/compiler/mxbuild/darwin-amd64/graaljdks/jdk11-cmp/lib目录下。

mx vm \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+EnableJVMCI \
  -XX:+UseJVMCICompiler \
  -XX:-TieredCompilation \
  -XX:+PrintCompilation \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintAssembly \
  -XX:CompileOnly=Foo.add \
Foo  

输出的汇编码信息如下:

我来解释一下这段汇编代码的含义:

好了现在你已经能够直观地了解JIT启动的时机检测出热点代码以及它最后生成的结果机器码。

但我们还想了解一下中间处理过程的细节因为这样才能理解编译器的工作机制。所以这个时候如果能够跟踪Graal的执行过程就好了就像调试一个我们自己编写的程序那样。那么我们能做到吗

当然是可以的。

跟踪Graal的运行

Graal是用Java编写的因此你也可以像调试普通程序一样调试它。你可以参考源代码中的这篇与调试有关的文档

由于Graal是在JVM中运行的所以你要用到JVM的远程调试模式。我们仍然要运行Foo示例程序不过要加个“-d”参数表示让JVM运行在调试状态下。

mx -d vm \
  -XX:+UnlockExperimentalVMOptions \
  -XX:+EnableJVMCI \
  -XX:+UseJVMCICompiler \
  -XX:-TieredCompilation \
  -XX:CompileOnly=Foo.add \
Foo  

这个时候在JVM启动起来之后会在8000端口等待调试工具跟它连接。

你可以使用Eclipse或Idea做调试工具。我以Eclipse为例在前面运行“mx ideinit”的时候我就已经设置了一个远程调试的配置信息。

你可以打开“run>debug configurations…”菜单在弹出的对话框中选择Remote Java Application可以看到几个预制好的配置。

然后点击“compiler-attach-localhost-8000”你可以看到相关属性。其中连接信息正是本机的8000端口。

把Project改成“org.graalvm.compiler.hotspot”然后点击Debug按钮。

补充如果你使用的是Idea你也会找到一个预制好的远程调试配置项GraalDebug。直接点击就可以开始调试。

为了方便调试我在org.graalvm.compiler.hotspot.compileMethod()方法中设置了断点,所以到了断点的时候,程序就会停下来,而不是一直运行到结束。

当你点击Debug按钮以后Foo程序会继续运行。在触发了JIT功能以后JVM会启动一个新线程来运行Graal而Foo则继续在主线程里运行。因为Foo一直不会结束所以你可以从容地进行调试不用担心由于主线程的退出而导致运行Graal的线程也退出。

现在你可以跟踪Graal的编译过程看看能发现些什么。在这个过程中你需要一点耐心慢慢理解整个代码结构。

Graal执行过程的主要结构如下图所示。

图3Graal执行过程的主要结构

首先你会发现在编译开始的时候Graal编译器要把字节码转化成一个图的数据结构。而后续的编译过程都是对这个图的处理。这说明了这个图很重要而这个图就是Graal要用到的IR在Graal编译器中它也被叫做HIR。

接着你会看到整个编译过程被分成了前端和后端两个部分。前端部分使用的IR是HIR。而且在前端部分HIR又分成了高HighTier、中MidTier、低LowTier三层。在每个层次里都要执行很多遍Phase对图的处理。这些处理指的就是各种的优化和处理算法。而从高到低过渡的过程就是不断Lower的过程也就是把IR中较高抽象度的节点替换成了更靠近底层实现的节点。

在后端部分则要生成一种新的IR也就是我们在第6讲中提到过的LIR并且Graal也要对它进行多遍处理。最后一步就是生成目标代码。

下图中,我举了一个例子,列出了编译器在前端的三个层次以及在后端所做的优化和处理工作。

**你要注意的是,**在编译不同的方法时,所需要的优化工作也是不同的,具体执行的处理也就不一样。并且这些处理执行过程也不是线性执行的,而可能是一个处理程序调用了另一个处理程序,嵌套执行的。

图4一个例子前端三个层次和后端所做的处理

不过通过跟踪Graal的运行过程你可以留下一个直观的印象Graal编译器的核心工作就是对图IR的一遍遍处理。

在下一讲中我就会进一步讲述Graal的IR并会带你一起探讨优化算法的实现过程你可以了解到一个真实编译器的IR是怎样设计的。

课程小结

今天这一讲我带你大致了解了Java的JIT编译器。你需要重点关注以下几个核心要点

  • JIT可能会用到多个编译器有的编译速度快但不够优化比如C1客户端编译器有的够优化但编译速度慢比如C2服务端编译器所以在编译过程中会结合起来使用。
  • 你还需要理解逆优化的概念,以及导致逆优化的原因。
  • 另外我还带你了解了Graal这个用Java编写的Java JIT编译器。最重要的是通过查看它的代码、修改代码、运行和调试的过程你能够建立起对Graal编译器的亲切感不会觉得这些技术都是很高冷的不可接近的。当你开始动手修改的时候你就踏上了彻底掌握它的旅途。
  • 你要熟练掌握调试方法并且熟练运用GraalVM的很多参数这会有利于你去做很多实验来深入掌握Graal。

本讲的思维导图我放在这里了,供你参考:

一课一思

你能否把示例程序的add函数改成一个需要计算量的函数然后你可以比较一下看看JIT前后性能相差了多少倍通过这样的一个例子你可以获得一些感性认识。

有相关的问题或者是思考呢,你都可以给我留言。如果你觉得有收获,你也可以把今天的内容分享给更多的朋友。

参考资料

  1. GraalVM项目的官方网站graalvm.org
  2. Graal的Github地址
  3. Graal项目的出版物。有很多围绕这个项目来做研究的论文,值得一读。