尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JDK 11升级到JDK 21实战:依赖迁移、JVM调优与避坑指南

JDK 11升级到JDK 21实战:依赖迁移、JVM调优与避坑指南 1. 升级前的准备别急着换先摸清家底说起JDK升级这事儿我一开始也没当回事。公司里有个老服务从JDK 8一路升到11跑得好好的日常没人动它。后来因为有新需求要用到虚拟线程和Record模式我在本地试了试确实香就琢磨着把它升到21。结果从动手到全量发布前后折腾了将近一周踩了一堆坑心态从“这不就改个版本号的事”变成“我到底在升什么”。先说个结论JDK 11到JDK 21可不是小版本平滑升级中间隔着12到20这好几个大版本的变化。Oracle从Java 17之后改成每两年一个大版本21是个LTS版本所以该升还得升但不能头铁直接一把梭。磨刀不误砍柴工升级前必须做几件事这里按优先级来。1.1 先从“依赖现状”入手别指望一把梭很多人在升级前最爱问的一句话是“我把Dockerfile里的镜像版本换成21能不能跑起来”表面上看能实际上你根本不知道项目里塞了多少藏在角落里的老依赖。我踩的第一个坑就是以为项目纯净得很结果一跑就报NoSuchMethodError。所以我建议的第一步是把你项目里的所有第三方依赖拉个清单。用Maven的dependency:tree或者Gradle的dependencies命令把完整依赖树导出来细细过一遍。重点盯这么几类字节码操作类库CGLIB、ASM、Javassist这类库跟JDK内部结构和字节码版本强绑定稍老一点就没法在21上跑。序列化/反序列化库Kryo、FST、Protostuff这些都是拿反射和Unsafe实现的在更高版本的JDK上最容易出事。动态代理/字节码增强框架Spring AOP、MyBatis、Hibernate这类带运行时代理机制的框架要么升到新版本要么等官方支持。日志桥接库Log4j、SLF4J、Logback之间层层叠叠老版本在不同JDK上表现差别极大。怎么更精准地判断哪些库有兼容问题呢我推荐你用JDK自带的jdeps工具对打好的jar包做一次静态分析看它依赖了哪些JDK内部API。这个工具在老版本上快被遗忘了但在升级场景里是真的好用。jdeps --multi-release 21 -s your-app.jar如果看到类似jdk.internal.misc.Unsafe或者sun.misc.Unsafe这样的输出那就得格外小心了这些内部API在21里被收得更紧能用新的方式替代就尽早换。1.2 列一张“我正在用哪些JDK特性”的清单自己的代码比第三方依赖更可控但也更需要过一遍。我在升级前把项目里的Java代码翻了一遍专门排查以下几类API的使用情况sun.misc.Unsafe这个是重灾区很多老一代的工具类和框架都拿它做内存操作。在JDK 21里Unsafe虽然在但很多路径加了强限制一不小心就报ExceptionInInitializerError。finalize()这个方法和System.runFinalization()系列在JDK 18被标记为废弃21里虽然还能用但强烈不建议再依赖它做资源释放。我们代码库里居然有一个老工具类还在用finalize做Socket清理我直接改成try-with-resources了。Thread.stop()、Thread.destroy()这些早已废弃的线程方法在21里遇到就直接报UnsupportedOperationException有些老代码在“优雅停机”逻辑里会嵌这个很隐蔽。Java EE相关包比如javax.annotation.PostConstruct、javax.annotation.PreDestroy这种——这是从Jakarta EE 9开始出现的经典改动javax.*改成jakarta.*Spring Boot 3.x、Tomcat 10.x全受影响这个我会在后面单独展开。顺便说一句如果你用了不少内部API且一时改不完JDK 21还保留了--add-exports和--add-opens这类命令行参数可以在小范围内做兼容。但注意这只是过渡手段千万别长期焊死在启动脚本里不然新版本升级的坑永远躲不掉。1.3 提前想好“回滚方案”这不是说丧气话而是真实教训。我第一次在一台预发机上跑新编译的包跑了半小时一切正常正当我以为大功告成时一个沉睡很久的定时任务突然被唤醒直接把一堆内存操作打爆了。预发环境的流量还好生产环境要是发生这种事没有快速回滚预案的后果不敢想。所以在你决定升级前先确认几件事旧版本的镜像/构建产物是否还保留着至少保留两版。发布系统能不能一键回滚如果不能请先把旧包上传到一个可直接拉取的目录。数据库、Redis这类外部依赖有没有版本适配问题JDK本身容易查但内存、GC参数换了外部资源的使用方式也得跟着调整。如果你用的是Kubernetes那套建议先升级一个副本观察一段时间再逐步扩展不要一口气把Deployment里的副本全换新。这个流程我们在后面“上线顺序”里再细说。2. 核心改动build文件和代码适配一处处抠升级前的工作做扎实了下一步就是动代码和构建脚本了。这里有一说一绝大多数“升级失败”的报错最终都能追溯到构建配置或依赖版本不匹配上真正要改自家业务代码逻辑的反而不多。2.1 Maven和Gradle配置的变化点我的项目用的Maven所以先从Maven说起。第一件事切maven.compiler相关配置。很多老项目的pom.xml长这样properties java.version11/java.version maven.compiler.source11/maven.compiler.source maven.compiler.target11/maven.compiler.target /properties改成21是最基础的但千万别只改版本号就完事。我建议顺手把maven-compiler-plugin升到3.11.0以上因为老版本插件对JDK 17的编译支持不完整可能会出现“非法目标发行版”或者注解处理器不生效的问题。第二件事升级你用的Spring Boot版本。这是JDK 11升级到21时最容易卡住的大头。Spring Boot 2.x是基于javax命名空间的而且官方对Java 17的适配只在小版本里做过测试真要稳妥地跑在JDK 21上建议直接跳到Spring Boot 3.2以上版本。这里要说明的是Spring Boot 3.x本身要求Java 17起步我们升到21完全兼容。Spring Boot从一个主版本跳到另一个主版本不只是版本号变化还牵扯很多配置项和自动配置类的改动。比如spring.factories改成META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports自定义starter的写法也变了。这块我建议你看看官方迁移文档但别指望文档全对还是以实际运行报错为准。第三件事Lombok。很多人忽视了它。早期的Lombok版本对高版本JDK支持很差因为Lombok是靠编译期注解处理器深入JDK内部API的。JDK 16开始强封装了JDK内部API老Lombok直接编译报错。我最初用1.18.24在本地编译都能过CI里换了台新机器直接Fail就是这个问题。升到1.18.30以上基本能稳。Gradle用户也一样除了看项目的build.gradle里sourceCompatibility和targetCompatibility还得检查你用的Gradle版本本身支持不支持Java 21。Gradle 7.5以下官方都不支持Java 18的特性建议升到8.5以上。2.2 javax到jakarta这段迁移最琐碎这个坑我在升级前是预料到了但没想到这么琐碎。以前写代码习惯性import javax.annotation.PostConstruct;Spring Boot 3.x一下全改成jakarta.annotation.PostConstruct了。不只是注解还有这些javax.servlet.*→jakarta.servlet.*javax.persistence.*→jakarta.persistence.*javax.validation.*→jakarta.validation.*javax.transaction.*→jakarta.transaction.*javax.xml.bind.*→jakarta.xml.bind.*如果你代码里直接用了这些包搜索引擎搜出来的老博客里全是旧写法一个不留神就复制粘贴错了。更麻烦的是有些库可能在传递依赖里把新旧两个命名空间都带进来了编译不报错运行时启动直接报Bean冲突——这类问题我后面会列在“隐蔽坑”里。那有没有快速定位的方法有全局搜索代码和配置文件里的javax.前缀一处一处改。注意改完编译后开发工具里的缓存最好清一下否则残留的编译缓存会迷惑你让你以为没改干净。我试过用IDEA自带的重构功能批量替换包名但因为Spring Boot Starter里的很多依赖自带javax和jakarta两套包全局替换容易把第三方的关系搞乱。我的做法是只替换自己源码里的javax.*第三方框架的包路径靠升级依赖版本解决不要自己动手改第三方包。2.3 内置API和废弃API的清理JDK 11升到21这里有个常见的“暗坑”很多你从没用过但框架底层在用API变了。比如SecurityManager在JDK 17被标记为废弃21里还在但未来大概率移除ThreadGroup虽然不是废了但很多方法已经不可靠。如果你的项目有安全管理器之类的东西基本可以删掉了。另一个高频改动是java.util.logging和其他日志框架的桥接问题这个不是JDK本身的问题而是某些老库对LoggerFactory的绑定方式不兼容。常见的报错是LoggerFactory is not a Logback LoggerContext but Logback is on the classpath。这个多半是slf4j-api版本太老导致的升级到SLF4J 2.x就能解决。还有一点是我自己项目里遇到的JDK 17开始java.lang.reflect.Proxy对于非public接口的动态代理默认不允许跨模块访问了。换句话说如果你的代码里给包级私有接口做了动态代理运行时会直接报IllegalAccessError。解决办法有两个一是把接口改成public二是在启动参数里加--add-opens。前者是治本后者是应急。JDK 21还默认启用了Dynamic Agent Loading的告警你在启动日志里可能会看到一行WARNING: A terminally deprecated method in java.lang.System has been called之类的字眼别慌它只是提醒你某些老方法快不行了。建议把启动日志里所有WARNING和deprecated提示搜集起来逐个处理别放着不管不然积累到下一个LTS版本升级时又是一笔大债。2.4 代码层面更值得关注的几个改动除了javax这波大迁徙还有几个相对容易忽略的API变动整理成一张表变更点JDK 11写法JDK 21推荐写法影响范围java.util.Date和Calendar相关老代码里到处是换用java.timeLocalDate/LocalDateTime日期时间处理、定时任务String构造函数new String(bytes)明确指定字符集或直接用new String(bytes, StandardCharsets.UTF_8)文本处理、编码相关BigDecimal构造方法new BigDecimal(double)用BigDecimal.valueOf(double)或字符串构造器金额计算精度问题File相关APIFile#delete()判断布尔值用Files.delete()它抛异常更明确文件操作Java EE包javax.*jakarta.*Web应用、ORM、Bean Validation别小看这些改动。看起来都不难但藏在大规模代码里的时候逐个排查特别耗时。我个人的建议是别想着一次性改完按照“编译能过 → 单元测试能过 → 集成测试能过 → 生产验证”的顺序来每走一步都提交一次。这里多说一句Java 21的虚拟线程Virtual Threads确实是个大亮点但别天真地以为“代码里加个参数就自动用上了”。要把线程池那些改成虚拟线程需要认真评估锁竞争、ThreadLocal使用、native方法对接等等。我这次升级没有大面积切虚拟线程只在新建的一个异步任务模块里用了求稳。3. 构建编译和运行时环境改镜像、调参数代码层面改完接着就到构建和运行环境了。这个环节看似简单实际是最容易出问题的地方尤其是你在CI/CD里用的构建工具和基础镜像版本不匹配时报错能绕晕你。3.1 基础镜像怎么选必须是“多阶段构造”的思路之前项目用的Dockerfile是FROM openjdk:11-jre-slim这种老写法。但注意OpenJDK官方镜像从JDK 17之后就不再更新带-jre标签了很多老镜像里的底层系统还是Ubuntu 20.04甚至更老安全性也跟不上。建议改用Eclipse Temurin或者Amazon Corretto的镜像比如FROM eclipse-temurin:21-jre-alpine我踩过一个坑alpine镜像很瘦小但带的是musl libc某些用到了JNI的原生库在alpine下没法跑。如果你项目里有JNA、OpenCV或者其他本地库依赖请选择基于Ubuntu或UBI的发行版镜像比如eclipse-temurin:21-jre-jammy。如果你不想在项目里直接改基础镜像也可以在发布系统里通过参数覆盖镜像版本但这样做最大的问题是版本漂移开发本地用的还是旧版本。一句话总结基础镜像在Dockerfile里固定死不要靠外部参数注入。另外如果你们公司有统一的私服镜像源记得同步拉取JDK 21对应镜像到私服。别到了发布当天才发现私服上没有临时去外网拉网络一波动整个发布流程全卡住。3.2 JVM参数从11到21的变化很多人升级完JDK启动脚本还是老一套参数这里就要出问题了。JDK 21的垃圾回收器和默认参数有过不少调整老参数有些已经没用了有些则不再推荐。举几个最实际的例子-XX:UseConcMarkSweepGCCMS垃圾收集器在JDK 14被移除直接不能用了。项目如果还用这个参数启动就直接失败。-XX:UseParNewGC同样被移除。-Xmn、-XX:PermSize、-XX:MaxPermSize这些参数在JDK 8时代就被元空间替代在21里会被忽略但如果你不小心还把PermSize挂在脚本里有可能被JVM警告。-XX:PrintGCDetails、-XX:PrintGCDateStampsJDK 9开始统一改成-Xlog:gc*旧参数虽然在后续版本中还能用但官方建议换用统一日志风格。你这会儿可能要问了那到底该怎么设没有万能参数但可以参考一个比较稳妥的起步配置java -Xms4g -Xmx4g \ -XX:MaxMetaspaceSize512m \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -Xlog:gc*:file/logs/gc.log:time,uptime,level,tags:filecount5,filesize100m \ -jar your-app.jarG1GC从JDK 9开始就是默认收集器在21里也是主流选择。如果你的堆内存特别大比如超过32G可以试试ZGC它的停顿时间更短但需要额外确认操作系统版本和容器内存限制之间的配合不然会看到奇怪的效果。容器环境里还要注意-XX:UseContainerSupportJDK 10以后默认开启不用你手动加。但如果你还习惯在启动脚本里写-XX:MaxRAMPercentage75.0这类百分比参数那就别再加固定-Xmx了二者混用容易导致内存分配不符合预期。3.3 模块化“坑王”module-info与classpath的碰撞JDK 9之后引入了模块系统如果你有同学在项目里加了module-info.java那升级21时特别容易踩到“包冲突”的坑。更多时候项目没主动加模块化但某些第三方库的模块描述符里写了强依赖你把它和其他库放在同一个classpath下就可能导致Module java.base does not export ...这样的启动错误。遇到这类报错最直接的解法就是用--add-opens或者--add-exports把它们加进启动脚本里。比如java --add-opens java.base/java.langALL-UNNAMED \ --add-opens java.base/java.utilALL-UNNAMED \ -jar your-app.jar不过我得提醒一句这是一种“缓解方案”并不是“修复方案”。有一堆add-opens就说明你的依赖没彻底升级干净。长期来看还是得把那些依赖模块内部机制的老库替换掉。重要提示如果你一个模块化应用用到了Spring或Spring Boot要格外注意。Spring框架在Java 9的模块化环境里一直强调“非模块化运行”千万别轻易给项目加module-info否则一堆反射操作会撞墙。3.4 构建工具的隐藏雷区说实话我这次折腾最久的不是业务代码而是CI里的构建工具版本。Maven用户请确认Maven本身在3.9以上否则可能不支持Java 21编译产物的处理。Gradle用户Gradle 8.5以上稳妥我项目里曾从7.6直接升8.4发现有插件报兼容问题升到8.6才好。如果你在Jenkins pipeline里用了老旧的JDK工具链配置也要把JDK 21的安装路径和版本号补进工具配置里。再有就是如果项目里用了maven-shade-plugin打胖包建议升到3.5.xmaven-assembly-plugin升到3.6.0spring-boot-maven-plugin随Spring Boot版本一起升。这些插件的旧版本在处理高版本class文件时多多少少有些脑血栓问题。4. 实际升级实录一次完整可参考的路径这里我把自己这次升级的过程完整梳理一遍给大家一个可以直接照抄的路径。当然每个项目情况不同但这套顺序是通用的踩坑概率会小很多。4.1 我的升级步骤总览我把整个过程拆成六步准备一个独立的Git分支专门做升级别跟业务需求混在一起。在本地把JDK切到21用IDE打开项目先让代码能编译过。把构建脚本里的Java版本、插件版本、依赖版本逐项更新。跑单元测试修所有编译期和运行期错误。在预发环境部署用典型接口做回归测试。灰度发布观察JVM指标和生产日志。你可能觉得第一步是废话但很多团队真就是直接在主干上改改到一半发现浪费了好几天想回滚都困难。独立分支很重要。4.2 编译期报错清单这些都在预料中在实际编译期间我遇到了这么几类报错第一类无法访问javax.servlet.*。这个就是包名迁移问题因为Spring Boot 3.x内置的Tomcat 10已经强制使用jakarta命名空间项目代码里的老import必须改。第二类程序包com.sun.nio.file不存在。有个老工具类用到了com.sun.nio.file.SensitivityWatchEventModifier这是我们自己代码的问题改掉这个调用就行。这类非官方API在JDK 17之后都被模块封装得死死的不再向外部暴露。第三类lombok相关报错比如java.lang.NoClassDefFoundError: lombok/launch/AnnotationProcessorHider$AnnotationProcessor。真的是措手不及因为我在本地编译都好好的跑到CI就炸了。后来发现是CI里的Maven进程用了老JDK去启动Lombok这个不只是版本号的问题而是整个链路的JDK都要对齐。4.3 运行期问题实录最折磨人的阶段编译过了、测试过了不代表就万事大吉。运行期遇到的问题更多也更隐蔽。第一个经典报错是java.lang.reflect.InaccessibleObjectException: Unable to make field ... accessible。面对这种问题先不要急着在启动脚本里--add-opens先去网上搜一下这个类是从哪个第三方库来的看看新版本有没有修复。比如有些CGLIB老版本动态生成子类时会尝试反射访问目标类的私有字段在JDK 17必然失败升级CGLIB版本就能解决。第二个经典报错是NoSuchMethodError。这十有八九是依赖中的方法签名跟运行时实际加载的类不一致。比如两个库分别传递依赖了不同版本的ASM或ByteBuddy构建时是A版本运行时被B版本覆盖了。处理方式就是利用Maven的dependencyManagement或Gradle的resolutionStrategy统一指定一份最新的版本别指望系统自动帮你解决。第三个是启动时候的ClassNotFoundException或NoClassDefFoundError尤其集中在JAXB、JAX-WS这些模块上。在JDK 11时代你可能已经习惯单独引javax.xml.bind:jaxb-api这类的依赖了到了JDK 17这类模块直接从JDK中移除了必须显式引入对应的独立依赖。但如果你已经升级到Spring Boot 3.x注意要用jakarta.xml.bind:jakarta.xml.bind-api不要再引javax的旧包。第四个是隐藏的ThreadLocal内存泄漏。Java 21里虚拟线程的ThreadLocal是虚拟线程私有的虽然数量多时更吃内存但真正让人头疼的是老代码里大量使用ThreadLocal存一些大型对象换成高并发、高线程数场景会明显放大内存压力。我在一个异步处理模块里就遇到过这个问题后来把大对象从ThreadLocal里挪出来改用方法参数传递。4.4 用两个工具帮你少走弯路如果你觉得上面这些排查太靠经验我推荐两个工具OpenRewrite这是一个批量重构框架有现成的迁移规则集可以自动把Spring Boot 2.x升级到3.x、把javax改成jakarta、把JUnit 4改成JUnit 5。虽然不是银弹但能解决大部分机械性替换。jdeps前面已提过一次少不了的静态分析工具建议在预发验证前对最终产物再跑一次它能提示有哪些类还依赖JDK内部API。用法示例jdeps --jdk-internals --multi-release 21 your-app.jar看到输出里列出JDK Internal API的行就一条条核对。这些内部API在后续版本中可能直接消失早改早安心。5. 上线部署与常见问题排查稳中带皮代码改完、本地跑通最后一步是上线。上线阶段的常见问题和前几个阶段又不太一样更偏向运维和运行环境视角。5.1 容器内存与JVM内存如何匹配这是个大坑很多团队升级后被OOMKilled打得很惨。原因在于你没给JDK 21的元空间、线程栈、直接内存留足空间。在容器里如果你设置了-Xmx4g但容器的memory limit只有4g那JVM在一开始会好好的一旦GC和直接内存占用上去直接被内核干掉。我目前的习惯是在Docker Compose或K8s里设置容器的内存limit为JVM堆内存的1.5倍左右。举个例子如果应用堆需要4g那么容器内存限制至少给6g再多留一点给线程栈和Metaspace。别迷信“极限压缩内存”那套稳定性优先。启动参数里也建议配合使用-XX:MaxRAMPercentage70.0这种方式而不是写死-Xmx。百分比方式的优势是当容器内存调整时JVM堆能自动跟着变不用我手动改参数。5.2 灰度发布和回滚策略升级JDK这种基础运行时的事故影响面往往比较大。我的建议是先找一台机器改tag重启观察5到10分钟JVM指标特别是GC频率、Full GC次数、老年代占用率。如果一切正常再按10%、30%、50%、100%的节奏逐渐放量。放量过程中重点盯四类数据接口响应时间P99尤其看有没有明显劣化。GC日志看有没有频繁Full GC或者大对象分配。错误日志重点搜Exception、Error、InaccessibleObjectException。业务指标比如下单量、支付成功率这种跟代码直接相关的数据。如果发现异常回滚优先级是先切流量到旧版本再排查问题原因不要在现场做代码修复。旧版本的镜像产物请一定提前保留。5.3 高频报错速查表我把这次升级过程中遇到的高频问题整理成速查表建议收藏报错现象常见原因解决路径Unable to make field accessibleJDK模块化强封装反射目标类所在模块不允许访问升级相关库版本必要时加--add-opensNoSuchMethodError依赖版本冲突或方法签名变更统一依赖版本清理无用的老版本ClassNotFoundException: jakarta.*项目还在用javax但框架已切到jakarta全局替换import升级依赖版本Module java.base does not export使用了JDK内部API改用标准API或用--add-exports过渡Unsupported class file major version 65构建工具/插件版本太老无法识别Java 21的class文件升级Maven/Gradle/编译插件版本GC overhead limit exceeded堆内存不足或者永假死循环检查堆参数排查代码里大对象分配System property java.specification.version相关异常某些库在启动时校验JDK版本老版本库不认21升级库版本Could not initialize class sun.misc.Unsafe使用了Unsafe内部API用标准API替代或者找新版本库5.4 我最后的避坑心得最后再分享几个我这次实操下来觉得特别有价值的小经验。升级前先把所有不再维护的旧依赖查一遍看看它最后发布的版本支不支持Java 21。如果项目停更在两三年前别抱幻想了尽早找替代品。在做全量回归测试时最好安排一个长时间运行的冒烟任务比如持续跑十分钟以上的并发压测。很多JDK升级产生的问题要等高并发或定时任务触发才会暴露光靠几个接口冒烟根本测不出来。我强烈建议把启动参数里加的--add-opens集中放到一个配置文件里边上注释写明“为什么加、谁需要它、何时可以移除”。这样后面的人接手时才知道哪些参数是临时补丁而不是当成金科玉律焊死在那里。升级JDK这事说难不难说简单也真不简单。只要不头铁按照“先摸清依赖 → 改构建和代码 → 本地验证 → 预发回归 → 灰度发布”的顺序来每一步稳扎稳打其实比想象中顺利。我在第一次跑通全流程的时候看到旧服务用上虚拟线程后在高并发下的毛刺明显减少还是觉得这一周的折腾挺值。
返回列表