JDK 26升级实战:从虚拟线程到ZGC的性能与兼容性排查指南
2023年我们把核心微服务从JDK 11升到21当时主要是冲着虚拟线程去的上线头一个月数据确实好看但半年后性能水位基本就固定在一个平台期上想再抠一点RT和GC停顿都特别费劲。今年年初我们定了一个新目标把运行环境推到JDK 26顺带把这两年陆续引入的新API吃透用一次系统性升级把性能和兼容性的账一起算清楚。先说结论升级本身没有想象中那么恐怖但前期的信息收集和兼容性排查占了整个项目六成以上的工作量。真正到了切换JDK、跑压测、灰度上线这个阶段反而比较平顺。这篇文章不写官方发布说明的翻译版就把我们实际踩过的坑、用过的工具、做性能对比的方法论完整交代一遍。如果你也在纠结“要不要升”“怎么升才稳”或者正在带团队做类似的JDK版本升级这篇文章应该能给你省下不少试错时间。1. 升级动机与版本差异盘点1.1 我们为什么要动JDK版本很多人一听说升级JDK第一反应是“Java版本越新越稳定吗”或者“不是LTS版本值不值得上”。我们的判断逻辑其实很简单先看当前痛点再看新版本能不能解决痛点最后评估破坏面。当时的痛点有三个。第一个是虚拟线程在JDK 21里还有不少限制最典型的是synchronized代码块执行期间虚拟线程会钉在载体线程上遇到高并发锁竞争的时候吞吐量表现并没有文档里说的那么美好。第二个是ZGC在21上虽然已经很成熟但默认还是非分代模式我们自己的压测数据显示长时间运行后内存占用偏高而且Full GC概率比预期高。第三个是JDK 21的API覆盖已经不够看很多并发、结构化并发、外部函数接口的能力在后续版本里才逐渐稳定我们在写新的业务模块时已经能感觉到版本机能的瓶颈。反过来说JDK 26虽然不是官方定义的长期支持版本但距离21已经有五个版本迭代虚拟线程的实现质量和GC的默认行为都有了明显变化。我们的态度是不唯LTS论只要生产环境验证足够充分、回滚方案足够扎实功能版本完全可以作为一个受控范围内的技术选型。关键不是“它是不是LTS”而是“你有没有能力在生产环境把它玩明白”。1.2 21到26之间的关键变化一览把21到26之间的主要变化分成四类对比起来会清晰很多。第一类是并发能力增强虚拟线程对锁的处理做了持续优化结构化并发API也在逐渐走向稳定IO密集型场景的编码模型有了根本性变化。第二类是GC和运行时行为调整分代ZGC逐步成为默认选项很多老旧的GC日志参数被彻底移除启动参数如果不跟着改JVM甚至会直接拒绝启动。第三类是API层面的演进和清理部分历史遗留API被标记为废弃或移除类文件API、外部函数接口成为新的标准能力。第四类是工具链和编译过程的调整类文件版本号提升以后老版本的字节码操作库、代理工具全部面临兼容性风险。对于一线开发团队来说真正值得注意的是第二条和第四条的叠加效应。GC日志参数这事情最容易被忽略因为项目里很多JVM参数是从上古版本一路复制粘贴过来的没人会去看每一个参数在新版本里是否仍然有效。以前升级JDK 17的时候就把-XX:PrintGCDetails这种参数踩了一遍这次升级26又遇到类似的问题。后面兼容性排查那一节我会专门讲这部分。2. 动手前的准备兼容性排查和工具链切换2.1 先给项目做一次“全身体检”我强烈建议在写第一行代码之前先花两三天时间做一次静态体检。具体做的事情包括四件检查第三方依赖的字节码增强能力、检查项目里的反射和内部API访问、检查JVM运行参数、检查构建和部署链路。字节码检查这一层最核心的工具是jdeps和jdeprscan。jdeps可以分析出项目依赖的JDK内部API并且检测出是否存在对即将移除模块的依赖。jdeprscan专门用来扫描废弃API的使用位置。这两个工具都是JDK自带的标准命令不需要额外装东西。# 分析某个jar包的JDK模块依赖 jdeps --multi-release 26 --print-module-deps my-service.jar # 扫描废弃API jdeprscan --release 26 my-service.jar注意--multi-release参数的意思是告诉jdeps我们按JDK 26的类文件版本去解析否则如果jar包里有多版本字节码分析会不准。扫描结果一般会输出一堆警告不用每条都处理优先级应该放在“即将删除”和“确定删除”的API上普通废弃警告可以记录在案后慢慢消化。除了命令行扫描还要做一次人工盘点。重点看有没有用过字节码操作——凡是依赖ASM、ByteBuddy、CGLIB、Lombok、Mockito这类框架的项目都要确认对应版本是否支持JDK 26的类文件版本。这个环节特别容易出问题原因在于很多字节码库有自己的版本兼容矩阵表面上引入的库版本很新但传递依赖里可能还挂着一个老版本。最好直接用dependency:tree把依赖树拉出来逐个确认。反射访问是另一个重灾区。JDK 17开始模块强封装已经很严格了21到26之间又收紧了几轮。我们的做法是在测试环境直接加上--illegal-accessdeny启动参数这样一旦有反射访问内部API的情况就直接报错而不是等到生产环境里某条边缘逻辑才触发问题。2.2 构建链切换本地、构建机与CI/CD体检做完之后就要动手切换工具链了。本地开发环境可以直接用SDKMAN来管理多个JDK版本日常开发可以继续用21只在需要验证新特性或跑兼容性测试时切到26。这种方式成本最低团队里其他同学接受度也高。# 安装并切换JDK 26 sdk install java 26-open sdk use java 26-open # 验证当前版本 java -version但对于构建机来说绝对不建议靠手动切换系统全局JDK来实现那样太容易出环境漂移。我们直接把Maven的Toolchains配起来了在项目根目录放一个.mvn/toolchains.xml明确声明编译和运行分别使用哪个JDK。toolchains toolchain typejdk/type provides version26/version /provides configuration jdkHome/path/to/jdk-26/jdkHome /configuration /toolchain /toolchains另外一定要在pom.xml里显式配置maven-compiler-plugin的release参数而不是只写source和target。release参数会强制让javac使用对应版本的API签名避免出现“编译时用新API、运行时跑在老版本上”的乌龙。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version3.13.0/version configuration release26/release /configuration /pluginCI镜像和Dockerfile也是单独一步。我们用的容器基础镜像把JDK换成了26的官方OpenJDK镜像同时把Gradle或Maven的wrapper版本升到能正确识别新JDK的版本。这里特别提醒一句Dockerfile里的FROM如果写了:latest标签可能某天基础镜像就被官方更新成新版本导致完全没经过验证的环境变更我们之后统一改成了带具体版本号的镜像标签。2.3 制定升级节奏与验证范围准备工作的最后一步是制定升级节奏。我们当时走了三条线并行第一条是线上低风险系统先行比如内部管理后台这类系统流量小、影响面窄即便出问题也能快速止损第二条是核心交易链路的影子验证也就是把流量复制一份打到新版本环境上不承担真实业务第三条是长稳测试用新版本环境连续跑三天的模拟交易和批处理重点观察内存曲线、GC频率、线程状态是否正常。验证范围一定要覆盖编译、单元测试、集成测试、契约测试、压测、前端联调、运维部署脚本、监控告警规则这八个环节。尤其是监控告警规则因为新老JVM暴露给JMX的指标项并不完全一样如果采集程序还按老指标去取数很可能某个关键节点监控直接静默失联。3. 兼容性问题的现场与解法3.1 编译期的坑不是所有“警告”都安全编译期问题往往是最容易被发现的但其中隐藏着一些表象温和的坑。我们遇到的第一个问题是Lombok的版本太老在JDK 26的编译环境下注解处理器直接罢工报错信息指向某个java.lang.VerifyError。这个原因在于Lombok依赖的ASM版本不认识新版类文件的字节码解决方式无非是把Lombok升级到支持JDK 26的版本同时检查代码里是否使用了Lombok的某些实验性功能。另一个编译期坑是旧代码里的“无害警告”。比如某个库在编译时输出了sun.misc.Unsafe相关的warning在老版本里这种警告可能已经存在了很久大家都没当回事但在新版本里Unsafe的很多方法已经加了强限制运行期是否报错完全取决于JVM的--sun-misc-unsafe-memory-access参数。我们的建议是把所有编译警告当成升级候选事项至少过一遍确定哪些是历史遗留、哪些是新出现的。编译期真的遇到报错时先看错误信息里有没有“has been removed”或“is obsolete”这类关键词然后再去查对应API在最新版中的替代方案。有一个快捷做法把新JDK安装好后直接看官方文档的API差异报告把所有废弃和移除条目拉出来和项目的依赖树做交叉比对。3.2 运行期强封装反射和Unsafe的制裁运行期问题比编译期隐蔽得多因为它们只在特定业务路径上触发。最常见的一类就是InaccessibleObjectException我们的一个老监控埋点组件就中招了它在启动时通过反射访问某个内部类来获取线程池状态。从JDK 17开始这种跨模块反射就被默认禁止了但17升级21的时候因为启动参数里贴了--add-opens侥幸逃过去了这次升级到26后之前贴的参数因为格式问题失效问题立刻暴露。解决思路分两步。第一步是定位到底谁在访问哪个模块这可以用-Xlog:modulereflectiondebug启动参数把所有模块访问日志打出来。第二步是分析这个访问是否必要如果只是获取某个指标通常可以改成官方支持的方式如果实在绕不开再决定使用--add-opens或者--add-exports精确放行。java -Xlog:modulereflectiondebug -jar my-service.jar这里特别强调一下放行参数一定要精确到模块和包名不要图省事直接--add-opens java.base/java.langALL-UNNAMED。因为这种宽泛放行在未来版本中可能直接被忽略而且会在问题排查时掩盖真正的依赖关系。我们有条老服务就是这么处理的升级到26之后参数失效花了大半天才定位到问题。Unsafe相关的坑也有代表性。一些性能组件比如无锁队列、序列化框架底层直接操作Unsafe内存JDK 26开始对Unsafe的内存访问行为有了更细粒度的控制。我们在压测阶段发现某个自研的本地缓存组件在大量并发写时偶尔会出现奇怪的内存报错最终也是靠启动参数--sun-misc-unsafe-memory-accessallow在测试环境逐一验证然后根据验证结果决定是修组件还是改参数。3.3 虚拟线程与锁的交互问题当初升到21就是为了用虚拟线程这次升级26当然也把虚拟线程的压测作为重点关注对象。第一轮压测跑下来发现虚拟线程池的表现不太稳定某些接口的P99抖动比较大排查后发现是代码里少数地方还在用synchronized在21上这些同步块会固定虚拟线程的载体线程一旦并发数上来了载体线程池就不够用出现大量线程挂起等待的情况。JDK后续版本对虚拟线程和synchronized的交互做了改进不再无条件固定载体线程但如果代码里仍然混用重量级锁性能收益依然会打折。更推荐的做法还是把老代码里不必要的synchronized块换成ReentrantLock或者StampedLock让虚拟线程真正不被阻塞机制拖累。另外线程池默认参数也值得重新审视虚拟线程不受操作系统线程数量限制但数据库连接池、HTTP客户端连接池这些外部资源限制仍然存在压测时不要只盯着虚拟线程的线程数而要看外部依赖的并发上限。3.4 日志与可观测性组件兼容GC日志这块是重灾区很多从JDK 8时代流传下来的启动脚本到21之后已经跑不起来了。比如-XX:PrintGCDetails、-XX:PrintGCDateStamps这类老参数在新版本里会被直接忽略或者导致启动失败。换成统一的新式-Xlog语法就行# 老参数已失效 -XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/gc.log # 新参数统一写法 -Xlog:gc*:file/path/gc.log:time,uptime,level,tags日志框架本身也要注意。如果项目用的是Log4j2或Logback需要确认版本是否支持新JDK的模块化限制。我们在测试环境发现一个怪现象应用启动时日志正常但运行一段时间后某个业务线程的日志消失排查后发现是异步日志组件的native部分和新JDK的内部线程模型有冲突。升级日志框架版本后问题解决。这种问题很难靠静态扫描发现一定要在长稳测试中安排日志完整性的校验。3.5 中间件与客户端库版本中间件相关的兼容性问题更隐蔽因为升级JDK后应用本身没报错但连接到中间件的方式或者协议协商失败了。我们遇到过一个数据库驱动的问题老版本驱动在建立连接时用了一些内部API去探测服务器版本升级后这些API被限制导致连接池里的连接时断时续。解决办法不复杂把驱动版本升上来就好但排查过程很折磨人因为错误信息一会儿是“Connection reset”一会儿是“Socket closed”很容易让人误判是网络问题。建议是升级前整理一份依赖清单把框架、数据库驱动、MQ客户端、Redis客户端、注册中心客户端、分布式追踪SDK这些核心依赖的新版本支持情况都查一遍。注意这里的“版本支持情况”不能只看库作者是否声明支持最好直接跑一遍对应的集成测试。很多库虽然在新JDK上也能跑但性能和资源占用可能有回退这需要实测数据来说话。4. 性能对比怎么做才不踩空4.1 微基准先用JMH测底层原语性能对比最忌讳“凭感觉”。我们这次是把性能对比拆成三个层次第一个层次是微基准用JMH去测JDK版本更替带来的基础原语变化。微基准测试的目标不是得到业务性能结论而是判断新版本在语言底层有没有明显的性能回退。JMH的使用其实不难但有几个关键配置必须弄对。第一个是Warmup和Measurement时间要足够建议至少预热5秒、正式测量5秒以上否则JIT优化还没稳定就采数据结果毫无参考意义。第二个是在基准方法里引入Blackhole来消费返回值防止编译器认为计算结果没有副作用而直接优化掉。第三是注意fork次数至少fork三次每次跑在独立的JVM里规避JIT状态残留。下面是一个简单的JMH基准方法示例Benchmark BenchmarkMode(Mode.Throughput) Warmup(iterations 3, time 5) Measurement(iterations 5, time 5) Threads(8) public void concurrentIncrement(Blackhole bh) { long value counter.incrementAndGet(); bh.consume(value); }我们拿这套方式对并发原子类、锁、字符串操作、哈希集合、JSON序列化做了对比整体来看新版本没有回退部分并发原语有小幅提升大概在5%到8%的范围这属于正常波动不足以作为升级决策的依据。微基准真正的价值在于如果某个原语在新版本上有两位数以上回退那大概率是你的基准方法写错了而不是JDK坏了。4.2 业务层压测场景设计比工具更重要微基准只能证明“语言层没退化”业务是否真的变快必须在接近生产的场景里压。我们选了两个最有代表性的业务链路做对比。第一个是网关转发链路特点是高并发、短连接、IO密集第二个是账户批处理任务特点是计算量大、依赖数据库、存在大量串行操作。压测前把条件固定下来同样的压测工具、同样的压力机配置、同样的数据规模、同样的流量模型唯一变量是JDK版本。我们用的是阶梯加压和峰值持续两种模式阶梯加压看吞吐和RT的拐点峰值持续看稳定性。这里有一个重要经验压测时间不能太短至少跑15分钟以上因为JIT预热和GC行为都在分钟级才会稳定短压测只会得到一堆抖动数据。压测指标不要只看平均RT还要看P99、P999和最大RT。平均RT很容易被大量低延迟请求“平均”掉掩盖少数慢请求的存在。我们第一次压测时JDK 26的平均RT看起来和21差不多但P999从原来的220毫秒降到了160毫秒这才暴露出真实差异。4.3 GC选型和实测G1、ZGC还是分代ZGCGC选型是这次升级的另一个大头。JDK 21时代默认的G1在新版本里依然是大多数应用的安全选择但ZGC的分代模式在这两年已经变得更加稳定官方也在逐步把分代ZGC往默认方向推。我们分别在两种业务链路上做了G1、ZGC非分代、分代ZGC三组对比。指标JDK 21 G1JDK 26 G1JDK 26 分代ZGC平均RT毫秒323129P99毫秒787571P999毫秒220185168GC暂停平均毫秒18162运行期内存稳定值2.8G2.7G3.2G上面的数据来自我们自己的一个模拟压测项目不具备普适性但趋势很有代表性分代ZGC对延迟的改善非常明显GC暂停几乎可以忽略代价是堆内存占用比G1高不少。如果你的服务内存余量充足、对RT敏感分代ZGC值得考虑如果内存本身就紧张那ZGC的高占用反而可能引发其他问题老老实实用G1才是稳妥路线。GC日志的解析工具我们用了在线看板加自脚本把每次GC的暂停时间、堆变化量、并发周期时长都聚合成统计图。长稳测试期间发现分代ZGC偶尔会出现一次较长的高额暂停后来定位到是因为年轻代配置过小触发过早回收。调整-XX:YoungGenerationSize并配合观察后曲线就平滑了。4.4 上线后的持续观测压测环境毕竟和真实流量有差距灰度上线后我还保留了一整套观测手段。最核心的是JFRJDK Flight Recorder我们会在灰度节点上持续录制JFR覆盖类加载、锁竞争、GC、JIT编译、堆分配这些核心维度跑两小时后通过工具解析出热点方法和锁竞争排行。内存层面的第二个关键工具是NMTNative Memory Tracking打开-XX:NativeMemoryTrackingsummary后可以通过jcmd VM.native_memory看到JVM进程的内存构成。升级到新版本后某些库对native内存的消耗可能变化很大不盯的话容易在深夜流量高峰触发容器内存上限。我们自己就遇到过新版本下Metaspace占用比旧版本高出几十兆的情况好在是预计内的波动但如果不监测一开始会让人误判为内存泄漏。5. 灰度发布与回滚预案5.1 灰度策略怎么定升级类灰度最怕的是“一刀切”我们用的是按节点分批加按流量比例混合的方式。第一批发1个边缘节点权重设为5%用于验证线上真实流量的稳定性同时观察监控曲线和日志有没有异常。观察12小时后无问题第二批放到核心服务的部分节点上权重逐步提到30%跑一个完整的业务周期。最后再把所有节点切到新版本。这里要特别关注灰度期间的“新旧共存”风险。如果服务是无状态微服务前后端版本混跑基本没问题但如果有分布式缓存格式变化、消息序列化格式变化、数据库表结构变动这类跨节点依赖混跑期间新旧版本可能无法互相识别。所以升级前最好确认一下有没有跨节点的序列化协议变动如果有要先做到新旧兼容再灰度而不是直接改协议。5.2 回滚的三种方式回滚预案不是到出问题才想而是在灰度前就要写清楚。正常情况下回滚最直接的方式是把启动脚本和依赖版本切回老版本重新发布也就是“构建回滚”。这种方式最稳但耗时最长可能要几分钟级别。第二种方式是把JVM启动参数调整回老版本前提是新老版本在同一进程里兼容性足够好比如只是GC参数不同那就不用改代码构建直接改配置重启即可。第三种方式是流量回滚也就是不改任何代码直接把灰度节点的流量降为零让负载均衡器把流量全部导回仍运行着JDK 21的节点。这种方式只要架构上支持几秒内就能完成是最快速的风险隔离手段。我们实际采用的就是第三种加第一种的组合先流量封顶然后构建回滚避免回滚期间新节点还在承接流量。5.3 上线后要重点盯的指标上线后24小时是我们定义的“风险窗口期”这个时间段内的盯盘指标和平时不太一样。除了常规的CPU、内存、RT、错误率之外我会重点盯三个比较容易被忽略的指标。第一个是Full GC次数和耗时新版本如果GC配置不合适Full GC会是一个滞后爆发的风险第二个是线程状态分布特别是虚拟线程模式下RUNNABLE线程数量如果异常飙升往往是锁竞争或者外部IO阻塞的信号第三个是堆外内存和Metaspace的斜率持续上涨不回落即是危险信号。另外要安排一个值班同学专门看JFR文件如果发现某个方法编译层级不稳定或者频繁解优化赶紧抓现场。因为这种JIT层面的抖动很难在压测环境重现只有真实流量的多样性才能暴露出来等它自己恢复后再想定位是不可能的。6. 避坑清单和一点私人经验6.1 高频问题速查表把这次升级过程中遇到的高频现象和对应解法整理成一张速查表方便大家直接对照排查。现象可能原因处理方式启动即报VerifyError第三方字节码库不支持新类文件版本升级ASM、ByteBuddy、Lombok等库版本反射调用突然抛InaccessibleObjectException模块强封装导致用modulereflection日志定位精确放行虚拟线程场景吞吐上不去synchronized固定载体线程替换为JUC锁升级JDK版本GC日志无输出老参数被废弃或忽略统一改用-Xlog:gc*语法连接池连接时断时续JDBC或客户端库版本过老升级客户端库检查协议协商逻辑某些监控指标消失JMX指标变更导致采集失败同步更新采集配置和告警规则分代ZGC高额暂停年轻代配置不合理调整YoungGenerationSize并观察6.2 我这次升级后留下的几个习惯有几件事是我这次升级之后真正养成的新习惯写出来供参考。第一JVM参数不再从老项目里直接复制粘贴了。每次升级都要求团队把启动参数逐项过一遍确认每个参数在新版本里还有效、含义是否变过。只要有一个参数含义不清就去查新版本的官方参数列表查不到就写个空参数启动试一下宁可多花时间也不要带着历史包袱上线。第二任何字节码库的版本升级都会连带检查类文件版本兼容性。这不是一次性的工作而是依赖升级时的常规检查项。一个库的新版本可能只是修了个bug但它底层引用的ASM版本可能完全没变甚至更老这种情况光看库版本号是看不出来的。第三性能对比的结论必须有场景描述。我们不写“JDK 26比JDK 21快”这种笼统的话只会写“在某某业务链路、某某配置条件下P99下降了多少”。因为性能结论本来就是有条件的脱离场景的结论迟早会在下一个项目里坑人。这次升级折腾下来我最大的体会是JDK版本升级的技术门槛并没有想象中那么高真正难的是组织内部对兼容性和可观测性的重视程度。我们前期做了大量文档化工作和依赖梳理过程中看起来“浪费”了不少时间但实际到灰度上线阶段几乎没有出现需要紧急回滚的重大问题。如果你正在计划类似升级建议把重心放在兼容性扫描、长稳测试灰度策略这三件事上其他的细节只要按流程走基本都能顺利过关。