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.jar

spring.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 metadata

Leyden是OpenJDK下面的一个长期研发项目,它的成果会拆成很多的JEP, 成熟一个就往主线JDK里面合并一个。一些激进的实验会留在openjdk/leyden的permain分支里。

Leyden 成果进入版本状态
JEP 483 AOT Class Loading & LinkingJDK 24主线
JEP 514 AOT Command-Line ErgonomicsJDK 25主线
JEP 515 AOT Method ProfilingJDK 25主线
JEP 516 AOT Object Caching with Any GCJDK 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 优化

image.png

Last modification:September 10, 2026
如果觉得我的文章对你有用,请随意赞赏