冷启动 4.2 秒,函数计算按毫秒计费
我们有一部分服务跑在函数计算上(内部 FaaS 平台),按实际运行时间计费。其中一个是文档解析入口,Spring Boot 3.5 应用,冷启动实测 4.2 秒——也就是说每次冷启动,用户先付 4.2 秒的钱,其中真正干活的只有不到 1 秒。
去年我们试过 GraalVM native image,启动确实降到 80 毫秒,但代价是构建时间从 90 秒变成 11 分钟、反射配置写了 400 多行、线上出问题没法用常规工具诊断。做了两个月放弃了。今年 JDK 24 带着 Project Leyden 的第一个成果(JEP 483)发布,我又试了一次。
先测一下基线
测试应用是一个标准的 Spring Boot 3.5 Web 服务,JDK 24(Temurin 24.0.1+9),包含 Spring MVC、MyBatis、Redis 客户端、Jackson。测量方式是从 java 进程启动到 /actuator/health 首次返回 200 的时间,取 20 次平均值。
# 基线
$ time java -jar parser-service.jar
启动完成,耗时 4213ms(20 次平均,标准差 187ms)
我先用 async-profiler 看了一下这 4.2 秒里到底在干什么:
| 阶段 | 耗时 | 占比 |
|---|---|---|
| JVM 启动 + 类加载 | 1180ms | 28% |
| 类链接与验证 | 640ms | 15% |
| Spring 容器初始化(Bean 扫描、实例化) | 1980ms | 47% |
| JIT 预热与业务初始化 | 413ms | 10% |
类加载 + 链接占了 43%,这部分是 JVM 每次启动都要重复做的固定工作。Leyden 的思路就是从这里下手。
Leyden 到底是什么
先澄清一个常见误解:Leyden 不是 AOT 编译,不会生成机器码。它的思路是"提前把运行时本来要做的一部分工作做掉,缓存起来,下次直接用"。
JVM 启动时必须做的几件事——找到类、解析字节码、验证、链接、创建初始的 Class 对象、初始化静态字段——这些工作对同一个应用来说每次启动的结果都一样。既然如此,就可以在第一次运行时记录下来,之后直接加载这份快照。
这个思路其实不新,CDS(Class Data Sharing)从 JDK 5 就有了,AppCDS 从 JDK 10 开始支持应用类。Leyden 是把这个能力大幅扩展,让它能覆盖动态代理、Lambda、反射生成的类等等这些 Spring 重度依赖的东西。
JEP 483:AOT 缓存
这是 Leyden 第一个进入主线 JDK 的成果,随 JDK 24 正式发布。用法是三步:录制、创建缓存、使用缓存。
# 第一步:录制。跑一次应用,记录加载了哪些类、做了哪些链接
$ java -XX:AOTMode=record \
-XX:AOTConfiguration=parser.aotconf \
-jar parser-service.jar --server.port=0
# 应用正常启动,跑一遍主要接口,然后停掉
# 第二步:根据录制结果创建 AOT 缓存
$ java -XX:AOTMode=create \
-XX:AOTConfiguration=parser.aotconf \
-XX:AOTCache=parser.aot
# 输出:Creating AOTCache: parser.aot
# Classes loaded: 18,432
# AOT cache size: 84.2 MB
# 第三步:使用缓存启动
$ java -XX:AOTCache=parser.aot -jar parser-service.jar
实测结果:
| 配置 | 启动耗时 | 相对基线 |
|---|---|---|
| 基线(无缓存) | 4213ms | — |
| AppCDS(JDK 21 时代的做法) | 3240ms | -23% |
| JDK 24 AOT 缓存 | 2604ms | -38% |
38% 的提升,比 AppCDS 好不少,但离 native image 的 80ms 差了两个数量级。原因很清楚:AOT 缓存只解决了类加载和链接,Spring 容器的初始化(那 1980ms)一点没省。Bean 的扫描、实例化、依赖注入这些是运行时逻辑,不是能快照的东西。
这里要说清楚 Leyden 的边界:它优化的是JVM 层的固定开销,不是框架层的初始化。如果你 4 秒启动里有 3 秒是 Spring 在做 Bean 扫描,Leyden 帮不了多少。
录制这一步很关键
AOT 缓存的效果完全取决于录制时跑了什么。我们第一次录制只是启动应用然后立刻停掉,结果缓存只覆盖了启动路径的类,等真正处理请求时又要加载新的类(Jackson 的序列化器、MyBatis 的 Mapper 代理),提升只有 21%。
后来我们改成录制时跑一遍核心接口的冒烟测试,把所有业务路径都走一遍,提升才到了 38%。这说明一个实践要点:录制脚本要覆盖真实的业务路径,而不只是启动。
# 我们的录制流程(CI 里跑)
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar &
APP_PID=$!
# 等待就绪
until curl -sf http://localhost:8080/actuator/health; do sleep 0.5; done
# 跑冒烟测试,覆盖主要业务路径
newman run smoke-test.json --insecure
kill $APP_PID
wait $APP_PID
使用上的限制
几个必须知道的约束:
- classpath 必须一致。缓存文件里记录了类的位置,如果你的 jar 重新打包后目录结构变了,缓存会失效(JVM 会打警告然后退回到普通模式,不会报错,这点还算友好);
- JVM 参数要匹配。录制品和运行时用的 GC、堆大小、JVM 版本不同会导致缓存不可用。我们的做法是在 Docker 镜像里把 AOT 缓存和 JVM 参数一起固化;
- 缓存文件不小。我们的应用缓存 84MB,直接放进镜像里镜像大了 84MB。这个要权衡——如果启动频率低,可能不划算;
- 目前只解决了类加载层,上面说过了。
Leyden EA 构建里能做到什么程度
主线 JDK 24 的 AOT 缓存只是第一步。Leyden 还有个 early-access 分支(leyden 仓库),里面有一批更激进的实验特性,官方叫 condensers(冷凝器)。我拿 EA 构建试了一下,包括:
- 常量折叠:把
static final字段的初始化结果在构建期算出来,运行时直接读; - 推测性优化:基于录制时的执行profile,提前把一些方法编译成本地代码存进缓存;
- 更激进的类初始化:把录制时能安全提前执行的静态初始化块直接执行掉,结果存进缓存。
# Leyden EA 构建的用法(注意:实验特性,API 会变)
$ java -XX:CacheDataStore=app.cds \
-XX:+PreloadSharedClasses \
... 更多实验开关 ...
实测同一应用,EA 构建下的启动耗时降到了 1470ms,相对基线提升 65%。这个数字开始接近实用了,但 EA 构建的稳定性我们没敢在生产验证——跑了一周,遇到过两次莫名其妙的 ClassCastException,怀疑是推测性优化的假设在某些路径下不成立。
EA 构建目前只适合研究,别上生产。官方也明确说了这些特性还在探索阶段,API 随时会变。
和 GraalVM native image 的关系
这是被问得最多的问题。两者都在优化启动性能,但思路完全不同。
| 维度 | GraalVM native image | Project Leyden |
|---|---|---|
| 基本原理 | 封闭世界假设,静态编译成机器码 | 开放世界,缓存运行时的工作结果 |
| 启动时间 | 80ms(我们实测) | 2604ms(JDK 24) |
| 峰值吞吐 | 比 JVM 低 15%~30% | 等同 JVM(JIT 照常工作) |
| 内存占用 | 低(我们的服务 120MB vs 480MB) | 基本相同 |
| 动态特性 | 反射/动态代理需配置,容易漏 | 完全支持,不需要任何配置 |
| 构建时间 | 11 分钟 | 90 秒 + 30 秒(录制) |
| 诊断工具 | 受限,JFR/Arthas 基本不能用 | 全部可用 |
| 迁移成本 | 高,依赖库必须兼容 | 低,加 JVM 参数即可 |
核心区别在于封闭世界假设。GraalVM 要求在构建期就知道所有可能被用到的类,所以能极致优化,但代价是反射、动态代理、运行时字节码生成这些都要显式配置。Leyden 不做这个假设,保留 JVM 的全部动态能力,所以上限低但兼容性好。
官方的定位是两者互补,不是替代。我认同这个说法——它们解决的是不同的问题:
- 如果你要极致启动速度、内存受限、依赖简单可控(比如 CLI 工具、短生命周期函数),用 GraalVM;
- 如果你要启动快一点但不想放弃任何 JVM 能力(比如现有的 Spring 服务),用 Leyden。
我们最后的选择是:文档解析那个函数用 GraalVM(它的依赖简单,只有 PDF 解析库和对象存储 SDK),其余三个服务用 Leyden 的 AOT 缓存。
"一次编写多次运行"的重新解读
Java 的口号是 WORA(Write Once Run Anywhere)。Leyden 提出了一个新说法:WORA 应该重新解读为 "Write Once, Run Anywhere" 到 "Write Once, Run Anywhere, Optimize Where It Matters"。
这个思路的实质是:可移植性和启动性能不必二选一。传统观念里,要启动快就得牺牲可移植性(静态编译成特定平台的机器码)。Leyden 的做法是保留字节码的可移植性,把优化做在"运行时工作结果的缓存"这个层面——AOT 缓存文件本身是平台相关的,但它是运行时产物,不是构建产物。
这意味着同一份 jar 可以在任何平台跑,想优化就在目标平台上录制一次生成缓存。这个设计我觉得很聪明,它把"平台相关"的部分推迟到了部署阶段而不是构建阶段,对 CI/CD 流程的侵入很小。
我们的流水线改动就只有:构建完 jar 之后,在目标环境跑一次录制,把生成的 .aot 文件和 jar 一起打包进镜像。
我们的实际收益
| 服务 | 原启动 | 现启动 | 方案 |
|---|---|---|---|
| 文档解析入口 | 4213ms | 78ms | GraalVM native image |
| 风控规则服务 | 3840ms | 2380ms | JDK 24 AOT 缓存 |
| 数据同步服务 | 2960ms | 1900ms | JDK 24 AOT 缓存 |
| 内部管理后台 | 5100ms | 3260ms | JDK 24 AOT 缓存 |
函数计算那块的成本降了 62%(冷启动时间直接计入计费时长)。常驻服务的收益主要是扩容时的响应更快——K8s HPA 扩容时新 Pod 从 4.2 秒就绪变成 2.6 秒,大促期间扩容能快不少。
一些期待和建议
Leyden 目前的进度只完成了路线图的一小部分,后面还有几个方向值得关注(都在实验阶段,没有明确的发布版本):
- 方法级 AOT 编译:把录制时用到的热点方法提前编译存进缓存,省掉 JIT 预热时间。这个对启动后的前几秒性能提升会很明显;
- 更完整的静态初始化提前执行:这块能省掉不少框架初始化的开销,但要解决副作用问题(静态初始化块里可能有 IO);
- 与 Spring AOT 的配合:Spring Framework 6 的 AOT 引擎能在构建期生成 Bean 定义代码,跳过运行时的 Bean 扫描。如果两者结合,那 1980ms 的容器初始化应该能砍掉一大半。我特别期待这个组合。
给想尝试的人几点建议:
- 先升级到 JDK 24,JEP 483 是主线特性,不需要特殊构建;
- 录制脚本要覆盖业务路径,只录启动效果很有限;
- 把录制放进 CI,否则每次改依赖都要手工重录,很快就荒废了;
- 镜像大小要评估,84MB 的缓存文件对某些场景不是小数目;
- 别在生产用 Leyden EA 构建,等特性进主线再说。
先到这
《Project Leyden 与 Java 启动性能的未来》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。