最近我们团队把核心交易链路从 JDK 21 一路升级到了 JDK 26,前后折腾了大半个月。整个过程最花时间的不是写代码,而是性能摸底、兼容性排查和灰度验证这三件事。这篇文章就把这次 JDK 21 升级 26 实战里最值钱的部分拆出来讲:升级理由怎么量化、性能对比怎么做才靠谱、兼容问题到底集中在哪几类,以及一套可以直接拿去用的灰度回滚方案。
如果你正在纠结要不要升级、或者已经被升级过程中的各种诡异报错搞得焦头烂额,这篇应该能帮你省下不少弯路。哪怕你手头项目还是 JDK 8 或者 JDK 11 的老古董,里面排查依赖、扫描废弃 API、灰度发布的方法也完全通用。
1. 为什么值得升级:先算账再动手
1.1 升级动机不是“新版更好”,而是具体收益
很多人一提升级 JDK,第一反应是“团队想尝鲜”或者“技术领导追新”。其实从工程角度看,这种动机撑不起一次升级带来的风险评估成本。我们这次决定升级,是先在内部列了一张收益账本,逐条对过业务场景后才拍的板。
账本上主要集中在几个方向。虚拟线程是我们最大的诉求,核心交易链路里有大量同步 IO 操作,虚拟线程能把线程数从几百个压到几十个,同时把吞吐拉高;ZGC 分代模式在 JDK 26 里已经足够成熟,GC 停顿能控制在个位数毫秒级别,这对秒杀和库存场景很关键;还有一些基础组件可以通过外部内存访问 API 替代原本必须借助 Unsafe 才能实现的操作,安全性上升一个台阶。
但也要说清楚,如果你们的业务是纯计算密集型、没有高并发 IO 压力,升级带来的性能收益可能非常有限。这种情况下仍然坚持升级,理由就更偏向于技术栈长期维护、新特性跟进和人才吸引力,属于成本账而不是收益账。
升级决策一定要量化。我们把每个收益点对应到具体场景,比如“虚拟线程对订单查询接口的 P99 能降低多少”这类指标,等到升级完成后再回头验证。不然很容易出现升级完了,大家却说不出到底哪里变好了的尴尬局面。
1.2 LTS 节奏与团队升级时机判断
JDK 21 本身是长期支持版本,按理说可以稳稳用到很多年。为什么不等 JDK 26 变成 LTS 之后再升级?这里有一个很现实的问题:JDK 21 到 JDK 26 中间隔了好几个中间版本,虽然每个版本变化都不算大,但叠加在一起之后,老的依赖生态会慢慢脱节。
很多第三方库和框架在发布新版本时,官方支持矩阵已经跳过了 JDK 21。这就意味着,如果你长期停留在 JDK 21,要么绑定在旧版本依赖上不敢动,要么每次升级依赖都得小心翼翼检测兼容性。与其把自己卡在旧版本上,不如选择一个跟依赖生态步伐一致的轨道。
从团队节奏看,升级时机通常建议选在业务低峰期。我们当时选在 Q2 中段,既避开了大促节点,又给后面留出了足够的灰度观察时间。不要在产品发布前夕干这事,纯粹给自己找不痛快。团队内部至少要留出 3 周到 1 个月的时间盒,否则遇到卡点很容易赶工出事故。
另外一个判断条件是团队的技术储备。升级过程中涉及 JVM 参数调优、字节码层面排查、模块系统知识,如果团队里没有人能看懂这些,建议先找资深的同学做一次技术预研,或者至少把风险点提前暴露出来。
1.3 升级前最重要的三份清单
正式动工之前,我们强制自己先做三份清单。第一份是依赖清单,把项目里所有直接依赖和间接依赖列出来,逐一人工核对哪些库已经发布了支持 JDK 26 的版本,哪些还停在旧版本。这一步听起来简单,实际上非常耗时间,尤其要注意那些传递依赖,很多坑都是藏在一层一层传递进来的旧库里。
第二份是内部代码清单,用 JDK 自带的 jdeps 工具对整个应用做一次扫描,专门找出代码里对 JDK 内部 API 的使用。下面是我们当时跑的基础命令:
jdeps --multi-release 26 --class-path "target/classes:lib/*" application.jar输出结果里凡是显示 JDK internal API 的条目,都要逐个确认是否有替代方案。特别是sun.misc.Unsafe、com.sun.*这些内部包,一旦新版 JDK 加强了封装,轻则多几个启动参数,重则直接 NoClassDefFoundError。
第三份是 JVM 参数清单。我们收集了所有环境下的启动参数,然后在新 JDK 上对比哪些参数已经废弃或失效。用下面这个命令在新版本里输出当前生效参数,再跟线上参数做 diff,一眼就能看出哪些配置在新版本里根本没用上:
java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "UseZGC|ConcGCThreads|MaxGCPauseMillis"如果发现某些参数在 JDK 26 里已经标记为 obsolete 或者直接失效,要提前更新参数模板,不然上线之后你以为自己在调优,其实 JVM 根本没按你的配置跑。
2. 性能对比实测:环境、方法与关键结论
2.1 评测环境与基准设计
性能对比最怕的就是环境不干净。我们专门申请了一批独立节点,硬件规格统一是 8 核 16G 内存,容器限制 CPU 配额,避免宿主机上其他应用抢占资源导致结果失真。JDK 21 和 JDK 26 跑在完全相同的节点上,中间只切换启动脚本里的 JDK 路径。
评测负载我们没有用纯粹的压测工具,而是录制了线上真实请求流量,通过网关回放到被测应用上。回放的请求里同时包含商品详情查询、库存扣减、订单状态流转和会员信息查询四类操作,尽量覆盖主链路的典型场景。每轮压测持续 30 分钟,前 10 分钟作为 JIT 预热和数据缓存预热,只统计后面 20 分钟的数据。
除了接口维度的压测,我们还专门用 JMH 写了几组微基准,针对虚拟线程的创建切换开销、Vector API 在浮点计算上的增益、以及外部内存访问 API 的读写性能。微基准的价值不在数字本身,而是帮助我们判断新特性到底该用到哪些模块,避免在整个系统里盲目铺开。
每组测试至少跑三轮,取中位数作为结果。同一组测试中间还要间隔一段时间,观察是否有长尾抖动。第一次跑完如果两个版本数据差异巨大,先不要下结论,检查是不是环境变量、JVM 参数或者 GC 策略不一致导致的。
2.2 从 G1 到 ZGC:GC 评测与调优
GC 是这次升级里变化最直观的部分。JDK 21 时代我们主要用 G1,偶尔会遇到 Full GC 引起的停顿峰值。JDK 26 给了我们一个更好的选择:分代 ZGC。我们先在预发环境把小流量切到 ZGC 上跑了两周,确认没有明显副作用之后才纳入正式对比。
评测数据对比下来,ZGC 的优势非常明显。下面是我们在同一业务压测场景下记录的 GC 指标:
| 指标 | JDK 21 + G1 | JDK 26 + ZGC |
|---|---|---|
| Avg GC Pause | 35ms | 8ms |
| P99 GC Pause | 180ms | 12ms |
| Full GC 次数 | 高频出现 | 0 |
| 吞吐量 | 基准 | 提升约 12% |
| CPU 额外开销 | 基准 | 提升约 5% |
这里要特别说一句,ZGC 不是万能的。如果你堆内存很小、或者应用本身几乎不产生大量垃圾,ZGC 的并发处理优势体现不出来,反而会带来额外的 CPU 开销。我们当时还压了一组 256M 小堆的会员服务,ZGC 和 G1 的表现几乎打平,后来又改回了 G1。
如果你决定用 ZGC,建议关注下面这几个参数。-XX:ConcGCThreads控制并发 GC 线程数,默认值通常是逻辑核数的八分之一左右,在容器环境下要根据 CPU 配额手动调。-XX:MaxGCPauseMillis在 ZGC 下更多是一个软约束,但可以给调度器提供参考。还有 ZGC 在 JDK 26 里默认使用分代模式,如果是从旧版本带过来的启动脚本,记得确认没有显式关闭分代参数。
2.3 业务侧的真实观测数据
GC 指标只是底层表现,真正让团队下定决心的是业务侧的可观测数据。我们采集了四个核心接口在同等流量下的响应时间和成功率:
| 接口 | JDK 21 P99 | JDK 26 P99 | 提升幅度 |
|---|---|---|---|
| 商品详情 | 180ms | 95ms | 47% |
| 库存扣减 | 320ms | 140ms | 56% |
| 订单创建 | 240ms | 130ms | 46% |
| 会员查询 | 60ms | 50ms | 17% |
从数据里能明显看出,IO 密集型的库存扣减和订单创建提升最大。这正是虚拟线程的功劳,原来一个请求要占用一个系统线程阻塞等待下游返回,现在虚拟线程在阻塞时会被 JVM 自动挂起,腾出执行资源给其他请求,吞吐自然就上来了。
有一个数据出乎我们意料:会员查询这种轻量级接口也有 17% 的提升。本来以为虚拟线程和小对象分配对它没什么影响,后来分析发现是 ZGC 消除了 GC 停顿尖刺,P99 自然也就被拉低了。这也说明了 GC 对长尾延迟的改善是全局性的,不只是高并发核心链路的利好。
计算密集型的业务,比如我们的价格计算服务,压测结果是 JDK 26 和 JDK 21 几乎持平。Vector API 在特定算法上确实有加速,但前提是代码得显式改写成向量化逻辑,不改造的话 JIT 自动向量化的收益在新版本上并没有想象中那么大。
3. 兼容性问题一站式解决:高频问题与排查速查
3.1 编译期问题:内部 API、泛型推断与 Lombok
升级过程中第一个撞上来的问题是编译失败。我们有两个模块直接用了sun.misc.Unsafe来做对象的浅拷贝和内存屏障,JDK 26 里编译器直接把这些引用标成错误,不是警告,而是直接编译不过。
这类问题的解法是替换 API。浅拷贝可以用标准库的Arrays.copyOf或者System.arraycopy替代,内存屏障相关的逻辑如果确实绕不开,就要考虑用VarHandle或者Foreign Function & Memory API重写。改造量不大,但需要对这个模块的实现场景有清晰理解,不能只是把报错压下去。
另一类编译问题是泛型推断变得更严格。很多老代码喜欢写new ArrayList<>().addAll(...)或者多层匿名内部类,在 JDK 21 能过,在 JDK 26 的编译器下会直接报“不兼容的类型”错误。这时候最省力的办法是给集合加上显式类型参数,把new ArrayList<>()改成new ArrayList<String>(),虽然代码看着啰嗦一点,但可读性也更好。
Lombok 也是绕不开的坎。老版本 Lombok 在 JDK 26 上会出现注解处理器异常,甚至生成的代码字节码版本不匹配。我们统一把 Lombok 升到了较新版本,同时逐个模块核对@Builder、@SuperBuilder这些注解生成的代码。如果你的项目里 Lombok 用得特别重,建议提前在一台机器上全量跑一次编译,把报错一次性收集齐。
编译期还有一个隐蔽问题来自代码生成工具。我们有一个模块依赖 MapStruct,升级 JDK 后注解处理器生成的代码出现了编译错误,根因是 MapStruct 版本太老,对新版本的javax.annotation.processing接口实现不兼容。解决办法就是老老实实升级 MapStruct,没有捷径。
3.2 运行期问题:模块化、反射与虚拟线程陷阱
编译过了只是第一关,运行期才是大头。我们启动应用后遇到了第一个问题:NoClassDefFoundError,提示找不到某个类。排查后发现是代码直接引用了java.desktop模块里的类库,而在 JDK 26 的默认模块化扫描下,这个模块没有被隐式加载。
解决办法是在启动脚本里显式添加模块:
java --add-modules java.desktop -jar application.jar这类问题用jdeps在编译期其实就能提前发现,我们当时步骤没走完整,结果到了启动阶段才暴露。建议大家在升级前就把jdeps的输出认认真真过一遍,能省掉不少启动期的排障时间。
反射相关的兼容问题也出现了。有一个模块通过反射读取了 JDK 内部类的字段,JDK 21 里只需要--add-opens java.base/java.util=ALL-UNNAMED就能打开,但升级后发现即便加了参数,某些敏感字段的访问还是被拒绝。后来查文档发现新版 JDK 对java.base里部分内部结构的保护更强了,需要额外的编译期参数配合。
如果你的代码里大量使用反射,建议趁这次升级做一次重构,把反射访问收敛到少数几个工具类里。否则以后每升级一次 JDK 都要处理一遍这类启动参数,维护成本很高。
虚拟线程的坑我们也踩了一个。主链路里有个老代码用synchronized关键字给一个共享对象加锁,在 JDK 21 老版本虚拟线程实现里,虚拟线程一旦在synchronized块内阻塞,会钉住底层平台线程,导致吞吐退化。新版 JDK 对锁钉住问题做了修复,但在高并发场景下还是建议把synchronized块内等待下游 IO 的代码改造成ReentrantLock或者无锁设计,实测吞吐能再提升一截。
还有连接池的问题。虚拟线程的数量可以很大,但很多老牌连接池在初始化时仍然会占用固定数量的平台线程,如果线程池配置和虚拟线程数量不匹配,还是会出现资源竞争。我们在升级后把连接池的最大线程数调低了一些,让位给虚拟线程调度,整体效果明显更平滑。
3.3 中间件与框架适配清单
升级过程中,中间件的版本管理也是一个大工程。我们整理了当前核心链路上使用的中间件和框架,逐一对照官方文档确认版本兼容情况。下面这个清单非常有用,值得你参考:
| 组件类型 | 老版本 | 升级后版本 | 关键变化 |
|---|---|---|---|
| RPC 框架 | 老版本基于 JDK 反射 | 新版本支持虚拟线程 | 需开启连接复用和异步选项 |
| 连接池 | 老版本线程池固定 | 新版本动态调整 | 调低核心线程数 |
| 消息队列客户端 | 老版本线程模型 | 新版本基于 NIO | 消费端最大并发数需重测 |
| 数据库驱动 | 老版本 | 新版本 | 连接属性新增了 TLS 相关参数 |
| 缓存客户端 | 老版本 | 新版本 | 连接超时参数单位变化 |
其中印象最深的是数据库驱动。升级后驱动默认开始校验服务器 TLS 证书,导致我们某个内网环境连接失败。排查了很久才发现是驱动新版本的安全策略变了,并不是 JDK 本身的问题。这个问题也提醒我们,升级 JDK 时往往会引起相关依赖的连锁升级,必须把周边组件一起纳入回归范围。
框架方面,Spring 版本如果比较老,建议至少升到支持 JDK 21 之后的版本。我们团队多个模块还在用较老的 Spring Boot,升级 JDK 后虽然能跑起来,但日志里时不时出现Unable to make field accessible的警告,这是反射访问内部字段被拦截的表现。短时间可以靠--add-opens压制,但长期还是建议同步升级框架版本。
如果你用了字节码增强类框架,比如某些 APM 探针和 Mock 工具,升级前务必要确认它们对 JDK 26 的支持。这类工具往往直接操作 JDK 内部类,JDK 升级后很容易产生兼容问题。我们在测试环境就遇到过探针上报线程 CPU 占用飙高的情况,降级探针版本后才恢复。
4. 让升级不出乱子:灰度发布、验证与回滚
4.1 灰度发布设计
升级动作本身其实不复杂,复杂的是如何让风险可控。我们的发布策略是分四步走:先上 1 个节点观察,再按 5% 流量放量,确认没问题后扩到 30%,最后全量。每一步中间至少观察 2 到 4 个小时,中间如果出现任何指标异常,立即暂停放量并把该节点摘除。
灰度期间的监控重点非常明确。第一个是接口响应时间,特别是 P99 和 TP999 是否有尖刺;第二个是 JVM GC 频率和耗时,尤其是 Full GC 次数有没有从 0 变成非 0;第三个是应用日志里是否出现异常堆栈,比如 NoClassDefFoundError、IllegalAccessError 和 NoSuchMethodError;最后还要盯 CPU 和内存使用率曲线,防止新版本存在资源泄漏问题。
为了让新旧版本能直接对比,我们在网关层给灰度流量打上了染色标记,全链路日志里都会带上这个标记。这样对比数据的时候可以直接按标记过滤,不用猜哪些请求是走的新版本。如果你的网关不支持染色标记,退一步可以通过单独的实例 ID 过滤日志,效果也差不多,只是麻烦一点。
灰度过程中我们还做了一个所有实例大版本统一的操作:在新版本实例启动完成后,用jcmd抽查线程状态和堆内存分布,确认虚拟线程调度正常、ZGC 内存池分配合理。这一步不需要每个实例都做,但前 3 台实例一定要做,能发现很多压测时不容易暴露的问题。
4.2 单实例验证清单
在灰度流量放进来之前,我们会对新版本实例跑一轮比较全面的功能验证。验证不能只靠自动化测试,很多低频功能必须靠手工点一遍。我整理了一份可以直接复用的单实例验证清单:
- 核心接口冒烟:订单创建、查询、取消,库存扣减与回滚
- 定时任务执行:确认任务正常触发并且没有重复执行
- 消息队列消费:上下游消息收发、重试和死信队列是否正常
- 数据库读写:分库分表路由、事务回滚、慢 SQL 日志
- 分布式锁:多个实例抢占锁时是否出现并发问题
- 权限和导出:后台导出 Excel、文件上传下载、权限校验
- 长连接场景:WebSocket 推送和心跳保活是否正常
每个验证项都要记录结果和时间点,出了问题和后面回滚时排查都方便。特别是定时任务和消息队列,这两个最容易在新 JDK 上出问题,因为线程模型变化会直接影响到调度器和消费线程的行为。
我们还额外做了一次“升级后复测”的动作:在新版本实例稳定运行 2 小时后,重新跑了一遍压测脚本,用压测结果和升级前数据对齐。这一步能确认性能提升是否真实存在,避免出现“功能正常但性能倒退”的隐性回归。
4.3 回滚策略与备份
灰度做得再好,也要给回滚留好后路。我们的回滚方案分两层:第一层是网关切流,把灰度实例从负载均衡里摘掉,流量全部回到旧版本实例;第二层才是物理回滚,把部署目录切换回旧版本包。两层配合起来,即使在最极端情况下也能在几分钟内恢复服务。
回滚最容易忽略的是数据兼容。升级过程中如果数据库连接池配置变了、或者某个中间件连接属性改了,回滚旧版本时会因为配置不匹配直接起不来。我们对所有配置变更都做了可逆处理,数据库连接池参数单独用一份配置文件管理,回滚时直接切换这份文件,不需要发布新包。
另外一个实操心得是:旧版本的 JDK 安装目录不要急着删。我们在某台机器上回滚时发现启动脚本里引用的 JDK 路径已经被覆盖了,只好临时重新解压一份,白白浪费了几分钟。回滚方案里明确要求保留每台机器上的旧 JDK 目录,至少保留一个发布周期。
我们还在发布系统里加了一个一键回滚的入口,整个操作流程固定为三个动作:切换网关权重、替换启动脚本里的 JDK 路径、重启实例。演练过一次回滚流程,耗时约 10 分钟,效果还算满意。这里要强调,回滚方案一定要演练,不演练就是纸上谈兵,真出事的时候手忙脚乱反而更容易出二次事故。
最后再分享一个小技巧
个人在整个升级过程中体会最深的一点是:升级 JDK 最大的价值不一定是那一堆性能数据,而是强制整个团队把 JVM、依赖、中间件这些平时不太关心但又至关重要的底层组件重新梳理了一遍。很多老代码里沉淀了几个版本都没人敢动的技术债,在这次升级中被迫清理掉了,这种收益比单纯的性能提升更值钱。
如果你接下来也要做类似升级,我的建议是先把jdeps扫描和第三方依赖适配这两件事做扎实,然后再去聊性能优化。兼容性没有解决之前,性能数据做得再漂亮也没意义。操作上记得多做灰度对比、多保留旧版本,永远给自己留一条安全后退的路。