JEP 544 的实现已经合进 JDK 28 EA build 20 了。训练跑里 JIT 编出来的原生码会写进 AOT 缓存,生产启动时直接加载,不用再等 C1/C2。我用 Spring Boot 4.1.1 在解压后的 jar 上跑了一遍:带代码的缓存 71M,关掉代码缓存只剩 60M;在同一台 8 核 x86_64 机器上各启动 5 次,启动时间中位数从 1.308 秒降到 0.420 秒,只做类缓存是 0.739 秒;业务方法 work 在 JVM 启动后约 0.109 秒就从 AOT Code Cache 里加载了。
主源是 JEP 544: Ahead-of-Time Code Compilation,状态 Integrated,Release 28,2026-10-14 更新。实现任务 JDK-8380476 的 Resolved In Build 是 b20,对应 jdk.java.net/28 上 2026-10-15 发布的 Build 20。之前写过一篇讲 JEP 544 目标边界的文章,那时候还是 Proposed to Target,没得实跑;把 fat jar 解压后再做 AOT 缓存,解决的是类加载和链接,这篇是在同一条工作流上再把编译好的原生码也放进缓存。
环境
$ java -version
openjdk version "28-ea" 2027-03-23
OpenJDK Runtime Environment (build 28-ea+20-1588)
OpenJDK 64-Bit Server VM (build 28-ea+20-1588, mixed mode, sharing)
Spring Boot 4.1.1,只引 spring-boot-starter-webmvc,端口 18084。应用里加了一个 CPU 热点接口,方便在训练跑里被编到:
@GetMapping("/work")
public Map<String, Object> work(@RequestParam(defaultValue = "200000") int n) {
long t0 = System.nanoTime();
long sum = 0;
for (int i = 0; i < n; i++) {
sum += (long) i * i;
}
long ms = (System.nanoTime() - t0) / 1_000_000;
return Map.of("sum", sum, "n", n, "elapsedMs", ms);
}
命令行工作流和以前一样,不用改业务代码。JEP 说得很清楚:不引入新流程,只是扩展现有的 AOT 缓存创建。
先解压再训练
fat jar 里的嵌套依赖进不了 AOT 缓存,所以先解压:
$ java -Djarmode=tools -jar target/aot-code-demo-0.0.1.jar extract --destination extracted
$ ls extracted/lib | wc -l
34
后面的训练和生产都在 extracted 目录里用 java -jar aot-code-demo-0.0.1.jar 跑,classpath 才能和缓存对得上。
训练时不能再用 -Dspring.context.exit=onRefresh,那个会在容器刷新完就退出,热点接口根本走不到。做法是启动应用,打一批 /work 请求,再发 SIGTERM 让 JVM 正常退出;-XX:AOTCacheOutput 会在退出时拉起子进程组装缓存。
$ java -XX:AOTCacheOutput=app-code.aot -jar aot-code-demo-0.0.1.jar
# 另开终端打一批 /work 请求,然后 kill -TERM
AOTCache creation is complete: app-code.aot 73920512 bytes
对照实验把诊断开关关掉代码缓存再训一次:
$ java -XX:+UnlockDiagnosticVMOptions -XX:-AOTCodeCaching \
-XX:AOTCacheOutput=app-nocode.aot -jar aot-code-demo-0.0.1.jar
AOTCache creation is complete: app-nocode.aot 62607360 bytes
$ du -h --apparent-size app-code.aot app-nocode.aot
71M app-code.aot
60M app-nocode.aot
两者相差 11313152 字节,约 11M,基本就是 AOT 代码。JEP 里给出的 -XX:-AOTCodeCaching 是诊断开关,可以在训练跑里关掉代码生成,也可以在生产跑里关掉代码加载。
缓存里装了什么
生产启动时打开 -Xlog:aot+codecache*=info,带代码的缓存一上来就映射了 12M 的代码区:
[0.020s][info][aot,codecache,init] Mapped 12247736 bytes at address 0x00007f1e2e058010 from AOT Code Cache
[0.020s][info][aot,codecache,init] Loaded 4488 AOT code entries from AOT Code Cache
[0.020s][info][aot,codecache,init] Adapter: total=555
[0.020s][info][aot,codecache,init] SharedBlob: total=17
[0.020s][info][aot,codecache,init] C1Blob: total=42
[0.020s][info][aot,codecache,init] C2Blob: total=23
[0.020s][info][aot,codecache,init] StubGenBlob: total=4
[0.020s][info][aot,codecache,init] Nmethod: total=3847
[0.020s][info][aot,codecache,init] AOT code total size: 12247736 bytes
[0.081s][info][aot,codecache,init] Read 473 preload entries from AOT Code Cache
实际加载了 3495 个 nmethod。Spring 自己的方法在里面,比如:
[0.097s][info][aot,codecache,nmethod] 678 (AP4) 'org.springframework.util.StringUtils.hasLength(Ljava/lang/String;)Z': Loaded nmethod from AOT Code Cache
[0.098s][info][aot,codecache,nmethod] 713 (AP4) 'org.springframework.util.Assert.notNull(Ljava/lang/Object;Ljava/lang/String;)V': Loaded nmethod from AOT Code Cache
业务方法也在,启动后大约 109 毫秒就预加载了:
[0.109s][info][aot,codecache,nmethod] 1052 (AP4) 'com.spring4all.aot.DemoApplication.work(I)Ljava/util/Map;' AOT nmethod id: 0x001094df, has clinit barriers: preloading from AOT Code Cache
[0.109s][info][aot,codecache,nmethod] 1052 (AP4) 'com.spring4all.aot.DemoApplication.work(I)Ljava/util/Map;': Loaded nmethod from AOT Code Cache
日志里的 AP4 表示 AOT、带预加载、编译级别 4(C2)。关掉代码缓存再看:
[0.016s][info][aot,codecache,init] Mapped 1131104 bytes at address 0x00007f499897b010 from AOT Code Cache
[0.016s][info][aot,codecache,init] Loaded 641 AOT code entries from AOT Code Cache
[0.016s][info][aot,codecache,init] Nmethod: total=0
[0.016s][info][aot,codecache,init] AOT code total size: 1131104 bytes
还剩大约 1M,主要是 adapter 和 stub,nmethod 是 0。类加载和链接的那部分 AOT 缓存还在,只是没有编译好的方法体。
启动和第一次请求
下面这组是三种配置各启动一次的单次测量,起来之后连续打 5 次 n=5000000 的 /work:
### nocache Started DemoApplication in 1.481
{"n":5000000,"elapsedMs":5,"sum":4773166019248396768}
{"n":5000000,"elapsedMs":11,"sum":4773166019248396768}
{"n":5000000,"elapsedMs":2,"sum":4773166019248396768}
{"n":5000000,"elapsedMs":2,"sum":4773166019248396768}
{"n":5000000,"elapsedMs":2,"sum":4773166019248396768}
### code Started DemoApplication in 0.463
{"n":5000000,"elapsedMs":2,"sum":4773166019248396768}
{"n":5000000,"elapsedMs":2,"sum":4773166019248396768}
{"n":5000000,"elapsedMs":2,"sum":4773166019248396768}
{"n":5000000,"elapsedMs":2,"sum":4773166019248396768}
{"n":5000000,"elapsedMs":2,"sum":4773166019248396768}
### nocode Started DemoApplication in 0.778
{"sum":4773166019248396768,"elapsedMs":6,"n":5000000}
{"sum":4773166019248396768,"elapsedMs":2,"n":5000000}
{"sum":4773166019248396768,"elapsedMs":2,"n":5000000}
{"sum":4773166019248396768,"elapsedMs":2,"n":5000000}
{"sum":4773166019248396768,"elapsedMs":1,"n":5000000}
这一轮里,没有缓存:启动 1.481 秒,前两次请求还在暖,后面稳定在 2 毫秒。只有类缓存、没有代码:启动 0.778 秒,第一次 6 毫秒,从第二次开始到 2 毫秒。类缓存加代码:启动 0.463 秒,第一次就是 2 毫秒。单次数字只能看量级,所以又补了两组重复测量。
第二组各启动三次,起来后连打 10 次 n=300000:
### nocache: java
run1 Started DemoApplication in 1.452; 10x /work=233ms; last={"sum":8999955000050000,"elapsedMs":0,"n":300000}
run2 Started DemoApplication in 1.394; 10x /work=210ms; last={"n":300000,"elapsedMs":0,"sum":8999955000050000}
run3 Started DemoApplication in 1.35; 10x /work=240ms; last={"n":300000,"sum":8999955000050000,"elapsedMs":0}
### with-code: java -XX:AOTCache=../app-code.aot
run1 Started DemoApplication in 0.426; 10x /work=123ms; last={"n":300000,"elapsedMs":0,"sum":8999955000050000}
run2 Started DemoApplication in 0.507; 10x /work=124ms; last={"elapsedMs":0,"n":300000,"sum":8999955000050000}
run3 Started DemoApplication in 0.5; 10x /work=120ms; last={"elapsedMs":0,"n":300000,"sum":8999955000050000}
### with-nocode: java -XX:AOTCache=../app-nocode.aot
run1 Started DemoApplication in 0.675; 10x /work=109ms; last={"n":300000,"elapsedMs":0,"sum":8999955000050000}
run2 Started DemoApplication in 0.662; 10x /work=120ms; last={"n":300000,"sum":8999955000050000,"elapsedMs":0}
run3 Started DemoApplication in 0.884; 10x /work=116ms; last={"sum":8999955000050000,"elapsedMs":0,"n":300000}
第三组只看启动,各启动 5 次,中位数是:没有缓存 1.308 秒(1.223 到 1.417),类缓存 0.739 秒(0.665 到 0.801),类缓存加代码 0.420 秒(0.396 到 0.437)。三组的量级一致,启动时间上,带代码比只做类缓存再快一截;10 次小请求的总耗时两者接近,说明这段循环在几次调用后 JIT 也能追平。差别主要出现在启动本身,以及冷启动后的第一下。
使用上要注意的
训练和生产必须用同一个 JDK 构建、同一套 CPU 特性,JEP 明确不支持交叉编译。jar 时间戳变了缓存会失效,这点和类缓存一样。生产默认会加载缓存里的代码;如果只想用类缓存、不要 AOT 代码,训练或生产都可以加 -XX:+UnlockDiagnosticVMOptions -XX:-AOTCodeCaching。
本文只用了 JVM 参数,没有用到 Spring Boot 的任何专门配置。fat jar 要先 jarmode=tools 解压,否则类都进不去,更别提代码。本文只在一台 8 核 Linux x86_64 机器上测试,没有测试其他 CPU 架构、容器镜像或 GraalVM Native Image。
资料来源是 JEP 544、JDK-8380476、JDK 28 Early-Access Builds,以及前置的 JEP 483 和 JEP 515。
一起交流
分享你的思考,让讨论更进一步。