《Java 虚拟机原理与性能调优》9.3 AOT 缓存与 Native Image:边界与取舍

JDK 25 的 AOT 缓存分 record、create、run 三阶段,缓存类链接结果和方法 profile,不是机器码。本节实测归档大小、目录 classpath 的失败、create 阶段漏传 classpath 的报错与 jar 变更后的降级行为,并把 AOT 缓存、AppCDS 与 GraalVM Native Image 的适用边界分开。

本节目标:把 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 的边界

三者的对比如下:

维度AppCDSJDK 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 或 CRAJVM 启动占比高,收益明显
反射/动态代理多、依赖复杂暂不上 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,最小验证集不是「能不能构建」,而是:

  1. Javalin 路由和 JSON 序列化在镜像里是否正常。
  2. PostgreSQL JDBC 驱动、连接池、SSL 是否正常。
  3. 反射注册的 DTO、枚举、异常映射是否齐全。
  4. 启动时间、RSS、p99 与 JVM 版本对比。
  5. 构建时间和 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 压测设计与指标口径:吞吐、分位与协调缺失 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发