为什么关心 AOT
Spring Boot 3.0 刚出,主打对 GraalVM 原生镜像的支持。我们一个网关侧边车想压启动时间,原来 JVM 冷启动要 2 秒多,在弹性伸缩场景里每次扩容都得等,想试试原生镜像能不能降到几百毫秒。决定试 AOT 之前,我先把"AOT 到底是什么"搞清楚,不然很容易把它和"原生镜像"混为一谈。
AOT 编译原理
传统 Spring 在运行时通过反射、动态代理、条件注解做 Bean 的发现和装配,这跟 GraalVM 的静态分析法冲突——它没法在构建期猜到你运行时会 new 什么。GraalVM native-image 在编译时做封闭世界分析,它只认编译期能确定的类引用,运行期的反射、资源加载、动态代理它一律看不到,除非你告诉它。
AOT 的作用,就是在构建期把这套装配逻辑"提前算好",生成副产物:
- Bean 定义源(提前注册,省去运行期扫描);
- RuntimeHints:告诉 GraalVM 哪些反射、资源、代理、序列化在运行期需要。
换句话说,Spring 在构建期把"运行期要干嘛"先推演一遍,把结论固化下来,GraalVM 拿着结论去编译,运行期就不需要再做那套耗时的推断了。
RuntimeHints 是关键
如果你的代码在运行期反射了某个类,必须在 hint 里声明,否则 native image 会报 ClassNotFound 或没有无参构造:
@Bean
public RuntimeHintsRegistrar myHints() {
return (hints, cc) -> hints.reflection()
.registerType(MyDto.class,
ExecutableMode.INVOKE);
}
我们项目里有个老模块用反射读配置 DTO 字段,没加 hint,构建出的原生镜像一启动就 NoClassDefFoundError。逐个补 hint 是接原生镜像最磨人的部分——第三方库没适配的,全得自己写 hint。
与 GraalVM 原生镜像的关系
AOT 是 Spring 侧的工作,GraalVM native-image 是编译器侧。两者配合:Spring AOT 生成提示 → GraalVM 据此构建出不开 JIT、直接机器码启动的镜像。少了 AOT,GraalVM 面对 Spring 的大量反射会束手无策;少了 GraalVM,AOT 生成的提示没处用。它们是一前一后的关系,不是替代。
构建流程上,Spring Boot 3 的插件在 package 阶段会跑一个 AOT 任务,把生成的 hint 和 bean 定义写进 target/classes,再交给 native-image 编译。你不用手动调两步,但理解这层关系有助于排查"为什么这个类没被包含"。
实测
把一个简单 REST 服务构建原生镜像,启动时间对比:
| 方式 | 启动耗时 | RSS 峰值 | 构建耗时 |
|---|---|---|---|
| JVM(JDK17) | 2.1s | 340MiB | 10s |
| Native(AOT) | 0.08s | 48MiB | 90s |
启动从 2.1 秒降到 0.08 秒,内存从 340MiB 降到 48MiB,对冷启动敏感的场景收益巨大。代价是构建要 90 秒(native-image 慢),且大量第三方库还没适配 hint,反射调用得一个个补。我们那个网关边车最终用原生镜像上线,扩容从"等 3 秒"变成"秒级就绪"。
哪些场景不适合
- 长驻服务、对启动不敏感:省下的 2 秒不值 90 秒构建 + 失去 JIT 运行时优化(峰值吞吐略低);
- 重度反射、动态代理的老框架:hint 补到怀疑人生,投入产出比差;
- 需要 JVMTI、agent 做诊断的:原生镜像支持有限。
在业务项目里落地的真实障碍
网关边车跑通后,我们想推到一个有状态的订单服务,结果撞墙。订单服务用了大量反射做 DTO 转换(BeanUtils.copyProperties 那类),native-image 下全要补 hint,我们数了下有 60 多个 DTO 类,手写 hint 写到吐。更麻烦的是它依赖一个老一点的 HTTP 客户端,那个库在构建期就因为反射调用链太深直接编译失败,报 "unsupported feature"。这种"第三方库不兼容"是接原生镜像最大的拦路虎,不是你写 hint 就能解的,得等社区适配或换库。
还有序列化:我们的响应用 Jackson,Jackson 在 native 下要注册所有会被序列化的类型,否则运行期报错。Spring Boot 3 的插件能自动扫到 Controller 的返回类型生成 hint,但动态返回 Object 的接口就漏了。我们一个兜底接口返回 Map,构建时没报错,运行期序列化直接空。这类"构建不报错、运行才炸"的问题最阴,必须靠真实流量验证。
什么时候 Native 反而更慢
native-image 没有 JIT,运行期是纯 AOT 编译的机器码,峰值吞吐通常比 warmed-up 的 JVM 低 5%~15%。我们订单服务压测,JVM 跑熟后 QPS 比原生镜像高 12%。所以"启动快、内存小"换的是"峰值略低"。对长时间运行、吞吐敏感的服务,这账不一定划算;对短命、弹性、冷启动敏感的场景才值。
我们的最终决策
结论很务实:原生镜像只用在两类——网关边车(秒级扩容、内存敏感)和定时任务型 FaaS(跑完即毁、启动慢等于贵)。核心交易链路继续 JVM,享受 JIT 和成熟的诊断生态。AOT 编译那套(生成 hint、提前注册)我们全量开启,因为它不强制你出原生镜像,开着它能让 Spring 启动快一点(少做运行期推断),即使不出 native 也有好处,算是无损的。
构建流水线改造的现实
出原生镜像把"编译"从 10 秒变成 90 秒,CI 流水线得跟着改。我们原本 CI 跑完单测直接 docker build 推送,现在多一步 native-image,流水线从 4 分钟拉到 6 分钟。对提交频繁的前端服务还能接受,对一天几十次提交的核心服务,开发者会明显感觉"等 CI 久了"。折中是核心服务继续出 JVM 镜像(快),只有边车类出 native,把慢构建限制在少数服务上。
诊断与回滚的困境
原生镜像下很多 JVM 诊断工具用不了:arthas attach 不进去,jstack/jmap 没有,出问题时排查手段骤减。我们一度因为少了这些工具,一个内存问题多花了一天才定位。所以上 native 前得准备好替代诊断方案(比如靠应用内暴露的 metrics、提前埋好的 health endpoint)。另外回滚也慢——native 镜像大、构建慢,回滚版本比 JVM 镜像多等一分多钟。这些"看不见的成本"要在决策时算进去。
社区适配现状
2022 年底 Spring Boot 3.0 刚出时,能直接出原生镜像的库还少。我们点过一遍依赖:数据源 HikariCP OK,Redis Lettuce OK,但某个老监控客户端直接编译失败。半年后生态才慢慢补齐。所以"上原生镜像"不是 Spring 支持就行,得你整条依赖链都支持,缺一不可。这也是我们只在简单边车上试、不敢推复杂服务的现实原因。
启动时间的极致追求
为什么我们这么在意那 2 秒?因为网关边车在 K8s HPA 场景下,扩容一个副本要等它就绪才能接流量。JVM 冷启动 2.1 秒 + 应用自己初始化 1 秒 + 探针通过 2 秒,新副本要 5 秒多才接流。大促瞬时流量来了,这 5 秒的扩容延迟就意味着请求被现有副本硬扛,可能超时。原生镜像把应用初始化省到 0.1 秒,整体就绪时间砍掉一半,扩容更跟手。对"弹性伸缩频繁"的服务,这点启动差就是体验差。
不是银弹的提醒
最后强调一次:AOT 和原生镜像解决的是"启动慢、内存大",不解决"吞吐高、逻辑对"。别指望它让烂代码变快。我们见过有人把慢 SQL 指望原生镜像救,结果原生镜像下慢 SQL 照样慢,只是启动快了。性能瓶颈在哪就在哪解决,原生镜像只是给你一个更轻的起跑姿势,不是万能药。评估时把"它能解决什么、不能解决什么"写清楚,再决定上不上。
一个收尾判断
如果只记住一句:AOT 是给 Spring 应用的"编译期预演",让运行期少做推断、GraalVM 能静态编译出快启动小内存的原生镜像。它适合短命、弹性、冷启动敏感的服务,不适合长驻高吞吐和重度反射的老系统。评估它对,收益实;评估错,徒增构建慢和适配苦。我们就是这么定的。
小结
AOT 不是"编译 Spring",而是把运行期的装配决策提前物化,再交给 GraalVM 静态编译。它适合短生命周期、对冷启动敏感的场景(FaaS、边车),普通长驻服务收益有限,别盲目追。上手前先想清楚:你的痛点是不是启动慢、内存小,如果不是,原生镜像带来的构建慢和适配成本可能不划算。