一、Java 很快,为什么启动还是慢
提到 Java 性能,很多人首先想到的是 JIT 编译、垃圾回收器和高吞吐量。
这些优势通常出现在应用已经运行一段时间之后。
一个 Java 服务从进程创建到进入稳定状态,大致要经历三个阶段:
| 阶段 | 主要工作 | 常见指标 |
|---|---|---|
| 启动阶段 | 读取 JAR、解析类、加载类、链接类、初始化框架 | 启动耗时、就绪耗时 |
| 预热阶段 | 收集方法执行画像,识别热点代码,触发 JIT 编译 | 首批请求延迟、吞吐爬升速度 |
| 稳态阶段 | 热点方法已经完成优化,系统进入稳定运行 | QPS、P99、CPU 使用率 |
传统 JVM 擅长第三个阶段,却需要在前两个阶段完成大量准备工作。
对于长期运行的单体系统,几秒钟的启动时间可能并不重要。但在下面这些场景中,冷启动会直接影响系统成本和稳定性:
- Kubernetes 根据流量快速扩容;
- 服务频繁滚动发布;
- Serverless 函数按请求启动;
- 命令行工具执行几秒后退出;
- 批处理任务持续创建新的 JVM;
- 测试环境频繁启动和销毁服务。
更容易被忽略的是,应用日志打印出“Started”并不代表已经达到最佳性能。此时部分热点方法可能仍处于解释执行或低层级编译状态,前几十秒的请求延迟依然可能明显高于稳态。
因此,真正需要优化的并不只是“启动到端口监听”,还包括“启动到稳定吞吐”。
二、Project Leyden 的核心:让下一次启动不再从零开始
Project Leyden 是 OpenJDK 中专门优化 Java 启动时间、预热时间和运行时占用的项目。
它的思路并不是彻底抛弃 JVM,也不是直接把所有 Java 代码编译成原生二进制,而是将一部分原本发生在运行阶段的计算,提前移动到训练阶段。
训练完成后,JVM 会生成一个与当前应用绑定的 AOT Cache。后续启动相同应用时,可以直接复用其中的类状态和方法画像,减少重复工作。未命中缓存的类仍然可以按照普通 JVM 流程继续加载,JIT、垃圾回收器和动态类加载能力也依然存在。(Inside.java)
flowchart LR
A[构建应用] --> B[训练运行]
B --> C[记录类加载与方法画像]
C --> D[生成 AOT Cache]
D --> E[生产环境启动]
E --> F[复用缓存中的状态]
F --> G[JIT 继续动态优化]
可以把它理解成:
Project Leyden 没有把 Java 变成静态语言,而是让 JVM 记住上一次启动过程中已经完成的工作。
三、从 JDK 24 到 JDK 26,AOT Cache 经历了什么
Project Leyden 并不是一次性发布的独立产品,而是通过多个 JEP 逐步进入正式 JDK。
| JDK 版本 | 相关能力 | 解决的问题 |
|---|---|---|
| JDK 24 | JEP 483:Ahead-of-Time Class Loading & Linking | 将已经读取、解析、加载和链接的类状态保存到 AOT Cache |
| JDK 25 | JEP 514:Ahead-of-Time Command-Line Ergonomics | 使用一个参数完成训练和缓存生成,降低接入复杂度 |
| JDK 25 | JEP 515:Ahead-of-Time Method Profiling | 将训练阶段收集的方法画像写入缓存,让 JIT 更早识别热点方法 |
| JDK 26 | JEP 516:Ahead-of-Time Object Caching with Any GC | 让 AOT 对象缓存兼容包括 ZGC 在内的所有垃圾回收器 |
JDK 24 解决的是“类不必每次重新加载和链接”。
JDK 25 进一步解决了“JIT 不必每次从零收集热点画像”,同时把原来的多阶段命令简化成了更容易接入流水线的形式。
JDK 26 则移除了此前 AOT Cache 对 ZGC 的限制,使低延迟垃圾回收场景也能使用这套机制。(Inside.java)
对于追求稳定版本的企业,JDK 25 是多数厂商提供长期支持的 LTS 版本,并且已经包含最关键的命令简化和方法画像缓存能力。使用 ZGC 的团队则需要重点评估 JDK 26 及后续版本。(OpenJDK)
四、AOT Cache 到底保存了什么
传统 JVM 启动一个应用时,需要重复完成以下工作:
- 从 JAR 中读取 class 文件;
- 解析 class 文件结构;
- 加载类并创建运行时表示;
- 验证、准备和解析类之间的引用;
- 执行类链接;
- 观察方法调用情况;
- 收集分支概率和类型信息;
- 判断哪些方法值得 JIT 编译。
AOT Cache 的价值,在于将其中可以安全复用的部分保存下来。
1. 已经加载和链接的类
JDK 24 的 JEP 483 不只保存预解析的 class 数据,还可以保存类完成加载和链接之后的状态。
这意味着生产环境启动时,JVM 不需要对缓存中的类重复走完所有步骤。
2. 方法执行画像
JIT 编译器不仅需要知道某个方法是否经常执行,还需要观察:
- 某个分支通常走哪一边;
- 接口调用的实际类型是什么;
- 某段循环执行多少次;
- 哪些方法适合内联;
- 哪些代码是真正的热点。
JDK 25 可以把训练运行中收集到的方法画像保存进 AOT Cache。生产实例启动后,JIT 能更早获得这些信息,而不是重新等待调用次数达到编译阈值。(OpenJDK)
3. 与类状态相关的可缓存对象
AOT Cache 中还会包含与已加载类相关的对象状态。JDK 26 将这部分缓存与特定垃圾回收器解耦,使其能够被 G1、Parallel GC、Serial GC 和 ZGC 等不同垃圾回收器使用。(OpenJDK)
4. 当前还不等于完整的原生代码缓存
需要特别区分两个概念:
- AOT Cache:提前保存类状态、方法画像等信息;
- AOT Code Compilation:提前将 Java 方法编译成机器码并保存。
将训练运行中生成的本地机器码写入 AOT Cache,是 Project Leyden 的后续方向,但对应的 Ahead-of-Time Code Compilation JEP 目前仍处于推进阶段,不能把它当作 JDK 25 或 JDK 26 已经完整交付的生产能力。(OpenJDK)
五、普通 JVM、Leyden 和 Native Image 有什么区别
Project Leyden 经常被拿来和 GraalVM Native Image 对比,但两者选择了不同的优化路线。
| 对比项 | 普通 JVM | Leyden AOT Cache | GraalVM Native Image |
|---|---|---|---|
| 运行方式 | JVM 解释执行与 JIT | JVM、AOT Cache 与 JIT | 原生可执行文件 |
| 启动速度 | 相对较慢 | 明显改善 | 通常最快 |
| 预热过程 | 需要重新收集画像 | 可复用部分画像 | 没有 JVM JIT 预热 |
| 动态能力 | 完整 | 保持 JVM 动态能力 | 受到闭世界假设约束 |
| 反射处理 | 正常使用 | 正常使用 | 部分场景需要元数据配置 |
| 构建成本 | 最低 | 增加一次训练和缓存生成 | 原生编译时间通常更长 |
| 调试工具 | 完整 JVM 工具链 | 完整 JVM 工具链 | 调试方式与 JVM 不同 |
| 峰值吞吐 | 可通过 JIT 持续优化 | 同样保留 JIT | 不再进行运行时 JIT |
| 部署内容 | JAR 与 JVM | JAR、JVM 与 AOT Cache | 原生可执行文件 |
| 内存占用 | 取决于应用 | 当前不保证显著下降 | 通常更有优势 |
GraalVM Native Image 更适合极致冷启动和较小内存占用的场景,但需要承担原生构建、动态特性处理和调试方式变化等成本。
Leyden 的优势不是在所有指标上击败 Native Image,而是在保留 JVM 兼容性、JIT 峰值性能和现有诊断工具的同时,降低启动及预热成本。(Quarkus)
六、使用 JDK 25 或 JDK 26 生成 AOT Cache
假设应用已经构建为 app.jar,主类为 com.example.Main。
首先确认当前 JDK 是否包含相关参数:
java -version
java -XX:+PrintFlagsFinal -version \
| grep -E "AOTCache|AOTMode"
1. 准备训练模式
训练过程必须能够正常结束,否则构建流水线会一直等待。
建议在应用中提供专门的训练参数,例如:
java -jar app.jar --mode=training
训练模式可以执行以下动作:
- 初始化完整应用上下文;
- 加载数据库驱动和序列化组件;
- 访问健康检查接口;
- 执行两三个高频业务路径;
- 完成一次典型的查询和响应转换;
- 正常关闭应用并退出进程。
它不应该真正发送短信、扣减库存、创建订单或者修改生产数据。
2. 生成 AOT Cache
JDK 25 开始,可以通过 -XX:AOTCacheOutput 在一次命令中完成训练和缓存组装:
java \
-XX:AOTCacheOutput=app.aot \
-cp app.jar \
com.example.Main \
--mode=training
对于使用标准类加载方式的可执行 JAR,也可以使用:
java \
-XX:AOTCacheOutput=app.aot \
-jar app.jar \
--mode=training
应用正常退出后,当前目录应该生成 app.aot。
test -s app.aot
ls -lh app.aot
3. 使用 AOT Cache 启动应用
java \
-XX:AOTMode=on \
-XX:AOTCache=app.aot \
-jar app.jar
其中,-XX:AOTMode=on 非常重要。
它会要求 JVM 必须正确加载指定缓存。如果缓存不存在或者与当前运行环境不兼容,JVM 会直接报告错误,而不是让生产环境在未使用缓存的情况下继续运行。
4. 查看缓存加载日志
java \
-XX:AOTMode=on \
-XX:AOTCache=app.aot \
-Xlog:aot,class+path=info \
-jar app.jar
通过日志可以检查:
- AOT Cache 是否成功打开;
- 哪些类从缓存中加载;
- 是否出现 classpath 不一致;
- 缓存是否因为 JDK 或制品变化而失效。
OpenJDK 官方同样建议使用 -XX:AOTMode=on 和 -Xlog:aot,class+path=info 验证缓存,而不是仅凭 app.aot 文件存在就认为优化已经生效。(Inside.java)
七、不要随便跑一次应用就叫“训练”
AOT Cache 的质量主要取决于训练运行与生产运行的相似度。
错误一:应用刚启动就退出
如果训练模式只完成了主类加载,没有执行真实业务路径,那么缓存只能覆盖少量启动类。
正确方式是让应用完成就绪,并执行一组最小但具有代表性的冒烟路径。
错误二:直接运行完整测试套件
完整测试套件可能加载大量生产环境永远不会使用的测试框架、Mock 类、测试容器和断言库。
这样不仅会增大缓存,还可能让方法画像偏离真实业务。
更合理的训练集合通常包括:
| 训练目标 | 推荐覆盖内容 |
|---|---|
| 应用初始化 | 配置解析、依赖注入、路由注册 |
| 基础组件 | JSON、日志、数据库驱动、连接池 |
| 高频接口 | 健康检查、查询接口、核心写入接口 |
| 常用分支 | 正常返回、参数校验、典型异常 |
| 序列化 | 常用请求对象和响应对象 |
| 安全链路 | 鉴权、权限判断、令牌解析 |
错误三:训练环境和生产环境配置完全不同
如果训练环境关闭了生产环境正在使用的模块,那么相关类不会进入缓存。
例如:
- 训练环境使用 H2,生产使用 MySQL;
- 训练环境关闭鉴权,生产开启鉴权;
- 训练环境不加载消息队列,生产使用 Kafka;
- 训练环境关闭某个功能开关;
- 训练环境和生产环境采用不同的启动 Profile。
可以替换外部地址和敏感数据,但应尽量保持类路径、功能开关、框架模块和初始化流程一致。
官方要求训练与生产使用相同的 JDK 版本、操作系统和硬件架构,并建议生产 classpath 至少包含训练阶段的全部内容。应用重新构建、依赖更新或 JDK 升级后,也需要重新生成缓存。(Inside.java)
八、在 Docker 构建阶段生成缓存
AOT Cache 应当和应用制品一起构建,而不是在每个生产容器启动后临时生成。
下面是一份简化的多阶段 Dockerfile:
ARG JAVA_IMAGE=eclipse-temurin:26-jdk
FROM ${JAVA_IMAGE} AS trainer
WORKDIR /opt/app
COPY target/app.jar app.jar
RUN java \
-XX:AOTCacheOutput=/opt/app/app.aot \
-jar /opt/app/app.jar \
--mode=training \
&& test -s /opt/app/app.aot
FROM ${JAVA_IMAGE} AS runtime
WORKDIR /opt/app
COPY --from=trainer /opt/app/app.jar /opt/app/app.jar
COPY --from=trainer /opt/app/app.aot /opt/app/app.aot
ENTRYPOINT [
"java",
"-XX:AOTMode=on",
"-XX:AOTCache=/opt/app/app.aot",
"-Xlog:aot=info",
"-jar",
"/opt/app/app.jar"
]
这里有三个关键点。
第一,训练和运行阶段应该使用相同的 JDK 发行版、版本、操作系统和 CPU 架构。更严格的做法是将基础镜像固定到具体摘要,而不是只使用可能发生变化的浮动标签。
第二,app.jar 和 app.aot 必须来自同一次构建。不能更新 JAR 后继续复制上一版本缓存。
第三,训练模式必须正常退出。不能简单启动一个永不结束的 Web 服务,也不要使用 kill -9 强行终止训练。
推荐的流水线为:
- 编译并生成不可变 JAR;
- 使用该 JAR执行训练模式;
- 生成 AOT Cache;
- 检查缓存文件;
- 使用同一份 JAR 和缓存构建镜像;
- 启动容器并强制验证缓存;
- 执行灰度启动性能测试;
- 性能和功能均通过后再扩大流量。
九、Spring Boot Fat JAR 和自定义类加载器要特别小心
AOT Cache 对 classpath 有明确要求。
官方建议 classpath 使用明确的 JAR 列表,不使用目录、通配符或者嵌套 JAR。生产 classpath 还需要与训练阶段保持兼容。(Inside.java)
普通 Spring Boot Fat JAR 会把依赖放进 BOOT-INF/lib,并通过自定义类加载器读取嵌套 JAR。这种布局不能简单地等同于标准 JVM classpath。
Spring Boot 已经提供更适合 CDS 和 Leyden 的提取式应用布局。实际接入时,应优先使用框架提供的提取工具,把应用类和依赖 JAR 展开,再使用标准类加载器启动。Spring 官方测试也表明,提取式布局比直接运行嵌套可执行 JAR 更适合 CDS 和 Project Leyden。(Home)
Quarkus 也遇到了类似问题。由于默认 fast-jar 使用自定义类加载器,Quarkus 3.32 专门增加了 aot-jar 打包方式,以便更充分地使用 Leyden 缓存。(Quarkus)
因此,看到缓存文件生成成功并不代表所有业务类都进入了缓存。
接入框架应用时,必须检查真实的类加载日志。
十、一个容易被忽略的问题:生成缓存可能需要双倍堆内存
JDK 25 的 -XX:AOTCacheOutput 看起来只执行了一条命令,但 JVM 内部仍然要完成训练和组装两个阶段。
缓存组装的子进程会使用自己的堆。如果命令设置了:
-Xms2g -Xmx2g
整个生成过程可能需要接近两个 2GB 堆的可用空间。
在资源受限的 CI Runner 或 Docker Builder 中,这可能直接导致缓存生成被 OOM Kill。OpenJDK 官方因此建议,在内存受限时拆分为记录、组装和运行三个阶段。(Inside.java)
# 第一阶段:执行训练并记录配置
java \
-XX:AOTMode=record \
-XX:AOTConfiguration=app.aotconf \
-cp app.jar \
com.example.Main \
--mode=training
# 第二阶段:根据配置生成缓存
java \
-XX:AOTMode=create \
-XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot \
-cp app.jar
# 第三阶段:使用缓存启动生产应用
java \
-XX:AOTMode=on \
-XX:AOTCache=app.aot \
-cp app.jar \
com.example.Main
拆分后,可以在接近生产规格的环境中完成训练,再在 CPU 和内存更充足的构建节点中完成缓存组装。
十一、不要把 AOT Cache 当成永久制品
AOT Cache 与普通静态资源不同,它和多个条件绑定:
- 应用 JAR 内容;
- 依赖版本;
- JAR 时间戳;
- classpath 顺序和结构;
- JDK 发行版本;
- 操作系统;
- CPU 架构;
- 部分 JVM 参数;
- 训练时加载的功能模块。
下面这些操作都应该触发缓存重新生成:
修改业务代码
升级第三方依赖
调整打包方式
升级 JDK
更换基础镜像
变更应用模块
调整关键功能开关
改变 classpath
因此,不要在对象存储中长期维护一个名为 latest.aot 的公共缓存,然后让多个应用版本共同使用。
更合理的命名方式是将缓存绑定到构建版本:
app-2.4.1-8f3c19d.aot
或者直接把缓存放在应用镜像内部,使镜像成为唯一部署单元。
十二、AOT Cache 不等于内存一定下降
Project Leyden 的长期目标包含降低运行时占用,但当前已经落地的主要收益仍然集中在:
- 减少类加载和链接工作;
- 缩短应用启动时间;
- 缩短 JIT 预热过程;
- 降低启动阶段 CPU 消耗。
它并不保证每个应用的稳态 RSS 都显著下降。
Quarkus 在介绍 Leyden 集成时也明确指出,当前 AOT Cache 的内存占用通常仍与普通 JVM 接近,同时还需要额外携带缓存文件。(Quarkus)
如果核心目标是把每个实例的内存压缩到最低,Native Image 仍然更值得评估。
如果核心目标是在保持 JVM 兼容性和峰值吞吐的同时缩短冷启动,那么 Leyden 更符合需求。
十三、应该怎样测试真实收益
不要只运行一次命令,然后拿两次启动日志做对比。
进程启动会受到多种因素影响:
- Linux Page Cache;
- 容器镜像层是否已经缓存;
- CPU 频率变化;
- Kubernetes CPU Limit;
- 磁盘和文件系统;
- 数据库连接速度;
- DNS 和服务发现;
- JIT 编译线程竞争;
- 同一节点上的其他工作负载。
OpenJDK 在 JDK 24 的示例测试中,HelloStream 从 31 毫秒下降到18 毫秒,PetClinic 从 4.486 秒下降到 2.604 秒,两个测试的启动时间均改善约 42%。这些数据说明了优化潜力,但不能直接当作所有业务系统的收益承诺。(Inside.java)
生产评估至少应该关注以下指标:
| 指标 | 说明 |
|---|---|
| 进程启动到端口监听 | JVM 和框架基础启动时间 |
| 进程启动到 Readiness 成功 | 容器真正可以接收流量的时间 |
| 第一条业务请求耗时 | 检查首次执行成本 |
| 前 30 秒 P95、P99 | 检查预热阶段抖动 |
| 达到稳定吞吐所需时间 | 判断方法画像缓存收益 |
| 启动阶段 CPU 时间 | 判断是否降低扩容成本 |
| 稳态 RSS | 检查内存是否发生变化 |
| AOT Cache 文件大小 | 评估镜像体积增量 |
建议至少设置四个对照组:
- 原有 JDK 和原有启动方式;
- 升级后的 JDK,但不使用 AOT Cache;
- 升级后的 JDK,并使用 AOT Cache;
- GraalVM Native Image,前提是项目具备构建条件。
这样才能区分“升级 JDK 本身带来的性能提升”和“Leyden 缓存带来的额外提升”。
每组至少重复启动 20 次,分别统计中位数和 P95,而不是只选择最好的一次。
十四、哪些应用值得优先接入
非常适合
- Kubernetes 中频繁扩缩容的微服务;
- 每天多次发布、滚动重启的服务;
- 启动后前几十秒延迟明显偏高的应用;
- Java 命令行工具;
- 短生命周期批处理任务;
- 测试平台和临时预览环境;
- 希望保留完整 JVM 调试能力的系统。
可以评估
- 重启频率较低,但启动过程非常复杂的单体系统;
- 使用大量反射和动态代理,又不方便迁移 Native Image 的项目;
- 需要 JVM 峰值吞吐,同时关注扩容速度的服务;
- 使用 ZGC,并准备升级到 JDK 26 或后续版本的服务。
优先级较低
- 一次启动后连续运行数月的后台系统;
- 启动时间已经低于业务可感知范围的应用;
- 主要瓶颈来自数据库初始化或远程配置中心;
- 大量使用自定义类加载器和运行时插件的系统;
- 没有稳定 CI/CD 流程,无法保证缓存与 JAR 一一对应的项目。
可能更适合 Native Image
- 要求毫秒级甚至更低的冷启动;
- 对实例内存和镜像体积极其敏感;
- 业务动态特性较少;
- 已经具备完整原生构建和测试能力;
- 可以接受更长的构建时间与额外兼容性验证。
十五、结语
过去优化 Java 冷启动,团队通常只有几种选择:
要么接受普通 JVM 的启动和预热成本,要么引入 Native Image,承担原生构建和动态能力约束。
Project Leyden 在两者之间提供了新的路径。
它保留 JVM、JIT、垃圾回收器和现有诊断工具,只把可以重复利用的启动工作提前完成。对于大量已有 Java 系统来说,这种方式的迁移成本通常低于彻底切换 Native Image,也更符合渐进式性能优化的思路。
但 Leyden 不是加上两个 JVM 参数就能自动获得收益。
真正决定效果的是:
- 训练路径是否接近真实生产;
- 缓存和应用制品是否严格绑定;
- classpath 和类加载器是否兼容;
- 构建环境与运行环境是否一致;
- 是否通过日志确认缓存真正加载;
- 是否测量完整的启动和预热过程。
AOT Cache 最有价值的地方,并不是让 Java 彻底变成静态程序。
而是让每一个新启动的 JVM,都不必再把相同的路从头走一遍。

评论区 0