Java 的云原生困局与破局

6 阅读13分钟

Java 的云原生困局与破局

前言:时间尺度变了

假设一个典型场景:一个 Spring Boot 应用需要迁移到 Serverless 平台,测试后发现冷启动要十几秒。

对于传统部署来说,这不算什么问题。一台 Tomcat 稳定跑几周甚至几个月,启动花个二三十秒?完全可以接受。JIT 有足够时间观察热点代码,做内联、去虚拟化、逃逸分析,最后把性能推到一个很高的水平。只要服务跑得够久,启动成本最终会被摊平。

但云原生把这个前提打碎了。

一个 Pod 可能刚跑热就被回收了;一个函数实例甚至还没完成 JIT 编译就结束使命了。容器生命周期常常以分钟计,Serverless 函数可能只跑几秒。弹性伸缩要求实例随起随用,资源按秒计费,冷启动的每一秒都直接反映在账单和用户体验上。

时间尺度从“天”和“月”压缩到了“秒”。JVM 最擅长的“用时间换性能”,反而成了负担。

Java 的云原生困局:三个要命的问题

把问题拆开看,JVM 在云原生场景下主要卡在三个地方。

启动慢

这不只是“数字难看”。

考虑一个电商系统在大促时的场景:流量突增,Kubernetes 触发扩容。但新 Pod 迟迟起不来——不是资源不够,而是 JVM 启动太慢。等实例终于启动完成,第一波流量高峰已经过去,该超时的请求都已经超时了。

在 Serverless 场景更明显。一个典型的 Spring Boot 应用,启动时间在 3-8 秒之间。Go、Node.js 写的函数毫秒级启动,Java 函数要等好几秒,这个差距对用户体验的影响太直接了。

启动慢的根源在类加载、框架初始化、反射扫描这些环节。一个中等规模的 Spring Boot 应用,启动时要加载数千个类,扫描几百个 Bean,还要处理各种注解和代理。这些工作在传统部署时可以“一次启动,长期使用”,但在容器里就成了反复交税。

内存占用高

一个基础的 Spring Boot 应用,启动后内存占用通常在 150MB 左右。

这不是应用写得烂,这是 JVM 的基线成本。即便是极简的 Spring Boot Web 应用,JVM 的非堆内存(Metaspace、线程栈、Code Cache 等)就要占用 40-50MB,再加上堆内存和 Spring 容器里的常驻对象,很容易就突破 100MB。

在传统部署时,一台服务器跑一个应用,这点内存不算什么。但在云上做高密度部署时,问题就来了。假设计划在一个 1GB 内存的容器里跑多个实例,如果每个实例基线 150MB,能跑的数量就很有限了。

对比一下 Go 或 Rust 写的服务,启动后可能只占 20-30MB。这个差距在小规模部署时还不明显,一旦实例数上来,成本差异就很可观了。

预热慢

这是最容易被忽视的问题。

新 Pod 启动完成了,healthcheck 也通过了,流量切过来了——然后性能可能只有峰值的一半甚至更低。因为 JIT 还没开始工作,热点代码还没被优化。很多系统不得不在流量切换前额外等几分钟让实例“跑热”,这和云原生“随时可用”的目标完全相反。

典型的表现是:服务扩容后,新实例前几分钟的延迟明显高于老实例。监控图上能清楚看到一条“爬坡曲线”——这就是 JIT 逐步优化的过程。在稳定流量下这不是问题,但在需要快速响应突发流量的场景下,这几分钟就是死穴。

破局:没有银弹,只有分裂

面对这些问题,Java 社区这些年尝试了好几条路。但必须承认:不存在一个完美方案。

我们看到的不是一次“大一统”升级,而是几条方向完全不同的路线在并行发展。

舒适区的修补:尽量不改架构

这条路线的思路是:JVM 的动态特性别动,想办法压缩启动阶段的固定成本。

AppCDS:共享类元数据

AppCDS 做的事情很直白:把类元数据提前处理好,存成归档文件,启动时直接内存映射进来,多个 JVM 实例还能共享这块内存。

效果怎么样?根据多个实际测试,AppCDS 可以看到 30% 以上的启动优化,个别场景甚至接近 40% 甚至更高。比如一个 Spring Boot 应用从 2.9 秒降到 1.6 秒,或者从 8 秒降到 4-5 秒左右。对很多老项目来说,几乎不需要改代码,只是多加配置就行。

但也就到这儿了。AppCDS 能减轻类加载的负担,但框架初始化、Bean 扫描这些大头它碰不了。内存占用也降得有限。

适合什么场景?传统应用的增量优化。不想大改,又希望启动快一点,AppCDS 是个性价比不错的选择。但如果目标是“毫秒级启动”,那它帮不上忙。

CRaC:冻结和恢复运行时

CRaC 的思路更激进一点:既然预热慢,那就在 JVM 已经“跑热”的状态下做个快照,需要新实例时直接恢复。

这样做的好处很诱人。Azul 的测试显示,一个 Spring Boot 应用的启动时间可以从 3.9 秒降到 38 毫秒。实际应用中,通常能看到 10 倍左右的性能提升,也就是 90% 的启动时间缩减。而且 JIT 的优化成果也保留了,从快照恢复的实例性能直接就是峰值水平,没有爬坡期。

听起来很美,但坑不少。

运行时状态远比想象中复杂。文件句柄怎么办?网络连接怎么办?时间戳怎么办?线程状态怎么办?应用需要配合——必须在代码里明确告诉 CRaC 哪些资源需要在冻结前关闭,恢复后重建。

这导致 CRaC 更适合负载模式稳定、可提前预热的服务。比如一个后台任务处理服务,业务逻辑固定,可以提前跑热一次,然后反复用快照启动新实例。但如果服务每次启动时外部依赖、配置都不一样,CRaC 就很难用了。

另外,CRaC 目前还不是标准 JDK 的一部分,需要用 Azul Zulu 或特定的 OpenJDK 构建。生态成熟度还在路上(考虑到它强依赖 Linux 内核且对业务侵入极强,目测后续会被 Project Leyden 取代)。

非舒适区的妥协:牺牲动态性

另一条路线更激进:既然动态性带来了成本,那就干脆放弃一部分。

GraalVM Native Image:提前编译成原生可执行文件

Native Image 是这条路线的代表。

它做的事情是在构建阶段把应用提前编译成原生可执行文件,启动时不需要类加载、不需要 JIT,直接跑。结果是启动时间大幅降低。内存占用也大幅下降,镜像大小降低一半左右。

这对 Serverless 和高密度部署来说很有吸引力。在实际案例中,使用 Native Image 后,冷启动可以从十几秒降到 100 毫秒以内,成本可以节省一半。

代价呢?不小。

反射、动态类加载、运行期代理这些传统 Java 特性都要被严格约束甚至放弃。需要在配置文件里明确列出所有反射用到的类,遗漏一个就会运行时报错。很多依赖反射的老框架根本没法用。Spring Boot 为了支持 Native Image,专门做了一套适配,但即便如此,还是有不少限制。

另外,在没有 PGO 的情况 AOT 编译的代码在某些高吞吐场景下,峰值性能可能不如 JIT(但是开了 PGO 可能会带来更多问题,因为 PGO 不可回退)。JIT 可以根据真实负载做针对性优化,AOT 做不到这一点。不过对大部分微服务来说,这个差距不明显——因为它们本来就跑不到那个量级。

更麻烦的是调试和可观测性的问题。Native Image 不再使用 HotSpot,传统的 Java 调试工具和 APM Agent 大多失效了。JDWP 调试协议不能用,HotSpot JFR(Java Flight Recorder)不能用,很多 APM 产品依赖的 Java Agent 机制也不支持。想要排查线上问题,可能需要使用 GDB 这样的原生调试器,或者自研可观测组件来适配 Native Image 的运行时。

还有一个隐蔽的坑:开发环境和生产环境的行为不一致。因为 Native Image 编译太慢(可能要几分钟),开发时大概率还是用 JVM 跑,只有构建生产镜像时才编译成 Native Image。但 JVM 和 Native Image 的行为并不完全一致——某些在 JVM 上跑得好好的代码,编译成 Native Image 后可能莫名其妙地出问题。典型的情况是反射配置遗漏、类初始化时机不同、或者某个依赖库在 Native Image 下有兼容性问题。这种问题往往要到 CI/CD 阶段甚至上线后才会暴露,排查起来非常痛苦。

争论一直都有。支持者说:大部分微服务根本跑不到需要 JIT 优化的程度,启动快才是王道,调试和可观测性的问题可以通过工具链完善来解决。反对者说:为了几秒启动时间放弃反射和动态代理不说,还要承担开发生产环境不一致、排查问题困难、工具链不成熟的风险,等于把 Java 生态的优势都扔了。两边都有道理,所以这个讨论到现在也没停过。

Project Leyden:在中间找平衡

OpenJDK 社区提出的 Project Leyden,试图在“极致动态”与“极致静态”之间切出一条中间路:在不彻底放弃 Java 动态特性的前提下,将原本属于运行时的决策,有选择地前移到构建期。

这种思路在设计上被称为 “冷凝(Condensation)”。它不是要将 Java 变成像 Go 那样的静态二进制文件,而是让 HotSpot 具备“状态持久化”的能力:

  • 元数据加速:通过增强的 CDS(类数据共享)技术,让 JVM 在启动时直接读取已经解析好的类元数据,跳过繁琐的扫描与验证过程。

  • JIT 结果持久化:这是最令人兴奋的突破。它允许 JVM 将之前的热点编译结果保存下来。新实例启动后,可以从一个“接近稳态”的位置起跑。最关键的是,它保留了 Java 的动态灵魂——如果运行时的环境发生变化,它依然可以触发“去优化”并重新编译,这是 Native Image 做不到的。

Leyden 的目标不是颠覆 HotSpot,而是通过“平滑退化”来缓解启动和预热的矛盾。它承认动态性是 Java 的灵魂,但主张在云原生场景下,用户应该有权决定“要多少动态性”来换取“多少启动速度”。

目前,Leyden 的部分成果(如增强型 CDS 和预编译缓存)已随 Java 24/25 初步落地,但这仍然是一个长期工程。由于它涉及对 Java 规范层面的精细调整,目前开发者仍需在“性能增量”与“配置复杂度”之间寻找平衡。它不是一粒能让 Java 瞬间变 Go 的药丸,而是一套让 Java 优雅瘦身的工具集。

Project Leyden、AppCDS 与 CRaC 的关系

在演进思路上,Project Leyden 与 AppCDS、CRaC 是一脉相承的。具体而言:

  • Leyden 是 AppCDS 的超级进化版:它在 AppCDS 缓存类元数据的地基之上,进一步实现了 AOT 编译缓存(截至 Java 25),解决了 AppCDS 无法覆盖 JIT 预热开销的痛点。

  • Leyden 是 CRaC 的工程化平替:两者的核心思想都是“缓存耗时决策”。但 CRaC 采取的是“粗暴冻结”模式——直接保存完整的内存镜像,虽快但带来了沉重的状态管理负担和安全隐患;而 Leyden 则是“斯文地提取”——选择性地保存类解析和编译优化后的结果。

此外三者隐含的缺点是镜像体积与分发效率。三者都是添加缓存来提高启动性能,因此会不可避免地增大镜像体积。

AppCDS/Leyden增量可控(前者通常增加10%左右,后者20-30%),且能较好地兼容镜像分层缓存机制;

CRaC 则基本无法享受分片缓存红利。由于其缓存的是包含了业务数据的内存快照,不仅体积巨大(和最大堆大小相关,通常以 GB 计),且每次构建生成的字节流均不相同。在实际生产中,这会导致镜像仓库存储成本激增,并可能导致拉取镜像的时间成本抵消掉其在启动速度上的收益,需要使用其它的基础设施(可能会重的难以接受)来解决分发问题。

没有统一解的未来

动态性和启动速度之间有天然张力。再加上不同业务对吞吐、成本、兼容性的要求差异巨大,想要一个统一方案基本是不可能的。

更现实的未来是:多条路线长期并存。

传统企业系统会继续用标准 JVM,通过 AppCDS、Leyden 这些技术逐步优化。它们不追求毫秒级启动,只要启动时间控制在可接受范围内就行。稳定性、成熟度、生态完整性才是第一位的。

面向云原生、短生命周期的服务,会越来越多地选择 Native Image。启动快、内存少、部署密度高,这些优势在成本敏感的场景下太关键了。虽然有动态性的牺牲,但对新写的微服务来说,这个代价完全可以接受。

对性能和稳定性都有高要求、负载又相对可预测的服务,可能会探索 CRaC 这样的方案。它能同时保证启动速度和峰值性能,但需要更多的工程投入来处理状态管理。

框架和工具链也不得不跟着分裂。Spring 既要支持传统 JVM,又要适配 Native Image,还要考虑 CRaC 场景。Quarkus 直接把 Native Image 作为主打方向。Micronaut 从一开始就设计成对 AOT 友好。

复杂度在上升,但用户的选择空间也在扩大。

写在最后

Java 不是适应不了云原生,只是再也不能用一套方案打天下了。

"Write Once, Run Anywhere" 正在变成 "Write Once, Compile Anywhere":同一份代码,根据部署目标编译成不同形态。传统服务器用 JVM,Serverless 用 Native Image,高性能场景用 CRaC,各取所需。

这不是退步,而是务实的进化。当时间尺度变了,JVM 没有固守旧假设,而是在分裂中找新的平衡点。

这个局面会长期存在。不是因为 Java 做得不好,而是因为云原生本身就不是一个场景,而是一堆需求完全不同的场景。

没有银弹,只有选择。