Graalvm的闭世界假设
Java的AOT之路
在Java24中JEP 483:AOT Class Loading & Linking。把一部分加载class、解析、链接的工作提前做好存进AOT启动时不必从头再做。
JDK25中 JEP 514 / 515。AOT Cache的使用方式变简单,同时可以保存上一次训练得到的profile,让JIT不必重新慢慢收集热点数据。
JDK26 JEP5165 AOT对象缓存可以更好配合不同GC,包括了ZGC的使用范围。
详细讲一下
JEP 514:Ahead-of-Time Command-Line Ergonomics
JEP 515:Ahead-of-Time Method Profiling
可以简单理解为让JVM,认真跑一遍笔记,以后启动时直接带着笔记本进场。
openJDK把这一次的执行称为traning run(训练运行)
普通一个HotSpot程序启动时,要经历。
JVM 启动
↓
读取 .class
↓
类加载
↓
验证 / 解析 / 链接
↓
开始执行 Java 字节码
↓
解释器 / C1 执行
↓
收集运行数据 Profile
↓
发现热点代码
↓
C1 / C2 JIT 编译
↓
生成高度优化的机器码
↓
达到稳定高性能这里其实有俩个问题
Startup
java -jar app.jar
↓
2.3 秒
↓Application started
这是类启动时间,类加载,类链接,Spring Bean初始化等都会影响它。
Warm-up
应用启动之后,在前几十秒不断变快,这是因为HotSpot在观察应用,然后进行JIT优化。
这就是预热。
Java 25 的 JEP 515 主要针对的是第二部分,同时由于启动阶段本身就会执行大量 Java 代码,因此也可能改善 startup。Oracle 对 JDK 25 的描述就是:以前 JIT 必须等待当前 JVM 收集 method profile,现在可以在启动时直接获得之前运行积累的 profile。
JVM在运行的时候,其实并不知道哪里是热点,因此需要观察程序怎么跑,确定执行的热点。JEP515保存的就是method-execution profile让下一次JVM不必完全从0开始观察,生产环境运行依然会收集profile,因此这样的训练数据更像一个初始先验,而不是不可改变的规则。
Java 24 的 JEP 483 已经引入了主要基础设施:,传统启动的时候,AOT cache
app.jar
↓
读取 class
↓
解析
↓
加载
↓
验证
↓
链接
↓
resolve
↓
执行有了AOT cahce之后
Training
↓
app.aot
↓
已加载的 class
已链接的信息
部分 heap objects
↓
下次 JVM 启动
↓
尽量直接复用java 25最大的变化是连JIT的经验也存。
这正是 JEP 515 的目标:把以前训练运行得到的 method-execution profile 在 JVM 启动时立即提供给 HotSpot,从而减少当前运行重新积累 profile 的等待。
java25并没有直接把方法机器码直接AOT好。
训练
Java 25 的 JEP 514 把流程简化得非常明显。
最简单的就是
java \
-XX:AOTCacheOutput=app.aot \
-jar app.jar这一次就是training run,程序是会真正运行的。
比如Springboot,你甚至可以在跑起来的时候调用各种http请求。最后程序被正常关闭。JEP5114的一条改进就是把以前显示的record+create俩阶段命令简化成
java -XX:AOTCacheOutput=app.aot ...这一个用户侧命令;JVM 在后面完成训练记录和 cache 创建。
当然,对于SpringBoot这种常驻的程序,我们需要命令让他能够结束。
java \
-XX:AOTCacheOutput=app.aot \
-Dspring.context.exit=onRefresh \
-jar application.jarspring.context.exit=onRefresh 会让 Spring 在 ApplicationContext 完成 refresh 后主动退出。
spring.context.exit=onRefresh能退出的原因非常直接:Spring Framework 在ApplicationContext.refresh()的最后阶段,会调用DefaultLifecycleProcessor.onRefresh();这个方法检测到该系统属性后,直接执行Runtime.getRuntime().halt(0)。
如果你执想优化Springboot的启动速度,那这样就够了。但是如果你还想解决,冷启动时,部分接口的前几次调用速度很慢。那么你就可以在启动的时候打一批额外的请求。
但是我们还具有玩业务预热。因为如果只是
-Dspring.context.exit=onRefresh主要训练的是Spring 启动,bean创建,自动配置。jackson初始化,tomcat/netty初始化等。
但是用户访问的接口可能没有被真正的执行。Spring Framework 官方因此特别建议:Java 25+ 如果想改善 warm-up,不要只 exit=onRefresh,而应该让训练实例经历一段类似生产环境的 workload。
因此可以
java \
-XX:AOTCacheOutput=app.aot \
-jar my-app.jar然后对他做一次预热
curl http://localhost:8080/actuator/health
curl http://localhost:8080/api/users/1
curl http://localhost:8080/api/orders/123
curl -X POST \
http://localhost:8080/api/search \
-H 'Content-Type: application/json' \
-d '{"keyword":"iphone"}'这时正常终止JVM,这时候生产环境直接
java \
-XX:AOTCache=app.aot \
-jar my-app.jar就可以直接加载预热之后的代码。但是这个预热需要有代表性,贴合生产环境流量。因为和运来的JVM一样,只有热点代码才会被JIT。
以前想要做到这一点,需要把Java跑热之后,把虚拟机冻结才可以实现。
闭世界假设
java JVM天生是open world。
比如
String className = readFromConfig();
Class<?> clazz =
Class.forName(className);
Object plugin =
clazz.getConstructor().newInstance();运行期你可能根本不知道这些信息。但是运行到了之后还可以加载,链接,然后JIT。
但是在GraalVM的native image中,需要closed world assumption,闭世界假设。
也就是说,构建native image时,需要能够确定运行时可能出现的代码。原生镜像构建会做静态可达性分析,从而确定程序入口可能到达哪些classes、methods和feileds。
因此Class.forName(someRuntimeString);这种就经常出问题。这就是为什么,native image需要
reachability metadata
reflection metadata
resource metadata
JNI metadata
serialization metadata
proxy metadataLeyden是OpenJDK下面的一个长期研发项目,它的成果会拆成很多的JEP, 成熟一个就往主线JDK里面合并一个。一些激进的实验会留在openjdk/leyden的permain分支里。
| Leyden 成果 | 进入版本 | 状态 |
|---|---|---|
| JEP 483 AOT Class Loading & Linking | JDK 24 | 主线 |
| JEP 514 AOT Command-Line Ergonomics | JDK 25 | 主线 |
| JEP 515 AOT Method Profiling | JDK 25 | 主线 |
| JEP 516 AOT Object Caching with Any GC | JDK 26 | 主线 |
| AOT Code Compilation | — | 仍在开发 |
leyden基本没有把Java的动态特性砍掉。
训练时见过 MyPlugin
↓
可以提前优化 MyPlugin
生产突然出现 OtherPlugin
↓
HotSpot:
“没问题,正常 class loading”
↓
load
↓
link
↓
JIT也就是说cache miss不代表程序不能正常运行。这是和native image的本质区别。
training观察哪些类实际被用到了,于是JVM就知道,这些
这些 class 实际会在启动过程中使用
这些 class 可以考虑进入 AOT cache
这些链接关系可以提前处理
让JVM不在每次从完全失忆的状态开始预热。
Leyden EA 正在继续下一步:连机器码也保存。这就很类似AOT了。此外leyden不会要求训练时必须看到生产环境未来的一切。因为没看到也没关系。
AOT cache hit
↓
快速路径
AOT cache miss
↓
普通 JVM 路径
↓
load / link / interpret / JIT这个思想非常类似安卓的ART。
Android ART JDK 25 Leyden
跨运行 profile ✓ ✓
profile-guided AOT
machine code ✓ ✗
运行时 JIT ✓ ✓
持续 profiling ✓ ✓未来 Leyden 的 AOT Code Compilation 补上的,恰恰就是 ART 已经拥有的这一块。
但手机上,需要在意启动时间,电池,安装时间等。因此不能让所有方法AOT。但是Leyden目标更偏向服务器,理想情况是不要从0开始爬性能曲线,而是直接从70-90开始,然后由JIT爬到100%。
| 技术 | 是否有闭世界假设 | 说明 |
|---|---|---|
| GraalVM Native Image | 是,而且很强 | AOT 编译时假设运行时可达的类、方法、字段等基本都能提前确定,因此反射、动态代理、JNI、动态类加载常需要额外配置 |
| Go | 某种程度上是 | 普通静态编译时,编译器/linker 能看到最终链接进来的包集合,方便 dead code elimination;但 Go 语言本身不是严格 closed-world,plugin、cgo、动态库会打破这种假设 |
| Rust | 某种程度上是 | 泛型 monomorphization、LTO、dead code elimination 等都能利用“最终程序集合已知”;但动态库、FFI、trait object 等意味着语言语义并不要求全局闭世界 |
| C++ | 通常不是 | 天生支持 separate compilation、动态链接、dlopen、虚函数、插件体系。只有开启 LTO/whole-program optimization,并且人为保证没有外部扩展时,编译器才可以做 closed-world 优化 |
