2464 字
约 8 分钟
15
S05-X12 · AOT 与 JIT:编译时机换了,换来了什么
2024-12-31
2026-11-08

把一个 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. 自测

  1. CDS/AppCDS 与 Native Image 都属于 AOT,它们各自把哪一段工作提前了?
  2. 为什么 Native Image 的峰值吞吐通常不如 JIT?失去的具体是什么信息?
  3. 闭世界假设为什么会逼出 reflect-config.json,不登记会在构建期还是运行期报错?
  4. -XX:AOTCache 在 JDK 17 上可用吗?JDK 17 对应的做法是什么?
  5. Spring Boot 的 process-aot 与 native:compile 分别产出什么,风险差异在哪?
  6. 什么样的服务不该选 AOT?给出两条判断依据。
S05-X12 · AOT 与 JIT:编译时机换了,换来了什么
https://www.clxhxhhr.top/posts/5763/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。