SpringBoot4系列26 - 使用GraalVM原生镜像与AOT

前段时间盯着大促扩容看了一个问题:流量上来,Kubernetes 给订单服务一口气扩 10 个副本,结果新 Pod 得等 JVM 把 8000 多个类加载完、几十个自动配置类解析完才能接流量,就绪探针配 60 秒都不保险。缩容再扩容这么来回几次,冷启动时间就是实打实的容量成本,要是放在 Serverless 场景按毫秒计费,这笔开销甚至能决定方案能不能用。

Spring Boot 4.1 在交付流程里给了两条加速路径:AOT 处理把运行期的 Bean 解析提前到构建期,CDS 把类加载结果缓存下来给 JVM 复用,再往前一步就是 GraalVM 原生镜像。这篇文章就是我把这三样在 Spring Boot 4.1.1(Spring Framework 7.0.9、JDK 21.0.10)上挨个试了一遍的结果:process-aot 到底生成了什么,ShopProperties 这个反射点位被处理成了什么样,自定义 hints 怎么写。然后普通 JVM、CDS、AOT 模式三种启动方式我各跑了 3 次,启动耗时、加载类数、堆内存全是同一份脚本一口气测出来的,没有手工拼数字。至于 native 镜像,说实话这次没编出来,三条编译路线我本机全走不通,所以这部分只有机制没有数字,测不了的东西我不编。

工程与依赖选型

配套工程叫 springboot4-native,一个订单查询 REST 服务。规模我刻意压小了:三个接口加一个配置属性类,单测 6 个,够把机制跑通就行,工程一大,AOT 产物就被淹在噪音里了。

1
2
3
4
5
6
7
8
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

两个 starter 各干各的:webmvc 承载三个接口,actuator 的 metrics 端点负责吐出 jvm.memory.used,三组内存对比就从这里取值。另外提醒一句,Boot 4 里 spring-boot-starter-web 已经弃用,得写 spring-boot-starter-webmvc,这个系列前面的文章说过,这里照旧。端口沿用系列约定 18260,application.yml 里 server.port: ${SERVER_PORT:18260} 让环境变量优先,验证脚本接管端口时不用动配置。

接口里我特意埋了一个反射点位。ShopProperties 用 @ConfigurationProperties(prefix = "shop") 声明,三个字段 name、vipDiscount、bulkThreshold 分别对应字符串、浮点、整数绑定,主类用 @ConfigurationPropertiesScan 显式扫描注册。/api/orders/stats 这个接口把绑定结果拼成字符串返回,AOT 模式和普通模式的返回值一字不差,才算 AOT 处理正确,它就是三组对比里的「行为一致性」断言。

1
2
3
4
@GetMapping("/stats")
public String stats() {
    return orderService.bulkStats();
}

bulkStats() 里读 shopProperties.getBulkThreshold() 做大额订单统计。返回值 shop=xncoding-shop, vipDiscount=0.88, bulkThreshold=9999, bulkOrders=1 会贯穿后文三组实测,每次启动脚本都会 curl 一遍留档。

AOT 处理在 Boot 4.1 里做了什么

Spring 应用在 JVM 上启动,一大半时间花在「反射式组装」上:@Configuration 类要被 ConfigurationClassPostProcessor 解析出 BeanDefinition,@Autowired 靠字节码代理注入,@ConfigurationProperties 按属性名反射调 setter。AOT(Ahead-of-Time)处理干的事,就是把这一步搬到构建期:打包时把应用上下文启动到「Bean 定义齐备」的程度,解析结果直接生成 Java 源码和字节码,运行时照着生成代码重建上下文,不再走运行期解析。

触发方式是给 spring-boot-maven-plugin 显式绑定 process-aot goal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution>
            <id>process-aot</id>
            <goals>
                <goal>process-aot</goal>
            </goals>
        </execution>
    </executions>
</plugin>

这个 goal 默认绑在 prepare-package phase 上,配好后 mvn package 就会执行,产物固定落在 target/spring-aot/main/ 下。不配 execution 它就不跑,很多人第一次用 AOT 扑空,九成是这个原因。

本工程打包完的产物长这样,sources 98 个文件、classes 125 个 class、resources 是给 GraalVM 用的 hints:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
target/spring-aot/main/
├── classes/        生成字节码(动态代理等),125 个 class
├── resources/
│   └── META-INF/native-image/com.xncoding/springboot4-native/
│       ├── native-image.properties
│       └── reachability-metadata.json
└── sources/        生成源码,98 个文件
    ├── com/xncoding/nativeapp/...__BeanDefinitions.java
    ├── com/xncoding/nativeapp/NativeDemoApplication__ApplicationContextInitializer.java
    └── org/springframework/boot/...(自动配置类的 Bean 定义代码)

Boot 4.1 的 hints 产物只有两个文件。如果你看过 3.x 时代的教程,配图里都是「五个 json」,这里不一样:reflect-config.json、proxy-config.json、resource-config.json、serialization-config.json、jni-config.json 五类 hints 在 GraalVM 22.2 之后合并成了单个 reachability-metadata.json,Boot 4.1 直接沿用合并格式。我这份文件里有 627 条反射登记、26 条资源登记。native-image.properties 里写的是 native 编译时的入口参数:

1
2
Args = -H:Class=com.xncoding.nativeapp.NativeDemoApplication \
--no-fallback

https://static.xiongneng.me/native-aot-pipeline-20260921201615.png

生成代码的两种形态

生成代码到底长什么样,拿本工程的反射点位 ShopProperties 看最直观。源码里它是个标准的 @ConfigurationProperties 类,三个字段带 setter;AOT 处理之后生成了这么个文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Generated
public class ShopProperties__BeanDefinitions {
  /**
   * Get the bean definition for 'shopProperties'.
   */
  public static BeanDefinition getShopPropertiesBeanDefinition() {
    RootBeanDefinition beanDefinition = new RootBeanDefinition(ShopProperties.class);
    beanDefinition.setInstanceSupplier(ShopProperties::new);
    return beanDefinition;
  }
}

原来运行期反射解析 @ConfigurationPropertiesScan 的活,现在就一行直接调用:new RootBeanDefinition(ShopProperties.class) 加构造器引用。业务类是这样,自动配置类也一样,TomcatWebServerAutoConfiguration__B__eanDefinitions、WebMvcAutoConfiguration__B__eanDefinitions 这些文件把几十个自动配置类的解析全部代码化,启动省下来的时间就是从这儿来的。

上下文重建的入口是生成的主类伴生文件,它实现 ApplicationContextInitializer<GenericApplicationContext>,向上下文注册全部 Bean 定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Generated
public class NativeDemoApplication__ApplicationContextInitializer
        implements ApplicationContextInitializer<GenericApplicationContext> {
  @Override
  public void initialize(GenericApplicationContext applicationContext) {
    DefaultListableBeanFactory beanFactory = applicationContext.getDefaultListableBeanFactory();
    beanFactory.setAutowireCandidateResolver(new ContextAnnotationAutowireCandidateResolver());
    beanFactory.setDependencyComparator(AnnotationAwareOrderComparator.INSTANCE);
    new NativeDemoApplication__BeanFactoryRegistrations().registerBeanDefinitions(beanFactory);
    new NativeDemoApplication__BeanFactoryRegistrations().registerAliases(beanFactory);
  }
}

mvn package 时 fat jar 会把这套产物一起收进 BOOT-INF/classes,我验证过:jar 里 META-INF/native-image 路径下有 5 条产物记录。普通 JVM 模式(spring.aot.enabled=false)启动时这些产物直接被忽略,行为跟没做 AOT 一模一样。也就是说,同一份构建物,两种运行模式随时切。

还有个有意思的细节:AOT 处理本身会「启动一次应用」,打包日志里能看到:

1
2
3
20:24:02.368 INFO  c.x.n.NativeDemoApplication - Starting NativeDemoApplication
using Java 21.0.10 with PID 35984 (...\springboot4-native\target\classes
started by Administrator in d:\projects\java\SpringBootBucket\springboot4-native)

process-aot 执行时会拿 target/classes 当类路径,把应用启动到 Bean 定义齐备的阶段,生成的代码就是这一次启动里解析出的 Bean 图谱。注意它不创建 Bean 实例,@PostConstruct、数据库连接这类初始化逻辑都不会跑,记录下来的只有 Bean 定义和 hints。

AOT 的分工在 ShopProperties 上看得很清楚:Bean 定义走生成代码,属性绑定这种真实的反射走 hints 登记。我翻了 reachability-metadata.json,它的条目把绑定器需要的成员一个不漏全列出来了:

字段、无参构造、全部 getter 和 setter 都在。native 镜像里属性绑定还能正常干活,靠的就是这份登记;而在普通 JVM 上,这份登记不花一分钱。

自定义 hints:RuntimeHints API

框架自动生成的 hints 有覆盖不到的地方,比如业务代码里 Class.forName("com.example.legacy.LegacyCodec") 这种字符串反射,框架根本看不见,得自己登记。Spring 给的口子是 RuntimeHints API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(LegacyHints.class)
public class LegacyConfig {
}

class LegacyHints implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
        hints.reflection().registerType(LegacyCodec.class, MemberCategory.values());
        hints.resources().registerPattern("legacy/*.dat");
        hints.proxies().registerJdkProxy(LegacyMBean.class);
    }
}

RuntimeHintsRegistrar 登记的内容最后也汇进 reachability-metadata.json,跟框架生成物合流。要不要写 hints,判断标准就一条:这段代码里有没有 GraalVM 静态分析看不到的反射、资源、代理或序列化。JVM 模式下 hints 不生效也不报错,所以这层代码可以放心常驻工程,等真编 native 的那天直接起作用。排查 hints 缺失有个趁手的工具,GraalVM 的 tracing agent(-agentlib:native-image-agent=config-output-dir=...),在 JVM 上把业务路径跑一遍,它会把运行期实际发生的反射全登记出来,再对照着合并进工程。

不过 tracing agent 有个使用边界得记住:它只记录你跑到的代码路径,没触发的分支不会进配置文件。直接拿 agent 输出当 hints 用,漏登记的反射要到 native 运行期才炸出来。稳妥的做法是 agent 输出打底,再人工过一遍代码里的 Class.forName 与 Method.invoke 补缺,然后才进 native 编译。

CDS 实测:把类加载结果缓存下来

CDS(Class Data Sharing)是 JDK 自带的老机制:先跑一次训练运行,把 JVM 加载的类写进归档文件,之后的启动直接映射归档,解析和验证全省。Boot 4.1 官方文档给了两步法,而且明确要求先解包,fat jar 那种嵌套 jar 布局 CDS 是吃不了的。

第一步,把 fat jar 解包成 lib/ 平铺布局:

1
java -Djarmode=tools -jar target/springboot4-native-1.0.0.jar extract --destination extracted

解包出来是一个目录:应用 jar springboot4-native-1.0.0.jar 加一个装着全部依赖的 lib/ 文件夹,manifest 里引用的是平铺类路径。官方文档说这个布局对 CDS 与 AOT Cache 都友好,生产部署也推荐它(嵌套 jar 的 URL 处理有一点启动开销)。

第二步,训练运行生成归档。-Dspring.context.exit=onRefresh 是官方给的退出技巧:上下文刷新完成就正常退出并写归档,应用不必对外服务:

1
2
java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh \
  -jar extracted/springboot4-native-1.0.0.jar

训练跑加载了哪些类,归档里就只有哪些类,这次没跑到的地方不会进归档。想扩大覆盖面,就在训练跑里多触发几条业务路径再退出。我这应用够小,一次刷新就覆盖全了。

之后每次启动带上 -XX:SharedArchiveFile。我在脚本里还加了 -Xshare:on,这个参数把共享归档从「尽力用」改成「必须用」,归档无效时 JVM 直接拒绝启动。换个角度说,起得来就是命中了的铁证:

1
java -Xshare:on -XX:SharedArchiveFile=app.jsa -jar extracted/springboot4-native-1.0.0.jar

本工程训练跑出来的归档是 6881280 字节,约 6.6MB。JDK 21.0.10 上归档一次生成成功,三次启动全过了 -Xshare:on 校验。有一点要注意:归档和类路径是强绑定的,jar 换了版本、lib 里加了依赖,归档立刻失效或者命中下降。所以归档生成得跟着构建流程走,不能生成一次用一年。

想看命中细节,加一条日志开关就行:

1
2
java -Xlog:class+load -XX:SharedArchiveFile=app.jsa \
  -jar extracted/springboot4-native-1.0.0.jar | grep "shared objects file"

输出里带 source: shared objects file 的行就是从归档映射来的类,我实测本工程一次启动有 2296 行(class,load 总量 7994 行,剩下近六成类还是得从 lib 下的 jar 一个个解析)。跟普通启动的同一份日志一对比,两种来源的差异就是 CDS 省掉的那部分活。

复现的时候还有个顺序问题不能乱:训练跑必须在归档文件不存在时执行(文件已存在会报错),归档生成之后的三次启动才带 -XX:SharedArchiveFile。我把这些步骤全固化在脚本一次调用里了,手工敲的时候很容易把训练跑和计时跑的参数搞混。

三种启动方式对比实测

我写了一份验证脚本,对三种方式各连续启动 3 次,每次记四个数:wall 毫秒(进程启动到核心接口首次返回 200,轮询粒度 50ms)、日志自报毫秒(Started NativeDemoApplication in X seconds)、已加载类数(jcmd <pid> VM.class_hierarchy 行数)、堆内存(就绪后读 /actuator/metrics/jvm.memory.used)。三种方式下 /api/orders/stats 都返回 shop=xncoding-shop, vipDiscount=0.88, bulkThreshold=9999, bulkOrders=1,行为一致,对比才有意义。脚本全程单次调用搞定「起服务、计时、取样、关服务」,Windows 上杀进程走 kill,每组之间轮询等端口释放,免得上一组的残留连接干扰下一组计时。

9 次启动的原始记录(脚本把每一次的四个数都留了底,下面是全部 9 行):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
方式  次序  wall(ms)  boot自报(s)  已加载类数  heap(MB)
jvm   1     3159      2.044        8796        96.5
jvm   2     3157      2.034        8796        96.1
jvm   3     3136      2.019        8805        96.9
cds   1     2624      1.762        8529        85.4
cds   2     2655      1.791        8529        93.8
cds   3     2616      1.794        8529        87.0
aot   1     2895      1.706        8722        77.7
aot   2     3204      1.969        8722        77.6
aot   3     3254      1.911        8722        77.5

取中位数汇总:

方式boot 自报启动wall 首请求 200已加载类数heap used
普通 JVM(fat jar)2.034s3157ms879696.1MB
CDS(extract + 归档 + -Xshare:on)1.791s2624ms852987.0MB
AOT 模式(spring.aot.enabled=true)1.911s3204ms872277.6MB

结果挺有意思,分三块说。

CDS 的收益先看启动时间:boot 自报从 2.034s 降到 1.791s,省了约 12%;类加载数从 8796 降到 8529,少的 267 个类全走归档映射;wall 指标也跟着从 3157ms 降到 2624ms。

AOT 模式的账要分开算。内存是稳稳的赚:堆中位数 77.6MB,比普通模式的 96.1MB 少了约 19%,三次取值 77.7/77.6/77.5MB,波动没超过 0.2MB;boot 自报 1.911s,快了 6%。反直觉的是 wall 指标,三次取值 2895/3204/3254ms,居然比普通模式的中位数还慢。波动来自进程调度,看不出统计显著性。想想也合理:生成代码省下的是解析,不是调度排队的时间。这组数容易被略过,但我觉得它是本次实测里最值得记住的:选 AOT 的理由是内存,别拿 wall 数字说事。

还有一点,两条路是正交的:CDS 管类加载,AOT 管 Bean 解析,先 AOT 打包再对 AOT 产物做 CDS 训练,可以叠着用。JDK 25+ 上 Boot 官方推荐的 AOT Cache(-XX:AOTCache)更干脆,直接把两条机制合成一条,JEP 483 里连方法剖析数据都进了缓存。

AOT 模式真的在跑生成代码吗?证据在类加载记录里。jcmd <pid> VM.class_hierarchy 的输出中,AOT 模式出现了 371 行 __B__eanDefinitions、__A__pplicationContextInitializer、__B__eanFactoryRegistrations 这样的 AOT 生成类,普通模式是 0 行。生成类加载进 JVM 且参与上下文重建,这是「AOT 上下文」最直接的铁证。

https://static.xiongneng.me/native-startup-components-20260921201615.png

https://static.xiongneng.me/native-startup-benchmark-20260921201615.png

内存口径得交代一句:jvm.memory.used 是堆内已用字节,而 CDS 的主要收益在类元数据(元空间)和加载耗时上,堆内存对它不敏感,三次取值 85.4/93.8/87.0MB 波动也大;AOT 模式的堆内存下降倒是稳稳的。对比启动时间时,boot 自报用的是 JVM 内部时钟,比 wall 指标扛机器负载波动,所以正文对比以 boot 自报为主。另外提醒一句,将来补测 native 镜像时口径得换:native 进程没有 JVM 堆,jvm.memory.used 取不到值,得换成进程 RSS(Windows 上 tasklist 的内存列、Linux 上 /proc 的 status 字段),脚本注释里已经把这个写好了,省得两套口径直接比大小闹笑话。

三种方式怎么选?看你的场景对启动时间有多敏感。常态扩缩容的服务,AOT 模式一行启动参数加一次打包配置就够,内存省 19% 意味着同样配额能塞更多副本;启动时间还嫌慢,再把 CDS 叠上去,两段收益直接相加。弹性伸缩剧烈、扩容要秒级就绪的场景,往 JDK 25 的 AOT Cache 走,或者具备工具链后直接编 native。代价也摆在明面上:AOT 牺牲运行期 Bean 定义的灵活性,CDS 多了归档生成的构建环节,native 把工具链要求和反射排查成本一次性前移。三段台阶彼此不冲突,按顺序逐段引入就行,每一段都有独立的退出成本。

native-image:路径与环境前提

GraalVM 原生镜像就是把上面的 AOT 推到终点:以 main 为入口做静态分析,把应用和 JDK 子集直接编译成平台专属的可执行文件,启动时没有 JVM、没有 JIT 预热,类在构建期就全部解析完。官方文档把它的约束归成三条:静态分析从 main 出发,不可达代码直接剔除;GraalVM 看不见反射、资源、序列化与动态代理,这些动态行为必须靠 hints 显式登记;类路径构建期固定,运行期不再有延迟类加载。Spring 的 AOT 处理正是为这三条做准备:闭世界假设下,Bean 定义构建期定型,动态行为全部登记成 hints,剩下的静态分析交给 GraalVM。

编译入口是 starter parent 4.1.1 内置的 native profile,一条命令串起整条链:

1
mvn -Pnative native:compile

profile 里包含 process-aot 与 native-maven-plugin,生成物 native-image.properties 里 -H:Class 指向主类、--no-fallback 禁用回退 JVM。另一条路是 buildpacks(mvn spring-boot:build-image -Pnative),在容器里完成编译,需要 Docker 或 Podman。

编译环境是 native 路线最大的门槛,官方要求:

  • GraalVM 发行版 JDK,当前 LTS 线为 GraalVM 25.0,另有 GraalVM for JDK 21 LTS 可与本系列的 JDK 21 对齐
  • Windows 上需要 Visual Studio 2022 MSVC 工具链(x64 Native Tools 命令行),或改用 WSL2
  • Linux 上需要 gcc/ld

我本机的环境侦察结论(2026-09-20)是这样的:where cl.exe 查无结果、Visual Studio 目录不存在、GRAALVM_HOME 没设置、GraalVM 没有安装目录,唯一可用的 Docker 在一台 ARM 架构的树莓派上。三条编译路线(本机 MSVC、本机 WSL、buildpacks)全部走不通,所以本文没有 native 镜像的实测数字,只有机制取证和 JVM 侧实测;前文的对比表里没有 native 一栏,就是这个原因。native 镜像的公开收益(启动毫秒级、RSS 显著低于 JVM)等环境齐了,用同一套脚本就能补测,脚本和工程都备好了。

还有个容易被忽略的维度:native 镜像是平台专属可执行文件,没有跨架构一说。在树莓派(aarch64 Linux)上编出来的产物只能在 aarch64 Linux 上跑,Windows x64 想复用,要么换 WSL2 或 CI 机器(x64 Linux)编译,要么走 buildpacks 让容器架构对齐。多架构团队就得每种目标架构各编一次,CI 矩阵里按 runner 架构拆 job 是常规操作。

https://static.xiongneng.me/native-compile-path-20260921201615.png

native 编译失败的高频原因也值得预演一遍:反射类没进 hints,报 ClassNotFound 或 NoSuchMethod,用 tracing agent 补登记;代理接口没登记,报 proxy 类找不到,hints.proxies().registerJdkProxy() 补上;资源没登记,模板与配置文件加载返回 null,registerPattern() 补上。这批问题的定位线索都在 GraalVM 编译日志的 missing registration 段落里。

避坑指南

坑一,process-aot 不配 execution 就不会跑。 这个 goal 不绑定默认生命周期,mvn package 静悄悄跳过它,target 下连 spring-aot 目录都不会出现。第一次用 AOT 扑空,九成是这个原因,配好 <execution> 再看 prepare-package 阶段的日志。

坑二,spring.aot.enabled=true 要求 bean 定义在构建期定型。 运行期用 @Profile 切换 bean、用 @ConditionalOnProperty 的 .enabled 属性开关联动 bean,在 AOT 模式下受限或直接不支持,官方文档 native 章节明确列了这批限制。存在这类写法的工程,切 AOT 模式前先过一遍配置类。

坑三,@ConfigurationPropertiesScan 注册的 bean 名不是类名。 它注册的 bean 名是 shop-<全限定类名> 格式,containsBean("shopProperties") 返回 false 是正常现象,按类型取 getBean(ShopProperties.class) 或者查全名。我测试就栽在这里,断言改按类型写。

坑四,CDS 对 fat jar 布局不生效。 直接对 fat jar 跑 -XX:ArchiveClassesAtExit,归档文件照样生成,但嵌套 jar 的类路径 CDS 无法索引,之后启动要么命中极差要么报错。官方流程必须先 java -Djarmode=tools -jar app.jar extract,对解包后的布局做训练与运行。

坑五,训练运行的退出方式选官方技巧。 -Dspring.context.exit=onRefresh 让上下文刷新完成即正常退出并写归档。用 kill 强杀进程不写归档,Windows 上尤其明显;开 actuator 的 shutdown 端点也能退,但要起完整服务,比 onRefresh 慢且引入额外配置。

坑六,-Xshare:on 是归档命中与否的判定开关。 默认的 -Xshare:auto 在归档不匹配时静默退回普通启动,CDS「用了但没生效」不会有任何提示。开发期加 -Xshare:on,归档无效直接拒绝启动,问题当场暴露。

坑七,GC.class_histogram 看不到 AOT 生成类。 它只列有存活实例的类,__B__eanDefinitions 这类生成类是静态方法调用,启动后没有实例存活,直方图里不出现。要看已加载类用 jcmd <pid> VM.class_hierarchy,AOT 生成类 371 行的证据就是从这个命令取的。

坑八,GC.class_histogram 会触发 Full GC。 计时阶段别跑它,一次 Full GC 足以把启动毫秒数污染掉。我的脚本把它放在接口验证与内存取样之后、kill 之前执行,墙钟计时早就定格了。

**坑九,Git Bash 下 ! 不是 Windows PID。** Git Bash 的 `! 给出的是 MSYS 包装进程号,jcmd、jps需要 Windows PID,直接拿$!去查会报 no such process。反查用jps -l` 按应用名过滤出真 PID,再喂给 jcmd。

坑十,jarmode extract 对非空目标目录直接报错。 extract --destination 指向已存在且非空的目录时抛 JarModeErrorException,不会覆盖。复跑脚本时先清理目标目录,或者检测到存在就跳过解包步骤。

坑十一,wall 计时的波动来自机器,不来自应用。 后台有编译任务时同一模式的启动时间能差 30%,单次取值没有意义。每组连跑 3 次以上取中位数,对比实验尽量挤在同一时段跑,正文的数据就是这么取的。

坑十二,堆内存口径覆盖不了 CDS 的收益。 jvm.memory.used 只量堆,CDS 的类元数据在元空间,归档映射的内存在共享段,都不在堆里。只看堆指标会得出「CDS 不省内存」的偏差结论,它省的主要是加载时间与元空间占用。

坑十三,AOT cache 与 CDS 按 JDK 版本二选一。 -XX:AOTCache(JEP 483)需要 JDK 25 及以上,Boot 4.1 文档推荐优先用它;JDK 21 上只能用 CDS 两步法。我的实测在 JDK 21 上进行,走的是 CDS 路线。

坑十四,AOT 处理时应用会「启动一半」。 process-aot 把上下文启动到 bean 定义齐备为止,Environment 占位符、随机端口这类运行期值要能解析,@Value("${...}") 引用不存在的属性会在构建期报错,提前在测试配置里补齐。

坑十五,AOT 产物随包进 jar 后不影响普通模式。 同一份 fat jar 加不加 -Dspring.aot.enabled=true 是两种运行模式,产物在普通模式下被忽略。灰度期同一构建物先跑普通模式、验证后切 AOT 模式,不需要两套制品。

坑十六,编译 native 不必换掉日常 JDK。 GraalVM for JDK 21 本身就是 HotSpot 发行版,日常开发、单测、JVM 模式运行都兼容,差别只在它额外带 native-image 工具。流程上可以统一用 GraalVM JDK 编译打包,native 编译只在发布环节触发,不必维护「普通 JDK 开发、GraalVM 编译」两套环境。

生成源码读起来偏长,但官方文档也承认它可读:每个自动配置类一个 __B__eanDefinitions 文件,方法名与 bean 名一一对应,排查「AOT 模式下某个 bean 为什么没起来」时直接打开对应文件搜索 bean 名,比在运行期堆栈里追 ConfigurationClassPostProcessor 的解析过程直接得多。这 98 个文件等于一份可检索的启动期行为快照。

小结

三段台阶各自独立,也叠得起来:process-aot 把 Bean 解析搬进构建期,CDS 用 6.6MB 归档省下 267 个类的加载,AOT 模式一行参数再把堆内存压掉 19%。常态扩缩容的服务,做到 AOT 模式一般就够了;启动还嫌慢,再叠 CDS。

真正没解决的是最后一段:native 镜像在我这台没有 MSVC 的 Windows 上编不出来。等环境补齐了,这套验证脚本和工程可以直接续测,把三行对比表补成四行,到时候我再补一篇。

参考链接