升级申请提了两年,评审会每次结论都是"再评估"。没人说它不该升,也没人敢批——因为没人能回答一个问题:升完出了问题,多久能退回去。
JVM 大版本迁移不是一次技术操作,而是一串决策:跨度、依赖、验证顺序、回滚路径。每项都有代价,且代价落在不同角色身上。把这些代价提前摆出来,比任何"升级收益"都更能推动决策。
1. 四个真实卡点
| 卡点 | 形态 | 排查成本 | 典型误判 |
|---|---|---|---|
| 依赖不兼容 | 某库没有 17 对应版本,或要跨大版本 | 低,依赖树可查 | "提个版本号就行" |
| 反射与内部 API | sun.misc.*、深反射进 java.base |
中,需运行期触发 | 编译通过就当事了 |
| 构建与插件 | 编译器、代码生成、覆盖率、打包 | 中 | 本地能编,CI 挂 |
| 运行参数与 GC | 默认收集器变了、参数含义变了 | 高,只在压测显现 | "参数照抄" |
前两项决定能不能升,后两项决定升完是不是变慢了。多数"升级失败"的故事其实属于第四类。
2. 跨度决定策略
| 跨度 | 优点 | 代价 |
|---|---|---|
| 8 → 17 一步 | 总工期短,无中间态 | 问题耦合,定位成本高 |
| 8 → 11 → 17 | 问题可归因,回滚粒度细 | 两轮回归与灰度 |
| 8 → 11 长停 | 改动最小 | 11 也会 EOL,欠账继续 |
判据:能否在两周内列出"编译不过的依赖清单"
能 → 一步到位(问题集已收敛,可分桶并行修)
不能 → 先升 11,暴露一轮不可编译问题,再决定
一步到位的真实风险不是技术,是验证期被压缩。8 → 17 的变更面约等于两段之和,中间没有"只改这些"的隔离带,所以回归清单必须显式覆盖两阶段的行为差异。
3. 先摸清三件事
# 1) 谁锁着版本:8-only 的库与被依赖管理强制的版本
$ mvn dependency:tree -Dverbose | grep -Ei "cglib|asm|javassist|byte-buddy|lombok|groovy"
# 2) 用了哪些内部 API / 非法反射
$ jdeps --jdk-internals target/app.jar
$ grep -rn "sun.misc.Unsafe\|--illegal-access" src/ pom.xml
# 3) 有多少自定义 agent(对字节码版本最敏感)
$ grep -rn "javaagent\|premain\|instrument" start.sh Dockerfile
产出应当是一张清单,每项标 阻塞级(不解决不能编译)、运行级(能编译跑不起来)、性能级(能跑但变慢)。分桶之后才谈得上估工期。
4. 模块化:多数应用没模块化,负担照收
多数 Spring 应用没有 module-info.java,迁移不需要模块化。但 JDK 17 的强封装默认开启,过去靠 --illegal-access=permit 混过去的深反射,现在要显式开放:
# 17 上的实际手段:按包逐个开放,最小集,逐条记录原因
java --add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
-jar app.jar
--add-opens 的负担是会沉淀成启动参数债:每加一条都有人问"还要不要",删要重跑全量回归。可控做法是集中在 profile 文件里、每条带注释(谁开的、为哪个库、何时可删),目标是条数逐年下降。
5. sun.misc.Unsafe 与字节码工具的兼容窗口
| 组件 | 17 上的状态 | 迁移动作 |
|---|---|---|
sun.misc.Unsafe |
仍在但多数方法标记将移除 | 换 VarHandle/ByteBuffer,或锁版本 |
| ASM | 需支持对应 class 版本 | 升级,并检查传递依赖里的旧版本 |
| cglib / javassist | 旧版生成代理时碰非法反射 | 升级或改 ByteBuddy |
| ByteBuddy | 适配快,版本窗口窄 | 固定较新版本 |
| Lombok | 编译器插件,最敏感 | 与编译插件同步升级 |
| Mockito | 依赖 ByteBuddy | 先升测试栈,否则回归跑不起来 |
判断窗口的方式是查该库 release notes 里"支持 JDK N"从哪一版开始,按手上的版本核对。旧版本往往不是"完全不能用",而是某些调用路径抛 InaccessibleObjectException——只能靠运行期回归发现。
6. GC 默认值变了,这是"升完变慢"的头号原因
| 项目 | JDK 8 默认 | JDK 17 默认 |
|---|---|---|
| 服务端收集器 | Parallel | G1 |
| 停顿特征 | 单次长、频率低 | 单次短、尾延迟稳 |
| 吞吐 | 批处理型负载占优 | 混合回收有并发开销 |
java -Xms4g -Xmx4g -Xlog:gc*:file=gc-17.log:time,uptime:filecount=5,filesize=20m -jar app.jar
# 判据看三处:P99 停顿、GC 频率、Full GC 次数(G1 下应为 0 或极少)
经典组合是:8 上靠 Parallel 的高吞吐扛住批处理,17 默认 G1 后单次停顿短了,但并发开销让吞吐掉几个百分点。这不是迁移失败,是默认值不匹配负载——显式选收集器并重压测,而不是回滚。
7. 构建工具与插件的最低版本
| 组件 | 支持 17 的起点(按版本核对) | 不注意的后果 |
|---|---|---|
| Maven | 3.6.3+,更稳 3.8+ | 插件解析或 class 版本解析失败 |
| maven-compiler-plugin | 3.8+ | release 不生效,误用新 API |
| Gradle | 7.x 起,8.x 更稳 | 构建脚本/工具链不兼容 |
| surefire | 与反射限制相关 | 测试在 17 上直接报错 |
| JaCoCo | 需支持新 class 版本 | 覆盖率 agent 启动失败 |
一条常被忽略的规则:编译 JDK 与运行 JDK 必须同版本。用 17 编译、8 运行(--release 8 只保 API 面)是"半迁移",问题会在几个月后以奇怪方式出现。
8. 运行参数的重新校准
-Xmx4g # 含义相同,但碎片与"可用"表现不同
-XX:MaxPermSize=256m # 8 起已移除,17 上直接启动失败
-XX:+UseConcMarkSweepGC # 14 起移除,17 上启动失败
-XX:+PrintGCDetails # 9 起由 -Xlog:gc* 取代
-XX:+UseCompressedOops # 17 上默认开启,无需显式设置
核对方式:把生产启动脚本里每条参数过一遍 java -XX:+PrintFlagsFinal -version,看是否仍被识别、最终值是多少。这一步能在上线前消灭大部分"启动就挂"。
9. 验证顺序不可交换
编译 → 单测 → 集成(含容器/DB) → 压测 → 灰度 → 全量
快速失败 代理/反射 真实中间件 默认 GC 回滚预案
| 阶段 | 只看一件事 | 判据 |
|---|---|---|
| 编译 | 依赖与插件 | 0 错误且 --release 一致 |
| 单测 | 反射/字节码工具 | 测试栈能启动跑完 |
| 集成 | 客户端兼容 | 连接、事务、序列化全通 |
| 压测 | 吞吐与尾延迟 | 与 8 基线在可接受区间 |
| 灰度 | 真实流量行为 | 错误率、停顿、内存无回归 |
顺序交换的代价:先压测再单测,等于用最贵手段发现最便宜的问题;先灰度再压测,等于把线上当验证环境。
10. 回滚:双版本并行是否可行
| 方式 | 前提 | 切换时间 | 风险 |
|---|---|---|---|
| 重新部署旧包 | 旧产物仍留存 | 分钟级 | 期间写入可能不兼容 |
| 蓝绿双栈 | 两套环境同时在线 | 秒级切流 | 成本翻倍,配置双份 |
| 同机多 JDK | 脚本可切 JAVA_HOME |
分钟级重启 | 只能整体切,无法按比例 |
真实判据是:新版本写下的数据,旧版本能否读。序列化格式、数据库字段、缓存键编码,任一项变化都会让回滚变成数据修复工程。迁移前必须显式确认这一条。
11. 不做迁移的代价
| 代价 | 表现 | 时间尺度 |
|---|---|---|
| 安全补丁缺失 | 8 的公开更新已停止 | 即时 |
| 新库不支持 | 框架/驱动的最低 JDK 门槛抬高 | 半年到一年 |
| 招聘与协作 | 候选人不愿维护 8,知识老化 | 持续 |
| 生态工具脱节 | profiler、agent 默认面向新 JDK | 一年 |
这些不是"会不会发生",而是"以什么速度累积"。折算成具体事件(哪次安全公告没补丁、哪个依赖升级被卡住)远比抽象的"技术债"能推动决策。
12. 迁移常与框架升级耦合
| 框架跃迁 | JDK 门槛 | 连带改动 |
|---|---|---|
| Spring 5.x → 6.x | 17 | javax.* → jakarta.* 全量替换 |
| Boot 2.x → 3.x | 17 | 配置键、starter 名、默认行为 |
| Hibernate 5 → 6 | 11/17 | 方言、类型映射、查询 API |
| Tomcat 9 → 10.1 | 11+ | Servlet API 包名变化 |
耦合意味着工作量不可加:单独升 JVM 两周,连带 Spring 6 可能两个月。决策时把两件事当一个包评估,否则会出现两次回归成本。
13. 度量与排错
| 指标 | 读法 |
|---|---|
| EOL 依赖数 | 逐季下降才算在推进 |
| 构建 JDK 与运行 JDK 一致率 | 不一致即"半迁移" |
--add-opens 条数 |
只增不减说明债在沉淀 |
| GC 停顿 P99 与吞吐 | 与 8 基线比,不跟绝对值比 |
| 现象 | 最可能原因 | 处理 |
|---|---|---|
Unrecognized VM option |
参数已被移除 | 删参数,全量 PrintFlagsFinal 核对 |
InaccessibleObjectException |
深反射进 java.base |
补最小 --add-opens 并记录 |
| 编译过、单测全挂 | Mockito/ByteBuddy/Lombok 过旧 | 先升测试栈 |
| 吞吐降、P99 更稳 | 默认 GC 换成 G1 | 显式选收集器后重压测 |
| 内存曲线整体上移 | Metaspace/堆策略不同 | 设 MaxMetaspaceSize,重估 -Xmx |
| 灰度正常、全量 OOM | 负载分布变化 | 按新默认值重做容量估算 |
14. 边界:可以延后,但要记录
| 场景 | 判断 | 必须留下 |
|---|---|---|
| 一年内下线 | 不迁移 | 下线日期与替代方案 |
| 极端稳定的内部工具 | 延后 | 风险接受人与复查时间 |
| 无法承担回归成本 | 延后,但先到 11 | 卡点清单与下次评估窗口 |
| 依赖已停维护的库 | 先解依赖 | 阻塞项与替代候选 |
| 性能敏感且验证 8 最优 | 延后 | 基线与偏离阈值 |
延后不是失败,没有记录的延后才是——它让下一次评审从零开始,重复两年来的同一场讨论。
15. 自测
- 四个卡点里哪两个决定"能不能升",哪两个决定"升完是否变慢"?
- 一步到位与逐级升级,判据为什么落在"两周内能否列出编译失败清单"?
- 没有
module-info.java,为什么仍要处理--add-opens? - 8 上跑得好的服务换到 17 后吞吐下降,先查什么而不是先回滚?
- 回滚可行性的真实判据是什么?它为什么和序列化、字段有关?
- 延后迁移时,最低限度要记录哪三样东西?