跳过正文
从 JDK 8 到 25:把跨 seventeen 个版本的升级当成平台工程
  1. 文章/

从 JDK 8 到 25:把跨 seventeen 个版本的升级当成平台工程

·4786 字·10 分钟
NeatGuyCoding
作者
NeatGuyCoding

从 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 -versionJAVA_HOME 一致;每次发布前导出并归档 dependency:tree;对 fat JAR 或 shade 产物额外跑一次 jdeps -R,因为合并后的包会掩盖传递依赖来源。

JavaOne 现场:依赖解析与迁移指南相关幻灯片
图:Practical Dependency Resolution — Maven、Gradle、SBT 与依赖复杂度


升级前的静态侦察:jdepsjdeprscanjnativescan
#

为什么: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,含 ~/.m2lib/*):

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 与 FFMjnativescan 给出静态画像,运行时是否还需 --enable-native-access 要以目标 JDK 的 JEP 472 阶段策略为准(JDK 24 起警告路径已产品化,未来可能收紧为 deny)。

Mermaid diagram 1


第一堵墙:JDK 9–17 的模块、GC 与封装
#

为什么:JDK 9 单版本就集中了模块路径、默认 GC 切换、CLDR locale、字符串拼接实现变更等多项断裂——幻灯片将其概括为「nine 到 seventeen 的第一堵墙」。若仍保留 Parallel GC 调优、CMS 标志、Java EE 内嵌 API,会在 9/14/17 等台阶叠加失败。

机制/约束(与官方 JEP 对齐的要点):

  • 拆分包 / 模块路径LayerInstantiationExceptionJEP 261 语境)
  • 默认 GC Parallel → G1JEP 248)→ 吞吐与暂停曲线变化,属静默类
  • CLDR 默认 localeJEP 252)→ 格式化输出漂移
  • 强封装JEP 396,JDK 16)→ InaccessibleObjectException
  • 移除 --illegal-accessJEP 403,JDK 17)→ 反射后门不可长期依赖
  • CMS / Nashorn / -XX:MaxPermSize 等 → 多为 Won’t Start

Breakages by Version: JDK 9 — 17,含 Won’t Start 与 Silent Change
图:Breakages by Version: JDK 9 — 17 — Split packages、JEP 248/252/280 等条目

JDK 9: seven breaking changes in a single release
图:JDK 9: seven breaking changes in a single release. The first wall.

怎么做(过渡,非终点):

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-8JEP 400Windows 上历史文件读写静默损坏;-Dfile.encoding=COMPAT 可过渡
Native accessJEP 472JDK 24 交付)--enable-native-access=ALL-UNNAMED
Security ManagerJEP 486JDK 24-Djava.security.manager=... 启动即失败
注解处理默认javac 手册 + JDK-8321314JDK 24 起未显式配置则不运行 processor(非 JEP 477,477 为隐式类/实例 main)
Compact headersJEP 519JDK 25 产品化;JEP 明确非目标为默认 object header
java.lang.IOJEP 512新 API 与 java.io 并存,需适配 import/教学代码习惯

Breakages by Version: JDK 18 — 25 — UTF-8、JEP 472、Unsafe 等
图:Breakages by Version: JDK 18 — 25 — Native access requires –enable-native-access (JEP 472)

怎么做

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 158JEP 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 -versionjava -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 400Charset.defaultCharset() 默认 UTF-8。
  • JEP 252:CLDR 为默认 locale 数据源。

Security paths silently succeed — JEP 486
图:Security paths silently succeed — No exception, that’s the problem

怎么做

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 格式(工程建议)。

Serialisation drift — InvalidClassException 与 serialver
图:Serialisation drift — Cached data from JDK 8 can’t be deserialised on JDK 25

常见误区:「编译通过」等同于序列化兼容;测试夹具从未包含 JDK 8 时代序列化字节。


构建陷阱:JDK 24+ 注解处理与 Lombok
#

为什么:自 JDK 24 起,javac 默认不再自动运行注解处理器;Lombok 若仍依赖隐式发现,可能出现编译成功、运行期 NoSuchMethodError(getter/builder 缺失)。这与 JEP 477(隐式类)无关,跟踪实现见 JDK-8321314

机制/约束Lombok changelog1.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+ 仍成立。

The Lombok trap — javac no longer runs annotation processors by default
图:The Lombok trap — Since JDK 23, javac no longer runs annotation processors by default(版本号以 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 库替换或商业支持路径。

What is a Software Supply Chain — + your dependencies
图:What is a Software Supply Chain — 每个依赖自带整条供应链

Mermaid diagram 2


可执行的收尾清单与 JDK 25 能力画像
#

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

Seven things to do on Monday — Audit startup flags, jdeps, dependency tree
图:Seven things to do on Monday — Diff PrintFlagsFinal between JDK 8 and 25

到达 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 */ });
}

Why JDK 25 — Virtual threads, GC, Pattern matching, Security posture
图:Why JDK 25 — migration matters more than the destination — Virtual threads, 2-3x GC, 20%+ throughput(性能数字需自行基准验证)

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

Mermaid diagram 3

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


参考与延伸阅读
#

相关文章