本节目标:把 JDK 25 的 AOT 缓存三阶段跑通,知道它缓存什么、不缓存什么,并能判断
libraryd该不该上 AOT 或 Native Image。
适用版本:JDK 25(Temurin 25.0.5+8)。本机未安装 GraalVM Native Image,本节不给出任何 Native Image 构建输出。机器口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125),默认 G1,压缩 oops。
承接的问题
9.2 的 AppCDS 把应用类元数据放进共享归档,最小服务的就绪中位数从 137.8 ms 降到 129.2 ms。团队接着问:
- JDK 25 的 AOT 缓存和 AppCDS 有什么区别,是不是「更快的 CDS」?
- 为什么网上说 AOT 能把启动降到 0.08 秒,本机却看不到?
- 能不能直接上 GraalVM Native Image,把 JVM 都省掉?
这三个问题的答案不在同一个层面。AppCDS 缓存类元数据;AOT 缓存类加载/链接结果和训练运行的方法 profile;Native Image 在构建期做封闭世界编译,生成不依赖 JVM 的可执行文件。三者解决的问题、失败模式和成本都不同。
本节先跑通 AOT 缓存,再讨论 Native Image 的取舍。所有数字都是本机实测,Native Image 因为未安装,只讲原理和决策条件。
一、三阶段:record、create、run
JDK 25 的 AOT 缓存分三步。第一步,记录一次训练运行:
java -XX:AOTMode=record \
-XX:AOTConfiguration=/tmp/vol4-e/aot/hello.aotconf \
-cp hello.jar Hello
本机输出:
libraryd hello
AOTConfiguration recorded: /tmp/vol4-e/aot/hello.aotconf
配置文件 8.8 MB。它记录的是这次运行加载了哪些类、链接了哪些符号、哪些方法被调用过。对最小 HTTP 服务,配置会更大:
record exit 0 conf bytes 15073280
15,073,280 字节,约 14.4 MiB,因为 HTTP 服务加载的类和调用路径更多。训练时要把 representative 请求打一遍,否则 handler 和领域类不会进入配置。
第二步,创建缓存。create 阶段必须重复 classpath,否则会报:
java -XX:AOTMode=create \
-XX:AOTConfiguration=/tmp/vol4-e/aot/hello.aotconf \
-XX:AOTCache=/tmp/vol4-e/aot/hello.aot
[0.045s][warning][aot] app classpath has fewer elements than expected
[0.045s][error ][aot] class path and/or module path are not compatible with the ones specified when the AOTConfiguration file was recorded
Error occurred during initialization of VM
Unable to use create AOT cache.
把 -cp hello.jar 补上就成功:
java -XX:AOTMode=create \
-XX:AOTConfiguration=/tmp/vol4-e/aot/hello.aotconf \
-XX:AOTCache=/tmp/vol4-e/aot/hello.aot \
-cp hello.jar
Reading AOTConfiguration /tmp/vol4-e/aot/hello.aotconf and writing AOTCache /tmp/vol4-e/aot/hello.aot
AOTCache creation is complete: /tmp/vol4-e/aot/hello.aot 9863168 bytes
9,863,168 字节,约 9.4 MiB。最小服务的缓存是 15,941,632 字节,约 15.2 MiB。缓存比配置小,因为它只保留可共享的部分,跳过了动态生成的类。
第三步,用缓存启动:
java -XX:AOTCache=/tmp/vol4-e/aot/hello.aot -cp hello.jar Hello
-Xlog:aot 能看到它确实被使用:
[0.016s][info][aot] Opened AOT cache /tmp/vol4-e/aot/hello.aot.
[0.016s][info][aot] Using AOT-linked classes: true (static archive: has aot-linked classes)
AOT-linked classes: true 是判断缓存是否命中的关键。如果只看到 Opened 而没有这一行,说明类链接结果没有被采用。排查时再加 -Xlog:class+path=info,看 classpath 是否匹配。
二、目录 classpath:本机比文档更严格
9.2 的 AppCDS 不支持非空目录,AOT 缓存也一样。用展开的 out 目录记录:
java -XX:AOTMode=record \
-XX:AOTConfiguration=/tmp/vol4-e/aot/hello-dir.aotconf \
-cp out Hello
本机输出:
libraryd hello
[0.133s][error][aot] Error: non-empty directory 'out'
Error occurred during CDS dumping
Cannot have non-empty directory in paths
配置文件 0 字节。再用这个空配置创建缓存,会得到:
[0.002s][warning][aot] Unable to read generic CDS file map header from AOT configuration file
Error occurred during initialization of VM
Must be a valid AOT configuration generated by the current JVM
即使目录里只有一个 Hello.class,结果相同:
[0.192s][error][aot] Error: non-empty directory '/tmp/vol4-e/hello-only'
Cannot have non-empty directory in paths
一些资料会提到 Skipping Hello: Unsupported location。本机 Temurin 25.0.5+8 上没有出现这条消息,而是在 dump 阶段直接拒绝非空目录。版本和构建不同,报错位置会不同;判断标准是「配置文件是否为 0 字节」,不是某一条固定文案。
把目录换成 jar 就正常。混合 classpath 也能记录,但目录里的类不会进入缓存:
java "-Xlog:aot=info" -XX:AOTMode=record \
-XX:AOTConfiguration=/tmp/vol4-e/aot/mixed.aotconf \
-cp "hello.jar:hello-only" Hello
日志里的 Skipping 说明哪些类被排除:
[0.165s][info][aot] Skipping jdk/internal/misc/CDS$UnregisteredClassLoader$Source: used only when dumping CDS archive
[0.166s][info][aot] Skipping jdk/internal/misc/CDS$UnregisteredClassLoader: used only when dumping CDS archive
[0.166s][info][aot] Skipping java/lang/invoke/BoundMethodHandle$Species_J because it is dynamically generated
动态生成的 BoundMethodHandle$Species_*、只在 dump 时使用的 CDS 内部类不会进缓存。它们仍然要在运行时生成或加载,这部分成本 AOT 缓存不解决。
三、启动实测:小应用没有稳定收益
先把 Hello 和最小服务各跑一组交替测量,避免机器状态漂移。Hello 跑 15 次,最小服务跑到监听跑 12 次:
Hello noAOT median=123.1 mean=136.2 | AOT median=144.6 mean=156.5
MiniHttpd ready noAOT median=211.2 mean=200.9 | AOT median=215.7 mean=202.9
Hello 用 AOT 反而慢了 21 ms;最小服务的 211.2 对 215.7 ms 在噪声范围内。AOT 缓存没有带来稳定收益。
这不是说 AOT 没用,而是这个实验的形态不利于它:
Hello只加载 415 个类,412 个已经由默认 CDS 覆盖,AOT 缓存省下的类链接工作很少。- 最小服务加载 873 个类,834 个来自默认 CDS,应用类只有个位数。
- AOT 缓存本身有 9.4 到 15.2 MiB,映射、重定位、校验都要花时间。macOS 上归档默认以
ArchiveRelocationMode: 1映射到替代地址,这部分成本对小应用不可忽略。 - 本机同时有多个 Java 实验进程,单次启动在 120 到 300 ms 之间波动,AOT 的收益小于噪声。
同一台机器上早先的单轮测量曾出现「默认 166.9 ms、AOT 142.0 ms」的差距,但换一组交替测量就消失了。这正是 9.1 强调的:启动数字必须重复测量,且要交替运行。AOT 缓存的收益在大应用、类链接工作占比高、启动 SLA 严格的场景才会显现;本机这两个玩具程序不构成证据。
AOT 缓存还可能改善「到达峰值性能的时间」,因为它带着训练运行的方法 profile,JIT 可以更早编译热点。本节的测量只覆盖进程启动到监听,没有测 time-to-peak;要证明这一点,需要在启动后按固定速率打请求,记录吞吐爬升曲线,而不是看第一行日志的时间。
四、AOT 缓存、AppCDS 与 Native Image 的边界
三者的对比如下:
| 维度 | AppCDS | JDK 25 AOT 缓存 | GraalVM Native Image |
|---|---|---|---|
| 产物 | .jsa 共享归档 | .aot 缓存 | 原生可执行文件 |
| 缓存内容 | 类元数据 | 类加载/链接结果 + 方法 profile | 构建期编译的机器码与运行时 |
| 运行时是否需要 JVM | 需要 | 需要 | 不需要完整 JVM |
| 能否处理动态类 | 训练时已加载的可以 | 动态生成类被跳过 | 需要反射/资源/代理配置 |
| 构建成本 | 一次训练运行 | 一次训练 + 一次 create | 分钟级构建,工具链独立 |
| 主要风险 | 归档失效 | classpath/JDK 不匹配 | 封闭世界漏配置,运行时才暴露 |
AOT 缓存不是「把 JIT 编译结果存下来」。它缓存的是类加载和链接的中间结果,以及方法 profile;最终机器码仍然由 C1/C2 在运行时生成。所以它不会让服务变成「无 JIT」,也不会消除解释执行阶段。
Native Image 则是另一条路:构建期做可达性分析,把「确定会用到」的类编译进原生镜像。代价是封闭世界假设——反射、动态代理、资源文件、JNI、JDBC 驱动注册都要显式配置。libraryd 用 Javalin、JDBC 和 PostgreSQL 驱动,这些库都涉及反射和资源加载;上 Native Image 不是改一个构建参数,而是要为每个库补配置并跑完整集成测试。
本机没有安装 GraalVM,也没有 native-image 命令,所以本节不给出镜像大小、构建时间或启动数字。任何「Native Image 启动 20 ms」的说法都必须来自实际构建,不能从 AOT 缓存的结果外推。
五、缓存失效:jar 改名后会发生什么
AOT 缓存同样记录字面 classpath。把 hello.jar 改名成 hello-stale.jar,再用旧缓存启动:
[0.016s][warning][aot] The name of app classpath [1] does not match: expected 'hello.jar', got 'hello-stale.jar'
[0.016s][error ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details.
[0.016s][error ][aot] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure)
[0.016s][error ][aot] Unable to map shared spaces
libraryd hello
关键在最后一行:应用仍然启动了。AOT 缓存加载失败是降级,不是致命错误;JVM 回到没有缓存的状态继续运行。这和 AppCDS 的 -Xshare:on 不同——后者在归档不可用时会直接退出。
这意味着生产上可以放心带 -XX:AOTCache,最坏情况是退回普通启动。但监控要能看到降级:把 -Xlog:aot 的 warning 收集起来,或者在启动脚本里检查 Using AOT-linked classes 是否出现。否则缓存失效半年也没人知道。
在 libraryd 里落地
先算一笔账。libraryd 当前的启动到就绪约 108 到 130 ms(9.1 的最小服务口径;真实 libraryd 还要加连接池和 Javalin 初始化),稳态 p99 在 10 到 20 ms 量级。AOT 缓存能影响的是启动和预热,不是稳态吞吐。
决策表:
| 场景 | 建议 | 理由 |
|---|---|---|
| 常规容器部署,启动 SLA 500 ms | 默认 CDS + AppCDS | 成本最低,收益已经拿到 |
| 启动 SLA 100 ms,类多、发布频繁 | 试 AOT 缓存 | 需要按 jar 实测,不能外推 |
| Serverless 冷启动 SLA 50 ms | 评估 Native Image | 构建和反射配置成本高 |
| 命令行工具、短命批处理 | Native Image 或 CRA | JVM 启动占比高,收益明显 |
| 反射/动态代理多、依赖复杂 | 暂不上 Native Image | 封闭世界配置和测试成本可能超过收益 |
libraryd 属于第一类:启动 120 ms 左右,发布频率以天计,没有 50 ms 冷启动约束。默认 CDS 已经覆盖核心类,AppCDS 再把应用类归档,足够。AOT 缓存可以放在 staging 做一次对照,但不能因为「JDK 25 新特性」就上生产。
AOT 脚本:
#!/usr/bin/env bash
set -euo pipefail
JAVA="$HOME/.jdks/jdk-25.0.5+8/Contents/Home/bin/java"
JAR="/opt/libraryd/libraryd-app.jar"
CONF="/opt/libraryd/app.aotconf"
CACHE="/opt/libraryd/app.aot"
rm -f "$CONF" "$CACHE"
"$JAVA" -XX:AOTMode=record -XX:AOTConfiguration="$CONF" \
-jar "$JAR" --train &
PID=$!
for i in $(seq 1 30); do
curl -fsS http://127.0.0.1:18080/health >/dev/null && break
sleep 0.2
done
curl -fsS http://127.0.0.1:18080/api/loans/1 >/dev/null
curl -fsS "http://127.0.0.1:18080/api/reports/summary?n=200" >/dev/null
kill -TERM "$PID"
wait "$PID" || true
"$JAVA" -XX:AOTMode=create -XX:AOTConfiguration="$CONF" \
-XX:AOTCache="$CACHE" -cp "$JAR"
"$JAVA" -XX:AOTCache="$CACHE" -Xlog:aot -jar "$JAR" --dry-run 2>&1 \
| grep -q "Using AOT-linked classes: true"
ls -l "$CACHE"
三个检查点:训练必须打 representative 请求;create 必须带与 record 相同的 classpath;运行必须出现 Using AOT-linked classes: true。最后一行打印缓存大小,和上一版对比;缓存突然变小通常意味着训练覆盖的路径变少。
如果 libraryd 未来要评估 Native Image,最小验证集不是「能不能构建」,而是:
- Javalin 路由和 JSON 序列化在镜像里是否正常。
- PostgreSQL JDBC 驱动、连接池、SSL 是否正常。
- 反射注册的 DTO、枚举、异常映射是否齐全。
- 启动时间、RSS、p99 与 JVM 版本对比。
- 构建时间和 CI 镜像体积是否可接受。
这五项里任何一项失败,Native Image 的成本都会超过收益。
常见错误与边界
错误一:create 阶段漏掉 classpath。 会得到 app classpath has fewer elements than expected 和 class path and/or module path are not compatible。create 不是纯离线转换,它要用同一个 classpath 解析类。
错误二:用目录训练。 Error: non-empty directory 'out'、Cannot have non-empty directory in paths,配置文件 0 字节。先打 jar。
错误三:改了 jar 还指望旧缓存生效。 本机会警告 expected 'hello.jar', got 'hello-stale.jar',然后降级启动。缓存不是内容哈希,路径和名称都参与匹配。
错误四:把 AOT 缓存当成机器码缓存。 它缓存类链接和方法 profile,最终代码仍由 JIT 生成。不要用它推断「关掉 JIT 也能跑」。
错误五:用 AOT 缓存的启动数字外推 Native Image。 两者产物、运行时和失败模式都不同;Native Image 必须在装好 GraalVM 的机器上实际构建和测量。
边界一:本机没有 GraalVM。 所有 Native Image 的构建时间、镜像大小、启动时间都不能写成实测。本节只给决策条件和验证清单。
边界二:AOT 缓存对短命进程可能负收益。 缓存映射和校验有固定成本,应用类少时省下的链接工作抵不过它。Hello 的 123.1 对 144.6 ms 就是例子。
小结
- JDK 25 的 AOT 缓存是三阶段:
AOTMode=record生成配置,AOTMode=create生成缓存,AOTCache启动时使用。 Hello的缓存 9.4 MiB,最小服务 15.2 MiB;create 阶段必须带与 record 相同的 classpath。- 目录 classpath 在 record 阶段直接失败,配置文件 0 字节;本机没有出现资料里的
Skipping ... Unsupported location,报错文案随构建变化。 - 交替测量下,
Hello的 AOT 启动中位数 144.6 ms,反而高于无缓存的 123.1 ms;最小服务 215.7 对 211.2 ms,没有稳定收益。 - AOT 缓存失败会降级启动;AppCDS 的
-Xshare:on会硬失败。生产要监控Using AOT-linked classes是否出现。 - AOT 缓存、AppCDS、Native Image 解决不同问题;
libraryd当前应停在默认 CDS + AppCDS,Native Image 只在冷启动 SLA 极严时评估。
启动这条线到此结束。下一章换到稳态:怎么设计压测、怎么读吞吐和分位、为什么协调缺失会让 p99 看起来很好。
阅读导航:上一节:9.2 CDS 与 AppCDS:共享归档的构建与验证 · 下一节:10.1 压测设计与指标口径:吞吐、分位与协调缺失 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。