把一个 Spring Boot 服务塞进 Serverless,第一个撞上的问题通常不是业务逻辑,而是冷启动:容器起来、JVM 起来、Spring 上下文起来、JIT 还没热,第一波请求已经超时了。于是所有人开始谈 AOT。但 AOT 不是"更先进的 JIT",它是把编译决策从运行期挪到构建期——挪走了什么、换来了什么、代价落在哪里,才是选型真正要看的东西。
1. 编译时机的光谱
| 模式 | 编译发生在 | 谁编译 | 典型代表 |
|---|---|---|---|
| 纯解释 | 不编译,逐条执行字节码 | 解释器 | -Xint |
| JIT | 运行期,按热点触发 | C1/C2 | 默认 JVM |
| AOT(类加载缓存) | 构建期,产出归档 | -Xshare:dump / -XX:AOTCache |
CDS / AppCDS / Leyden |
| AOT(原生镜像) | 构建期,产出可执行文件 | GraalVM native-image |
Native Image |
| 混合 | 构建期预编译 + 运行期继续 JIT | 两者 | 大部分生产 JVM |
"混合"是最容易被忽略的一档:CDS/AppCDS 只是把类加载与部分链接提前,方法体仍然解释 + JIT;Native Image 才是把方法体真正静态编译掉。两者经常被笼统称为 AOT,但解决的问题不同。
$ java -Xshare:dump # 生成默认 CDS 归档
$ java -Xshare:on -XX:SharedArchiveFile=/app/app.jsa -jar app.jar # 使用
$ java -Xshare:auto -jar app.jar # 可用则用,否则静默回退
$ java -Xlog:cds -jar app.jar # 看共享是否生效
2. AOT 换到了什么
| 收益项 | 机制 | 适用场景 |
|---|---|---|
| 启动快 | 省掉类加载/链接、省掉解释执行阶段 | 冷启动敏感的 Serverless、CLI |
| 常驻内存低 | 无 JIT 编译器线程与代码缓存增长;元数据构建期固化 | 高密度部署、按内存计费 |
| 可预测 | 无运行期编译抖动,行为与启动时一致 | 延迟敏感 |
| 镜像可裁剪 | 闭世界分析可剔除未用类 | 容器镜像体积 |
对 CDS/AppCDS 而言,收益集中在"类加载与验证"这一段;对 Native Image 而言,收益覆盖到"方法体已编译"。文档里常看到"启动提升数倍",但那是量级描述而不是可移植的指标——真实差距取决于应用启动时到底花了多少时间在类加载与 Spring 上下文构建上。
CDS 归档的命中情况看这一行就够了:
$ java -Xshare:on -XX:SharedArchiveFile=app.jsa -Xlog:cds=info -jar app.jar | head -5
典型输出形态(JDK 17 输出截取)会给出 Mapped static ... archive、UseSharedSpaces: shared 与 CDS archive 的路径,以及命中/未命中的类数量统计。UseSharedSpaces: disabled 代表这次启动没走共享,多数是参数不一致导致的静默回退。
3. AOT 失去了什么:profile
JIT 的峰值优势几乎全部来自运行期 profile:真实调用的类型分布、分支走向、循环次数。这些信息在构建期不存在,Native Image 只能:
| JIT 能做 | Native Image 只能 |
|---|---|
| 按实测类型内联虚调用 | 按静态分析保守处理 |
| 按分支概率重排代码 | 无概率信息 |
| 按实际调用链做去虚化 | 依赖封闭世界 + 全程序分析 |
| 用 PGO 反哺编译 | 除非构建时喂入 profile 数据 |
所以经验判断很直接:短命进程选 AOT,长驻高吞吐选 JIT。一个跑 30 秒的 Serverless 函数永远到不了 C2 的峰值;一个跑 30 天的交易服务用 Native Image 反而把峰值让掉了。
4. Native Image 的基本流程
$ native-image -jar app.jar app \
--no-fallback \
-H:Name=app \
-H:+ReportExceptionStackTraces \
--initialize-at-build-time=org.slf4j
关键概念是闭世界假设(closed-world assumption):构建时必须能静态确定所有可达的类、方法、字段。运行期不会再加载新类,所以任何"运行期才知道"的东西都要在构建期登记:
| 动态特性 | 为什么破坏闭世界 | 需要的配置 |
|---|---|---|
| 反射 | 字符串到成员的映射运行期才确定 | reflect-config.json |
| 资源读取 | 文件不在分析范围内 | resource-config.json |
| 动态代理 | Proxy.newProxyInstance 运行期生成类 |
proxy-config.json |
| 序列化 | 依赖反射访问字段 | serialization-config.json |
| JNI | 需要显式链接 | -H:JNIConfigurationFiles= |
采集配置不需手写:
$ java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image \
-jar app.jar
# 跑一遍主流程,agent 会把碰到的反射/资源/代理写进 json
$ native-image -jar app.jar --no-fallback app \
-H:ConfigurationFileDirectories=src/main/resources/META-INF/native-image
--no-fallback 的语义是"无法静态确定就构建失败",而不是"运行时退化为 JVM"。不加这个参数,构建可能产出混合镜像,问题被推迟到运行期。
5. -XX:AOTCache 与 CDS 的关系(按版本核对)
| 版本 | 可用能力 | 说明 |
|---|---|---|
| JDK 8 | CDS(默认归档) | 仅 JDK 自身类 |
| JDK 10+ | AppCDS 开源 | 应用类可入归档 |
| JDK 12+ | 默认开启 AppCDS,-XX:ArchiveClassesAtExit |
一次运行生成归档 |
| JDK 17 | CDS/AppCDS 成熟;无 -XX:AOTCache |
生产常用 -XX:SharedArchiveFile |
| JDK 24+ | -XX:AOTCache(Leyden 早期成果) |
类加载与链接缓存的一体化表达 |
JDK 17 上的标准做法是两条命令:
$ java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar # 跑一次,导出归档
$ java -XX:SharedArchiveFile=app.jsa -Xlog:cds=info -jar app.jar # 用归档启动
要点:归档与 JDK 版本、GC、启动参数强绑定,换了 -Xmx 或 GC 就可能失效并回退到普通启动。-Xshare:auto 静默回退,-Xshare:on 强制使用、失败即报错——排查期用 on,生产用 auto 更稳。JDK 24 的 -XX:AOTCache 是这一路线的后续演进,在 JDK 17 上写了不会生效,只会得到 "Unrecognized VM option"。
6. Spring Boot 的 AOT 处理
Spring Boot 3 的 AOT 是构建期生成 Java 源码,与 Native Image 不是同一件事:
| 层次 | 产物 | 运行期还需要什么 |
|---|---|---|
| Spring AOT | 生成的 bean 定义、RuntimeHints、代理类源码 |
普通 JVM 即可运行 |
| Native Image | 上面产物 + 静态编译的二进制 | 无需 JVM |
| 两者组合 | 原生可执行文件 | 最快启动 |
$ mvn -Pnative spring-boot:build-image # 走 Buildpacks
$ mvn -Pnative native:compile # 直接调 native-image
$ mvn spring-boot:process-aot # 只做 AOT 处理,仍跑 JVM
自定义动态行为要显式声明 hint:
@Configuration
public class HintsConfig implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader cl) {
hints.reflection().registerType(Order.class,
MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
MemberCategory.INVOKE_PUBLIC_METHODS);
hints.resources().registerPattern("templates/*.html");
}
}
process-aot 这条路径值得单独记住:它让你只用 AOT 的启动收益、不承担原生化风险,JDK 17 上这是低风险试水的第一步。
7. 常见坑
| 坑 | 现象 | 真实原因 | 处理 |
|---|---|---|---|
| 构建耗时被低估 | CI 从几分钟变几十分钟 | Native Image 全程序分析 + 编译 | 单独流水线、缓存、只在发布构建 |
| 镜像体积反而变大 | 加了一层镜像仍很大 | 原生镜像含堆镜像与元数据;调试符号未剥离 | -H:-DeleteLocalSymbols 反向确认;用 distroless 基础层 |
运行期 ClassNotFoundException |
构建成功、启动失败 | 反射/资源未登记 | 用 agent 采集配置并纳入版本库 |
| 构建机与运行机不一致 | 本地能跑、生产报错 | 可达性受 JDK/CPU 特性/依赖版本影响 | 固定构建镜像与 JDK 版本 |
| 把 CDS 当 AOT | 以为能省掉 JIT | CDS 只省类加载 | 明确收益边界 |
在 JDK 17 写 -XX:AOTCache |
启动失败 | 该参数 JDK 24 才出现 | 按版本核对参数 |
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
原生镜像启动报 NoClassDefFoundError: com/sun/proxy/$Proxy0 |
动态代理未登记 | 加 proxy-config.json 或用 RuntimeHints |
UnsupportedFeatureError |
用了闭世界不支持的特性(如运行期字节码生成) | 改设计或放弃原生化 |
--initialize-at-build-time 导致静态状态串味 |
构建期初始化了本应运行期初始化的类 | 缩小该参数范围,改为 --initialize-at-run-time= |
| CDS 归档未生效 | 参数或 JDK 版本变更 | -Xlog:cds 看 shared 段是否命中 |
| 原生镜像内存占用高于 JVM | 堆上限与元数据策略不同 | 调 -Xmx 与 -XX:MaxRAMPercentage 需重新评估 |
9. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| Serverless / FaaS | AOT(原生镜像) | 冷启动与计费按毫秒算 |
| CLI 工具、短任务 | AOT | 没有预热窗口 |
| 长驻高吞吐交易服务 | JIT | 峰值来自 profile |
| 重度反射框架、脚本引擎 | JIT | 闭世界假设难满足 |
| 只想缩短启动、不改架构 | AppCDS / process-aot |
低风险,可回滚 |
| 高密度容器部署 | AOT 或 CDS + 小堆 | 内存是主要成本 |
10. 自测
- CDS/AppCDS 与 Native Image 都属于 AOT,它们各自把哪一段工作提前了?
- 为什么 Native Image 的峰值吞吐通常不如 JIT?失去的具体是什么信息?
- 闭世界假设为什么会逼出
reflect-config.json,不登记会在构建期还是运行期报错? -XX:AOTCache在 JDK 17 上可用吗?JDK 17 对应的做法是什么?- Spring Boot 的
process-aot与native:compile分别产出什么,风险差异在哪? - 什么样的服务不该选 AOT?给出两条判断依据。