Administrator
发布于 2026-01-25 / 174 阅读
2

Java 模块化与原生化的最新进展

年初做技术盘点:Java 的启动性能和 AOT,现在到哪一步了

每年一月我会花几天时间把 Java 生态的重点项目过一遍,看看有哪些东西从「观望」变成了「可以用」。今年重点看了三块:Leyden 的进展、AOT 生态的成熟度、以及启动性能的现状。

结论先说:JDK 25 这一代,启动性能有了不需要改代码就能拿到的实质提升,但完整的 AOT 编译时优化(Leyden 的最终目标)还没落地。原生镜像的生态成熟度比我去年预期的好,但离「默认选择」还有距离。

Leyden:已经交付的和还没交付的

Leyden 项目从 2020 年启动,目标是把 Java 的启动时间、预热时间、内存占用做到接近静态编译语言的水平。它的核心思路是「把运行时的工作往前挪」——能提前做的类加载、链接、方法分析、甚至代码编译,都在运行前做掉,存成一份 AOT 缓存,运行时直接加载。

到 JDK 25,已经交付了三块:

特性JEP版本作用
提前类加载与链接JEP 483JDK 24启动时跳过类的加载、验证、链接
AOT 命令行易用性JEP 514JDK 25两步命令简化成一条,加入 -XX:AOTCacheOutput
提前方法性能分析JEP 515JDK 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 21JDK 25JDK 25 + AOT 缓存
order-serviceSpring Boot 3.5,含 Tomcat + MyBatis3.42s3.19s1.68s
batch-etl纯 Java,无框架0.31s0.28s0.19s
ai-gatewaySpring Boot + WebFlux + Netty2.87s2.71s1.44s

光是升级 JDK 21 → 25 就有 5~7% 的启动提升,这个来自各种零散优化。开了 AOT 缓存之后,启动时间直接砍半。

更重要的是预热时间的改善,这才是 Leyden 真正的价值。我们测的是「启动后到 P99 延迟稳定」的时间:

服务JDK 21 预热时间JDK 25 + AOT
order-service47s11s
ai-gateway62s14s

这个指标对生产的影响比启动时间大得多。我们的服务在 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 256今年内推到 20 个以上
JDK 2119逐步迁移
JDK 174今年必须清掉(安全支持快到期了)
JDK 82遗留系统,今年做迁移方案

JDK 25 我们是从 25.0.2 开始上的(去年十二月的版本),前面两个补丁版观望了一阵。目前跑了一个多月,六个服务零问题。紧凑对象头和 AOT 缓存都是默认/可选开启,没有强制,风险可控。

Spring Boot 4.0 我们还在测试环境,没动生产。它是去年十一月发布的,基于 Spring Framework 7,要求 JDK 17+。API 层面兼容性还行,但有几个依赖的 starter 还没跟上,我们打算等 Spring Boot 4.1 再评估。这条经验说过很多次了:新的大版本,让别人先踩三个月

几条今年会做的事

  1. 给所有长驻服务开 AOT 缓存。投入小(CI 加一步),收益明确(启动砍半、预热从 60 秒降到 15 秒)。预计 Q1 完成 20 个服务。
  2. ClI 工具和短任务全部上原生镜像。这部分已经有 4 个了,今年再迁 6 个。
  3. 把 JDK 17 的 4 个服务清掉。不是因为新特性,是因为安全支持。
  4. 建立启动性能的基线监控。以前只监控运行时指标,启动时间和预热时间完全没有数据。现在我们给每个服务记录了启动耗时和「到 P99 稳定」的耗时,画在面板上。这次能评估 AOT 缓存的效果,靠的就是这个。

小结

这一代 Java 在启动性能上的进步是实打实且不要求改代码的——升级 JDK 25 加一条命令行参数就能让启动时间砍半、预热时间降到四分之一。这种投入产出比在 Java 生态里不多见,我觉得是所有团队都该做的事情。

但要说清楚边界:Leyden 目前交付的是「把加载、链接、方法画像提前做完」,还不是「把字节码提前编译成机器码」。后者才是 Leyden 的最终目标,规范还在草案阶段。所以现在的 AOT 缓存是「省掉启动阶段的工作」,不是「让 Java 变成静态编译语言」。想要后者,目前还是得用 GraalVM 原生镜像,代价是峰值性能和可观测性。

最后一点感慨:我在这个行业待了八年,Java 的启动性能被吐槽了至少六年。这两年终于看到系统性的改善,而且是以不破坏兼容性的方式做的——没有要求开发者重写代码,没有分裂生态。这种渐进式演进的速度看着慢,但对企业用户来说,可能比激进的变革更值钱。

参考