生产环境JDK 21升到25 LTS,这6个坑我踩完了才敢发出来

39 阅读6分钟

去年九月JDK 25发布,作为新的LTS版本,我当时没急着升。JDK 21我们用得挺好,虚拟线程也开了,没什么非升不可的理由。

真正让我动念头的是今年年初的一次故障排查。我们一个核心服务在高并发下出现了诡异的内存占用,排查到最后发现是JDK 21的一个已知问题——具体细节不说了,反正是LTS版本也会有的老毛病。那会儿我开始认真考虑升级。

从立项到灰度完成,前后折腾了一个多月。中间踩的坑比预想的多,而且很多坑在官方文档里根本找不到,全靠社区讨论和试错。写下来,给准备升级的兄弟省点时间。


先说结论

JDK 25值得升,尤其是你还停留在JDK 17或更老版本的话。虚拟线程已经成熟了,结构化并发也有预览版,String Templates转正了,对象头从96位压缩到64位,内存占用平均能降10%-20%。

但升级不是换个JDK版本那么简单。我们升级过程中遇到的实际问题,我按踩坑顺序写。


坑1:虚拟线程的默认线程数,别照抄网上的配置

JDK 21里虚拟线程刚出,网上教程千篇一律教你怎么开:

spring:
  threads:
    virtual:
      enabled: true

开了之后确实爽,Tomcat直接跑虚拟线程上,连接数上限一下从200变成几千。但JDK 25里虚拟线程已经是成熟特性,很多组件的行为和21不一样了。

我们踩的坑是:JDK 25里Tomcat对虚拟线程的默认处理变了,连接数和超时配置的语义和21完全不同。照着21的经验配,生产环境上线当天连接就爆了,用户请求大量超时。后来查官方文档才发现,JDK 25的Tomcat版本里,server.tomcat.threads.max在虚拟线程模式下默认值都变了,而且虚拟线程下这个配置基本没意义,应该调整的是server.tomcat.accept-count和连接数。

一句话总结:网上那些JDK 21的虚拟线程教程,升级后别照搬。


坑2:第三方库的兼容性,比你想的严重

升级前我列了个清单,把项目里的依赖全过了一遍,重点看字节码操作类库(CGLIB、ByteBuddy、ASM)、动态代理库、序列化库(Kryo、FST)、监控agent。

结果还是漏了一个:一个老的日志库的agent模块,直接在JDK 25下启动报UnsupportedOperationException,查下来是它内部用了JDK 25移除的内部API。

经验:升级前不要只跑单元测试,一定要在生产同规格的镜像里跑一遍全量集成测试。 单元测试很多都是Mock的,根本走不到那些第三方库的真实调用路径。我们就是因为跳过了这一步,白白在生产灰度时排查了一个下午。

还有一点,别用老的构建工具。Maven 3.6以下对JDK 25的支持有问题,编译期会报奇怪的错误。升级JDK的同时,把Maven、Gradle、IDE全升到支持JDK 25的版本,这个钱和时间省不了。


坑3:String Templates,别急着大规模用

JDK 25把String Templates转正了,这玩意确实好用:

// 以前
String msg = "用户 " + user.getName() + " 下单了 " + order.getAmount() + " 元";

// 现在
String msg = STR."用户 \{user.getName()} 下单了 \{order.getAmount()} 元";

可读性强了不少,尤其拼复杂日志和SQL的时候。但我们没敢大规模用,原因很实在:

这个特性在生产环境踩坑的成本太高。 模板里如果表达式抛异常,报错信息不如以前拼接直观,而且团队里不是所有人都熟悉这个语法,混着写很容易出问题。我们的策略是:新代码可以用,存量代码不动。等团队都熟了再逐步迁移。

顺便说一句,网上有些文章写STR模板要引入包,那是错的。JDK 25里这是语言内建的,不需要任何import。


坑4:对象头压缩带来的连锁反应

JDK 25的紧凑对象头(compact object headers)是这次升级里最实在的优化,对象头从96位缩到64位,堆里对象多了能省不少内存。

但副作用是:JOL(Java Object Layout)这类内存布局分析工具、以及一些依赖对象头做特殊操作的库,会受到影响。 我们有个内存分析工具直接失效了,显示的对象大小和实际完全对不上。

还有,如果你用了-XX:+UseCompressedOops相关的参数调优,JDK 25里默认行为有变化,别再用老的调优参数,先跑起来看默认表现,再用JFR和JMC去实际观测,缺什么再补什么。


坑5:反射限制,你项目的框架可能中招

JDK 25开始为"final字段不可变"做准备,用反射去改final字段会打警告。这本来是好事,安全性和不变性都提升了。但对一些老框架和写法很不友好。

我们有个老代码,用反射往final的静态字段里注入测试值,升级后测试环境开始疯狂打警告。虽然还能跑,但这是给未来埋雷——JDK 26已经正式限制这个行为了,到时候不修直接报错。

建议升级的时候顺手把这些依赖反射改final的代码清一遍,早晚要还的债,不如现在还。


坑6:G1 GC的行为变化,监控告警规则要跟着改

JDK 25对G1做了优化,GC暂停时间更短了。但GC日志的格式变了,我们监控平台的解析规则直接失效,升级当天告警刷屏。

升级JDK的同时,一定记得把GC日志格式的解析规则、监控平台的版本一起评估。 这个很容易被忽略,但生产环境出问题的时候特别致命——你想查GC日志,结果监控平台解析不了,等于瞎了。


升级路线图,我建议这样做

如果你也在考虑升级,按这个顺序来,能少踩一半的坑:

  1. 先升级开发环境,让团队全员用JDK 25跑两周,有问题尽早暴露
  2. 编译期先过,处理掉废弃API的警告,别无视
  3. 全量集成测试,尤其关注第三方库、agent、序列化这些容易出问题的环节
  4. 灰度环境跑一周,重点观察GC、内存、线程池表现,对比升级前后
  5. 逐步放量,先接5%流量,再30%,再全量,每步观察24小时

我们这次全流程走下来,最深的感受是:升级JDK的收益是真的,但风险也真实存在,关键是把测试做足。 千万别觉得"只是换个版本号"。


如果这篇对你有点用,点个关注。后面我计划写JDK 26的Project Detroit——Java直接调Python AI库的实战,感兴趣的话可以蹲一下。