Java虚拟机有一个新秀,叫做GraalVM,其代表性的功能就是Native Image以及使用Java编写的C2编译器,之前折腾项目尝试过各种JVM,HotSpot,OpenJ9,GraalVM,这篇文章就记录一下自己所学到的知识。
GraalVM简介
GraalVM 是由 Oracle 开发的一款高性能的多语言运行时环境,旨在提升应用程序的执行效率并支持跨语言互操作性。它不仅是传统 Java 虚拟机(JVM)的增强替代品,还通过创新的技术(如提前编译和多语言引擎)扩展了应用场景。
如果你想尝试GraalVM,直接去Download GraalVM下载就可以了,GraalVM可以直接替代HotSpot的jdk。
GraalVM的一些黑科技
Native Image
最引人注目的当然是Native Image这项技术,通过 AOT(提前编译) 技术,将 Java 程序编译为本地可执行文件,彻底摆脱 JVM 依赖。不需要在分发软件的时候再要求用户去自己安装一个Java,或者自己带一个庞大的jre。即使通过jlink
制作定制版的jre,最后的大小仍然有几十M。
我也在自己的项目上尝试使用Native Image进行编译。XUANXUQAQ/File-Engine-Core: File-Engine-Core
但是说实话,想让Native Image通过编译,比让Rust通过编译都难,并且社区版的Native Image只能使用Serial GC,说实话可用性并不是很高。
Native Image运行时:Substrate VM
GraalVM并不是完全脱离了jvm,而是在Native Image中运行了一个小型的vm,称为Substrate VM Project。通过Native Image的Close World的假设,在编译期间就直接确定所有可达的代码,并进行激进的优化,避免了运行时的类加载和初始化开销,提高性能。
在Substrate VM中存在一片区域,被称为:Image Heap,这片区域存放的就是已经在编译器就被初始化好的类以及一些常量对象,在程序启动时就可以直接访问,而不需要再一个个的进行初始化,提高了启动速度。
在Image Heap中的对象被认为是不会被清除的,他们的生命周期与程序相同,因此GC的时候,Image Heap中的对象也会被认为是GC Roots。但实际上GC并不会全部扫描,只会专门去扫描没有被标记为Read-Only
,也就是只读的那部分对象。
关于JVM的GC,这又是一个很长的话题,各种各样的GC算法,标记清除算法,复制算法,分代收集。如果判断是否一个对象是垃圾对象,又涉及到对象引用计数,GC Roots可达性算法。以及各种各样的垃圾收集器,单线程的Serial GC
,多线程的Parallel GC
,使用Region分区的G1 GC
,以及最新的前沿ZGC
,ZGC在JDK23中未分代的版本(Non-Generational ZGC
)被弃用,只留下了Generational ZGC
。这里就不讨论GC了,只要知道Substrate VM有一片特殊的区域Image Heap,和Eden Space,Survivor 0,Survivor 1,以及老年代和元空间分开了就可以了。
- 如何进行编译
官方有一篇文章介绍了最基本的从jar编译成可执行文件的文章Build a Native Executable from a JAR File,不过这里面省去了很多细节和坑。
这里介绍了一些Native Image的基本用法Native Image Build Overview
首先需要进行环境的准备
这里我就只介绍Windows上的操作了。需要安装Visual Studio 2022,然后在Visual Studio Installer中选择Visual Studio Community 2022,安装Desktop development with C++,然后在单个组件中选择C++生成工具,之后就可以在开始菜单中找到x64 Native Tools Command Prompt for VS 2022,打开这个即可看到命令行。

对jar包的处理
由于Native Image脱离了jvm环境,被编译成了原生代码,因此对于反射和动态生成代码这样的特性就无法支持了,除非你使用fallback编译出来的镜像,但那样做的话Native Image还有什么意义呢。
因此第二步就是要找出项目中有反射调用以及代理相关的代码,并告诉Native Image,然后Native Image就可以进行处理,顺利编译。
对此Native Image已经给出了解决方案,那就是Reachability Metadata
通过Reachability Metadata,你就可以告诉Native Image,代码在哪些地方进行了反射调用,使用了动态代理,以及使用JNI调用了原生代码。Metadata文件使用JSON格式。
json
{
"condition": {
"typeReached": "<condition-class>"
},
"type": "<class>|<proxy-interface-list>",
"fields": [
{"name": "<fieldName>"}
],
"methods": [
{"name": "<methodName>", "parameterTypes": ["<param-type>"]}
],
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true,
"allDeclaredFields": true,
"allPublicFields": true,
"unsafeAllocated": true
}
以上就是一个反射类的完整配置,type
就是类的全限定名,fields
就是通过反射访问的字段,methods
就是通过反射访问的方法。
对应的还有JNI的配置
比如对应的C代码为
c
jclass clazz = FindClass(env, "jni/accessed/Type");
那么在metadata里就要写上对应的type,如果要访问Type类的某个字段,还要添加fields
,或者直接使用allDeclaredFields
,allPublicFields
允许所有field
访问。
如果JNI要调用Java的方法,那么对应的Java方法也要写进methods中。
json
{
"jni":[
{
"type": "jni.accessed.Type",
"fields": [{"name": "value"}]
....
"allDeclaredFields": true,
"allPublicFields": true,
"methods": [
{"name": "<methodName1>", "parameterTypes": ["<param-type1>", "<param-typeI>", "<param-typeN>"]},
{"name": "<methodName2>", "parameterTypes": ["<param-type1>", "<param-typeI>", "<param-typeN>"]}
]
}
]
}
还有关于资源文件,众所周知,maven项目有一个src/main/resources文件夹,gradle也类似。对于资源文件也需要在编译器就定好。
因此还需要一个文件去定义jar包中的资源文件。
json
{
"resources": [
{
"module:": "library.module",
"glob": "resource-file.txt"
}
]
}
你可能会说,啊我哪知道代码里这么多地方哪里调用了反射,手写怎么写这么多配置,有没有什么办法可以直接让Java自己生成呢
有的兄弟,有的。Native Image官方提供了一个Java agent,Collect Metadata with the Tracing Agent。你只需要在启动时添加一段vm options
bash
java -agentlib:native-image-agent=config-output-dir=./metadata -Xmx1G -jar app.jar
然后启动你的项目就可以了。
注意:你需要完整的过一遍你的项目的所有功能,包括各种接口的参数条件也都需要覆盖,测试用例覆盖的越全,生成出来的Metadata越全面
完成之后,在metadata目录下就会生成出jni-config.json
,reflect-config.json
等等几个文件。
将这几个文件放到src/main/resources
的META-INF/native-image/
下即可。
将jar编译为exe
接下来我们就需要使用上面打开的x64 Native Tools Prompt for VS 2022了,在里面输入
bash
native-image --no-fallback -H:Path=./outDir -jar app.jar -H:+JNI -R:MaxHeapSize=512M -R:MinHeapSize=32M -H:+UseCompressedReferences -R:MaxHeapFree=16777216
就可以开始编译了。编译出的结果将会放到当前目录下的outDir
中。
事实上目前Native Image的编译产物还是不太稳定,有时候跑着跑着就会出现Segment Fault导致崩溃。建议不要用到生产环境,尝尝鲜或者你并不在意崩溃问题只希望性能够高,可以尝试一下。
高性能 JIT 编译器
GraalVM 的 Graal 编译器 替代了传统的 HotSpot C2 编译器,性能表现更优。
Graal Compiler是通过Java来进行编写的,通过Java的编译器来编译优化Java的代码,是不是听着很神奇,其实这是编译器的一个特性,叫做自举,这里就不展开了。
关于Graal Compiler的介绍在这里Graal Compiler。
关于Graal的底层的细节,这篇文章写的很棒,不过是全英文的Understanding How Graal Works - a Java JIT Compiler Written in Java
首先,为什么要使用Java来写C2编译器,原来的C++版本不好吗。问题在于,经过经年累月的代码积累,C2编译器的代码已经越来越难以维护,C++作为偏向底层的语言,对内存的操作稍有不慎,便会导致jvm直接崩溃,事实上C2编译器貌似也很久没有什么大更新了。
那么JVM是如何实现不改动代码直接实现替换编译器的呢。这都得益于JEP 243: Java-Level JVM Compiler Interface,引入了JVMCI,JVM编译器接口。编译器的工作实际上非常简单,就是输入一段代码,输出另一端代码。因此可以非常简单的理解为
java
interface JVMCICompiler {
byte[] compileMethod(byte[] bytecode);
}
当然实际上,还有一些其他的信息,比如局部变量个数,栈的大小,解释器的Profiling信息用于优化,因此实际上可能类似于下面这样。
java
interface JVMCICompiler {
void compileMethod(CompilationRequest request);
}
interface CompilationRequest {
JavaMethod getMethod();
}
interface JavaMethod {
byte[] getCode();
int getMaxLocals();
int getMaxStackSize();
ProfilingInfo getProfilingInfo();
...
}
编译出来之后,只需要通过API告诉虚拟机,装配上机器代码,不需要走解释执行了即可。
java
Hotspot.installCode(targetCode);
关于Graal如何生成并优化ideal graph,这里就不讲了,感兴趣的大家可以自己去看看上面贴出的原文章。
比如一个简单的代码
java
int average(int a, int b) {
return (a + b) / 2;
}
生成的图长下面这样

将代码编译成机器码的过程其实就是将图中的每一个节点转换成对应的机器码。
对于最简单的加法
java
int workload(int a, int b) {
return a + b;
}
相加对应的指令就是incl,输出的字节都会放到一个Byte Buffer里
java
void incl(Register dst) {
int encode = prefixAndEncode(dst.encoding);
emitByte(0xFF);
emitByte(0xC0 | encode);
}
void emitByte(int b) {
data.put((byte) (b & 0xFF));
}
最后输出的机器码就是一个字节数组
编译结果就是从Java字节码->机器码 [26, 27, 96, -84] → [15, 31, 68, 0, 0, 43, -14, -117, -58, -123, 5, ...]
bash
workload machine code: [15, 31, 68, 0, 0, 3, -14, -117, -58, -123, 5, ...]
...
0x000000010f71cda0: nopl 0x0(%rax,%rax,1)
0x000000010f71cda5: add %edx,%esi ;*iadd {reexecute=0 rethrow=0 return_oop=0}
; - Demo::workload@2 (line 10)
0x000000010f71cda7: mov %esi,%eax ;*ireturn {reexecute=0 rethrow=0 return_oop=0}
; - Demo::workload@3 (line 10)
0x000000010f71cda9: test %eax,-0xcba8da9(%rip) # 0x0000000102b74006
; {poll_return}
0x000000010f71cdaf: vzeroupper
0x000000010f71cdb2: retq
关于图的优化
Canonicalisation
,首先是对Node的规范化,对于一些双重取反操作,比如 int a = -(-x)
,那么将会直接优化为int a = x
。
Global value numbering
,对于相同计算的合并
java
int workload(int a, int b) {
return (a + b) * (a + b);
}
以上代码a + b计算了两次,在ideal graph中,Graal编译器会比较两个node,看他们是否相等,如果是相等的,那么使用一个node进行简化,
类似于node缓存,下次计算就可以直接拿缓存中的结果。这个优化必须是在节点是固定的情况下才会使用。
java
int workload() {
return (getA() + getB()) * (getA() + getB());
}
如果是上面的代码,由于不能确定两次调用getA()
和getB()
是否会有副作用,因此无法进行优化。
Lock coarsening
锁的粗化
java
void workload() {
synchronized (monitor) {
counter++;
}
synchronized (monitor) {
counter++;
}
}
对于这样的代码,伪代码实际上是这样的
java
void workload() {
monitor.enter();
counter++;
monitor.exit();
monitor.enter();
counter++;
monitor.exit();
}
中间的monitor.exit()和monitor.enter()是可以消除的。在Graal编译器中由LockEliminationPhase
实现,run方法就会查看所有的monitor.exit()节点,然后检查是否在他们后面马上又跟了一个monitor.enter()节点,如果是的话则会进行优化,最后在方法中只留下一个monitor enter以及monitor exit。
java
void run(StructuredGraph graph) {
for (monitorExitNode monitorExitNode : graph.getNodes(MonitorExitNode.class)) {
FixedNode next = monitorExitNode.next();
if (next instanceof monitorEnterNode) {
AccessmonitorNode monitorEnterNode = (AccessmonitorNode) next;
if (monitorEnterNode.object() == monitorExitNode.object()) {
monitorExitNode.remove();
monitorEnterNode.remove();
}
}
}
}
GraalVM还有很多其他的黑科技,比如Truffle Language Implementation Framework可以实现在GraalVM上运行不同的代码,Intellij idea的JavaScript执行引擎也换成了GraalJSIntelliJ IDEA 2024.2 Is Out! | The IntelliJ IDEA Blog。
虽然GraalVM还有一些问题,但是目前保持着高频率的更新,发展潜力巨大,相信以后超越HotSpot也只是时间问题。