Ohhnews

分类导航

$ cd ..
foojay原文

侏罗纪JDK:迁移还是灭绝

#jdk迁移#java升级#遗留系统#hibernate#openrewrite

[LOADING...]

6500万年前,恐龙没有适应环境。它们灭绝了。你的 JDK 7 应用正在释放同样的气息。 🫠

我花了整整一年半的时间,全职迁移了生产环境中超过 15 个项目。真实的团队、真实的截止日期、凌晨两点的真实崩溃。下面就是我希望在开始之前有人能告诉我的所有经验。

为什么我们还停留在 JDK 7? 👀

走进任何一家代码库超过 5 年的公司,你都会听到同样的话:

“我们不能迁移,太冒险了。”

老实说,我能理解。JDK 7 能跑。应用没问题。没人抱怨。为什么要动它?

原因如下:JDK 7 的公共支持多年前就已结束,如今大多数运行它的组织依靠的是厂商的扩展支持,或者根本没有支持。你添加的每个依赖都像是在兼容性上赌博。而你招来的每个初级开发者,看过你的代码库后都会默默更新他们的 LinkedIn。

风险不在于迁移,而在于停滞。 🦖

人人都会犯的错误:一步到位

当团队终于决定迁移时,他们往往会做最糟糕的事:

试图从 JDK 7 一步跳到 JDK 21。

一个巨型分支,一个巨型 PR,三个月的工作量。然后合并那天,一切爆炸。团队 burnout,迁移被放弃。然后你又回到了 JDK 7,甚至更没动力再试一次。

别跳,要跳板! 🐸

跳板策略:LTS 接 LTS

Java 每两年发布一个新 LTS 版本。这就是你的路线图。

JDK 7 → JDK 8 → JDK 11 → JDK 17 → JDK 21 → JDK 25

每一步都可管理,每一步都能上线,每一步都能在下一次之前增强团队的信心。

对于大型遗留系统,一次迁移一个 LTS 能降低风险并简化问题排查。有些团队能成功从 8→17 或 11→21 直接跳,但跨度越大,越难隔离是哪里出了问题、为什么出问题。根据你的代码库规模和测试覆盖率来决定。

第一步:动代码前先读懂你的代码库 🔍

在修改任何一行代码之前,你需要知道自己在面对什么。

运行依赖审计。每个库都有其支持的最高 JDK 版本。先找到那些会首先出问题的。

mvn versions:display-dependency-updates

寻找:

  • 自 2015 年以来从未更新的库 🚩
  • 包装已废弃 JDK API 的内部工具
  • Spring Boot 3+ 不再支持的基于 XML 的配置
  • 大量使用反射的代码(Project Jigsaw 对此会有意见)

记录下你找到的一切。这是你的迁移地图。没有它,你就像在丛林里盲目徒步。 🌿

第二步:真正有用的工具(以及那些会忽悠你的工具)

OpenRewrite ✅

这是真家伙。OpenRewrite 是一个自动化重构工具,能帮你处理迁移中的大部分工作。例如升级 Spring Boot 版本、将 JUnit 4 迁移到 JUnit 5、替换已废弃的 API。它不是魔法,但也差不多了。

每个 LTS 跳板都有对应的 recipe。对于 Java 21,具体如下:

// build.gradle
plugins { 
id("org.openrewrite.rewrite")
version("latest.release")
 }
 
rewrite {
 activeRecipe("org.openrewrite.java.migrate.UpgradeToJava21") 
setExportDatatables(true)
 } 
dependencies { 
rewrite("org.openrewrite.recipe:rewrite-migrate-java:latest.release")
 }

然后运行:

./gradlew rewriteRun # Gradle 
mvn rewrite:run # Maven

然后 git diff。审查每一个修改。提交。继续。

OpenRewrite 不会碰的一个关键点: pom.xml 中的 <java.version>build.gradle 中的 sourceCompatibility / targetCompatibility。你需要手动更新,然后再运行编译器检查。别忘了。 🚩

运行它,审查 diff。不要盲目提交。有些改动你接受,有些你跳过。你自己判断。

jdeprscan ✅

内置于 JDK 本身。扫描你的代码中已废弃的 API 用法,免得它们在你面前炸开。

jdeprscan --class-path your-app.jar your-app.jar

在每次跳板之前运行它。是之前,不是之后。

Maven Enforcer Plugin ✅

锁定最低 JDK 版本,防止团队中有人意外地使用错误的 JDK 编译。

<plugin> 
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId> 
  <configuration>
    <rules>
      <requireJavaVersion>
         <version>[21,)</version> 
       </requireJavaVersion> 
    </rules> 
  </configuration>
 </plugin>

会忽悠你的工具 ❌

任何声称能“完全自动化迁移你的项目”的工具。别信。这些工具处理的是机械性的工作。架构决策、库的不兼容性、Hibernate 的痛苦(下面 👇 会详述)——这些都还得靠你自己。每个 diff 都需要人工审查。每个自动化变更都需要跑一次测试。永远不要跳过这一步。永远。

第三步:那些会让你崩溃的破坏性变更

JDK 7 → JDK 8

主要是新增功能:Lambda、Stream、Optional、新的日期/时间 API。这里的痛苦通常是间接的:那些尚未支持 Java 8 的库。首先检查你的依赖。

JDK 8 → JDK 11

这是人们第一次哭出来的地方。 😭

Java EE 模块从 JDK 中移除了。javax.xml.bindjavax.activation,所有都移除了。你需要将它们作为显式依赖添加回来。

<dependency>
 <groupId>javax.xml.bind</groupId>
 <artifactId>jaxb-api</artifactId>
 <version>2.3.1</version>
</dependency>

<dependency>
 <groupId>com.sun.xml.bind</groupId>
 <artifactId>jaxb-impl</artifactId>
 <version>2.3.1</version>
</dependency>

注意: 这只能让你能编译。如果你最终也想迁移到 Spring Boot 3,你还需要将命名空间迁移到 jakarta.*,但那是另一个独立的决策和另一个跳板。不要混在一起。

此外:sun.*com.sun.* 内部 API 现在被严格封装。如果你的代码或任何依赖使用了它们,你很快就会发现。

JDK 11 → JDK 17

Project Jigsaw 现在变得严肃了。以前悄无声息就能工作的反射式访问现在会产生警告,最终导致错误。

值得了解:Spring Boot 3 最低要求 Java 17。但你不必在刚达到 Java 17 时就升级到 Spring Boot 3。许多团队在应对 Boot 3 迁移之前,会先运行 Java 17 + Spring Boot 2.7 一段时间。这是两个独立的决策。分开对待。javax.*jakarta.* 的重命名是 Spring Boot 3 的事,不是 JDK 17 的事。

JDK 17 → JDK 21

虚拟线程。Project Loom 发布,你的每请求一线程应用几乎无需改动代码就能显著提升扩展能力。结果因你的驱动和阻塞依赖而异,所以在宣布胜利之前先做基准测试。但潜力是真实存在的。 🎉

这也是 Hibernate 开始发威的地方。而且很厉害。

如果你在用 Hibernate 5,你需要升级到 Hibernate 6。而 Hibernate 6 不是一个小版本升级。

Hibernate 6 的变化:

  • Hibernate 6 改为按位置读取 JDBC ResultSet,而不是按名称。这听起来像是内部实现,但影响是实实在在的:以前能正确映射的查询结果可能会静默映射错误或在运行时抛出异常。特别是原生查询和多表连接。
  • @Type 注解完全重写。你在 Hibernate 5 中的自定义类型映射在 Hibernate 6 中无法编译。你需要使用新的 @JavaType / @JdbcType 注解重写它们。
  • @Any@ManyToAny 映射行为发生了变化。如果你使用了多态关联,每样东西都测试两遍。然后再测一遍。 🧪
  • Criteria API 有了相当大的变化,一些以前能正常工作的查询现在产生了不同的 SQL。静默的行为变化是最糟糕的,而 Hibernate 6 就有几个。检查你的查询结果,而不只是查询能否运行。
  • Hibernate 6 已弃用 hbm.xml 映射格式,并将在 6.x 之后移除。如果你还有遗留的 XML 映射,现在就开始迁移到注解。

JDK 21 → JDK 25

Java 25 是最新 LTS,于 2025 年 9 月发布。如果你是在还不了解它有什么功能之前读到这段话,那么本节就是你对即将面对的内容的预览。

OpenRewrite 的 UpgradeToJava25 recipe 涵盖了机械性的变更。其中包括:

  • process.waitFor(5000, TimeUnit.MILLISECONDS) 变为 process.waitFor(Duration.ofSeconds(5)) ⏱️
  • new java.io.StringReader("x") 变为 Reader.of("x")
  • inflater.end() 变为 inflater.close(),因为 Inflater 现在实现了 AutoCloseable
  • ZipErrorZipException 取代
  • 未使用的 lambda 参数可以使用 _(匿名变量,自 Java 22 的 JEP 456 起正式可用)
  • SecurityManager 实际上已被移除,不再作为支持的安全机制。该类仍然存在,但已无功能。任何依赖它的代码都需要修改。
  • 实例 main 方法即将到来(不再需要 public static void),但仍然是预览特性,尚未正式发布。不要在没有 --enable-preview 的情况下在生产环境中使用。

但 Hibernate 依然是痛苦的主要来源。 🐛

现在让我真正经历噩梦的是:IdGeneratorStrategyInterpreter

如果你有自定义 ID 生成策略,你很可能正在使用或扩展这个类。Hibernate 在 6.0 中弃用了它,并在 6.4 中完全删除。没有替代的垫片,没有兼容层。就是删除了。

你的代码无法编译。如果你在 15 个项目中都散布了自定义生成器,你将一个一个地追查它们。 🔍

修复方法是迁移到 Hibernate 6 中引入的新注解 @IdGeneratorType。它更简洁、类型安全。但这是一次重写,而不是查找替换。

// 之前 - 使用现已删除的 IdGeneratorStrategyInterpreter 
public class CustomIdGenerator implements IdentifierGenerator 
{ 
@Override
public Serializable generate(SharedSessionContractImplementor session, Object object) {
 // 你的逻辑 
}
}
 // 之后 - Hibernate 6+,使用 @IdGeneratorType 
@IdGeneratorType(CustomIdGenerator.class) 
@Retention(RUNTIME)
@Target({ FIELD, METHOD }) 
public @interface CustomGenerated {}

public class CustomIdGenerator implements BeforeExecutionGenerator {
 @Override
  public Object generate(SharedSessionContractImplementor session, Object owner, Object currentValue, EventType eventType) {
 // 你的逻辑 
 } 
}

另外:从 Hibernate 6.4 开始,如果你在非标识符字段上使用了 @GeneratedValue,Hibernate 现在会在启动时抛出 AnnotationException。之前的版本会静默忽略。运行了多年的代码会突然拒绝启动。在这次跳板之前,用 grep 搜索整个代码库中的 @GeneratedValue。 🚩

在这些跳板中处理 Hibernate 的底线:OpenRewrite 处理 JDK 级别的东西。Hibernate 需要手动工作。为它预留真正的时间。

第四步:制定一个不让团队崩溃的迁移计划 📋

以下是在超过 15 个项目中行之有效的结构。

第一阶段:盘点(1 周)

运行所有扫描工具。记录每个依赖版本、每个已废弃的 API 用法、每个可能受影响的 Hibernate 映射。什么都别修,先摸清地形。

第二阶段:依赖清理(1-2 周)

将所有依赖更新到支持你的目标 JDK 的版本。在动 JDK 本身之前完成这一步骤。将 JDK 升级和依赖升级混在一起,就是你浪费一周调试时间的方式。

第三阶段:一个跳板(1-3 周,取决于代码库大小)

更改 JDK 版本。运行 OpenRewrite。手动更新构建文件中的 java.version。修复 OpenRewrite 捕捉不到的问题。运行完整的测试套件。修复暴露的问题。上线。在下一个跳板之前,确保本次跳板在生产环境中稳定运行。

第四阶段:重复

对每个 LTS 跳板重复相同的过程。第一个最难。到第三个时,你的团队闭着眼睛都能完成。

没人会告诉你的那些事 🤫

你的 CI/CD 管道也需要更新。 忘记更新 Docker 基础镜像,你就会用新的 JDK 构建,但用旧的 JDK 在生产环境中运行。那会是一个有趣的周五下午。在每次跳板上线之前先更新管道。

测试覆盖率是你最好的朋友,也是你最坏的敌人。 高测试覆盖率给你信心。零测试覆盖率意味着你只能从用户那里发现故障。如果你是零覆盖率的阵营,在开始之前至少为你的关键路径编写集成测试。你会感谢自己的。

一次一个项目。 如果你有多个服务,不要同时迁移所有服务。先选最小的、最不关键的那个。从它身上学习。然后把学到的经验应用到其余部分。

与团队保持持续沟通。 迁移疲劳是真实存在的。当人们在一个星期五下午 5 点被 Hibernate 映射错误卡住时,他们会感到沮丧。让团队了解进展,庆祝每一个成功上线的跳板,并明确这代表进步而不是惩罚。

在另一端等着你的是什么 🏆

经历了 15 多次迁移之后,下面这部分依然让我每次都感到激动。

团队中第一个开发者用 record 写了一个 40 行的 POJO,而不是写 getter、equals、hashCode 等等,然后抬起头来,脸上露出那种表情。你知道那种表情:“等等,就这样?”

// 之前 
public class RaceResult {
 private final int round;
 private final String gp;
 private final int points;

 public RaceResult(int round, String gp, int points) {
 this.round = round;
 this.gp = gp;
 this.points = points;
 }
 // getter, equals, hashCode, toString... 
}


 // 之后 🎉 
record RaceResult(int round, String gp, int points) {}

虚拟线程让你的应用以更少的开销处理更多的负载。模式匹配让你的 switch 语句真正可读。Stream Gatherers 让你无需繁琐的收集器就能进行批处理、滑动窗口和分组数据。

JDK 21 上的语言已经不是 JDK 7 时的语言了。它更好了。好得多。你的团队值得使用它。 ✨

停滞的真正代价 💸

你停留在 JDK 7 的每一个月:

  • 安全漏洞在堆积,没有补丁可用
  • 新员工入职的第一周都在困惑于古老的设计模式
  • 你想使用的依赖需要更新的 JDK,但你用不了
  • 你与现代 Java 之间的差距在拉大,最终的迁移只会越来越困难

迁移不是免费的。但停滞也不是免费的。只是账单更加悄无声息。

TL;DR 🦕

别跳,要跳板。动任何代码之前先扫描。使用 OpenRewrite,但不要盲目信任。手动更新构建文件。先更新依赖,再更新 JDK。为 Hibernate 预留真正的时间。一次一个项目。在开始下一个跳板之前,确保本次跳板已上线。

6500 万年前,恐龙没有选择。但你有! 🦖

所以,请适应,迁移,生存!

本文首发于 foojayJurassic JDK: Migrate or Extinct