项目还在 JDK 8,新同事写的代码得先查语法支不支持。这个"先查一下"的动作本身就是成本:它把一条本该由编译器回答的问题,变成了团队口口相传的经验。停在旧版本的代价不在升级那一刻的工时,而在每天多一点的不确定。
1. 停在旧版本的四类代价
| 代价 | 表现 | 观测方式 |
|---|---|---|
| 安全补丁 | 安全更新以新版本为目标,回补窗口有限 | 按发行方的版本支持公告核对 |
| 语言能力 | 新写法不可用,样板代码与错误处理更啰嗦 | 评审中反复出现的同类意见 |
| 生态兼容 | 新库最低要求高于当前运行时,只能选旧版本 | 依赖升级报 UnsupportedClassVersionError |
| 招聘与士气 | 新人对旧版本容忍度低,产出感受差 | 入职反馈与日常技术讨论 |
四类里前三类都可以写进文档、被度量,只有最后一类常被低估。技术栈是候选人筛选条件的一部分,长期停在旧版本会直接反映在候选人池的规模上;而团队内部的挫败感通常表现为"能跑就行"的表述,它不会出现在任何指标里。四类里最容易在预算评审中被接受的是生态兼容:它不是偏好问题,而是"不升就选不了库"的硬约束。
先摸清"当前是什么、谁在锁着版本",这两个问题的答案决定后续所有判断:
// 当前运行时与字节码目标
java -version
javap -v CLASS_FILE | grep "major version" // 52=8 / 61=17 / 65=21,按对应 JDK 核对
// 谁的字节码版本在拉低整体门槛
jdeps --multi-release BASE -verbose:class JAR_FILE | head
// 生成物里残留的旧字节码,常来自未一起升级的子模块
find build -name "*.class" -newer pom.xml | head -n 5
// 依赖里明确要求更高运行时的那些
mvn -q dependency:tree -Dverbose | grep -i "jdk\|runtime"
这一节先摆出两句判据:版本不是一个属性,而是一条链;升级的终点不是"能跑",而是"下一次升级更容易"。前者决定怎么排期,后者决定该不该在这次迁移里顺手动结构。
2. "升级很难"难在依赖链条
升级本身通常不难:换一个 JDK、改几个参数、跑一遍测试。难的是依赖链条上的每一环都要同时就绪。
| 环节 | 就绪的标志 | 未就绪的表现 |
|---|---|---|
| 构建工具与插件 | 支持目标 JDK 的最低版本 | 编译插件报不认识的字节码 |
| 框架 | 官方支持矩阵包含目标版本 | 启动时报模块或反射相关错误 |
| 第三方库 | 最低要求不高于目标 JDK | 需要 fork 才能兼容 |
| 字节码工具 | agent 与插桩库支持该字节码版本 | 启动即报字节码版本不支持 |
| 自研组件 | 已在新 JDK 上真实跑过一次 | 只有"应该可以"的判断 |
链条里任何一环卡住,整体就停在原地。这也是为什么"等别人先升"看起来理性,却会导致集体停滞——每一环都在等别人先就绪。就绪状态应当写进一张迁移跟踪表,每环一个字段:口头同步在跨团队时必然失真,而失真的部分恰好是"到底谁在等谁"。
要判断卡在哪一环,最有效的办法是做一次最小实验:把目标 JDK 装到一台临时环境上,用同一份源码跑一次构建与启动。这一步不解决任何问题,但它能把"每一环都可能有问题"收敛成"具体是这一环有问题",从而避免把迁移讨论变成印象之争。查的顺序有一条经验:先看字节码工具与构建插件,再看框架与库——前者失败得最早,也最容易在讨论里被跳过。
3. 支持窗口与三种节奏
支持窗口是外部给定的,不受团队意愿影响:LTS 有明确的终止时间,过了窗口"还能跑"和"有人管"是两件不同的事。
| 节奏 | 含义 | 风险 |
|---|---|---|
| 跟随 LTS | 每个 LTS 到期前完成迁移 | 每次迁移量较大,但窗口内始终有人管 |
| 滞后一个 LTS | 始终落后一个版本 | 压力集中到一次迁移,时间余量小 |
| 原地不动 | 不设迁移计划 | 被外部事件推动时只能被动跳级 |
"等下一个 LTS 再升"是无限延后的开始:等到下一个 LTS 发布时,当前版本的支持窗口往往也接近尾声,于是又出现"再等一个"的理由。三种节奏的选择往往在第一次迁移时就定下来了——第一次按 LTS 节奏走完,之后才有"滞后一个版本"的余地;第一次就原地不动,后面只会越来越难回到节奏里。
支持与"能跑"的差别在实践中体现为两点:遇到新披露的安全问题时是否还会有人修,遇到与新版依赖的兼容问题时是否还有回补。过了窗口的运行时依然能长期稳定运行,这恰恰是它最容易被无限期搁置的原因——没有故障,就没有推动力。把版本登记写进仓库里的工具链声明文件,比留在少数人的记忆里可靠。
把版本写死在镜像里,会让这件事更隐蔽:
// 版本写死在镜像标签里,没人会主动回头看它
FROM BASE_IMAGE:JDK_VERSION
ENV JAVA_HOME=/opt/java/JDK_VERSION
// 版本登记(缺任一项都会导致"没人记得为什么是这个版本")
运行时: 名称与主版本
当前版本: 具体构建号
计划目标: 下一个 LTS 或具体版本
冻结原因: 被哪个依赖或插件锁住
4. 成本非线性与最低成本路径
升级成本随滞后时间增长,但不是线性增长——因为中途会跨过不兼容断点。跨过一个断点要付一次代价,跨过三个断点不等于三次代价的算术和。
| 断点类型 | 跨过时会发生什么 | 常见量级 |
|---|---|---|
| 模块化与内部 API 收口 | 非法反射、内部包引用被拒 | 需逐包最小开放并记录原因 |
| 默认值变更 | GC、编码、时区等默认行为改变 | 表现是"升完变慢或乱码" |
| 移除的 API | 编译或启动直接失败 | 改动点明确,成本可估 |
| 字节码版本 | agent 与构建插件需同步升级 | 升级面扩散到工具链 |
最低成本的跟进方式是只升运行时、不动语言特性:保持源码与目标字节码级别不变,先把运行时换上去,稳定运行一段时间,再单独决定是否采用新写法。它的另一个好处是把迁移拆成两次独立观察:第一次只看兼容性,第二次才看收益。代价是早期会觉得"升了没感觉",因此这次迁移的目标要明确写成生态兼容与安全补丁,而不是承诺语言能力。
<!-- 只升运行时的做法: 语言级别暂时不动 -->
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<!-- 运行时换成新 JDK 后先观察,语言级别另行排期 -->
先升构建工具还是先升运行时:构建工具与编译插件必须先能产出目标字节码,所以顺序通常是构建工具链 → 运行时 → 语言特性。反过来做会在编译阶段就失败。灰度与回滚的关键,是同一制品多版本并行是否可行:
| 方式 | 可行性 | 代价 |
|---|---|---|
| 双环境并行(同一制品、两台不同 JDK) | 高 | 资源翻倍,需要流量切换 |
| 同机多 JDK 目录切换 | 中 | 硬编码路径的地方会出问题 |
| 镜像标签回退 | 高 | 需保留旧镜像与其配置兼容性 |
双环境并行是最接近"零风险"的一种,但它的成本常常被低估在另一处:两套环境的参数已经不同,真正出问题时无法判断差异来自代码还是来自运行参数。因此并行期间要把 JVM 参数显式对齐,只让 JDK 版本成为唯一变量。
把版本写死在镜像里的隐性成本,是它让"版本"离开配置进入了制品历史。查询当前版本要去看镜像标签,而镜像标签又常被固定成 JDK_VERSION 之类的变量名,结果是想知道实际用哪个构建号,得去查一次真实的容器。这类成本不会出现在任何一次评估里,但它决定了"跟进版本"这件事会不会被想到。
5. 排错与边界
5.1 排错
| 现象 | 机制 | 处理 |
|---|---|---|
| 换 JDK 后启动报模块或反射错误 | 内部 API 被收口 | 按包最小开放并逐条记录原因 |
| 换 JDK 后变慢 | 默认 GC 或默认值变化 | 单独核对 GC 参数与默认值差异 |
| 编码异常 | 默认字符集被改变 | 显式指定字符集,不依赖默认值 |
| 构建通过但运行失败 | 字节码目标与运行时不一致 | 核对编译目标与运行时版本对应关系 |
| 升级久拖不动 | 依赖链条上有一环未就绪 | 列出各环节就绪状态,找唯一卡点 |
排错表里"升级久拖不动"最常见的原因并不是技术,而是缺一个明确的时间点。把触发条件设成"下一次依赖大版本发布"或"下一次安全补丁无法回补",比设一个日历日期更容易被共同接受,也更容易在窗口关闭前被想起来。
5.2 边界
| 场景 | 结论 |
|---|---|
| 系统一年内退役 | 跟进优先级下调,只处理安全补丁 |
| 无外部依赖的极简服务 | 迁移成本低,收益也低,可按窗口统一做 |
| 用不到新语言特性 | 仍值得升运行时,安全与依赖兼容的收益独立存在 |
| 被强依赖锁死 | 记录冻结原因与解除条件,而不是不记录 |
边界表里"系统一年内退役"这条需要配合一条记录要求:不跟进不等于不登记。冻结原因与解除条件写下来,下一页 owner 才知道这是有意的决定,而不是遗留的疏忽。
度量四个数:滞后 LTS 数、跨断点数、升级周期(决定到上线)、版本相关故障数。
// 版本相关故障的归因线索: 出问题的堆栈里带哪些工具链痕迹
grep -n "UnsupportedClassVersionError\|IllegalAccessError\|InaccessibleObjectException" LOG_FILE
// 命中即说明存在版本层面的错配,而不是业务逻辑缺陷
6. 自测
- 为什么"升级很难"通常不是升级本身难?
- "过了支持窗口但还能跑"与"有人管"的差别是什么?
- 为什么跨过多个不兼容断点的成本不是线性叠加?
- "只升运行时不动语言特性"为什么成本最低?
- 构建工具与运行时的升级顺序为什么不能颠倒?
- 被强依赖锁死版本时,最少要记录什么?