Java 长期版本基线:Java 8、11、17、21、25 怎么选、怎么升级
一次项目评审里,文档写着“统一使用 Java 17 LTS”。这句话看起来很稳,继续问下去却没人答得上来:是哪个厂商的 JDK,完整补丁号是多少,安全更新从哪里拿,容器镜像用什么摘要,Maven 到底按 17 还是 21 编译,产物最低要在哪个 JVM 上运行?更麻烦的是,开发机执行 java -version 显示 21,流水线插件用的却是另一套 JAVA_HOME,最后谁也说不清发布包是怎样编出来的。
这不是少写了几个配置项,而是把几件不同的事都塞进了“Java 17 LTS”这一个标签。Java 8、11、17、21、25 之间确实有语言、标准库、JDK 封装、部署和运行时差异;但“维护多久、补丁从哪里获得、生产使用受什么许可约束”,又要看具体厂商。要让这份评审结论能用于构建、发布和排障,至少要说明:Java SE 版本、JDK 发行版与厂商、更新通道和许可、编译目标、框架与工具的兼容范围、生产运行参数。
下面就沿着一次真实的选型和升级过程,把这六件事逐一讲清。重点不是背完每个版本的 JEP,而是看到一个编译错误、启动异常或性能变化时,知道它究竟属于哪一层,以及应该在哪里验证。
版本问题先收敛到选型、升级和回退
版本决策必须同时回答三个问题:多个长期版本真正差在哪里;新系统怎样选择团队接得住的版本;存量系统怎样逐步升级,并保留退回旧运行时的能力。只有会改变依赖、代码边界、并发模型、部署或运维方式的变化,才足以影响架构基线。
Java SE 全部特性、JVM 参数调优和各厂商逐项报价不影响这条选型主线,不在这里铺开。Preview 或 Incubator 功能也不进入生产基线;框架是否支持某个 JDK,仍要回到框架与所选发行商的正式支持矩阵。
开始选型前先拿齐现有基线
能区分源码、class 文件、JDK 和运行中的 JVM 进程。已知应用使用的框架、构建工具、Java Agent、数据库驱动和部署平台。有一套能在旧基线重复执行的单元、集成与关键业务回归测试。
别急着选 17 还是 21,先把四个“Java”分开
评审会上最容易混淆的是 Java SE、OpenJDK、某个厂商的 JDK 和 LTS。可以先记住一句话:Java SE 规定平台应该是什么样,OpenJDK 提供参考实现和开源代码,厂商把代码做成可安装、可更新、可支持的发行版,LTS 则是厂商对某个版本作出的长期维护承诺。
Oracle 的支持路线图把 8、11、17、21、25 列为 LTS,同时明确表中的日期只适用于 Oracle 对应的商业支持产品。OpenJDK 的版本页面则使用“将成为多数厂商的 LTS”这类表述。两个来源并不冲突,反而说明了边界:一个功能何时进入 Java SE 是发布事实,谁愿意维护多久是供应商事实。LTS 不会让 class 文件获得额外兼容性,也不表示所有厂商都有相同的补丁周期和许可。
所以,当同事说“我们选 Java 21”时,可以顺着下面六件事问下去:
Java SE 版本决定语言、标准 API 和 class 文件基线。JDK 发行版与厂商决定具体构建、支持平台、更新包和附加能力。支持与许可决定安全更新能取多久、生产使用是否需要合同,以及退出条件。
生态兼容决定框架、构建工具、Agent、驱动和容器平台能否共同工作。运行时基线决定 GC、堆、容器资源识别、诊断参数和启动命令怎样配置。编译目标决定产物最低能在哪个 Java SE 版本运行;它不必等于构建机 JDK 版本。
这六个答案缺少任何一个,后面都可能出现“开发机正常、流水线失败”或“应用能启动、厂商不支持”的情况。尤其不要把 Oracle 的支持日期直接当成另一个 JDK 发行版的承诺,也不要因为代码在某个 JDK 上启动过,就推断这组操作系统、CPU 和加密配置处于厂商支持范围。
同一个 Java SE 版本,换厂商也要重新验证
Java SE 是平台规范集合。语言规则由 Java Language Specification 约束,字节码与虚拟机行为由 Java Virtual Machine Specification 约束,标准 API 也有相应规范。JDK 11、17、21、25 的 OpenJDK 项目页把各自称为相应 Java SE 版本的开源参考实现。OpenJDK 同时是源码社区和项目集合,但它不会替每一家生产用户承担补丁 SLA。
厂商基于 OpenJDK 源码生产可安装的二进制发行版,并决定支持哪些操作系统、CPU 架构、容器基础镜像和加密能力,怎样回移安全修复,补丁发布多久,以及商业支持如何计费。两个发行版即使实现同一 Java SE 版本,标准 Java 代码大体共享契约,也不能推导出它们的镜像布局、证书库、监控组件、附加 GC、更新节奏与服务承诺完全相同。因此“从 JDK A 换到同版本 JDK B”仍是一项运行时变更,至少要重做镜像、证书、Agent 和容量验证。
这就是为什么“同为 Java 17”仍不能直接替换。比如容器从一个发行版换到另一个发行版,业务源码可能一行没改,但基础镜像、CA 证书、字体、诊断工具甚至附加 GC 都可能变化。实际做法是先在环境里留下证据:
java -XshowSettings:properties -version 2>&1 | grep -E "java.runtime.version|java.vendor|java.home"输出里至少应看到完整运行版本、厂商和 java.home。再把容器镜像摘要、操作系统和 CPU 架构一起记录到发布清单。这样 TLS 握手或 Agent 注入出问题时,才能判断是 Java SE 大版本变化,还是发行版和镜像发生了变化。
“Java 21”还不够,补丁版本也要能追溯
“Java 21”仍然过粗。同一功能版本还会持续收到 CPU/PSU 等更新,安全算法限制、根证书、时区数据和缺陷修复都可能随补丁变化。容器标签若只写可漂移的 21 或 latest,今天和下周重建出来的镜像就可能不是同一套运行时;出了问题,即使 Git 提交相同也无法复现。
一般需要把完整运行版本、厂商、镜像摘要和补丁来源写进制品元数据。补丁升级先进入预生产,跑完功能、TLS、日期格式和容量回归后再发布。这样既不会因为害怕变化而长期不打安全补丁,也不会把补丁更新偷偷混入普通业务发布。
五个长期版本先看一张速查表
| 基线 | 会改变架构或迁移工作的差异 | 合理定位 |
|---|---|---|
| Java 8 | 形成 Lambda、Stream 和新日期时间 API 的传统企业基线;没有模块系统,很多老框架仍可能依赖后来被移除的 Java EE/CORBA 模块、JDK 内部 API 或旧式部署组件 | 仅在遗留依赖、认证产品或迁移收益尚不足以覆盖风险时保留;新系统不应仅因团队熟悉而默认选择 |
| Java 11 | 已跨过 Java 9 的模块化断层;标准 HTTP Client、JFR、TLS 1.3 可进入正式能力,同时移除了 Java EE 与 CORBA 模块 | 从 8 迁出的重要兼容检查点,但不应在没有生态或支持约束时机械作为最终落点 |
| Java 17 | records、sealed classes、instanceof 模式匹配等已成熟,可更直接表达不可变数据和封闭领域层次;JDK 内部 API 强封装使历史反射和 sun.* 依赖集中暴露 | 对成熟框架生态和保守升级节奏通常是稳健基线;选它的理由应是支持矩阵,而不是“改动最少”的直觉 |
| Java 21 | 虚拟线程正式可用,可能重塑高并发阻塞式服务的线程与容量模型;record patterns、模式 switch、有序集合接口和分代 ZGC 已正式进入版本 | 新建服务的重要候选;采用虚拟线程前仍要对连接池、限流、ThreadLocal、监控和锁竞争重新做容量验证 |
| Java 25 | Scoped Values 正式可用,紧凑对象头、AOT 相关能力和 JFR 增强带来新的性能与可观测性选择;32 位 x86 端口被移除 | 最新 LTS 候选,适合生态矩阵已明确支持、团队能承接新基线的系统;不能只因“最新”跳过依赖和回滚验证 |
这张表只用来找方向,不是升级收益排行榜。版本越新,支持窗口通常越靠后,但迁移成本要看项目踩中了哪些断层。一个依赖持续更新、没有内部 API 的普通服务,从 17 到 21 可能很平滑;一个绑定旧应用服务器、字节码增强器和安全组件的 Java 8 系统,即使业务源码很少,也可能要改一整套运行平台。
Preview、Incubator 和 Experimental 能力可以进入技术验证,却不能悄悄成为生产架构前提。它们可能在后续版本再次预览、改变接口或退出;若业务必须依赖,应单列例外评审、升级责任人和移除计划,而不是把“运行时加了 --enable-preview”当作版本基线已经完成。
从 8 到 11:先处理突然找不到的类
一套 Java 8 应用第一次放到 JDK 11 上,常见现象不是新语法不会写,而是启动到 XML、SOAP 或代码生成路径时突然报错:
java.lang.NoClassDefFoundError: javax/xml/bind/JAXBContext这里不要先去找一个“兼容参数”。8 到 11 跨过了 Java 9 模块系统,JDK 运行时映像和模块可见性已经改变;JDK 11 又正式移除了 JAX-WS、JAXB、JAF、Common Annotations、CORBA 等原先随 JDK 提供的 Java EE/CORBA 模块,以及 wsimport、xjc 等相关工具。--add-modules 只能解析仍然存在的模块,不能把已经删除的 class 重新变出来。
先在依赖和脚本里搜索 javax.xml.bind、wsimport、xjc、javax.annotation.Generated、RMI-IIOP,再决定是显式引入受维护的替代依赖,还是退出已经不用的能力。JEP 320 给出的正是这种迁移方向。加完依赖后,要实际跑到 XML 编解码、Web Service 客户端和代码生成任务,不要只看主进程启动成功。
这一跳也带来了标准 HTTP Client、JFR 和 TLS 1.3 等正式能力。不过这里要注意,升级运行时和替换组件最好分两次做。JDK 自带了 HTTP Client,不代表已有客户端的连接池、重试、指标和链路追踪可以当天删除;JDK 支持 TLS 1.3,也不代表代理、上游和密码套件都已经兼容。先让原有业务在 JDK 11 上保持行为不变,再单独验证新能力,发生故障时才知道该回退哪一部分。
从 11 到 17:看到反射异常,先查是谁越过了模块边界
从 11 升到 17,应用有时能编译,却在 ORM、序列化、Mock 或代理初始化时停住,日志里会出现类似信息:
java.lang.reflect.InaccessibleObjectException:
Unable to make field ... accessible: module java.base does not "opens java.lang"这条异常已经把原因说得很具体:某段代码想通过深反射访问 JDK 未开放的内部实现。可以先用下面的命令找静态可见的内部 API:
jdeps --jdk-internals app.jar输出会列出依赖方、内部类型以及部分可替代 API。它查不到字符串拼接的反射类名和 Agent 动态改写,所以命令通过后仍要跑完整启动与集成测试。
JEP 403 在 JDK 17 强封装 JDK 内部元素。除 sun.misc.Unsafe 等关键内部 API 外,已经不能再靠一个全局 --illegal-access 选项恢复 JDK 8 时代的宽松访问。升级依赖是优先做法;若为了定位临时加入 --add-opens java.base/java.lang=ALL-UNNAMED,要同时记下是哪一个库需要、准备升级到哪个版本、何时删除。参数能让服务启动,只说明主动打开了一个洞,并不表示问题消失。
11 到 17 之间,records、sealed classes、instanceof 模式匹配、switch expressions、文本块,以及更完整的 JFR、ZGC、Shenandoah 都已经成为正式能力。它们适合在运行时升级稳定后逐步使用。与此同时还要搜索 Nashorn、Pack200、CMS、RMI Activation 等已移除能力,并留意 Security Manager 已被标记为待移除。不要一边处理强封装,一边重写领域模型和 GC;变量太多时,任何性能变化都会失去对照。
从 17 到 21:服务启动成功,也可能悄悄改了文本字节
17 到 21 之间有一类问题不会抛异常。比如旧服务一直用 new FileReader(path) 读取供应商文件,开发机默认 UTF-8,生产 Windows 节点却使用另一个默认编码。JDK 18 起,标准 Java API 的默认字符集统一为 UTF-8,控制台 I/O 除外。升级后进程健康、文件也能打开,但签名原文或对账字段已经变了。
可以先在新旧环境执行:
java -XshowSettings:properties -version 2>&1 | grep "file.encoding"再用脱敏的真实文件比较输入字节、解码结果和重新编码后的字节。正确修复是让文件与协议显式使用 StandardCharsets.UTF_8 或双方约定的 Charset,而不是永久依赖 file.encoding 把旧环境伪装回来。
JDK 21 的虚拟线程、record patterns、模式 switch、Sequenced Collections 和分代 ZGC 都是正式能力。字符串模板、Scoped Values、Structured Concurrency、Foreign Function & Memory API 在 JDK 21 仍是 Preview,Vector API 仍是 Incubator。版本页面把它们列在一起,不代表稳定性状态相同;公共库和长期生产接口只能采用团队明确接受的正式能力。
虚拟线程适合以线程表达任务、主要等待网络或存储 I/O 的高并发服务;JEP 444 明确它不会移除平台线程,也不会静默迁移现有应用。把线程池替换成“每任务一个虚拟线程”以后,数据库连接、HTTP 上游配额和消息队列容量并不会跟着增加。如果原来靠 200 个工作线程间接限制数据库并发,改造后就需要信号量或连接池等显式限制,否则线程变便宜了,上游反而先被压垮。
升级到 21 和启用虚拟线程应分成两次发布。第一次仍使用原有线程模型,验证结果、延迟和资源曲线;第二次才改变执行器,并检查 ThreadLocal、线程名维度指标、线程转储、Agent 和连接池等待。这样出现吞吐变化时,才能判断是 JDK 变化还是并发模型变化。
从 21 到 25:旧安全参数可能让 JVM 在 main 之前退出
如果一套老应用仍在启动脚本里启用 Security Manager,放到 25 上时业务日志甚至来不及初始化。可以先在目标 JDK 验证旧参数:
java -Djava.security.manager=allow -jar app.jarJDK 24 起会在初始化 JVM 时直接报告“Enabling a Security Manager is not supported”并退出;运行中调用 System.setSecurityManager(...) 则会抛 UnsupportedOperationException。这不是换一个 policy 文件能解决的问题。原来依赖 Security Manager 的沙箱、文件和网络权限要迁到进程/容器隔离、操作系统最小权限与应用自身授权,迁移完成后再进入 25。
JDK 25 正式提供 Scoped Values,用有界生命周期共享不可变上下文,尤其适合大量虚拟线程;紧凑对象头从 Experimental 变成可选产品能力,但 JEP 519 明确它并非默认对象头布局。Foreign Function & Memory API 已在 22 正式,为访问本地内存和函数提供标准边界。25 还正式包含 Key Derivation Function API、AOT 相关改进、JFR 增强、Generational Shenandoah,并移除 32 位 x86 端口。依赖 JNI、特定 CPU、对象布局估算、探针或堆转储解析器的系统,都要拿真实制品验证。
同时,Structured Concurrency 在 25 仍是第五次 Preview,Stable Values、PEM 编码和基本类型模式匹配也仍是 Preview,Vector API 仍是 Incubator,不能把它们与正式的 Scoped Values 混写。模块导入声明、紧凑源文件等虽已正式,但主要改善源码表达,不应单独成为服务器升级理由。先问系统现在缺什么,再判断正式能力是否值得采用,顺序不要反过来。
新系统选型:先找约束,再选最高可承接基线
假设团队准备新建一个订单服务。框架已经正式支持 21 和 25,APM 只承诺支持到 21,生产平台又要求未来三年持续获得安全补丁。此时选择 21,不是因为一句含糊的“21 更稳定”,而是 21 落在所有关键组件共同支持的范围内,而且支持窗口覆盖系统计划寿命。等 APM 正式支持 25,再重新评估即可。
一般按下面的顺序收集证据。先找操作系统、CPU、合规和关键组件这些不能绕过的条件,再讨论虚拟线程或 AOT 等新能力:
图走到“共同支持的版本集合”时,如果只剩 17,就不要为了追新强推 21;如果 21 和 25 都满足,也不要凭个人喜好拍板,要比较支持年限、关键正式能力、团队验证成本和下一次升级时间。版本选完后,把发行版、完整补丁号、镜像摘要、Toolchain 和启动参数一起固定下来,才算真正落到项目里。
若应用不需要向旧运行时分发,源码版本、目标 class 版本和生产 JVM 一般保持一致,这样组合最少。基础库若必须同时服务多个 Java 基线,可以使用较新的构建 JDK 配合 javac --release N。下面这条命令表示用当前 javac 编译,但把源码、class 格式和可见的 Java SE 公共 API 都限制在 17:
javac --release 17 -Xlint:all -Werror src/main/java/com/example/*.java-Xlint:all -Werror 会把编译器告警提升为失败,便于尽早发现弃用、未检查操作和错误选项。命令通过后,只能说明当前源码没有越过 Java SE 17 的公开 API 边界;它不会验证第三方依赖的最低运行版本,也不会模拟某个厂商 JVM 的 GC、加密或 Agent 行为,所以最后仍要用目标运行时执行测试。
source、target、classfile 和运行时不是一个旋钮
-source 控制编译器接受哪一代 Java 源码语法,-target 控制生成的 class 文件版本,但两者单独使用不会阻止代码链接构建 JDK 中较新的标准 API。于是“语法像 17、字节码也像 17”仍可能调用 21 才存在的方法,到 Java 17 运行时才以 NoSuchMethodError 失败。--release 17 通过面向 17 的公开、受支持 API 视图同时约束语法与目标,解决的正是这一漏洞;它仍不覆盖第三方依赖及 JDK 内部 API。
class 文件自己的 major_version 决定 JVM 能否装载。Java 8、11、17、21、25 对应的正式 class 主版本分别是 52、55、61、65、69。较新的 JVM 通常能读取旧 class,旧 JVM 不能读取未来 class,因此“在 JDK 25 编译后拿到 17 跑”必须显式指定 --release 17,不能依赖向后兼容口号。Preview class 还带特殊 minor version 65535:Java SE 25 的 JVM 只有在启用 Preview 时才能装载 69.65535,旧版本 Preview class 不能拿到新 JVM 继续运行。这正是 Preview 不适合作为跨版本库契约的底层原因。
Toolchain 解决的是“哪一个 JDK 真正执行编译、测试、javadoc、插件”问题。只设置 Maven/Gradle 的 sourceCompatibility 或项目属性,未必能阻止某个插件使用启动构建进程的 JAVA_HOME。CI 应输出并校验编译器、测试 JVM 和打包 JVM 的完整版本;构建工具的 Toolchain 固定 vendor、版本范围和安装来源,容器阶段也使用同一来源。IDE 的 Project SDK 只是开发体验配置,不能充当可重复制品证据。
还要把四种兼容性分开验收。源码兼容回答旧源码能否重新编译,二进制兼容回答旧 class 能否在不重编译时链接,运行时兼容回答依赖、反射、Agent 与本地库能否装载,行为兼容回答同一输入是否仍得到相同业务结果和资源曲线。一次“编译通过”只覆盖第一种的一部分;一次“健康检查为绿”也只覆盖运行时的一小段。
--release 只约束当前编译单元,不会把依赖 JAR 的 class 降级,也不会替依赖承诺 Java 17 兼容。流水线要扫描最终制品内所有 class 的最高 major version,并在最低受支持 JVM 上运行集成测试。Multi-Release JAR 还可能在 META-INF/versions/N 放入针对新 JVM 的实现,同一个 JAR 在 17 与 21 上实际装载不同 class;做依赖升级时需要分别测试各目标运行时,而不是只读取根目录 class 得出结论。
先把当前机器与制品基线变成可提交的证据。仓库探针位于 examples/backend-development/java/version-baseline/src/example/BaselineProbe.java。进入 examples/backend-development/java/version-baseline/ 后执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out src/example/BaselineProbe.java
java -cp out example.BaselineProbe
javap -verbose out/example/BaselineProbe.class前一条命令会输出 java.specification.version、完整 java.version、java.vendor、VM 名称、操作系统和 CPU 架构;具体值随实际运行环境变化,不能把某台开发机的结果写成通用期望。javap 必须出现 major version: 61,它证明制品目标是 Java 17。两份证据要分开保存:运行属性说明“谁在执行”,class 主版本说明“产物要求什么”,任何一份都不能代替另一份。
探针没有验证发行商支持期、许可、框架兼容、容器识别或 Agent。工程基线因此至少保存以下机器可比字段:构建/测试/生产各自的完整 JDK 标识,制品最高 class 主版本,镜像摘要,临时模块开放项,以及旧制品在目标运行时的集成结果。补丁升级后逐字段比较;若 class 主版本意外升高、构建与测试 JVM 不一致、临时开放项增加,流水线应直接阻断,而不是等性能压测替它发现配置漂移。
用三个失败实验看清这些边界
先准备一个最简单的类。这里故意不使用任何新 API,只观察编译器默认生成什么 class:
public class Hello {
public static void main(String[] args) {
System.out.println("hello");
}
}用 JDK 21 直接编译,再交给 Java 17 运行:
javac Hello.java
javap -verbose Hello | grep "major version"
/path/to/jdk-17/bin/java Hello
javac --release 17 Hello.java
javap -verbose Hello | grep "major version"
/path/to/jdk-17/bin/java Hello第一次 javap 会看到 major version 65,Java 17 只能支持到 61,因此运行时报 UnsupportedClassVersionError。第二次加上 --release 17 后,major version 变为 61,Java 17 会正常打印 hello。这说明“新 JVM 通常能运行旧 class”是单向关系,旧 JVM 不认识未来的 class。
第二个实验专门验证 -source/-target 为什么不够。Thread.threadId() 在 Java 17 还不存在,下面的源码语法本身却没有新特征:
public class ApiBaseline {
public static void main(String[] args) {
System.out.println(Thread.currentThread().threadId());
}
}在 JDK 21 上只限制 source 和 target,再改用 release 编译:
javac -source 17 -target 17 ApiBaseline.java
/path/to/jdk-17/bin/java ApiBaseline
javac --release 17 ApiBaseline.java第一条编译可能成功,因为编译器仍看得见 JDK 21 的标准库,生成的 class 也确实是 61;交给 17 运行时才会报 NoSuchMethodError。第二条命令会在编译期直接指出 threadId() 不存在。一个可靠的构建应该让这种错误停在流水线,而不是留到生产首次执行该分支。
第三个实验验证 Preview 不能跨代携带。找一个目标 JDK 当期的 Preview 示例,用下面的成对命令编译和运行:
javac --enable-preview --release 25 PreviewSample.java
java --enable-preview PreviewSample
javap -verbose PreviewSample | grep -E "major version|minor version"同版本并同时启用 Preview 时才能运行,class 会显示 major 69、minor 65535。去掉运行开关会被拒绝,拿到下一代 JVM 也不能继续运行旧版本 Preview class。正式流水线因此要扫描 minor version 65535,并检查启动脚本中是否意外出现 --enable-preview;否则一个公共库可能把整套生产运行时绑死在某一代 Preview 上。
存量系统不要一步跳,按五次可验证的变化来做
Oracle 的迁移指南建议先用新 JDK 运行现有程序,再并行更新第三方库、重新编译并用 jdeps 检查依赖。这个顺序很重要:如果一开始就升级框架、改源码、换 GC,最后即使服务能跑,也不知道究竟是哪项变化解决或制造了问题。实际项目里可以拆成下面五步,每一步都保留输入、输出和退回点。
第一步先把旧系统的真实样子记下来
不要只抄配置中心里的 java.version。在实际容器和流水线里分别执行 java -version、javac -version,保存完整启动命令、GC 日志头部和镜像摘要;再记录框架、构建插件、Agent、JNI/本地库、JDBC 驱动和证书来源。当前状态若无法从制品与部署记录复原,就先把这件事补齐,否则失败时无法区分代码、发行版和环境变化。
第二步只换运行时,先不重新编译
把经过旧流水线验证的同一个 JAR 放到目标 JDK 上,先不改源码。这样出现 JVM 参数失效、模块缺失、反射访问、字符集、TLS、Agent 注入或监控采集问题时,可以确认变化来自运行时。服务启动后还要执行真实业务回归;健康检查为绿只证明主类和少量 Bean 已经加载,低频 XML、批处理或序列化路径可能尚未执行。
第三步再升级框架、驱动和 Agent
升级到明确支持目标 JDK 的框架、构建工具、插件、驱动和 Agent,并运行 jdeps --jdk-internals。每加一条 --add-opens 或 --add-exports,都写清是哪个组件需要、准备在哪个版本删除。关键闭源 Agent 若没有支持声明或替代路线,就停在测试环境;不要先发布再等供应商回答。
第四步固定 Toolchain,重新编译全部产物
这时才使用固定 Toolchain 和 --release 重新编译,开启弃用、移除、Preview、模块和未检查操作等告警。编译通过后继续跑单元、集成、序列化兼容、数据库迁移、消息契约、日期金额格式和安全协议回归。21/25 的新并发或 GC 能力先不采用,保持业务行为不变,避免把“升级 JDK”和“改变程序模型”塞进同一次发布。
第五步在相同流量下比较,再做灰度和回滚
在相同流量模型下比较启动时间、吞吐、尾延迟、CPU、堆与非堆、GC 暂停、线程/载体线程、连接池和错误率。灰度制品应能快速切回旧运行时与旧镜像;数据库、消息和序列化格式不得因使用新语法而产生不可逆变化。回滚演练失败,就不能把灰度成功视为迁移完成。
生产风险盘点:版本兼容不只发生在业务源码
一次升级在测试环境正常、到生产才失败,往往不是业务源码突然变了,而是测试环境没有带上生产的 Agent、证书、容器限制或低频依赖。下面四组检查最好在真正迁移前就跑一遍,后面每次换补丁版本也可以复用。
依赖、插件和 Agent
框架 BOM 声明兼容,不代表日志桥接、序列化、脚本引擎、JDBC 驱动和字节码生成库都已经兼容。测试没走到的可选分支,可能在生产收到第一条特殊消息时才装载。Java Agent、APM、覆盖率、热修复和安全探针还会解析或改写 class,它们同时受 class 版本、模块强封装和动态加载策略影响。
先对最终发布包执行:
jdeps --jdk-internals app.jar
jdeprscan --for-removal app.jar第一条输出静态可见的 JDK 内部 API,第二条寻找已标记为移除的 API。如果结果为空,也不能宣布安全,因为反射拼接的类名、JNI、运行时生成代码和 Agent 变换未必能被发现。接下来要带上与生产完全相同的 -javaagent 链运行启动、类重转换、线程转储、JFR 和故障注入。去掉 Agent 后服务恢复,只能证明 Agent 是变量之一,不能证明带 Agent 的生产组合可用。
这里还要检查 Agent 厂商说的“支持 Java 21”究竟指启动成功、基础采集可用,还是包括字节码增强、异步链路和虚拟线程。只有发布说明、实际回归和监控结果三者一致,才能把它写进支持范围。
容器、操作系统和 CPU
基础镜像变化会同时带来 libc、时区数据、CA 证书、字体、DNS、用户权限和诊断工具变化。即使 JDK 大版本不变,从 glibc 镜像换到 musl/Alpine、从 x86_64 换到 AArch64,也要单独验证本地库、压缩算法和性能。
进入实际容器后执行:
java -XshowSettings:system -XshowSettings:vm -version输出会显示 JVM 识别到的系统与虚拟机设置。这里重点对照容器实际可见的 CPU、内存和最大堆,不要只看 Pod limit。若 JVM 看到的是节点资源而不是容器预算,GC 线程数、ForkJoinPool 并行度和堆启发式都可能被放大,压测结果也会失真。
不要为了减小几十兆镜像,把故障时需要的工具全部删掉。生产镜像若不含 jcmd、jstack 或 JFR 操作入口,就准备经过授权、版本匹配的诊断镜像或 sidecar 流程,并提前演练怎样附加到进程。事故发生后临时下载一个不同补丁版本的工具连接生产 JVM,只会再增加一个变量。
GC、内存与启动参数
跨 LTS 期间,GC 实现、默认值、启发式和参数支持都会演进。旧参数有的只告警,有的被忽略,还有的会让 JVM 直接退出。升级前先把生产启动命令原样放到目标 JDK 执行,检查标准错误和 GC 日志头部;再用下面的命令查看目标进程真正采用的关键参数:
jcmd <pid> VM.flags
jcmd <pid> VM.info堆大小相同也不代表 RSS 相同,因为 Metaspace、线程栈、Code Cache 和直接内存都在堆外。先保持与旧系统等价的收集器和资源预算建立对照;若原收集器已经移除,再把 GC 变化单独测试。不要同时启用分代 ZGC、紧凑对象头和 AOT,最后仅凭一次压测就说“JDK 25 更快”,那样无法知道收益和退化分别来自哪里。
比较新旧版本时,至少记录稳定负载下的 p50/p95/p99、每请求 CPU、分配率、峰值 RSS、GC 暂停与并发周期、启动和预热时间。微基准适合解释一个局部机制,却不能替代带真实依赖、Agent、容器限额和上游等待的服务压测。
证书、TLS、加密与区域数据
JDK 更新会带来 CA 根证书、禁用算法、TLS 实现、时区和区域数据变化。服务升级后若只对某个老接口报 SSLHandshakeException,先确认实际使用的信任库,而不是立刻全局关闭证书校验:
keytool -list -keystore /path/to/truststore.p12 -storetype PKCS12输出应该包含预期的企业 CA 和证书别名。接着针对每个外部端点验证协议、密码套件、证书链、SNI 和代理路径,并确认 FIPS 或硬件加密提供者在目标发行版和平台上受支持。关闭校验只能掩盖问题,还会把一次兼容故障变成安全漏洞。
日期、货币、排序与字符集变化更危险,因为调用可能成功,数据却已经改变。对账文件、签名原文、消息摘要、CSV、模板和批处理要显式固定 Locale、ZoneId 与 Charset;拿线上脱敏样本比较最终字节、字段和排序,不要只看 Java 对象打印出来“差不多”。
常见失败模式怎样定位
UnsupportedClassVersionError:产物 class 版本高于运行时,先核对真正执行的 java 和构建的 --release,不要只看 IDE 设置。NoClassDefFoundError 或模块缺失:重点检查 8 到 11 之间被移除的模块、拆分包和依赖是否显式声明。InaccessibleObjectException、非法反射或 Agent 失败:检查内部 API 与模块开放;优先升级依赖,不把永久 --add-opens 当修复。
NoSuchMethodError、AbstractMethodError:通常是依赖图或运行时加载了错误版本,不是“JDK 向后兼容失效”的充分证据。启动成功但日期、货币、TLS、证书或序列化结果变化:这是行为和数据兼容问题,必须用金丝雀样本与契约测试比较,而不是只看健康检查。升级后吞吐下降:先恢复原有并发和 GC 模型做对照;不要在同一次发布中同时更换 JDK、启用虚拟线程、调整 GC 和连接池。
最后把结论写成下一位维护者能执行的记录
回到开头那场评审。如果结论仍然只有“使用 Java 21 LTS”,下一位维护者还是无法构建和升级。记录里应写到可以照着执行:例如“生产使用某厂商 JDK 21 的固定补丁版本;镜像按摘要引用;Maven Toolchain 与 --release 均为 21;每个 CPU 周期进入预生产;框架、APM、JDBC 驱动的支持链接附后;出现哪些指标立即回切到哪个旧镜像”。
这份记录不需要抄一遍版本特性,但要回答为什么支持窗口覆盖系统计划寿命、生产许可由谁确认、临时 --add-opens 还剩哪些、下一次版本复核由谁在什么时候发起。供应商支持页面、流水线配置和压测报告都应能从记录中找到,而不是散落在聊天记录里。
新系统还要留下候选版本被淘汰的原因。选择 21 而不是 25,可以写“关键 Agent 尚未正式支持 25,21 的供应商支持窗口覆盖计划寿命”;不要写无法验证的“21 更稳定”。选择 25,则说明框架、平台和团队已经支持哪些正式能力,并明确哪些 Preview 禁止进入生产。供应商许可改变、关键组件停止支持、剩余支持时间不足,或出现无法回移的安全问题时,自动触发下一次复核。
存量升级则把兼容迁移和新能力采用分开写。第一阶段只改变 JDK 与必要依赖,业务、GC 和并发模型保持不变;第二阶段才评估 records、虚拟线程、Scoped Values 或新 GC。每个阶段都有独立制品、指标和回滚点。如果一次发布还夹带数据库不可逆变更和协议升级,就继续拆分,否则出故障时既无法定位,也无法安全退回旧 JDK。
灰度也不是简单地把 5% 实例换成新镜像。要让真实租户和请求覆盖冷启动、长连接、定时任务、消息消费、批处理和低频反射路径;新旧版本分别打标签,比较同一时间窗口的错误码、业务结果、延迟、资源与 GC。错误率、p99、RSS 或消息积压超过什么值、持续多久后自动回切,都在发布前写清。旧镜像、旧配置和旧依赖仓库在整个观察期保持可用,回滚时才不会临时重建出另一套 JDK。
版本维护不是这次发布结束就不再碰。一般按季度或 CPU 周期刷新补丁,至少每半年复核发行版、许可和依赖支持,在支持终止前留出完整迁移窗口。java -version、镜像摘要、GC 和启动参数进入资产记录,废弃 API、内部 API 和临时模块开放持续扫描。目标不是追每一个六个月功能版本,而是避免等到安全补丁、云平台或框架都停止支持时,被迫一次跨越多个断层。
“Oracle 支持到某年”只能作为 Oracle 对应产品和客户条件下的证据。若使用其他发行版,必须替换成该厂商自己的支持与许可材料。支持窗口、采购合同和安全补丁通道都是会变化的外部事实,应在每次基线评审时重新核对,不能把一次页面查询的日期当作永久结论。
什么时候可以结束这次升级
版本选型或迁移不是“应用启动成功”就可以收工。下面这些证据都拿得到,团队才有理由结束观察期:
Java SE 版本、JDK 厂商/发行版、生产许可、更新通道和支持期限已经书面确认。框架、构建工具、Agent、驱动、本地库和部署平台都有目标基线的支持证据。构建 JDK、Toolchain、--release、基础镜像和生产 java -version 可被流水线核验。
内部 API、废弃/移除能力和临时 --add-opens 已清零,或具有有期限的例外记录。功能、契约、安全、容量和可观测性回归达到旧基线或预先批准的新 SLO。灰度和回滚演练成功;升级没有夹带虚拟线程、GC 或序列化模型等独立架构变更。
Preview/Incubator/Experimental 能力没有进入生产硬依赖;例外已单独审批。
如果关键厂商不支持、生产许可不明确、闭源 Agent 无法替换、回滚路径不可用,或性能退化原因尚未隔离,应明确停止迁移。停止不是失败,而是避免用生产流量替代兼容性实验。
版本、支持与兼容性要回到这些入口
Oracle Java SE Support Roadmap:Oracle 对 LTS、许可转换和商业支持时间的当前说明;时间只适用于其声明的产品与客户范围。
OpenJDK JDK 11、JDK 17、JDK 21、JDK 25:各 Java SE 参考实现的正式特性与状态,页面明确区分 Preview、Incubator 和 Experimental。
OpenJDK:JEP 320、JEP 403、JEP 444、JEP 506、JEP 519:Java EE/CORBA 模块移除、强封装、虚拟线程、Scoped Values 与紧凑对象头的正式状态和边界。
OpenJDK:JEP 400、JEP 486:默认 UTF-8 与永久禁用 Security Manager 两个容易影响存量行为的跨 LTS 变化。
Oracle:Preparing for Migration:先运行、更新依赖、重新编译与 jdeps 检查的官方迁移顺序。Oracle:javac Tool Specification:--release、告警和 Preview 编译规则。
Java Virtual Machine Specification:class File Format:class 主/次版本、Java SE 25 可装载范围与 Preview class 的规则。OpenJDK:JEP 238 Multi-Release JAR Files:同一 JAR 按运行时版本选择不同 class 实现的正式规则。
Oracle:jdeps、jdeprscan:内部依赖和弃用 API 的静态扫描入口。
Maven Toolchains:让编译器、测试和插件使用统一 JDK 的官方构建工具说明。
结论
以后再看到“项目使用 Java 17 LTS”,先不要急着通过评审。继续问清 Java SE 版本、JDK 厂商和完整补丁号、许可与更新来源、依赖支持范围、运行参数以及 --release,再用目标运行时把旧产物、重新编译产物、Agent、证书和容量逐项跑出来。能说明这些证据,能灰度,也能退回旧镜像,这次版本选择才真正落到了项目里。类型、异常、反射与并发机制分别验证自身契约,版本基线只负责证明这些契约在目标运行时组合中仍然成立。
