直接拿 Spring Boot 的 fat jar 去生成 AOT 缓存,启动几乎没变快。先用 -Djarmode=tools 把 jar 解压再训练,同一个应用在 JDK 27 上各启动 5 次,Spring 报告的启动时间中位数从 1.228 秒降到 0.542 秒,约为原来的 44%,启动速度约为原来的 2.3 倍。原因是 AOT 缓存只认 classpath 上的普通 jar,fat jar 里嵌套的依赖缓存不了。

AOT 缓存要求训练和生产的环境保持一致,其中有一条很容易被忽略:

the classpath is a list of plain JARs, with no folders, wildcards, or nested JARs

classpath 只能是普通 jar 的列表,不能有目录、通配符和嵌套 jar。Spring Boot 的可执行 jar 恰好就是嵌套 jar。Spring Boot 自己的 AOT Cache 文档其实也写了,训练要在解压后的应用上做,缓存文件只能配解压后的形式用,否则没有效果。我用 Spring Boot 4.1.1 把两种方式都跑了一遍,看看差别到底多大。之前写过 JEP 544 要把 JIT 编译好的原生码也放进 AOT 缓存,那是 JDK 28 的事,这篇用的是已经 GA 的 JDK 27,缓存里装的是类加载和链接的结果(JEP 483,JDK 24 交付),以及方法的执行剖面(JEP 515,JDK 25 交付)。

环境

$ java -version
openjdk version "27" 2026-09-15
OpenJDK Runtime Environment (build 27+35-2325)
OpenJDK 64-Bit Server VM (build 27+35-2325, mixed mode, sharing)

Spring Boot 4.1.1,只引 spring-boot-starter-webmvc,内嵌 Tomcat,端口 18083。应用里只有一个 /info 接口。

在 fat jar 上训练

JDK 25 起(JEP 514)可以一步生成缓存:带上 -XX:AOTCacheOutput,JVM 跑完训练会自己拉起子进程把缓存组装好。训练时用 -Dspring.context.exit=onRefresh 让 Spring 在容器刷新完就退出,不用手动停应用。注意这个 -D 要写在 -jar 前面,写在后面就成了应用参数,应用会一直跑下去。

$ java -XX:AOTCacheOutput=fat.aot -Dspring.context.exit=onRefresh -jar target/aot-cache-demo-0.0.1.jar
Picked up JAVA_TOOL_OPTIONS: -Djava.class.path=target/aot-cache-demo-0.0.1.jar -Dspring.context.exit=onRefresh -XX:AOTCacheOutput=fat.aot -XX:AOTConfiguration=fat.aot.config -XX:AOTMode=create
[2.062s][warning][aot] Preload Warning: Verification failed for org.springframework.core.ReactiveAdapterRegistry$ReactorAdapter because a java.lang.NoClassDefFoundError was thrown: reactor/core/CorePublisher
[2.110s][warning][aot] Preload Warning: Verification failed for org.springframework.boot.logging.log4j2.Log4J2LoggingSystem because a java.lang.NoClassDefFoundError was thrown: org/apache/logging/log4j/core/Filter
[2.133s][warning][aot] Preload Warning: Verification failed for org.springframework.web.servlet.view.freemarker.FreeMarkerView because a java.lang.NoClassDefFoundError was thrown: freemarker/core/ParseException
[2.250s][warning][aot] Skipping org/springframework/web/servlet/view/freemarker/FreeMarkerView: Failed verification
[2.281s][warning][aot] Skipping org/springframework/boot/logging/log4j2/Log4J2LoggingSystem: Failed verification
[2.310s][warning][aot] Skipping org/springframework/core/ReactiveAdapterRegistry$ReactorAdapter: Failed verification
Temporary AOTConfiguration recorded: fat.aot.config
Reading AOTConfiguration fat.aot.config and writing AOTCache fat.aot
AOTCache creation is complete: fat.aot 28184576 bytes
Removed temporary AOT configuration file fat.aot.config

上面省略了 Spring 的启动日志和拉起子进程的那一行。三条 Verification failed 是 Spring 里引用了 Reactor、Log4j2、FreeMarker 的可选类,这个项目没引这些依赖,验证不过就不进缓存,不影响运行。

缓存生成成功了,27M。看起来一切正常,问题要到跑起来才看得出来。

解压后再训练

Spring Boot 自带解压工具:

$ java -Djarmode=tools -jar target/aot-cache-demo-0.0.1.jar extract --destination extracted

$ ls extracted; ls extracted/lib | wc -l
aot-cache-demo-0.0.1.jar
lib
34

解压后的 aot-cache-demo-0.0.1.jar 是个瘦 jar,manifest 里的 Class-Path 指向 lib/ 下的 34 个依赖 jar,可以直接 java -jar 启动。在它上面重新训练:

$ java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar extracted/aot-cache-demo-0.0.1.jar
AOTCache creation is complete: app.aot 56299520 bytes

缓存大了一倍,56299520 字节,约 54M,fat jar 那份是 28184576 字节,约 27M。这次训练日志里还多了 8 条类似这样的警告,行尾是 spring-core jar 的路径,下面只截到 from 为止:

[0.699s][warning][aot] class org/springframework/core/type/classreading/ClassFileMetadataReaderFactory cannot be archived because it was not defined from

这几个类在 spring-core 的 META-INF/versions/24/ 下面,是多版本 jar 里给 JDK 24 及以上准备的实现:

     1605  1980-02-01 00:00   META-INF/versions/24/org/springframework/core/type/classreading/ClassFileMetadataReaderFactory.class

多版本 jar 里按版本选出来的类没有进缓存,运行时照常从 jar 加载,功能不受影响。

启动时间

四种组合各启动 5 次,取 Spring Boot 打印的 Started 行:

### fat-nocache: java -jar target/aot-cache-demo-0.0.1.jar
Started DemoApplication in 1.256 seconds (process running for 1.612)
Started DemoApplication in 1.246 seconds (process running for 1.585)
Started DemoApplication in 1.319 seconds (process running for 1.692)
Started DemoApplication in 1.255 seconds (process running for 1.594)
Started DemoApplication in 1.232 seconds (process running for 1.592)
### fat-cache: java -XX:AOTCache=fat.aot -jar target/aot-cache-demo-0.0.1.jar
Started DemoApplication in 1.252 seconds (process running for 1.522)
Started DemoApplication in 1.556 seconds (process running for 1.826)
Started DemoApplication in 1.171 seconds (process running for 1.658)
Started DemoApplication in 1.078 seconds (process running for 1.35)
Started DemoApplication in 1.191 seconds (process running for 1.444)
### extracted-nocache: java -jar extracted/aot-cache-demo-0.0.1.jar
Started DemoApplication in 1.244 seconds (process running for 1.495)
Started DemoApplication in 1.253 seconds (process running for 1.48)
Started DemoApplication in 1.228 seconds (process running for 1.486)
Started DemoApplication in 1.096 seconds (process running for 1.311)
Started DemoApplication in 1.148 seconds (process running for 1.366)
### extracted-cache: java -XX:AOTCache=app.aot -jar extracted/aot-cache-demo-0.0.1.jar
Started DemoApplication in 0.47 seconds (process running for 0.585)
Started DemoApplication in 0.542 seconds (process running for 0.647)
Started DemoApplication in 0.458 seconds (process running for 0.561)
Started DemoApplication in 0.548 seconds (process running for 0.653)
Started DemoApplication in 0.566 seconds (process running for 0.693)

按 5 次的中位数算,fat jar 不带缓存是 1.255 秒,带上缓存 1.191 秒,波动比差值还大。解压后不带缓存 1.228 秒,带缓存 0.542 秒,1.228 / 0.542 ≈ 2.27。看 process running for 的中位数,进程总耗时从 fat jar 不带缓存的 1.594 秒降到解压带缓存的 0.647 秒。样本只有 5 次,数字只说明量级。

缓存里到底装了多少类

用 -Xlog:class+load 数一下,每种组合各跑一次,加载来源写着 shared objects file 的就是从缓存里来的:

$ java -Xlog:class+load=info:file=cl-<label>.txt [-XX:AOTCache=...] -Dspring.context.exit=onRefresh -jar <jar>
fat-nocache: total=6806 aot=1446
fat-cache: total=6853 aot=2486
extracted-nocache: total=6728 aot=1445
extracted-cache: total=6992 aot=6781

不带缓存时也有 1400 多个类来自共享归档,那是 JDK 自带的默认 CDS 归档,装的是 JDK 自己的核心类。fat jar 的缓存只多装了 1000 来个,按包名分一下,缓存里来源是 shared objects file 的类一共 2486 个,其中 2388 个是 JDK 自己的类(包括 org.jcp.xml.dsig 这类 JDK 模块里的包),98 个是 org.springframework.boot.loader 下的启动器类,应用依赖一个都没有。BOOT-INF/lib 里的依赖由 Spring Boot 自己的类加载器从嵌套 jar 里读出来,JVM 没法把它们和 classpath 上的某个文件对上,也就不会缓存。解压后的缓存覆盖了 6992 个类里的 6781 个,Spring、Tomcat、Jackson 都进去了。

缓存什么时候会失效

jar 的时间戳也必须和训练时一致。我改一下其中一个依赖的修改时间,再用 -XX:AOTMode=on 启动,这个模式下缓存用不上就直接报错退出:

$ touch extracted/lib/spring-web-7.0.9.jar
$ java -XX:AOTCache=app.aot -XX:AOTMode=on -Dspring.context.exit=onRefresh -jar extracted/aot-cache-demo-0.0.1.jar
[0.011s][warning][aot] This file is not the one used while building the AOT cache: 'extracted/lib/spring-web-7.0.9.jar', timestamp has changed
[0.011s][error  ][aot] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure)
[0.011s][error  ][aot] An error has occurred while processing the AOT cache. Run with -Xlog:aot for details.
[0.011s][error  ][aot] unrecoverable error
Error occurred during initialization of VM
exit=1

不加 -XX:AOTMode=on 时默认是 auto,同样会打出时间戳告警和 shared class paths mismatch,但没有 unrecoverable error,最后一行是:

[0.014s][error  ][aot] Unable to map shared spaces

然后应用照常启动,退出码是 0,只是没用上缓存。线上如果只看应用起没起来,很容易注意不到缓存已经失效了。CI 里验证镜像时加上 -XX:AOTMode=on,缓存失效会直接让启动失败。这个值后来有了新名字 required,JDK 27 两个都认。

有个让人担心的场景反而没问题:重新解压。我删掉 extracted 目录重新执行一次 extract,然后用旧的 app.aot 加 -XX:AOTMode=on 启动,应用正常起来了,没有报时间戳不匹配。解压出来的文件时间取自 fat jar 里记录的条目时间,不是解压那一刻。拿 commons-logging 对比一下,fat jar 里的条目时间是下面这样,解压后 ls -l 看到的文件也是 74010 字节、2026-03-04 23:05:

$ unzip -l target/aot-cache-demo-0.0.1.jar | grep commons-logging
    74010  2026-03-04 23:05   BOOT-INF/lib/commons-logging-1.3.6.jar

所以同一个 fat jar 解压多少次,缓存都对得上。换了一个重新构建的 jar,就要重新训练。

参考

文中数字都来自 JDK 27 上的实测,解压后训练的做法见 Spring Boot 文档 AOT Cache,JVM 一侧参考了 JEP 483、JEP 514 和 JEP 515。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。