年初做技术盘点:Java 的启动性能和 AOT,现在到哪一步了
每年一月我会花几天时间把 Java 生态的重点项目过一遍,看看有哪些东西从「观望」变成了「可以用」。今年重点看了三块:Leyden 的进展、AOT 生态的成熟度、以及启动性能的现状。
结论先说:JDK 25 这一代,启动性能有了不需要改代码就能拿到的实质提升,但完整的 AOT 编译时优化(Leyden 的最终目标)还没落地。原生镜像的生态成熟度比我去年预期的好,但离「默认选择」还有距离。
Leyden:已经交付的和还没交付的
Leyden 项目从 2020 年启动,目标是把 Java 的启动时间、预热时间、内存占用做到接近静态编译语言的水平。它的核心思路是「把运行时的工作往前挪」——能提前做的类加载、链接、方法分析、甚至代码编译,都在运行前做掉,存成一份 AOT 缓存,运行时直接加载。
到 JDK 25,已经交付了三块:
| 特性 | JEP | 版本 | 作用 |
|---|---|---|---|
| 提前类加载与链接 | JEP 483 | JDK 24 | 启动时跳过类的加载、验证、链接 |
| AOT 命令行易用性 | JEP 514 | JDK 25 | 两步命令简化成一条,加入 -XX:AOTCacheOutput |
| 提前方法性能分析 | JEP 515 | JDK 25 | 把预热期的热点方法数据存入缓存,省掉预热 |
第三项(JEP 515)是今年最大的惊喜。JIT 编译器需要运行一段时间才能收集到足够的方法调用数据来做优化,这就是「预热」。JEP 515 的做法是先跑一次训练,把热点方法的方法画像存进 AOT 缓存,正式运行时 JIT 一上来就有数据可以优化,等于跳过了预热期。
用起来是这样,两条命令:
# 第一步:训练,生成 AOT 缓存
java -XX:AOTCacheOutput=app.aot -jar order-service.jar --train-mode
# 第二步:正式运行,加载缓存
java -XX:AOTCache=app.aot -jar order-service.jar
JEP 514 之前要用 -XX:AOTMode=record / -XX:AOTConfiguration 之类的多个参数,现在简化成 AOTCacheOutput / AOTCache 两个,可用性提升不少。
实测:我们的三个服务
我拿团队三个典型服务做了测试,都是 JDK 21 升到 JDK 25(都是 -Xmx2g,同一台机器,跑 20 次取中位数)。
| 服务 | 类型 | JDK 21 | JDK 25 | JDK 25 + AOT 缓存 |
|---|---|---|---|---|
| order-service | Spring Boot 3.5,含 Tomcat + MyBatis | 3.42s | 3.19s | 1.68s |
| batch-etl | 纯 Java,无框架 | 0.31s | 0.28s | 0.19s |
| ai-gateway | Spring Boot + WebFlux + Netty | 2.87s | 2.71s | 1.44s |
光是升级 JDK 21 → 25 就有 5~7% 的启动提升,这个来自各种零散优化。开了 AOT 缓存之后,启动时间直接砍半。
更重要的是预热时间的改善,这才是 Leyden 真正的价值。我们测的是「启动后到 P99 延迟稳定」的时间:
| 服务 | JDK 21 预热时间 | JDK 25 + AOT |
|---|---|---|
| order-service | 47s | 11s |
| ai-gateway | 62s | 14s |
这个指标对生产的影响比启动时间大得多。我们的服务在 K8s 上滚动发布,新 Pod 起来后如果预热没完成就接流量,前几十秒的延迟会很难看。以前我们的做法是配置 readinessProbe 延迟 60 秒,现在可以缩到 20 秒,发布速度快了不少。
缓存文件大小:order-service 的 app.aot 是 61 MB。要打进镜像,镜像体积从 287 MB 涨到 348 MB。可接受。
AOT 缓存的坑
用了两周,遇到三个限制。
第一,训练环境和生产环境必须一致。AOT 缓存里存的是类元数据和方法画像,如果两边的类路径、JDK 版本、JVM 参数不一致,缓存会直接失效:
[ warning][cds] The AOT cache cannot be used because
the JVM options are different from the ones used to create it
[ warning][cds] Expected: -Xmx2g -XX:+UseG1GC
[ warning][cds] Actual: -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
[ warning][cds] AOT cache is ignored, running in normal mode
注意它不报错,只是警告然后降级。如果你的监控没覆盖启动时间,根本发现不了缓存没生效。我们的做法是在启动脚本里检查这个日志,发现降级就告警。另外把 AOT 缓存的生成放进了 CI,保证和运行环境同源。
第二,类加载器的自定义行为会破坏缓存。我们有个服务用了自定义 ClassLoader 做插件隔离,AOT 缓存对它加载的类无效。这部分类的加载时间没省下来,实测该服务的启动只从 4.1s 降到 2.9s(其他服务都是砍半)。
第三,方法画像会过期。业务逻辑改了,热点方法变了,旧的 AOT 缓存就会变成负优化——JIT 拿着过时的画像去做优化决策。我们的策略是每次发版重新生成缓存,不能复用。这在 CI 里加了一步,构建时间多了约 90 秒。
原生镜像:生态比去年好,但仍有门槛
GraalVM 原生镜像这块,今年有两个实质变化。
一是 Spring Boot 4.0 去年十一月发布,基于 Spring Framework 7,对 AOT 的支持又进了一步。Spring 的 AOT 引擎现在能处理大部分反射、资源配置,不需要手写 hints。我们在测试环境试了把一个 Spring Boot 4.0 的服务打成原生镜像:
mvn -Pnative native:compile
# 编译耗时 4 分 12 秒(M1 Max,10 核)
# 产物大小 84 MB
./target/demo-service
# 启动时间 0.142s
# RSS 峰值 128 MB
对比同一个服务在 JVM 上跑:启动 1.68s(带 AOT 缓存),RSS 峰值 412 MB。
二是第三方库的原生镜像支持覆盖率上来了。我们统计了项目里用到的 47 个依赖,有官方 reachability metadata 的从去年的 28 个涨到 41 个。剩下 6 个需要自己写配置,主要是些内部库和较老的工具包。
但原生镜像还不是默认选项
尽管数字很漂亮,我们只在两个场景用了原生镜像:CLI 工具和短生命周期的批处理任务。核心服务(那些跑几天的长驻进程)全部还是 JVM。理由有三:
| 问题 | 影响 |
|---|---|
| 峰值吞吐低 | 实测同一服务原生镜像的稳态吞吐比 JVM 低 12~18%(少了 JIT 的自适应优化) |
| 构建成本高 | 单次编译 4~7 分钟,CI 压力大;本地开发基本不可能用 |
| 调试困难 | 出了生产问题无法用常规手段诊断(没有 jstack、jmap、JFR) |
第三条是最劝退的。我们去年有个原生镜像的批处理任务出现内存问题,最后只能靠加日志二分定位,花了两天。同样的情况在 JVM 上,一次 jmap 就解决了。
我的判断:原生镜像适合「启动频繁、生命周期短、对内存敏感」的场景(Serverless、CLI、短任务),不适合需要长期稳定运行且追求峰值性能的服务。这个结论和两年前一样,没变。
紧凑对象头:被低估的改进
JEP 519 在 JDK 25 里转正了,把对象头从 96 位压到 64 位。这个改动听起来不起眼,但对特定类型的应用收益很大。
我们在几个服务上测了(都是开箱默认,JDK 25 里这个特性默认启用):
| 服务 | 对象特征 | 堆占用变化 | GC 频率变化 |
|---|---|---|---|
| ai-gateway | 大量短生命周期小对象 | -14.8% | -11.2% |
| order-service | 中等对象,含较多集合 | -9.3% | -6.1% |
| report-etl | 少量大数组 | -1.2% | -0.8% |
规律很明显:对象越小、数量越多,收益越大。对象头占 96 位(12 字节)时,一个只含一个 int 字段的对象实际占用 24 字节,头占了一半;压到 64 位后占用 16 字节,省了 33%。而大数组对象本来头占比就低,几乎没收益。
report-etl 那个服务基本没变化,因为它 90% 的堆是几个大 double[]。
需要留意的兼容性问题:如果代码里用了 jol(Java Object Layout)做对象布局分析,数据会变。另外依赖对象头大小的 unsafe 操作(极其罕见)需要检查。我们全项目扫了一遍,只有测试代码里用了 jol,改一下断言就行。
模块化的现状
JPMS 这块,说实话进展比我想象的慢。我们自己的项目里只有两个工具库做了完整的模块化(带 module-info.java),业务服务全部还在 classpath 上跑。
主要的阻碍不是技术,是投入产出比。完整模块化一个 20 万行的服务,我们估算要 3~4 人周,收益是:更强的封装、更清晰的依赖、理论上更好的启动性能和更小的镜像(配合 jlink)。而风险是大量第三方库还没有 module-info,需要用自动模块名,处理起来很琐碎。
我们真正在用的只有 jlink——给 CLI 工具打最小化运行时:
jlink --module-path $JAVA_HOME/jmods:target/modules \
--add-modules com.example.clitool \
--output dist/cli-runtime \
--strip-debug --no-man-pages --no-header-files \
--compress=zip-6
# 结果对比
du -sh dist/cli-runtime # 42 MB
du -sh $JAVA_HOME # 321 MB(完整 JDK)
42 MB 的运行时(比完整 JDK 小 87%),配合原生镜像,我们的 CLI 工具最终产物是 61 MB 的单文件,启动 28ms。这个场景 jlink 很有用。
对服务端应用,我们没用 jlink——因为需要动态加载能力,而且镜像大小的收益不如直接用 eclipse-temurin:25-jre-alpine 基础镜像来得简单。
版本升级节奏
顺带说下我们今年的版本策略。团队手上一共 31 个 Java 服务,现在的分布:
| JDK 版本 | 服务数 | 计划 |
|---|---|---|
| JDK 25 | 6 | 今年内推到 20 个以上 |
| JDK 21 | 19 | 逐步迁移 |
| JDK 17 | 4 | 今年必须清掉(安全支持快到期了) |
| JDK 8 | 2 | 遗留系统,今年做迁移方案 |
JDK 25 我们是从 25.0.2 开始上的(去年十二月的版本),前面两个补丁版观望了一阵。目前跑了一个多月,六个服务零问题。紧凑对象头和 AOT 缓存都是默认/可选开启,没有强制,风险可控。
Spring Boot 4.0 我们还在测试环境,没动生产。它是去年十一月发布的,基于 Spring Framework 7,要求 JDK 17+。API 层面兼容性还行,但有几个依赖的 starter 还没跟上,我们打算等 Spring Boot 4.1 再评估。这条经验说过很多次了:新的大版本,让别人先踩三个月。
几条今年会做的事
- 给所有长驻服务开 AOT 缓存。投入小(CI 加一步),收益明确(启动砍半、预热从 60 秒降到 15 秒)。预计 Q1 完成 20 个服务。
- ClI 工具和短任务全部上原生镜像。这部分已经有 4 个了,今年再迁 6 个。
- 把 JDK 17 的 4 个服务清掉。不是因为新特性,是因为安全支持。
- 建立启动性能的基线监控。以前只监控运行时指标,启动时间和预热时间完全没有数据。现在我们给每个服务记录了启动耗时和「到 P99 稳定」的耗时,画在面板上。这次能评估 AOT 缓存的效果,靠的就是这个。
小结
这一代 Java 在启动性能上的进步是实打实且不要求改代码的——升级 JDK 25 加一条命令行参数就能让启动时间砍半、预热时间降到四分之一。这种投入产出比在 Java 生态里不多见,我觉得是所有团队都该做的事情。
但要说清楚边界:Leyden 目前交付的是「把加载、链接、方法画像提前做完」,还不是「把字节码提前编译成机器码」。后者才是 Leyden 的最终目标,规范还在草案阶段。所以现在的 AOT 缓存是「省掉启动阶段的工作」,不是「让 Java 变成静态编译语言」。想要后者,目前还是得用 GraalVM 原生镜像,代价是峰值性能和可观测性。
最后一点感慨:我在这个行业待了八年,Java 的启动性能被吐槽了至少六年。这两年终于看到系统性的改善,而且是以不破坏兼容性的方式做的——没有要求开发者重写代码,没有分裂生态。这种渐进式演进的速度看着慢,但对企业用户来说,可能比激进的变革更值钱。