🤖
AI审核中

别急着上Native Image:Project Leyden 如何用AOT Cache 加速Java冷启动

Java 20分钟 107浏览 0评论

一、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 启动一个应用时,需要重复完成以下工作:

  1. 从 JAR 中读取 class 文件;
  2. 解析 class 文件结构;
  3. 加载类并创建运行时表示;
  4. 验证、准备和解析类之间的引用;
  5. 执行类链接;
  6. 观察方法调用情况;
  7. 收集分支概率和类型信息;
  8. 判断哪些方法值得 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.jarapp.aot 必须来自同一次构建。不能更新 JAR 后继续复制上一版本缓存。

第三,训练模式必须正常退出。不能简单启动一个永不结束的 Web 服务,也不要使用 kill -9 强行终止训练。

推荐的流水线为:

  1. 编译并生成不可变 JAR;
  2. 使用该 JAR执行训练模式;
  3. 生成 AOT Cache;
  4. 检查缓存文件;
  5. 使用同一份 JAR 和缓存构建镜像;
  6. 启动容器并强制验证缓存;
  7. 执行灰度启动性能测试;
  8. 性能和功能均通过后再扩大流量。

九、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 文件大小 评估镜像体积增量

建议至少设置四个对照组:

  1. 原有 JDK 和原有启动方式;
  2. 升级后的 JDK,但不使用 AOT Cache;
  3. 升级后的 JDK,并使用 AOT Cache;
  4. 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 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论