从 Java 8 升到 Java 21,中间隔着 13 个版本和两次大改(模块化、Jakarta 包名),盲目升级会导致一堆类找不到、反射报错、编码错乱。这篇按顺序带你走完整个流程,学完你能在自己的项目上跑通一次可回滚的升级。
第一步:先定版本策略,别一上来就升 21
Java 的 LTS 只有 8、11、17、21。建议目标直接定 21,但中间必须经过 17 做验证,因为 Spring Boot 3.x、Jakarta EE 9+ 这一代生态的基线是 17。
实际做法是分两步,而不是四个 LTS 逐级走:先把编译目标提到 17,跑通全部测试和一次线上灰度;再提到 21,主要为了虚拟线程、分代 ZGC 这些收益。
如果项目是几百个模块、依赖一团乱,退一步,用 8 → 11 → 17 → 21 逐级升,每级都跑一次完整回归。慢,但出问题时容易定位。
第二步:用官方工具扫不兼容点
先把构建产物(jar 或 war)交给 JDK 21 自带的两把扫描工具,不需要先改代码:
jdeps --multi-release 21 --jdk-internals your-app.jar
# 找出使用了已弃用/已移除 API 的类
jdeprscan --release 21 --class-path "libs/*" your-app.jar
jdeps 的输出里出现 JDK internal API 的行,就是升级后会被强封装挡掉的代码;jdeprscan 报 cannot find class 的,多半就是被移除的 Java EE 模块。
注意:这两个工具要用 JDK 21 的版本去扫旧 jar,不要用 JDK 8 里的 jdeps,老版本没有 --jdk-internals 参数。
第三步:改构建脚本的编译目标
Maven 项目不要再写 source/target,改用 release,它同时约束字节码版本和 API 签名,能防止你在 21 上编译却误用了 21 才有的类:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>21</release>
<compilerArgs><arg>-Xlint:all</arg></compilerArgs>
</configuration>
</plugin>
Gradle 用 toolchain,避免构建机 JDK 和项目 JDK 混用:
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}
先把 release 设成 17 编译一遍,编译不过的报错就是源码级不兼容(比如 _ 作变量名、var 与老代码冲突、switch 表达式语法冲突),这些必须优先修。
第四步:处理被移除的模块和内部 API
Java 11 移除了 Java EE 和 CORBA 那批模块,javax.xml.bind.*、javax.annotation.*、javax.activation.*、javax.corba.* 全部没了。缺什么补什么:
<dependency>
<groupId>jakarta.xml.bind</groupId>
<artifactId>jakarta.xml.bind-api</artifactId>
<version>4.0.2</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>4.0.5</version>
</dependency>
Java 16 起 jdk.internal.* 默认强封装,--illegal-access=permit 彻底失效。必须用反射访问 JDK 内部的框架(老版本 Hibernate、老 Netty、老 Mockito 都有),只能显式开洞:
java --add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
-jar app.jar
先按报错临时加,但记在债务清单里——--add-opens 是过渡手段,正确的做法是把依赖升到支持 21 的版本。
注意:Java 21 里 sun.misc.Unsafe 的内存操作方法会打印警告,SecurityManager 已标记为待移除。启动日志里出现这类警告不代表失败,但别在新代码里继续用。
第五步:盯住三个“不报错但行为变了”的坑
第一是默认字符集。Java 18 起 file.encoding 默认就是 UTF-8,不再跟随操作系统。以前在 Windows 上跑得好好的 new String(bytes)、FileReader、导出 CSV,升级后可能中文乱码。JDK 18 提供过一个 COMPAT 兼容开关,但那只是过渡,21 上必须改代码,统一显式传 StandardCharsets.UTF_8。
第二是默认垃圾回收器。Java 8 服务端默认 Parallel,9 起默认 G1。升级后吞吐可能下降、停顿特性变化,先看业务是延迟敏感还是吞吐敏感,必要时用 -XX:+UseParallelGC 回退再逐步调优。
第三是容器内存。Java 8u191 之后虽然能识别 cgroup,但默认 MaxRAMPercentage 只有 25%,容器给了 4G 却只用 1G 堆。上线前显式声明:
java -XX:MaxRAMPercentage=75 -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20M -jar app.jar
第六步:同步升级框架与字节码工具
这一步最容易卡住。Spring Boot 2.x 的包名是 javax.*,Spring Boot 3.x 全部改成 jakarta.*,这不是换个版本号的事,是源码级改造。如果只想先升 JDK,把框架留在 2.7.x 并在 21 上做兼容验证;如果要吃 21 的新特性,就得整体上 3.x 并改包名。
另外几个常见卡点:Lombok 需要 1.18.30 以上,字节码库 ASM、cglib、javassist、Groovy 都必须升到支持 class 文件版本 65(对应 Java 21)的版本,否则报 Unsupported class file major version 65。
注意:--enable-preview 能让你提前用未定稿特性,但同一个 class 只能在对应版本跑,生产环境不要开。
第七步:灰度与回滚方案
升级前把旧 JDK 目录、旧启动脚本、旧镜像标签都留着,新版本先用独立路径部署一套,通过内网流量或单台节点验证。验证清单至少包含:启动日志无 IllegalAccessError/NoClassDefFoundError、核心接口压测 P99、定时任务正常触发、文件上传下载编码正常、GC 日志无频繁 Full GC。确认没问题再全量切。
小结
- 目标定 21,但一定经过 17 做验证,模块多就走逐级升
- 先用
jdeps --jdk-internals 和 jdeprscan 扫,再动代码
- 编译用
release 而非 source/target,构建用 toolchain 锁定 JDK
- Java EE 模块被移除,补
jakarta.* 依赖;内部 API 用 --add-opens 临时开洞
- 三个隐形坑:默认 UTF-8 编码、默认 G1、容器内存只给 25%
- 框架从 Spring Boot 2.x 到 3.x 是包名级改造,单独排期
- 保留旧 JDK 与旧脚本,单节点灰度后再全量