从 JDK 8 到 25:把跨 seventeen 个版本的升级当成平台工程#
许多团队仍把「升级 JDK」理解成替换生产环境的 java 可执行文件。若编译、测试、构建三条 classpath 仍停留在 Java 8 时代的依赖与参数上,问题往往不会在切换当天爆发,而是在集成测试、灰度或数周后的缓存反序列化里集中出现。
跨 seventeen 个主版本,断裂点可按影响面粗分为四类:进程起不来(废弃 GC/SM 参数)、跑起来就崩(模块拆分包、NoClassDefFoundError、反射封装)、编译通过但行为变了(locale、UTF-8、字符串拼接)、更晚才炸(序列化 UID、注解处理缺失)。本文把 JDK 8→25 当作全链路 Java 资产迁移:先用 JDK 自带工具摸清断裂面,再按上述分层处理,最后把「到达 25」与「保持六个月节奏」拆开——前者是项目,后者才是能力。
迁移对象:每一条 classpath,而不是一个 runtime JAR#
为什么:传递依赖里的 sun.*、已标记 for removal 的 API、JNI/FFM 用法,很少出现在业务 src/ 里,却会在目标 JDK 上触发 NoClassDefFoundError 或启动警告。只升级生产运行时、测试仍用旧 JDK 编译,会在 CI 后期才暴露不一致。
机制/约束:Maven/Gradle 的 compile、test、runtime 三套解析树可以不同;容器镜像里的 JAVA_OPTS 也可能与本地开发脚本分叉。JDK 25 的 System.getProperty(“java.class.path”) 只反映当前进程实际加载路径。
怎么做:
mvn -q dependency:tree -DoutputFile=target/deps.txt
mvn -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt
public final class ClasspathProbe {
public static void main(String[] args) {
String cp = System.getProperty("java.class.path");
if (cp == null) return;
for (String e : cp.split(java.io.File.pathSeparator))
if (e.endsWith(".jar")) System.out.println(e);
}
}
常见误区:认为「主工程已升到 25」就等于全栈升级;忽略测试 scope、annotation processor、构建插件自带的 JAR。建议在升级章程里写清三条 invariant:开发机、CI agent、生产镜像的 java -version 与 JAVA_HOME 一致;每次发布前导出并归档 dependency:tree;对 fat JAR 或 shade 产物额外跑一次 jdeps -R,因为合并后的包会掩盖传递依赖来源。

升级前的静态侦察:jdeps、jdeprscan、jnativescan#
为什么:JDK 9 起的模块强封装、JEP 403 移除 --illegal-access、以及 JEP 472 对 native access 的约束,都会先体现在字节码与启动参数上,而不是业务逻辑 diff。
机制/约束:
| 工具 | 典型用途 | 手册 |
|---|---|---|
jdeps --jdk-internals | 定位对 JDK 内部 API 的依赖 | 应对 JEP 396 |
jdeprscan --for-removal | 扫描即将移除的 API | 与 --release 25 联用 |
| jnativescan | 扫描 JNI/本地库引用 | 与 JEP 472 运行时策略互补 |
| japicmp | 依赖小版本升级的 API 二进制差异 | 第三方,非 JDK 自带 |
怎么做(classpath 须覆盖全部 JAR,含 ~/.m2 或 lib/*):
jdeps --multi-release 25 --jdk-internals -R app.jar lib/*.jar
jdeprscan --release 25 --for-removal --class-path "lib/*:app.jar"
jnativescan --class-path "lib/*:app.jar"
常见误区:只对 target/classes 扫描;遗漏 jdeprscan 的 --for-removal,把「已废弃」与「计划移除」混为一谈。另一个隐蔽点是 JNI 与 FFM:jnativescan 给出静态画像,运行时是否还需 --enable-native-access 要以目标 JDK 的 JEP 472 阶段策略为准(JDK 24 起警告路径已产品化,未来可能收紧为 deny)。

第一堵墙:JDK 9–17 的模块、GC 与封装#
为什么:JDK 9 单版本就集中了模块路径、默认 GC 切换、CLDR locale、字符串拼接实现变更等多项断裂——幻灯片将其概括为「nine 到 seventeen 的第一堵墙」。若仍保留 Parallel GC 调优、CMS 标志、Java EE 内嵌 API,会在 9/14/17 等台阶叠加失败。
机制/约束(与官方 JEP 对齐的要点):
- 拆分包 / 模块路径 →
LayerInstantiationException(JEP 261 语境) - 默认 GC Parallel → G1(JEP 248)→ 吞吐与暂停曲线变化,属静默类
- CLDR 默认 locale(JEP 252)→ 格式化输出漂移
- 强封装(JEP 396,JDK 16)→ InaccessibleObjectException
- 移除
--illegal-access(JEP 403,JDK 17)→ 反射后门不可长期依赖 - CMS / Nashorn /
-XX:MaxPermSize等 → 多为 Won’t Start


怎么做(过渡,非终点):
java --add-opens java.base/java.lang=ALL-UNNAMED -jar legacy.jar
java -Djava.locale.providers=COMPAT,CLDR -jar app.jar
常见误区:把 --add-opens 写进永久配置而不推动库升级;在 JDK 17+ 仍假设 --illegal-access=permit 可用。
第二堵墙:JDK 18–25 的编码、native access 与编译器行为#
为什么:18–25 的变更常混在同一升级窗口:有的进程起不来,有的无异常但结果变了,还有的重编译后才暴露。
机制/约束:
| 主题 | JEP / 依据 | 典型影响 |
|---|---|---|
| 默认 charset UTF-8 | JEP 400 | Windows 上历史文件读写静默损坏;-Dfile.encoding=COMPAT 可过渡 |
| Native access | JEP 472(JDK 24 交付) | 需 --enable-native-access=ALL-UNNAMED 等 |
| Security Manager | JEP 486(JDK 24) | -Djava.security.manager=... 启动即失败 |
| 注解处理默认 | javac 手册 + JDK-8321314 | JDK 24 起未显式配置则不运行 processor(非 JEP 477,477 为隐式类/实例 main) |
| Compact headers | JEP 519 | JDK 25 产品化;JEP 明确非目标为默认 object header |
java.lang.IO | JEP 512 | 新 API 与 java.io 并存,需适配 import/教学代码习惯 |

怎么做:
java --enable-native-access=ALL-UNNAMED -jar app-with-jni.jar
java -Dfile.encoding=COMPAT -jar app.jar
常见误区:把 JEP 472 仅锚定在 JDK 25;把幻灯片脚注「JEP 477」当成注解处理变更(编号已过期,应以 javac 手册为准)。对 JEP 471/498 涉及的 sun.misc.Unsafe 内存访问,应把「警告」当作移除倒计时,而不是可忽略的编译器噪声;字节码工具链(ASM、ByteBuddy)须与目标 class file version 对齐,否则会在构建链更早阶段失败。
启动期硬失败:JVM 参数审计优先于业务代码#
为什么:过时 GC 标志(如 CMS,JEP 363)、遗留 GC 日志参数(JEP 158、JEP 271,统一为 -Xlog),以及 Security Manager 相关启动项,会让 JVM 在业务 main 之前退出。若不做 diff,团队会在「应用没改」的前提下全站不可用。
机制/约束:java 手册 提供 GC logging 到 -Xlog 的转换说明;-XX:+PrintFlagsFinal 可导出两版 JDK 的 flag 全集做对比。
怎么做:
"$JAVA8_HOME/bin/java" -XX:+PrintFlagsFinal -version > flags-jdk8.txt
"$JAVA25_HOME/bin/java" -XX:+PrintFlagsFinal -version > flags-jdk25.txt
diff -u flags-jdk8.txt flags-jdk25.txt | rg -i 'gc|logging|security' || true
ENV JAVA_OPTS="-XX:+UseG1GC"
# 移除 -Xloggc:、CMS、SecurityManager 相关项
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar /app/app.jar"]
常见误区:只检查应用仓库的 JAVA_OPTS,遗漏 Helm chart、systemd、CI 矩阵里的旧 flag。实践中可先对最小可启动的 java -version 与 java -jar app.jar --help 做冒烟,再逐步打开完整集成套件;若使用 Kubernetes,记得同步检查 init 容器与 sidecar 的 JDK 版本,它们往往复制了主容器的旧参数模板。
运行时反射与「被吞掉的」封装异常#
为什么:JEP 396/403 之后,对 JDK 内部类型的 setAccessible(true) 可能抛出 InaccessibleObjectException。框架若 catch 后忽略,线上表现为功能随机失效而非清晰栈迹。
机制/约束:静态结果来自 jdeps --jdk-internals;运行时可用 -Xlog:exceptions=info 让 JVM 记录的异常更易在集成环境浮现(针对 JVM 抛出路径,无法替代应用层吞异常)。
怎么做:
java -Xlog:exceptions=info -jar build/integration-tests.jar
try {
var f = LegacyLib.class.getDeclaredField("internal");
f.setAccessible(true);
} catch (InaccessibleObjectException ex) {
System.getLogger("migrate").log(System.Logger.Level.ERROR, ex.getMessage(), ex);
}
常见误区:仅靠单元测试、未跑全量集成;未把 jdeps 命中与日志中的 InaccessibleObjectException 交叉验证。
静默语义:Security Manager 移除、locale 与 UTF-8#
为什么:这类问题属于「Runs but different」——测试只断言无异常时极易漏检。
机制/约束:
- JEP 486(JDK 24):Security Manager 永久禁用;六类
AccessController.doPrivileged在 SM 不启用时立即执行 action,不再经权限裁决。 System.getSecurityManager()恒为null;原先依赖SecurityException阻断路径的代码会静默继续执行。- JEP 400:
Charset.defaultCharset()默认 UTF-8。 - JEP 252:CLDR 为默认 locale 数据源。

怎么做:
java -Djava.locale.providers=COMPAT,CLDR -Dfile.encoding=COMPAT -jar app.jar
Files.writeString(path, text, StandardCharsets.UTF_8);
审计代码中 catch (SecurityException)、doPrivileged()、getSecurityManager() != null 等模式。
常见误区:把 SM 移除仅当作「删掉启动参数」;忽略依赖库里的权限假设(风险判断,非 JEP 字面结论)。
序列化:shipped 二进制与 freshly recompiled 必须分轨验证#
为什么:Java 内置序列化在跨主版本时,若类未固定 serialVersionUID,重编译后运行期计算的 UID 可能变化,导致 InvalidClassException——可能在迁移数日后的缓存读取才爆发。
机制/约束:Serializable 文档说明显式 serialVersionUID 的作用;serialver 可在仍使用 JDK 8 时为类生成 UID 常量。
怎么做:
serialver com.example.model.OrderDto
public class OrderDto implements java.io.Serializable {
private static final long serialVersionUID = 1234567890123456789L;
}
CI 上并行两条轨:test-shipped-jar(旧构建产物)与 test-recompiled(JDK 25 新编译)。长期更稳妥的方向是 JSON、Protobuf、Avro 等显式 schema 格式(工程建议)。

常见误区:「编译通过」等同于序列化兼容;测试夹具从未包含 JDK 8 时代序列化字节。
构建陷阱:JDK 24+ 注解处理与 Lombok#
为什么:自 JDK 24 起,javac 默认不再自动运行注解处理器;Lombok 若仍依赖隐式发现,可能出现编译成功、运行期 NoSuchMethodError(getter/builder 缺失)。这与 JEP 477(隐式类)无关,跟踪实现见 JDK-8321314。
机制/约束:Lombok changelog 在 1.18.40 声明 JDK 25 支持;1.18.42 主要为 IDE/ErrorProne 修复。幻灯片写「Since JDK 23」与当前手册不符,应以 24+ 为准。
怎么做:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.42</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
常见误区:升级 Lombok 版本却未在 annotationProcessorPaths / Gradle annotationProcessor 中显式声明;单测未调用 Lombok 生成方法。建议在 CI 增加一步「反编译或 javap -c 抽查」关键 DTO,确认 getter/builder 字节码确实存在;对 MapStruct、Micronaut 等其它 processor 适用同一规则,不要假设「以前能编过」在 JDK 24+ 仍成立。

机械重构与供应链现实#
为什么:javax→jakarta、弃用 API 替换可用 OpenRewrite 批量处理,但卡点常在第三方库是否跟进。演讲者给出典型现代应用约 150 个传递依赖、仅约 22% 开源 Java 组件仍活跃维护的印象数——无法独立核实,须用 SBOM 与仓库元数据自行验证。
怎么做:
mvn -U org.openrewrite.maven:rewrite-maven-plugin:run \
-Drewrite.recipeArtifactCoordinates=org.openrewrite.recipe:rewrite-migrate-java:RELEASE \
-DactiveRecipes=org.openrewrite.java.migrate.UpgradeToJava25
常见误区:把升级 scope 限在自有 repo;未与维护者沟通 EOL 库替换或商业支持路径。


可执行的收尾清单与 JDK 25 能力画像#
为什么:把一次性大跳拆成可重复步骤,能降低「不知道坏在哪一层」的概率。幻灯片强调:若 JVM 起不来,其余都无从谈起——应先 diff PrintFlagsFinal,再对完整 classpath 跑 jdeps/jdeprscan,开 -Xlog:exceptions=info 做集成测试,并分开验证 shipped 与 recompiled 产物。

到达 JDK 25 后,平台能力包括 虚拟线程 JEP 444、现代 GC(G1/ZGC/Shenandoah)、records/sealed/pattern matching、强封装与 FFM 等。闭幕幻灯片上的「2–3× GC」「20%+ throughput」属于演讲者/幻灯片 marketing 表述,须在你的负载上用 JFR 与基准测试验证:
java -XX:StartFlightRecording=duration=60s,filename=baseline.jfr -jar app.jar
try (var ex = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
ex.submit(() -> { /* IO-bound work */ });
}

持续升级(演讲者观点):OpenJDK 六个月发布节奏 下,「数年一跳」会把本文列出的多层断裂叠在同一窗口。可操作的工程习惯包括:每个发布列车对全量 classpath 重跑 jdeprscan --for-removal;依赖升级用 japicmp 做二进制兼容门;用 OpenRewrite 的 UpgradeToJava25 等菜谱消化机械变更;用 JFR 在相同负载下对比 GC 暂停与分配率;对无法升级的传递依赖建立 EOL 台账并与维护者或商业支持方对齐。分发版(Adoptium、Corretto、Liberica、Zulu 等)的补丁节奏各异,但语言与 JVM 行为以 OpenJDK 为准——不要把「我们只换运行时、不改构建」当作低风险路径。

常见误区:把 JDK 25 当作终点;忽视构建插件、测试容器、IDE 与生产 JDK 版本漂移。若团队仍按「数年一跳」规划,宜在立项阶段就把本文字段的断裂表拆进风险登记册,并为每一类断裂指定 owner(平台、中间件、业务线),避免把所有兼容性工作压到发布前两周。



