《Spring Boot 高级》9.3 原生镜像的构建与权衡

对比原生镜像的收益与代价:启动时间、常驻内存、镜像体积的量级与测量方法,构建慢、构建期内存高与无 JIT 导致的吞吐损失,说明哪类服务值得上、哪类不该上,理清与 CDS、AppCDS、Project Leyden 的关系,并给出从 JVM 模式迁移的检查清单与回滚策略。

本节目标:把原生镜像的收益与代价摆到同一张表上,讲清它适合什么样的服务、和 CDS / AppCDS / Project Leyden 是什么关系,并给出从 JVM 模式迁移过去、再必要时回滚的完整检查清单。
适用版本:Spring Boot 4.1.x(Java 21)

9.3 原生镜像的构建与权衡

前两节讲了 AOT 怎么把装配固化成代码、hint 怎么声明动态能力。这一节回答一个更实际的问题:值不值得上原生镜像。它常被当成「启动快、内存省」的银弹,但这两项收益是有代价换来的,而且代价往往落在「构建管线」和「吞吐」这两处最容易被忽略的地方。

本节不重复前两节的机制,只做决策层面的对比。所有性能数字只给量级与测量方法,不编造精确值。

诚实性提示:本机没有 GraalVM native-image,本节所有构建耗时、镜像体积、启动时间、内存占用、吞吐数字都是示例输出或量级描述,不是本机实测。凡要真实数字,请按 9.3.1 的步骤在装有 GraalVM 的机器上自测。

9.3.1 收益:启动时间、内存、镜像体积

原生镜像把「类加载 + 注解扫描 + 装配 + 首个请求前的预热」这些工作搬到了构建期,运行期只剩「加载镜像 + 执行」。三项收益的量级与成因:

维度收益量级成因
启动时间通常从秒级降到百毫秒级无 JVM 启动、无类扫描、无 JIT 预热,main 直接跑生成好的装配代码
常驻内存通常显著低于 JVM(JIT 元数据、类元数据都被裁剪)未达的类不进镜像;但 GC 与堆仍存在,堆大小由运行时决定
镜像体积单个可执行文件,通常几十 MB 量级依赖封闭世界裁剪,比 JRE + uber jar 更小,但比纯静态语言的二进制大

怎么测(步骤,不是数字):

# 1) JVM 模式:测启动到 Started 的耗时(本机可实测的部分)
java -jar target/library.jar
#    看日志里 "Started LibraryApplication in X seconds"

# 2) 原生模式(需装 GraalVM native-image 25+):先构建再测
./mvnw -Pnative native:compile
./target/library
#    用 time 包一层,或用 hyperfine 做多轮取中位数

内存用 /usr/bin/time -v 或容器 cgroup 的 memory.max_usage_in_bytes 读峰值常驻内存;吞吐用同一压测工具对两种模式各跑一轮再对比。不要拿单次数字下结论,原生镜像与 JVM 的差距会随负载形态变化。

测量时的几个坑:

  • JVM 的吞吐要跑够久再测:JIT 需要预热,测太早会低估 JVM、高估原生镜像。
  • 原生镜像的吞吐要测稳态:别只测冷启动后的前几秒。
  • 两边的堆上限要配一致,否则内存对比无意义。
  • 同一台机器、同一份数据对比,否则差异可能来自环境。

9.3.2 代价:构建慢、峰值内存高、吞吐下降

收益的反面是三项实打实的代价:

(1)构建慢。 native-image 要静态分析整个类图、做指针分析与死代码消除,构建耗时通常是常规 JVM 打包的数倍到十几倍,一个中等服务动辄数分钟。这意味着 CI 反馈环变长,本地「改一行看效果」的开发体验明显变差。缓解手段是只在 CI 或发布阶段构建原生镜像,日常开发仍用 JVM 模式。

(2)构建期峰值内存高。 构建器本身要吃掉大量内存,构建机配置不足会直接 OOM。这是运维侧常被忽略的一点——原生镜像省的是运行期内存,代价之一是构建期内存。

(3)无 JIT,峰值吞吐下降。 原生镜像默认只有 AOT 编译(或解释执行),没有运行期即时编译去优化热点路径。对长跑、计算密集、吞吐优先的服务,原生镜像的稳态吞吐通常低于经过 JIT 充分预热后的 JVM。对短命、突发、并发不高的服务,因为它省掉了预热,反而常常更快。这是最关键的权衡点。

(4)动态特性受限。 9.2 讲的三类动态能力都要显式声明;声明漏了就是运行期 ClassNotFoundException。另外,某些重度依赖运行期字节码生成的库(老版本 ORM、部分 mock 框架、脚本引擎)在原生镜像下要么需要额外配置,要么根本不支持。

对比项JVM 模式原生镜像
启动秒级百毫秒级
构建秒级~分钟级数分钟起
构建期内存低高
稳态吞吐高(JIT 优化)通常较低
动态特性无限制需声明 hint
运行期内存较高较低
可观测工具链完整(JFR、heap dump、attach)受限(见 9.3.5)

还有一点常被忽略:两者的 GC 表现不同。原生镜像里可选 GC 更少(通常用 Serial GC 或 G1),堆行为与 JVM 上调优过的大堆不同;JVM 上惯用的 -XX 参数大多在原生镜像里不存在。所以「把 JVM 的 GC 调优参数照搬过去」是行不通的,内存相关的调优要按原生镜像自己的运行时参数重来。

另外,构建耗时会随应用规模非线性增长:依赖越多、反射面越广,静态分析的类图越大,构建越慢。一个 JVM 下几十秒打完的服务,切到原生镜像动辄数分钟,属于正常现象,不要误判为配置错误。

9.3.3 什么类型的服务适合上

结合上面的权衡,适合与不适合的画像很清晰:

适合:

  • Serverless / FaaS:冷启动直接决定成本与体验,百毫秒级启动是刚需。
  • CLI 工具:单文件可执行、无 JVM 依赖、分发简单,启动快是主要卖点。
  • 短命批处理:进程存活时间短,JIT 来不及预热,原生镜像反而占优。
  • 高密度部署:单机要塞很多实例时,更小的常驻内存直接换来更高密度。
  • 对启动时间有 SLA 的边缘服务:如边车代理、准入控制。

不适合(或要谨慎):

  • 长跑的高吞吐核心服务:JIT 的稳态优化价值大于启动收益。
  • 重度依赖运行期反射/字节码生成的系统:hint 补不全,或根本不可行。
  • 频繁变更、需要快速迭代的服务:构建慢会拖垮开发节奏。
  • 强依赖 JFR / attach / heap dump 排障的场景:原生镜像的工具链支持弱于 JVM。

判断口诀:「进程活得越短、启动越频繁,原生镜像越划算;进程活得越长、吞吐要求越高,JVM 越划算。」

再补几个容易判断错的边界:

  • Kubernetes 上的普通微服务:如果它常年常驻、靠 HPA 扩缩容,启动时间不是瓶颈,原生镜像的启动收益会被摊薄;但如果扩缩容频繁(流量潮汐明显、频繁弹性伸缩),更快的启动能显著缩短扩容延迟,这时收益就真实了。
  • 批处理 / CronJob:进程越短,原生镜像越划算,因为 JIT 根本来不及预热。
  • 消息消费者:长跑、吞吐敏感,通常留在 JVM。
  • 边缘 / 边车组件:资源受限、启动敏感,原生镜像往往合适。

一句话:把「启动时间在整体 SLA 里占多大权重」当成第一判断标准,而不是看「是不是新项目」。

9.3.4 与 CDS / AppCDS、Project Leyden 的关系

原生镜像不是「让 JVM 启动更快」的唯一路线,另外两条值得放在一起看:

CDS(Class Data Sharing)与 AppCDS。 CDS 把 JDK 核心类的解析结果缓存成一个归档,JVM 启动时直接内存映射,省掉一部分类加载与验证开销;AppCDS 把应用自己的类也加进归档。它不改变运行模型——还是 JVM、还是有 JIT,只是启动更快。Spring Boot 有对应的启动优化支持(配合 spring-boot-maven-plugin 的 jar 模式与 java -XX:SharedArchiveFile=... 使用)。

方案运行模型启动收益吞吐影响迁移成本
原生镜像无 JVM,AOT最大通常下降高(hint、构建管线)
AppCDS仍是 JVM中等基本不变低(加启动参数)
Project Leyden仍是 JVM目标介于两者之间目标基本不变待定

AppCDS 的典型工作流是「一次训练、多次复用」:先以 -XX:ArchiveClassesAtExit=app.jsa 跑一遍(训练运行),把这一轮加载的类解析结果归档成 app.jsa;之后启动时加 -XX:SharedArchiveFile=app.jsa 直接内存映射。它不改运行模型、不动业务代码,是启动优化里性价比最高的一步——在没有明确证据表明必须上原生镜像之前,先试 AppCDS。

Project Leyden。 它是 OpenJDK 的一个长期项目,目标是「把 AOT 能力引入 JVM 本身」,让标准 JVM 也能提前固化一部分类加载与链接结果,从而在不放弃 JIT 的前提下改善启动。它仍处于预览/进行中状态,具体 API 与产品化程度以官方为准,不要当成现成方案写进生产设计。它和原生镜像的关系不是替代,而是「给不想放弃 JVM 的团队一条中间路线」。

选型建议:先试 AppCDS(成本最低),不够再评估原生镜像;Project Leyden 保持关注。

9.3.5 从 JVM 模式迁移的检查清单

真决定上原生镜像,按这份清单逐项过:

依赖与动态特性

  • 逐个检查三方库是否有原生镜像支持(Spring 生态大多支持,但版本要新)。
  • 给所有自研库补 RuntimeHintsRegistrar(放 META-INF/spring/aot.factories)。
  • 数据绑定 DTO 用 @RegisterReflectionForBinding 声明。
  • 检查是否有 Class.forName、Proxy.newProxyInstance、按名字读资源等动态用法。
  • 确认没有依赖运行期字节码生成的库(或已找到替代)。

构建管线

  • 引入 org.graalvm.buildtools:native-maven-plugin(版本以 GraalVM 官方为准,BRIEF 口径:native-image 25+)。
  • CI 用 GraalVM 镜像构建,构建机内存按构建期峰值配足。
  • 本地开发仍走 JVM 模式,只在 CI/发布阶段构建原生镜像。

一个最小的原生构建配置长这样(插件版本以 GraalVM 官方为准):

<plugin>
  <groupId>org.graalvm.buildtools</groupId>
  <artifactId>native-maven-plugin</artifactId>
  <executions>
    <execution>
      <id>build-native</id>
      <goals><goal>compile-no-fork</goal></goals>
      <phase>package</phase>
    </execution>
  </executions>
</plugin>

配一个 native profile 把 Spring 插件的 process-aot 与 native-maven-plugin 串起来即可;process-aot 由 Spring 插件在 prepare-package 阶段自动触发(9.1 已核实),无需手工编排顺序。

验证

  • 在 JVM 上先开 -Dspring.aot.enabled=true 跑一遍,暴露 hint 缺失。
  • 构建加 --exact-reachability-metadata 校验 hint 无遗漏无冗余。
  • 跑一遍完整集成测试(注意:部分测试框架在原生镜像下受限,必要时用 JVM 模式跑测试、原生模式跑冒烟)。

可观测性与运维

  • 确认排障工具链:原生镜像下 JFR 支持受限、attach 与动态 heap dump 受限,需提前规划替代手段。
  • 确认探针/健康检查在原生镜像下行为一致。
  • 明确日志与指标采集方式不变(日志走 stdout、指标走 micrometer,通常不受影响)。

9.3.6 回滚考虑

原生镜像的回滚成本很低,这是它相对「换语言/换框架」最大的优势:原生镜像和 JVM 模式共享同一份源码与业务依赖,差别只在构建方式。因此:

  • 保留 JVM 模式的构建产物(java -jar 那条线)作为随时可切回的备份。
  • 用配置开关而不是代码分支区分两种模式(例如 CI 里两条流水线,产出两个制品)。
  • 上线策略上,先让原生镜像旁路运行(影子流量或小比例灰度),用 9.3.1 的方法对比真实负载下的启动、内存、吞吐,再决定是否全量。
  • 如果发现吞吐不达标或某个动态特性无法补齐,直接切回 JVM 制品,无需改代码。

一个可落地的双流水线形态(示意):

# 同一份源码,两条构建线
jvm-build:      # 每天多次,秒级反馈,产出 library.jar
  steps: [mvn package]
native-build:   # 仅在发布分支触发,产出 library 可执行文件
  steps: [mvn -Pnative package]

两条线共享源码、依赖与测试;切换只需改部署清单里指向哪个制品。这就是原生镜像「回滚成本低」的具体含义。

反过来,从 JVM 迁到原生镜像最大的沉没成本在构建管线与 hint 维护——这部分在回滚时不会浪费,因为 AppCDS 等方案也能受益于同样的 AOT 基础设施。

9.3.7 常见迁移失败案例

把踩坑按「现象」归类,比逐条背 API 更实用:

现象根因对策
启动报 ClassNotFoundException某类只被字符串引用,被封闭世界裁剪补反射 hint(见 9.2)
反序列化得到空对象 / 字段全 nullDTO 缺反射 hint@RegisterReflectionForBinding
资源读不到、返回 null资源未被静态分析到resources().registerPattern(...)
JDK 动态代理报错代理接口组合未登记proxies().registerJdkProxy(...)
@Conditional 结果与预期不符条件在构建期求值、结果固化检查构建时的 profile / 属性
构建机 OOM构建期峰值内存高加大构建机内存或换机型
稳态吞吐不达标无 JIT,热点优化缺失评估是否本就不该上原生

最后一行的意义在于:有些「迁移失败」不是技术问题,而是选型问题。如果服务本身就是长跑高吞吐,再完美的 hint 配置也补不回 JIT 的稳态优化——这时正确的结论是「不上原生镜像」,而不是继续调参。

9.3.8 知道之后能做什么

做决策而非追潮流。 拿到一个新服务,先问「它活多久、启动多频繁、吞吐要求多高」,再决定是否原生镜像,而不是默认「新项目就上原生」。

用数据说话。 按 9.3.1 的步骤在真实负载下测三项指标,而不是信「启动快 10 倍」这类脱离负载形态的口号。

把构建慢隔离出去。 让原生镜像构建只发生在 CI,开发者的日常循环保持 JVM 模式的秒级反馈——这是能否长期维护原生镜像的关键。

小结

  • 原生镜像的收益是启动、内存、体积;代价是构建慢、构建期内存高、无 JIT 导致的稳态吞吐下降、动态特性受限。
  • 适合短命/突发/高密度服务,不适合长跑高吞吐核心服务与重度反射系统。
  • CDS / AppCDS 是不改运行模型的低成本启动优化,应优先评估;Project Leyden 仍是预览/进行中的项目,别当现成方案。
  • 迁移按「依赖 → 构建管线 → 验证 → 可观测性」四段清单走,先在 JVM 上用 spring.aot.enabled 暴露问题。
  • 回滚成本低:源码与依赖不变,保留 JVM 制品即可随时切回,代价主要在构建管线与 hint 维护。

至此第 9 章结束:AOT 是构建期把装配固化成代码(9.1),hint 是声明动态能力(9.2),原生镜像是把这些能力用起来并接受它的代价(9.3)。下一章转向可观测性,从 Micrometer 的指标模型讲起。

阅读导航:上一节:9.2 反射与资源配置 · 下一节:10.1 Micrometer 指标模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计