一、先决定升到哪:2026 年的目标版本
不要追非 LTS,也不要一步跳到最新。2026 年的现实选择:
| 目标 | 定位 | 建议 |
|---|---|---|
| JDK 11 | 老 LTS,Oracle 免费更新已于 2023 年结束 | 只当跳板,不停留 |
| JDK 17 | LTS,Oracle 免费(NFTC)更新 2026-09-30 到期 | 仍是合格的中间站 |
| JDK 21 | LTS,虚拟线程 GA,支持到 2031 年 | 已在 21 上的可以不急 |
| JDK 25 | 当前 LTS,支持到 2033 年 | 生产落点首选 |
| JDK 26 | 非 LTS,2026-03 GA、2026-09 已停止更新 | 只做验证 / 试点,别当终点 |
| JDK 27 | 非 LTS,2026-09-15 GA,支持到 2027-03 | 只适合追 6 个月节奏的团队 |
建议路线:老系统8 → 11 → 17 → 21 → 25分阶段走,每步都有测试兜底;站稳 25 之后,再单独跑一轮 26 的冒烟,验证 HTTP/3、AOT 缓存这类新能力。
为什么现在是必须动的时候(三条现实压力):
- JDK 8 / 11 的免费更新早已结束,安全补丁要商业订阅;
- JDK 17 的免费更新 2026-09-30 到期,停在 17 的团队要么升级,要么付费;
- Oracle JDK 21 的免费 NFTC 许可在 2026-09 到期,之后的更新走 OTN 许可。
具体政策以你用的发行版为准(Adoptium / Amazon Corretto / Azul / 腾讯 Kona 各有自己的节奏),但方向一致:往 25 LTS 迁是成本最低的一条路。
二、迁移第一步:静态评估
动手前先用工具扫一遍代码,量化风险:
# 1. jdeps:分析依赖与对 JDK 内部 API 的使用jdeps --jdk-internals --multi-release25myapp.jar# 输出会列:用了哪些 JDK 内部 API(如 sun.misc.Unsafe、com.sun.*)# 2. jdeprscan:看废弃 API 使用(目标版本填 25 或 26)jdeprscan--release25myapp.jar# 3. 26 上新增的两个"照妖镜",建议在 25 阶段就先跑一遍java--sun-misc-unsafe-memory-access=warn-jarmyapp.jar# 谁还在用 Unsafejava--illegal-final-field-mutation=deny-jarmyapp.jar# 谁还在反射改 final 字段# 4. Spring Boot 项目用官方迁移指南 + spring-boot-properties-migrator 找配置项变化重点看四类问题:
- 内部 API 使用:
jdeps --jdk-internals报告的,必须换等价公共 API 或加--add-opens临时放行。 - 被移除的模块:JDK 11 移除 JAXB(
javax.xml.bind)、JAX-WS、CORBA、JAF、JavaFX、JNLP;JDK 15 移除 Nashorn;JDK 26 移除 Applet API。用到就补第三方依赖(jakarta.xml.bind:jakarta.xml.bind-api+ 实现)。 - 终将移除的 API:
sun.misc.Unsafe的内存访问方法、finalize()、Security Manager——这些在 24/26 上已经从"警告"升级成"报错"或"永久禁用",见第六、七节。 - 编译目标退化:JDK 22 起
--release 8已开始告警(JEP 182 的退役策略),别指望永远靠旧 target 拖着不升级。
三、依赖与字节码的坑
1. 字节码版本
| JDK | 8 | 11 | 17 | 21 | 25 | 26 | 27 |
|---|---|---|---|---|---|---|---|
| class 文件版本 | 52 | 55 | 61 | 65 | 69 | 70 | 71 |
规则:
- 用目标 JDK 的 javac编译,别用旧 JDK 编新字节码。
- 第三方库只提供旧字节码一般还能跑(向前兼容),但有些会校验运行 JDK 版本。
- Maven / Gradle 设
release而不是source/target,避免引导类路径错配:
<plugin><artifactId>maven-compiler-plugin</artifactId><configuration><release>25</release><!-- 比 source/target 更安全 --></configuration></plugin>2. 框架基线(以 Spring 为例,这往往比 JDK 本身更卡人)
| 版本 | 状态 | 对策 |
|---|---|---|
| Spring Boot 2.7 | 最后一个支持 Java 8 的一代,OSS 支持已结束 | 必须先跨到 3.x |
| Spring Boot 3.x | 基线JDK 17,包名javax.*→jakarta.* | 先落到 3.5,清完弃用告警 |
| Spring Boot 4.0 / 4.1 | 2025-11 / 2026-06 GA,JDK 17 基线 + 一等支持 Java 25;Jakarta EE 11、Jackson 3、移除 JUnit 4 和 Undertow | 官方推荐路径:3.5 → 清告警 → 4.0 |
关键顺序:JDK 和框架要一起升,但框架往往先卡住你——Boot 2.7 撑不到 JDK 17+,而 Boot 3+ 又要 JDK 17。所以老项目的第一步通常是「先升框架到 3.x」,再升 JDK。
3. 常见依赖雷区
| 依赖 / 技术 | 8 → 25/26 的坑 | 对策 |
|---|---|---|
| JAXB | 11 移除 | 加jakarta.xml.bind依赖,包名javax→jakarta |
| Lombok | 与新 JDK 的 record / 密封类可能有冲突 | 升到支持目标 JDK 的最新版 |
| 字节码增强(ASM、CGLIB、javassist、ByteBuddy) | 高版本 class 文件(70)解析不了 | 升到支持对应 JDK 的版本;新代码可考虑 JDK 自带的 Class-File API(JDK 24 转正) |
| 序列化框架(Kryo、Hessian) | 模块封装下访问字段受限;部分老版本还用 Unsafe | 升版本,或用--add-opens止血 |
| 早期 Netty / 老版本缓存框架 | 用Unsafe内存访问方法 | 26 上会直接抛异常,必须升到已迁移到 VarHandle / FFM 的版本 |
| APM 探针、Arthas、Mockito inline、Jacoco | 运行时动态加载 Agent | JDK 21 起默认告警,加-XX:+EnableDynamicAgentLoading |
| 日志(Log4j / Logback) | 基本无碍,注意模块 | 升到近年版本 |
4. 运行时参数(过渡期)
# 强封装放行(临时)--add-opens java.base/java.lang=ALL-UNNAMED# native 访问放行(JNI / FFM,JDK 24 起)--enable-native-access=ALL-UNNAMED# Agent 动态加载(JDK 21 起)-XX:+EnableDynamicAgentLoading四、代码层面的改动清单
升到 25 之后,顺手把代码现代化(可选但推荐):
var简化局部变量。- 文本块替代拼接的多行字符串(SQL / JSON)。
List.of/Map.of不可变集合;Optional的isEmpty、ifPresentOrElse。- 数据载体换
record,有限子类换sealed。 switch表达式 + 模式匹配替代if-else instanceof。- IO 密集场景试用虚拟线程(
newVirtualThreadPerTaskExecutor);链路上下文传递改用ScopedValue(JDK 25 转正)替代ThreadLocal。 - 25 起可以写
import module java.base;,单文件脚本可以省掉类和public static void main。
注意:生产迁移先把"能编译能跑"作为第一目标,现代化重构分批做,别一次梭哈。
五、测试与灰度策略
- 编译通过 ≠ 运行正确:务必跑全量单测 + 集成测试。强封装、反射访问、Unsafe 调用都在运行期才暴露。
- 启动期验证:关注
NoClassDefFoundError、InaccessibleObjectException、UnsupportedOperationException(26 的 Unsafe deny)、UnsupportedClassVersionError。 - 性能基线对比:升级后压测,关注 GC 停顿、启动时间、吞吐量。25/26 上可以白拿的收益:
-XX:+UseCompactObjectHeaders(官方数据约省 22% 堆、8% CPU)、AOT 缓存(启动提速 20%-40%)。 - 灰度发布:新 JDK 先在预发 / 小流量验证,监控异常、GC、线程数;确认稳定再全量。
- 可回滚:保留旧 JDK 镜像 / 容器,异常一键回退。
六、JDK 22 → 26:一次跳上去会集中踩到的破坏性变更
从 8 直接往 25/26 跳,这些变更会在一次升级里全部落到你头上,建议提前逐条对照:
| 版本 | 变更 | 影响与对策 |
|---|---|---|
| 18 | 默认字符集改为 UTF-8(JEP 400) | 详见下方「中文项目第一坑」 |
| 18 | finalize()标记废弃(JEP 421) | 改用Cleaner或 try-with-resources |
| 21 | 动态加载 Agent 默认告警(JEP 451) | 加-XX:+EnableDynamicAgentLoading |
| 23 | ZGC 默认启用分代模式(JEP 474) | 显式写-XX:+UseZGC即可,无需额外参数 |
| 24 | 移除 ZGC 非分代模式(JEP 490) | 老的-XX:-ZGenerational直接失效 |
| 24 | Security Manager 永久禁用(JEP 486) | 见第七节 |
| 24 | JNI 使用开始告警(JEP 472) | 加--enable-native-access |
| 24 | sun.misc.Unsafe内存访问默认 warn(JEP 498) | 见第七节 |
| 24 | 移除 Windows 32 位 x86 端口(JEP 479) | 32 位 Windows 环境必须淘汰 |
| 25 | 彻底移除 32 位 x86 端口(JEP 503) | 同上,Linux 32 位也不再支持 |
| 25 | 紧凑对象头转正(JEP 519,非默认) | 用-XX:+UseCompactObjectHeaders开启 |
| 25 | 分代 Shenandoah 转正(JEP 521) | 低延迟 GC 多一个选择 |
| 26 | sun.misc.Unsafe内存访问默认 deny(JEP 471/498 阶段 3) | 直接抛异常,见第七节 |
| 26 | 深反射修改 final 字段开始告警(JEP 500) | 见第七节 |
| 26 | 移除 Applet API(JEP 504) | 基本无影响(17 已废弃) |
| 27(已 GA) | G1 成为全环境默认 GC(JEP 523)、紧凑对象头默认开启(JEP 534) | 小内存容器不再自动退回 Serial GC;升 27 时记得重测内存与 GC |
中文项目第一坑:默认 UTF-8
JDK 8 / 11 在中文 Windows 上file.encoding默认是 GBK,JDK 18 起默认变成 UTF-8(JEP 400)。老项目里这些写法会突然"能跑但乱码":
byte[]b=str.getBytes();// 不指定字符集 → 8 上是 GBK,18+ 是 UTF-8newString(b);// 同上newFileReader("a.txt");// 用默认字符集,读写都可能错位另外 JDK 9 起ResourceBundle读取.properties默认按 UTF-8(此前是 ISO-8859-1)。
对策:
- 全局搜索
getBytes()/new String(byte[])/FileReader/FileWriter/InputStreamReader的无字符集重载,显式指定StandardCharsets.UTF_8; - 存量文件统一转 UTF-8;
- 过渡期可显式
-Dfile.encoding=UTF-8让 8 和新版本行为一致,先把差异抹平再升级。
回看:9 → 21 还有四个老坑
如果你是从 8 起步,这四条也躲不掉:
- 模块强封装(JDK 9 引入,JDK 17 JEP 403 起默认禁止):反射访问 JDK 内部 API 抛
InaccessibleObjectException,--illegal-access已失效,只能--add-opens。 - JAXB / JAX-WS / CORBA 被移除(JDK 11):补
jakarta.*依赖。 - 第三方 GC 参数失效:CMS 在 JDK 14 被移除(JEP 363),
-XX:+UseConcMarkSweepGC直接启动失败。 _不能当标识符(JDK 9 起是关键字,22 起可作未命名变量)。
七、26 上的五个硬门槛
这一节是本文最值得划线的一段——前四条在 26 上已经不是"警告"而是"可能直接启动失败 / 运行抛异常"。
门槛 1:Security Manager 永久禁用(JDK 24,JEP 486)
- 现象:
System.setSecurityManager(...)抛UnsupportedOperationException;命令行-Djava.security.manager=...除allow外都会报错。 - 对策:删掉所有 SM 相关代码与启动参数,改用容器 / OS 层安全机制(seccomp、AppArmor、K8s SecurityContext)。依赖 SM 做沙箱的老 RMI / 动态类加载场景必须重新设计。
门槛 2:sun.misc.Unsafe内存访问方法(JDK 26 默认 deny)
五阶段路线:JDK 23 编译期弃用 → 24 默认 warn →26 默认 deny(抛UnsupportedOperationException)→ 之后版本彻底移除。
# 排查谁在用(打印调用栈)java--sun-misc-unsafe-memory-access=debug-jarmyapp.jar# 26 上的临时降级(只能降到 warn,不能再回到 allow)java--sun-misc-unsafe-memory-access=warn-jarmyapp.jar# 也可以用 JFR 精确抓调用点java-XX:StartFlightRecording:filename=rec.jfr-jarmyapp.jar jfr print--eventsjdk.DeprecatedInvocation rec.jfr真正的解法是升级依赖:绝大多数情况是老版本 Netty、Kryo、Hadoop、Cassandra、某些缓存框架在用。官方替代方案是VarHandle(堆内,JDK 9 起)和FFMMemorySegment(堆外,JDK 22 JEP 454 转正)。
门槛 3:JNI / native 访问限制(JDK 24 起,JEP 472)
- 现象:加载 native 库或使用 JNI 时告警,未来版本会默认禁止。
- 对策:
--enable-native-access=ALL-UNNAMED(或指定模块名);新代码优先用 FFM API。 - 注意:Netty、Netty tcnative、RocksDB、各类 JNI 编解码库都可能命中。
门槛 4:深反射修改 final 字段(JDK 26,JEP 500)
- 现象:26 起,通过
Field::setAccessible+set修改final字段会输出告警(每个模块最多一次):
WARNING: Final field f in p.C has been mutated by class com.foo.Bar in module N WARNING: Use --enable-final-field-mutation=N to avoid a warning- 逃生:
--enable-final-field-mutation=ALL-UNNAMED(注意还要配合--add-opens打开对应包)。 - 提前演练:
--illegal-final-field-mutation=deny,直接让它抛IllegalAccessException,把问题在测试阶段逼出来——未来版本这就是默认行为。 - 常见命中者:老版本 ORM、序列化 / 反序列化库、mock 框架(Mockito / EasyMock 的某些用法)、自己写的反射 hack。改法是升版本,或改用构造器注入 /
VarHandle。
门槛 5:Agent 动态加载告警(JDK 21 起,JEP 451)
- 现象:运行时 attach agent(APM 探针、Arthas、热部署、Mockito inline、Jacoco on-the-fly)会告警。
- 对策:
-XX:+EnableDynamicAgentLoading放行,长期应改为启动期-javaagent。
八、一套 25 / 26 的启动参数模板
过渡期可以直接照抄,然后逐条把--add-opens/=warn之类的止血项消灭掉:
java\--add-opens java.base/java.lang=ALL-UNNAMED\--enable-native-access=ALL-UNNAMED\--sun-misc-unsafe-memory-access=warn\--enable-final-field-mutation=ALL-UNNAMED\-XX:+EnableDynamicAgentLoading\-XX:+UseZGC\-XX:+UseCompactObjectHeaders\-XX:AOTCache=app.aot\-jarapp.jarAOT 缓存(Project Leyden)在 25 里是两条命令:
-XX:AOTCacheOutput=app.aot训练一次,-XX:AOTCache=app.aot正式运行;26 起支持任意 GC(含 ZGC)。
白拿收益的三条(不用改代码):-XX:+UseCompactObjectHeaders、ZGC / G1 的新版本优化、AOT 缓存。
九、一个典型迁移剧本(8 → 25,26 试点)
1. 跑 jdeps --jdk-internals + jdeprscan --release 25,列风险清单 2. 升级构建工具与核心依赖(Maven/Gradle/Lombok/Netty/Kryo…) 3. 框架先跨过 JDK 17 门槛:Boot 2.7 → 3.5,包名 javax → jakarta 4. 补 JAXB 等被移除模块的依赖 5. 全局搜索无字符集重载(getBytes/new String/FileReader),显式指定 UTF-8 6. 编译目标设为 release 25,本地编译通过 7. 加必要 --add-opens / --enable-native-access 跑单测,修 InaccessibleObjectException 8. 用 --sun-misc-unsafe-memory-access=warn 和 --illegal-final-field-mutation=deny 各跑一轮, 把命中的依赖全部升版本 9. 集成测试 + 压测,对比性能基线(GC 停顿 / 启动时间 / 吞吐) 10. 预发灰度一周,监控异常 / GC / 线程数 11. 全量上线,保留旧版本回滚能力 12. 单独跑一轮 JDK 26 冒烟,验证 HTTP/3、AOT 缓存等新能力;等 JDK 29 LTS 再决定是否跟进 13. (可选)分批做 record/sealed/var/文本块/ScopedValue 现代化十、本篇记住的清单
- 落点选 25 LTS;26 / 27 是非 LTS,只做验证与试点,别当生产终点。下一个 LTS 是 29(预计 2027-09)。
- 先用
jdeps --jdk-internals+jdeprscan量化风险;26 上再加--sun-misc-unsafe-memory-access=warn和--illegal-final-field-mutation=deny两个照妖镜。 - 老项目先升框架(Boot 2.7 是最后支持 Java 8 的一代),再升 JDK。
- 用
release而非source/target编译;class 文件版本 8=52、17=61、21=65、25=69、26=70。 - JDK 18 起默认 UTF-8,中文老项目必须先把无字符集重载改干净。
- 26 上的五个硬门槛:Security Manager 永久禁用、Unsafe 内存访问 deny、JNI 限制、final 字段深反射告警、Agent 动态加载告警。
- 强封装
--add-opens是止血带不是处方,长期必须换掉依赖反射内部的库。 - 现代化改造(var / record / sealed / 文本块 / ScopedValue)分批做,先保"能编译能跑"。
- 测试全量 + 灰度 + 可回滚,性能基线对比后再全量。