1. 为什么现在必须认真对待 JDK 升级
JDK 升级早已不是"想升就升"的可选项,而是被三条线同时推着走的必答题:
- 安全线:Oracle 每季度发布 CPU(Critical Patch Update),旧版本上的高危漏洞(反序列化、JNDI、TLS 相关等)不再回补。继续停留在 8/11 意味着长期裸奔。
- 生态线:Spring Boot 3.x 要求 JDK 17+,Spring Framework 6 全面转向 Jakarta EE 9+(
javax.*→jakarta.*);大量主流中间件客户端、构建插件、APM Agent 的新版本也只对 17/21 做兼容测试。 - 成本线:新版本带来的 ZGC/Shenandoah 低延迟、虚拟线程、Records、Pattern Matching、
switch表达式等能力,直接对应机器成本与研发效率。
但现实是:升级本身不难,难的是升级过程中那些"看不见的依赖"。一个跑了七八年的单体应用,可能牵扯上百个三方包、几十个自研 SDK、若干字节码增强框架,任何一处不兼容都会在运行时才暴露。
本文按"评估 → 改造 → 验证 → 灰度 → 收尾"五个阶段梳理开发团队在 JDK 升级中要做的工作任务,并把业界反复踩到的坑和对应措施整理成可执行的清单。
2. 先搞清楚:升级到哪个版本
2.1 版本选择的基本原则
| 目标版本 | 定位 | 适用场景 | 建议 |
|---|---|---|---|
| JDK 8 | LTS(已停止免费商用更新) | 存量系统 | 仅作为过渡,制定退出计划 |
| JDK 11 | LTS | 存量系统 | 可作为中间跳板,不建议作为终点 |
| JDK 17 | LTS | 主流选择 | 大多数团队的首选落地版本 |
| JDK 21 | LTS | 新项目 / 追求性能 | 虚拟线程成熟,可作为新项目默认 |
| JDK 25 | LTS(2025 年发布) | 前瞻试点 | 先在非核心链路验证 |
实践建议:
- 不要跨太多大版本。8 → 17 是常见路径,但中间建议先用 11 跑一轮,把
javax相关、模块化相关问题提前暴露。 - 优先选 LTS,非 LTS 版本(如 12/13/14…)只有半年支持期,不适合生产。
- 统一版本。同一公司内尽量收敛到 1~2 个 LTS 版本,否则构建镜像、CI 矩阵、Agent 兼容测试的成本会指数级上升。
2.2 JDK 8 到 17/21 关键变化对照表
下表把正文中提到的关键变化点按「引入版本」汇总,方便读者按目标版本快速定位风险。引入版本指该变化首次生效的 JDK 版本,升级路径跨过该版本即需关注。
| 序号 | 变化项 | 引入版本 | 对业务代码的影响 | 应对动作 |
|---|---|---|---|---|
| 1 | CMS 垃圾回收器移除 | JDK 14 移除(JDK 9 起标记废弃) | 启动参数-XX:+UseConcMarkSweepGC直接报错,JVM 无法启动 | 改用 G1(默认)或 ZGC;压测对比 GC 停顿与吞吐后再定参数 |
| 2 | -XX:+PrintGCDetails等旧 GC 日志参数失效 | JDK 9(统一日志框架 JEP 158) | 旧日志参数被忽略或报错,GC 日志缺失导致线上问题无法定位 | 迁移到-Xlog:gc*统一日志写法,并纳入配置中心统一管理 |
| 3 | 永久代移除、元空间取代 | JDK 8 | -XX:MaxPermSize参数无效,OutOfMemoryError: Permafrost变为Metaspace | 删除旧参数,按需设置-XX:MaxMetaspaceSize,排查动态代理类膨胀 |
| 4 | Nashorn JavaScript 引擎移除 | JDK 15 移除(JDK 11 起标记废弃) | 依赖ScriptEngineManager.getEngineByName("nashorn")的脚本逻辑直接失败 | 迁移到 GraalJS 等独立引擎,或把脚本逻辑改为 Java 实现 |
| 5 | JAXB / JAX-WS / CORBA 等 Java EE 模块移除 | JDK 11 | NoClassDefFoundError: javax/xml/bind/*、javax/activation/*等启动即失败 | 显式引入jakarta.xml.bind-api+jaxb-runtime等依赖,并同步迁移包名 |
| 6 | sun.misc.Unsafe部分方法移除 | JDK 11 起逐步移除(defineClass、defineAnonymousClass) | 字节码增强、序列化、Mock 框架抛NoSuchMethodError | 升级依赖版本;改用MethodHandles.Lookup.defineClass等公开替代 API |
| 7 | 强封装(Strong Encapsulation)默认开启 | JDK 16 默认开启,JDK 17 中--illegal-access=permit失效 | 反射访问 JDK 内部类抛InaccessibleObjectException,老版本 Jackson/Groovy/Mock 框架受影响 | 优先升级框架版本;过渡期用--add-opens/--add-exports并登记台账 |
| 8 | String内部实现变化(Compact Strings) | JDK 9 | 依赖String.value字段反射的代码失效,序列化/加解密库可能异常 | 升级相关依赖;禁止反射访问String内部字段,改用公开 API |
| 9 | SecurityManager废弃 | JDK 17 标记废弃,JDK 24 起永久禁用 | 依赖System.setSecurityManager的沙箱、插件隔离逻辑失效或告警 | 移除 SecurityManager 依赖,改用进程隔离、模块化或容器级隔离方案 |
| 10 | TLS 1.0 / 1.1 默认禁用、弱算法禁用 | JDK 17(jdk.tls.disabledAlgorithms收紧) | 与老系统、银行、第三方对接时 TLS 握手失败,MD5withRSA、DES等算法不可用 | 服务端升级到 TLS 1.2+;临时用jdk.tls.disabledAlgorithms放开并限期整改 |
| 11 | 默认字符编码改为 UTF-8 | JDK 18(JEP 400) | 依赖平台默认编码的读写出现中文乱码,文件、HTTP 报文、数据库交互均可能受影响 | 显式指定-Dfile.encoding=UTF-8,代码中显式声明Charset,修正隐式依赖 |
| 12 | 模块化(JPMS)与类路径变化 | JDK 9 | Automatic-Module-Name缺失导致模块名冲突,split package 在模块路径下报错 | 业务应用继续使用 classpath;自研库补齐Automatic-Module-Name |
| 13 | javax.*→jakarta.*命名空间迁移 | 由 Jakarta EE 9 / Spring Boot 3 驱动(非 JDK 本身) | Servlet、JPA、Validation、JMS 等 import 全部失效,编译不通过 | 升级 Spring Boot 3.x 等框架,同步迁移 import 与反射/配置中的类名字符串 |
说明:表中「引入版本」以 OpenJDK 官方 JEP 为准;实际项目中还需结合所用框架(如 Spring Boot 3.x 要求 JDK 17+)综合判断升级路径。
2.3 用工具先摸清家底
升级前必须回答三个问题:我们用了什么、依赖了什么、跑在什么上面。
# 1. 查看当前 JDK 版本与供应商java-versionjava-XshowSettings:properties-version2>&1|grep-E"java.version|java.vendor|java.home"# 2. 列出所有依赖(Maven)mvn dependency:tree-Dverbose>deps.txt# 3. 检查字节码目标版本,找出仍在用 Java 8 编译的模块find.-name"*.class"-path"*/target/classes/*"|head-1|xargsjavap-verbose|grep"major version"major version对照:52 = Java 8,55 = Java 11,61 = Java 17,65 = Java 21。
更系统的做法是引入jdeprscan(JDK 自带)扫描废弃 API:
# 扫描 jar 中使用的、已废弃或将被移除的 APIjdeprscan--release17--for-removal target/app.jar# 扫描整个依赖目录jdeprscan--release17--for-removal --class-path"libs/*"target/app.jar以及jdeps分析模块依赖:
jdeps --multi-release17--ignore-missing-deps-summarytarget/app.jar3. 升级工作任务全景
下面这张图是团队升级 JDK 时通常要走的完整链路:
3.1 阶段一:评估与盘点
工作任务清单:
- 应用清单:列出所有需要升级的应用,标注重要性、流量、负责人、当前 JDK 版本。
- 依赖矩阵:整理核心三方依赖(框架、数据库驱动、序列化库、字节码增强、APM Agent、加解密 SDK)的最低兼容版本。
- 中间件与运行时:确认容器基础镜像、K8s 节点、Sidecar、JVM 参数模板、监控采集器是否支持目标版本。
- 构建链路:Maven/Gradle 版本、编译插件、代码质量插件(Checkstyle/PMD/SpotBugs)、打包插件(shade/assembly)的兼容性。
- 产出评估报告:明确"可直接升级 / 需改造 / 需替换 / 需暂缓"四类应用。
关键动作:把依赖矩阵做成一张表,逐项标注"当前版本 / 目标最低版本 / 风险等级 / 负责人",这张表就是后续所有工作的基线。
3.2 阶段二:代码与依赖改造
这是工作量最大、也最容易出问题的阶段。按问题类型拆解:
(1)javax.*→jakarta.*命名空间迁移
Spring Boot 3 / Jakarta EE 9 之后,Servlet、JPA、Validation、JMS 等 API 全部迁移到jakarta.*。这是升级到 17+ 时最容易被低估的工作量。
// 升级前importjavax.servlet.http.HttpServletRequest;importjavax.validation.constraints.NotNull;importjavax.persistence.Entity;// 升级后importjakarta.servlet.http.HttpServletRequest;importjakarta.validation.constraints.NotNull;importjakarta.persistence.Entity;应对措施:
- 优先升级框架版本(Spring Boot 2.7 → 3.x),让框架自身完成迁移,而不是手工改 import。
- 自研 SDK、内部公共包必须同步发版,否则会形成"依赖冲突地狱"。
- 使用 IDE 的批量替换 + 编译验证,不要用正则全局替换(会误伤字符串、注释、反射中的类名)。
- 注意反射与配置中的类名字符串:
Class.forName("javax.xxx")、XML 配置、spring.factories里的全限定名都要一并检查。
(2)被移除的 API 与内部 API
JDK 17 移除了大量在 8/11 中已废弃的 API,典型如:
sun.misc.Unsafe的部分方法(defineClass、defineAnonymousClass已移除)NashornJavaScript 引擎(JDK 15 移除)SecurityManager(JDK 17 标记废弃,JDK 24 起被永久禁用)java.lang.ref.Finalizer相关内部实现- CORBA、JAXB、JAX-WS 等 Java EE 模块(JDK 11 起移除,需显式引入依赖)
<!-- JDK 11+ 需要显式引入 JAXB --><dependency><groupId>jakarta.xml.bind</groupId><artifactId>jakarta.xml.bind-api</artifactId><version>4.0.2</version></dependency><dependency><groupId>org.glassfish.jaxb</groupId><artifactId>jaxb-runtime</artifactId><version>4.0.5</version></dependency>应对措施:用jdeprscan --for-removal全量扫描,把结果按"必须改 / 可延后 / 可忽略"分类;对sun.*、com.sun.*的调用逐个评估替代方案。
(3)强封装(Strong Encapsulation)与反射
JDK 16 起默认开启强封装,JDK 17 中--illegal-access=permit已失效。大量依赖反射访问 JDK 内部类的框架(老版本 Jackson、Groovy、部分序列化库、Mock 框架)会直接抛InaccessibleObjectException。
java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module应对措施:
- 首选升级框架版本,新版本框架已适配。
- 过渡期用
--add-opens/--add-exports显式开放:
java--add-opens java.base/java.lang=ALL-UNNAMED\--add-opens java.base/java.util=ALL-UNNAMED\--add-opens java.base/java.lang.reflect=ALL-UNNAMED\-jarapp.jar- 把
--add-opens集中到启动脚本或JAVA_TOOL_OPTIONS,并记录每一行的原因,避免变成永久技术债。 - 长期方案:推动依赖方修复,或用
jdk.internal的公开替代 API 重写。
(4)模块化(JPMS)相关
大多数业务应用不会真正模块化,但会受"类路径 → 模块路径"的间接影响:
Automatic-Module-Name缺失导致模块名冲突。- 同名包被拆分到多个 jar(split package),在模块路径下直接报错。
ServiceLoader在模块路径下需要provides ... with ...声明。
应对措施:业务应用继续使用 classpath,不要贸然上模块路径;对自研库补齐Automatic-Module-Name。
(5)语言与编译器行为变化
var与类型推断:不影响兼容性,但团队需统一代码规范。switch表达式与yield:新语法,注意与旧代码风格统一。- 文本块:注意缩进处理规则(incidental whitespace)。
- Records:适合 DTO,但不适合需要可变性或框架反射赋值的场景(部分 ORM 对 Record 支持有限)。
- 密封类(sealed):可用于领域建模,但注意与序列化框架的兼容。
String内部实现变化(JDK 9 起 Compact Strings):依赖String.value反射的代码会失效。
(6)垃圾回收器与 JVM 参数
JDK 17 默认 GC 是 G1,JDK 21 起可用 ZGC 分代。升级时 JVM 参数需要重新审视:
# JDK 8 常见参数(部分在 17 中已废弃或行为变化)-XX:+UseConcMarkSweepGC# CMS 在 JDK 14 移除-XX:+PrintGCDetails# 改为 -Xlog:gc*-XX:MaxPermSize=256m# 元空间已取代永久代,参数无效-XX:+UseCompressedOops# 默认开启,无需显式指定# JDK 17/21 推荐写法-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=50m-XX:+UseG1GC-XX:MaxGCPauseMillis=200-XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/var/log/app/heap.hprof应对措施:把 JVM 参数模板纳入配置中心统一管理,升级前用压测对比 GC 日志与停顿时间。
3.3 阶段三:构建与 CI/CD 改造
工作任务清单:
- 编译参数:
maven.compiler.source/target或release统一改为目标版本,推荐用--release而非source/target,避免误用高版本 API。
<properties><maven.compiler.release>17</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>- 构建工具版本:Maven 3.8+、Gradle 7.3+ 才完整支持 JDK 17;Gradle 8.x 才支持 JDK 21。
- 插件升级:
maven-compiler-plugin3.11+、maven-surefire-plugin3.0+、maven-shade-plugin3.4+、jacoco-maven-plugin0.8.8+。 - 基础镜像:
openjdk:8-jdk→eclipse-temurin:17-jre-jammy或amazoncorretto:17-al2023,注意时区、字体、/etc/localtime等差异。 - CI 矩阵:并行跑 8 与 17 两套构建,逐步切换;保留回滚能力。
- 制品仓库:确认 Nexus/Artifactory 中的 SNAPSHOT 清理策略,避免旧字节码混入。
# 推荐的多阶段构建写法 FROM eclipse-temurin:17-jdk-jammy AS builder WORKDIR /build COPY . . RUN ./mvnw -B clean package -DskipTests FROM eclipse-temurin:17-jre-jammy ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /app COPY --from=builder /build/target/app.jar app.jar ENTRYPOINT ["java", "-jar", "/app/app.jar"]3.4 阶段四:测试与验证
测试分层策略:
| 层级 | 重点 | 工具 |
|---|---|---|
| 单元测试 | API 兼容、反射、序列化 | JUnit 5 + Mockito 5 |
| 集成测试 | 数据库驱动、消息队列、缓存客户端 | Testcontainers |
| 契约测试 | 对外接口行为不变 | Pact / Spring Cloud Contract |
| 性能测试 | GC 停顿、吞吐、内存占用 | JMH + 全链路压测 |
| 兼容性测试 | 上下游系统联调 | 影子流量 / 回放 |
必须重点验证的场景:
- 序列化/反序列化:JDK 自带序列化、Jackson、Fastjson、Protobuf、Hessian 的跨版本兼容。
- 加解密与证书:JDK 17 默认禁用 TLS 1.0/1.1,禁用弱算法(如
MD5withRSA、DES),老系统对接银行/第三方时极易踩坑。 - 日期时间:
SimpleDateFormat线程安全问题、java.time与数据库时区映射。 - 字符编码:JDK 18 起默认 UTF-8(JEP 400),依赖平台默认编码的代码会行为变化。
- 反射与代理:CGLIB、ByteBuddy、ASM 版本必须与目标 JDK 匹配。
// TLS 兼容性排查示例:打印实际协商的协议与套件SSLContextctx=SSLContext.getInstance("TLS");SSLSocketFactoryfactory=ctx.getSocketFactory();try(SSLSocketsocket=(SSLSocket)factory.createSocket("example.com",443)){socket.startHandshake();SSLSessionsession=socket.getSession();System.out.println("Protocol: "+session.getProtocol());System.out.println("CipherSuite: "+session.getCipherSuite());}3.5 阶段五:灰度发布与收尾
- 灰度策略:按流量比例(1% → 5% → 20% → 100%)或按机房/单元逐步放量,每档观察至少一个完整业务周期。
- 监控指标:GC 次数与停顿、堆内存曲线、线程数、CPU、P99 延迟、错误率、
InaccessibleObjectException/NoClassDefFoundError等异常计数。 - 回滚预案:镜像保留上一版本、配置中心保留旧 JVM 参数、数据库变更需向前兼容。
- 收尾动作:更新团队开发规范、统一
pom.xml父 POM、清理--add-opens技术债、归档升级报告。
4. 常见问题与应对措施速查表
| 序号 | 问题现象 | 根因 | 应对措施 |
|---|---|---|---|
| 1 | NoClassDefFoundError: javax/xml/bind/DatatypeConverter | JDK 11 移除 JAXB | 显式引入jakarta.xml.bind-api+jaxb-runtime |
| 2 | InaccessibleObjectException | 强封装禁止反射访问 JDK 内部 | 升级框架版本;过渡期用--add-opens |
| 3 | Unsupported class file major version 61 | 构建工具/插件版本过低 | 升级 Maven/Gradle 与编译插件 |
| 4 | java.lang.NoSuchMethodError: sun.misc.Unsafe.defineClass | 使用已移除的内部 API | 改用MethodHandles.Lookup.defineClass或升级依赖 |
| 5 | javax.servlet找不到 | 未迁移到jakarta.servlet | 升级 Spring Boot 3.x,同步迁移 import |
| 6 | TLS 握手失败 | 默认禁用 TLS 1.0/1.1 与弱算法 | 服务端升级协议;必要时用jdk.tls.disabledAlgorithms临时放开 |
| 7 | 中文乱码 | JDK 18 默认 UTF-8 | 显式指定-Dfile.encoding=UTF-8或修正代码 |
| 8 | OutOfMemoryError: Metaspace | 动态代理类过多 / 类加载器泄漏 | 调大-XX:MaxMetaspaceSize,排查 CGLIB 滥用 |
| 9 | 启动变慢 | 模块化校验、类加载顺序变化 | 使用 AppCDS(-XX:SharedArchiveFile)加速启动 |
| 10 | 序列化不兼容 | 类结构变化 / 序列化框架版本不匹配 | 显式serialVersionUID,统一框架版本 |
5. 团队协作与工程化建议
- 设立升级专项:指定 Owner,按季度推进,避免"人人有责等于无人负责"。
- 建立依赖准入规范:新引入的三方包必须声明支持的最低 JDK 版本,CI 中自动校验。
- 父 POM 统一管控:把 JDK 版本、插件版本、依赖版本收敛到公司级 BOM。
- 保留双版本构建能力:至少在过渡期内,CI 同时产出 8 与 17 制品,便于灰度与回滚。
- 技术债台账:所有
--add-opens、--add-exports、临时放开的安全算法都要登记,设定清理期限。 - 知识沉淀:把每次踩坑写成 FAQ 或内部 Wiki,新项目启动时直接复用。
6. JDK 升级 Checklist
下面这份清单按「评估 → 改造 → 验证 → 灰度 → 收尾」五个阶段组织,可直接复制到项目管理工具中逐项勾选,作为升级专项的执行基线。
阶段一:评估
- 应用清单已完成,标注重要性、流量、负责人与当前 JDK 版本
- 依赖矩阵已完成,逐项标注「当前版本 / 目标最低版本 / 风险等级 / 负责人」
jdeprscan --release <目标版本> --for-removal已对全部制品与依赖目录全量扫描jdeps --multi-release <目标版本>已分析模块依赖与 split package 风险- 中间件与运行时(基础镜像、K8s 节点、Sidecar、APM Agent、监控采集器)兼容性已确认
- 评估报告已产出,应用已归类为「可直接升级 / 需改造 / 需替换 / 需暂缓」
阶段二:改造
- 框架版本已升级(如 Spring Boot 2.7 → 3.x),
javax.*→jakarta.*迁移完成 - 反射与配置中的类名字符串(
Class.forName、XML、spring.factories)已逐一核查 - 被移除 API(
sun.misc.Unsafe、Nashorn、JAXB/JAX-WS 等)已替换或显式引入依赖 --add-opens/--add-exports已集中到启动脚本,并登记台账(含原因与清理期限)- 自研 SDK 与内部公共包已同步发版,
Automatic-Module-Name已补齐 - JVM 参数模板已按目标版本重写(GC 日志、GC 选择、废弃参数清理)
阶段三:验证
- 编译参数已统一为
maven.compiler.release,构建工具与插件版本满足目标 JDK 要求 - 基础镜像已切换为
eclipse-temurin/amazoncorretto等目标版本镜像,时区与字体已验证 - 单元测试、集成测试(Testcontainers)、契约测试全部通过
- 序列化/反序列化跨版本兼容已验证(JDK 原生、Jackson、Protobuf、Hessian)
- TLS 弱算法与协议已排查,与银行/第三方对接链路握手成功
- 字符编码(JEP 400 默认 UTF-8)、日期时间、反射代理(CGLIB/ByteBuddy/ASM)专项验证通过
阶段四:灰度
- 灰度方案已确定(按流量 1% → 5% → 20% → 100%,或按机房/单元放量)
- 监控大盘已覆盖 GC 停顿、堆内存、线程数、P99 延迟、错误率与关键异常计数
- 回滚预案已就绪:上一版本镜像、旧 JVM 参数、数据库向前兼容均已确认
- 每档灰度观察至少一个完整业务周期,并留存对比数据
- 压测结果与旧版本对比完成,GC 日志与停顿时间无明显劣化
阶段五:收尾
- 全量上线完成,异常与告警在观察期内保持平稳
--add-opens/--add-exports技术债台账已复核,明确清理计划与责任人- 父 POM / 公司级 BOM 已统一到目标 JDK 与插件版本
- 团队开发规范、CI 模板、Dockerfile 基线已更新并同步到所有仓库
- 升级报告已归档,踩坑记录沉淀为 FAQ 或内部 Wiki
7. 总结
JDK 升级是一项跨代码、依赖、构建、运维、测试的系统工程,技术难度往往不在语言本身,而在那些"看不见的耦合"上。几条核心经验:
- 先评估后动手:
jdeprscan+jdeps+ 依赖矩阵,把不确定性前置。 - 框架先行:优先升级 Spring Boot 等框架,让生态替你完成大部分迁移。
- 小步快跑:8 → 11 → 17 分步走,每步都可回滚。
- 测试兜底:序列化、TLS、反射、编码是四大高危区,必须专项验证。
- 技术债要还:
--add-opens是止痛药不是解药,登记并限期清理。
升级不是一次性项目,而是团队工程能力的持续演进。把流程沉淀成规范,下一次升级就会轻松得多。