2947 字
约 9 分钟
16
S05-A08 · JVM 版本迁移:从 8 到 17 的决策链
2024-12-31
2026-11-09

升级申请提了两年,评审会每次结论都是"再评估"。没人说它不该升,也没人敢批——因为没人能回答一个问题:升完出了问题,多久能退回去。

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. 自测

  1. 四个卡点里哪两个决定"能不能升",哪两个决定"升完是否变慢"?
  2. 一步到位与逐级升级,判据为什么落在"两周内能否列出编译失败清单"?
  3. 没有 module-info.java,为什么仍要处理 --add-opens?
  4. 8 上跑得好的服务换到 17 后吞吐下降,先查什么而不是先回滚?
  5. 回滚可行性的真实判据是什么?它为什么和序列化、字段有关?
  6. 延后迁移时,最低限度要记录哪三样东西?
S05-A08 · JVM 版本迁移:从 8 到 17 的决策链
https://www.clxhxhhr.top/posts/6227/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。