11.5 实战:深入理解Graal编译器 11.5.3 JVMCI编译器接口

11.5.3 JVMCI编译器接口

image-20211126123752046

图11-11 手动关闭其他版本的工程

现在请读者来思考一下,如果让您来设计JVMCI编译器接口,它应该是怎样的?既然JVMCI面向的是Java语言的编译器接口,那它至少在形式上是与我们已经见过无数次的Java接口是一样的。我们来考虑即时编译器的输入是什么。答案当然是要编译的方法的字节码。既然叫字节码,顾名思义它就应该是“用一个字节数组表示的代码”。那接下来它输出什么?这也很简单,即时编译器应该输出与方法对应的二进制机器码,二进制机器码也应该是“用一个字节数组表示的代码”。这样的话,JVMCI接口就应该看起来类似于下面这种样子:

1
2
3
interface JVMCICompiler {
byte[] compileMethod(byte[] bytecode);
}

事实上JVMCI接口只比上面这个稍微复杂一点点,因为其输入除了字节码外,HotSpot还会向编译器提供各种该方法的相关信息,譬如局部变量表中变量槽的个数、操作数栈的最大深度,还有分层编译在底层收集到的统计信息等。因此JVMCI接口的核心内容实际就是代码清单11-13总所示的这些。

代码清单11-13 JVMCI接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface JVMCICompiler {
void compileMethod(CompilationRequest request);
}
interface CompilationRequest {
JavaMethod getMethod();
}
interface JavaMethod {
byte[] getCode();
int getMaxLocals();
int getMaxStackSize();
ProfilingInfo getProfilingInfo();
...
// 省略其他方法
}

我们在Eclipse中找到JVMCICompiler接口,通过继承关系分析,可以清楚地看到有一个实现类HotSpotGraalCompiler实现了JVMCI,如图11-12所示,这个就是我们要分析的代码的入口。

image-20211126123918694

图11-12 JVMCI接口的继承关系

为了后续调试方便,我们先准备一段简单的代码,并让它触发HotSpot的即时编译,以便我们跟踪观察编译器是如何工作对的。具体代码如清单11-14所示。

代码清单11-14 触发即时编译的示例代码[^1]
1
2
3
4
5
6
7
8
9
10
public class Demo {
public static void main(String[] args) {
while (true) {
workload(14, 2);
}
}
private static int workload(int a, int b) {
return a + b;
}
}

由于存在无限循环,workload()方法肯定很快就会被虚拟机发现是热点代码因而进行编译。实际上除了workload()方法以外,这段简单的代码还会导致相当多的其他方法的编译,因为一个最简单的Java 类的加载和运行也会触发数百个类的加载。为了避免干扰信息太多,笔者加入了参数-XX: CompileOnly来限制只允许workload()方法被编译。先采用以下命令,用标准的服务端编译器来运行清单11-14中所示的程序。

1
2
3
4
5
6
7
8
9
10
$ javac Demo.java 
$ java \
-XX:+PrintCompilation \
-XX:CompileOnly=Demo::workload \
Demo
...
193 1 3 Demo::workload (4 bytes)
199 2 1 Demo::workload (4 bytes)
199 1 3 Demo::workload (4 bytes) made not entrant
...

上面显示wordload()方法确实被分层编译了多次,“made not entrant”的输出就表示了方法的某个已编译版本被丢弃过。从这段信息中我们清楚看到,分层编译机制及最顶层的服务端编译都已经正常工作了,下一步就是用我们在Eclipse中的Graal编译器代替HotSpot的服务端编译器。

为简单起见,笔者加上-XX:-TieredCompilation关闭分层编译,让虚拟机只采用有一个JVMCI编译器而不是由客户端编译器和JVMCI混合分层。然后使用参数-XX:+EnableJVMCI、-XX: +UseJVMCICompiler来启用JVMCI接口和JVMCI编译器。由于这些目前尚属实验阶段的功能,需要再使用-XX:+UnlockExperimentalVMOptions参数进行解锁。最后,也是最关键的一个问题,如何让HotSpot找到Graal编译器的位置呢?

如果采用特殊版的JDK 8,那虚拟机将会自动去查找JAVA_HOME/jre/lib/jvmci目录。假如这个目录不存在,那就会从-Djvmci.class.path.append参数中搜索。它查找的目标,即Graal编译器的JAR包,刚才我们已经通过mx build命令成功编译出来,所以在JDK 8下笔者使用的启动参数如代码清单11-15所示。

代码清单11-15 JDK8的运行配置

1
2
3
4
5
6
7
8
9
-Djvmci.class.path.append=~/graal/compiler/mxbuild/dists/jdk1.8/graal.jar:~/graal/sdk/mxbuild/dists/jdk1.8/graal
-sdk.jar
-XX:+UnlockExperimentalVMOptions
-XX:+EnableJVMCI
-XX:+UseJVMCICompiler
-XX:
-TieredCompilation
-XX:+PrintCompilation
-XX:CompileOnly=Demo::workload

如果读者采用JDK 9或以上版本,那原本的Graal编译器是实现在jdk.internal.vm.compiler模块中的,我们只要用–upgrade-module-path参数指定这个模块的升级包即可,具体如代码清单11-16所示。

代码清单11-16 JDK 9或以上版本的运行配置
1
2
3
4
5
6
7
8
9
--module-path=~/graal/sdk/mxbuild/dists/jdk11/graal.jar 
--upgrade-module-path=~graal/compiler/mxbuild/dists/jdk11/jdk.internal.vm.compiler.jar
-XX:+UnlockExperimentalVMOptions
-XX:+EnableJVMCI
-XX:+UseJVMCICompiler
-XX:
-TieredCompilation
-XX:+PrintCompilation
-XX:CompileOnly=Demo::workload

通过上述参数,HotSpot就能顺利找到并应用我们编译的Graal编译器了。为了确认效果,我们对HotSpotGraalCompiler类的compileMethod()方法做一个简单改动,输出编译的方法名称和编译耗时,具体如下(黑色加粗代码是笔者在源码中额外添加的内容):

1
2
3
4
5
6
7
public CompilationRequestResult compileMethod(CompilationRequest request) {
long time = System.currentTimeMillis();
CompilationRequestResult result = compileMethod(request, true, graalRuntime.getOptions());
System.out.println("compile method:" + request.getMethod().getName());
System.out.println("time used:" + (System.currentTimeMillis() - time));
return result;
}

在Eclipse里面运行这段代码,不需要重新运行mx build,马上就可以看到类似如下所示的输出结果:

1
2
3
4
97  1           Demo::workload (4 bytes) 
……
compile method:workload
time used:4081

[^1]: 本节部分示例和图片来自于Chris Seaton的文章《Understanding How Graal Works-a Java JIT Compiler Written in Java》:https://chrisseaton.com/truffleruby/jokerconf17/。