AI助力JDK8到21迁移实战
2026 年 9 月,Oracle JDK 8 的 NFTC(免费使用条款)将到期。而 Oracle JDK 8 的公共更新早在 2019 年 1 月就已停止。
不管从哪个时间点看,还在 JDK 8 上的企业都不得不面对一个现实:迁移到 21 不是"要不要"的问题,而是"什么时候做"的问题。
但 JDK 8 到 21,中间隔了 13 个版本、10 年的语言演进。一个中型项目几百万行代码,纯手工迁移,测试排期、兼容性问题、行为变更——想想就头大。
所以很自然的一个问题:大模型能不能帮上忙?
我用一个真实的 Java 8 项目做了实验,让大模型辅助做 JDK 21 迁移。下面是完整的过程和结果。
迁移的核心工作量
JDK 8 → 21 的迁移,工作量大致分三层:
第一层:语法升级(简单,可自动化)
• 匿名内部类 → Lambda 表达式
• 传统 for 循环 → Stream API
• 字符串拼接 → Text Blocks
• switch 语句 → switch 表达式
• 普通类 → Records
•
Optional.isPresent() + get()→ifPresent()/orElse()
第二层:API 替换(中等,大部分能自动)
•
java.util.Date/Calendar→java.time.LocalDateTime/Instant• JAXB 依赖外置(
javax.xml.bind在 JDK 11 被移除,需添加外部依赖)•
javax→jakarta命名空间迁移(Jakarta EE 9+ 的包名变更,与 JDK 升级配套处理)
第三层:行为变更(高风险,需要人工判断)
• 虚拟线程兼容性(
synchronized在虚拟线程中的 pinning 问题,JDK 25 已修复)•
ThreadLocal在虚拟线程下的表现变化• GC 参数变化(CMS 已被移除,需要切换到 G1/ZGC)
• 某些
sun.misc包下的内部 API 已被移除• 模块系统(JPMS)强封装:反射访问受限,需要
--add-opens/--add-exports• Security Manager 废弃与禁用(JDK 17 标记废弃,JDK 24 永久禁用)
大模型在第一层做得很好,第二层做得还行,第三层经常翻车。下面具体看。
第一层:语法升级——AI 的舒适区
我喂给 Claude Code 一段典型的 JDK 8 代码:
// 原始 JDK 8 代码
public class OrderProcessor {
public List<String> getActiveUserEmails(List<User> users) {
List<String> emails = new ArrayList<>();
for (User user : users) {
if (user.getStatus() == UserStatus.ACTIVE) {
String email = user.getEmail();
if (email != null && !email.isEmpty()) {
emails.add(email.toLowerCase());
}
}
}
Collections.sort(emails);
return emails;
}
public Optional<User> findUser(List<User> users, Long id) {
for (User user : users) {
if (user.getId().equals(id)) {
return Optional.of(user);
}
}
return Optional.empty();
}
public String formatReport(String title, List<String> items) {
StringBuilder sb = new StringBuilder();
sb.append("======== " + title + " ========\n");
for (int i = 0; i < items.size(); i++) {
sb.append((i + 1) + ". " + items.get(i) + "\n");
}
return sb.toString();
}
}
AI 输出:
// AI 迁移后的 JDK 21 代码
public class OrderProcessor {
public List<String> getActiveUserEmails(List<User> users) {
return users.stream()
.filter(u -> u.getStatus() == UserStatus.ACTIVE)
.map(User::getEmail)
.filter(email -> email != null && !email.isEmpty())
.map(String::toLowerCase)
.sorted()
.toList(); // JDK 16+ 直接返回不可变 List
}
public Optional<User> findUser(List<User> users, Long id) {
return users.stream()
.filter(u -> u.getId().equals(id))
.findFirst();
}
public String formatReport(String title, List<String> items) {
return STR."""
======== \{title} ========
\{IntStream.range(0, items.size())
.mapToObj(i -> STR."\{i + 1}. \{items.get(i)}")
.collect(Collectors.joining("\n"))}
""";
}
}
评价:令人满意。
Stream API、方法引用、toList()、Text Blocks——AI 全部正确应用。
唯一要注意的是字符串模板(STR."...")。这个特性在 JDK 21 中只是首次预览(JEP 459),JDK 22 进入第二次预览,JDK 23 被撤回,至今仍未成为正式特性。所以这段代码在 JDK 21 上必须加--enable-preview才能运行,而且即使能跑,API 在后续版本中也发生了变化。稳妥的做法是先用 Text Blocks 替代字符串拼接,字符串模板等它正式定型再迁移。
第一层迁移,AI 几乎是完美的。手动做的话,这些代码可能要改 20 分钟,AI 20 秒搞定。
第二层:API 替换——有好有坏
第二层就有点意思了。
我给了 AI 一段包含java.util.Date和SimpleDateFormat的代码:
// 原始 JDK 8 代码
public Date parseDate(String dateStr) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
try {
return sdf.parse(dateStr);
} catch (ParseException e) {
throw new RuntimeException("日期解析失败: " + dateStr, e);
}
}
public long daysBetween(Date start, Date end) {
long diff = end.getTime() - start.getTime();
return TimeUnit.DAYS.convert(diff, TimeUnit.MILLISECONDS);
}
AI 迁移为:
// AI 迁移后的代码
public LocalDateTime parseDate(String dateStr) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
try {
return LocalDateTime.parse(dateStr, formatter);
} catch (DateTimeParseException e) {
throw new RuntimeException("日期解析失败: " + dateStr, e);
}
}
public long daysBetween(LocalDateTime start, LocalDateTime end) {
return Duration.between(start, end).toDays();
}
评价:语法正确,但要注意返回值变更。
Date→LocalDateTime本身没问题。但调用方如果之前拿Date去和java.sql.Date交互,或者用了Date的after()/before()方法,迁移后需要联动修改。这是 AI 看不到的——它只改了当前文件,不知道调用方怎么用这个返回值。
这就是第二层迁移的真正痛点:API 替换不是简单的搜索替换,它涉及接口契约的变化。如果你返回类型从Date变成了LocalDateTime,所有调用方都得改。
AI 迁移第二层的安全做法是:告诉它"保留公开方法的签名不变,内部实现改为 java.time"。
// 更好的迁移:保持签名,改内部实现
public Date parseDate(String dateStr) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
LocalDateTime ldt = LocalDateTime.parse(dateStr, formatter);
return Date.from(ldt.atZone(ZoneId.systemDefault()).toInstant());
}
这样减少连锁改动,可以分批迁移。
另外还有一个容易忽略的边界问题:Duration.between(LocalDateTime, LocalDateTime).toDays()和原来的TimeUnit.DAYS.convert(diff, TimeUnit.MILLISECONDS)在大多数情况下结果一致,但LocalDateTime不感知时区和 DST(夏令时)切换。如果原始代码涉及跨时区或夏令时的日期计算,迁移后需要额外验证。
第三层:行为变更——AI 翻车的地方
第三层涉及 JDK 内部行为的变化,AI 的表现开始不稳定。
我给它一段使用了ThreadLocal的代码:
// 原始代码
public class RequestContextFilter implements Filter {
private static final ThreadLocal<RequestContext> contextHolder =
new ThreadLocal<>();
@Override
public void doFilter(ServletRequest request, ...) {
RequestContext ctx = buildContext(request);
contextHolder.set(ctx);
try {
chain.doFilter(request, response);
} finally {
contextHolder.remove();
}
}
public static RequestContext getContext() {
return contextHolder.get();
}
}
我问 AI:"这段代码迁移到 JDK 21 后,在虚拟线程环境下有什么问题?"
AI 的回答:
"这段代码在虚拟线程下可能有问题,因为虚拟线程是 JVM 管理的协作式调度,ThreadLocal 在虚拟线程中的行为与传统线程一致,但在使用虚拟线程池时需要注意 ThreadLocal 的清理。"
这个回答只对了一半。
真实的问题是:虚拟线程支持数以万计的并发,但 ThreadLocal 的内存开销是按线程算的。如果用传统线程池(如 Tomcat 的 200 线程池),200 个 ThreadLocal 实例可以接受。但如果用虚拟线程(可以创建 10 万个),ThreadLocal 的内存和 GC 压力会急剧上升。ThreadLocal 在虚拟线程中仍然可用,但建议改用ScopedValue(JDK 20+预览)。
AI 没有主动提到ScopedValue,也没有量化 ThreadLocal 在大量虚拟线程下的内存问题。如果你完全相信 AI 的迁移建议,上线后可能会遇到意外的性能问题。
第三层的 AI 输出只能作为提醒,不能作为决策依据。
实操建议:AI 辅助迁移的工作流
这次实验下来,我觉得最优的迁移方式不是"让 AI 全量迁移",也不是"完全不用 AI",而是这样:
第一步:AI 做语法扫描
用 AI 扫描整个项目,定位需要迁移的位置,输出一个清单:
需要升级的语法模式:
- 匿名内部类 → Lambda:237 处
- for 循环 → Stream:108 处
- switch 语句 → switch 表达式:45 处
- 字符串拼接 → Text Blocks:62 处
需要替换的 API:
- java.util.Date:89 处
- SimpleDateFormat:34 处
- javax.xml.bind:12 处
- ThreadLocal:16 处(需检查虚拟线程兼容性)
这个清单帮你评估工作量,而不是猜。
第二步:AI 做批量语法迁移
第一层语法升级可以直接让 AI 批量处理。用脚本或 AI 工具对整个模块做一次性替换,然后走代码审查。
注意:每次只提交一个包或一个模块的变更,不要一个 PR 改几百个文件。
第三步:API 替换按模块推进
第二层的 API 替换建议按模块推进。每个模块在独立的分支上做迁移,做完后充分测试,再合并到主分支。
第四步:行为变更做专项审查
第三层的问题需要人工列一个清单,逐项检查:
• 是否有
synchronized在可能被虚拟线程调用的路径上(JDK 25 已修复 pinning,但 JDK 21 仍需关注)• 是否有 CMS GC 参数需要替换为 G1/ZGC
• 是否有
sun.misc.*的调用• 是否有反射相关的
setAccessible需要调整,是否需要添加--add-opens/--add-exportsJVM 参数• ThreadLocal 的使用是否需要替换为 ScopedValue
• 是否有
SecurityManager的使用(JDK 24 已永久禁用)
把这些交给 AI 去"查",让它在代码库里辅助定位这些风险点,再做人工决策。
附:迁移工具链
除了大模型,JDK 迁移还有一些专门工具值得用上:
工具 | 用途 | 说明 |
|---|---|---|
jdeps | 分析模块依赖关系 | JDK 自带,扫描代码对内部 API 的依赖 |
jdeprscan | 扫描已废弃 API 的使用 | JDK 自带,定位需要替换的废弃方法 |
IntelliJ IDEA 迁移助手 | IDE 内置检测 | 升级 JDK 版本后自动提示不兼容的代码 |
OpenJDK Migration Guide | 官方迁移指南 | 每个版本的 JDK Release Notes 都有 "Migration" 章节 |
Claude Code / Cursor | AI 辅助批量迁移 | 适合第一层语法升级和第二层 API 替换 |
建议的组合方式:先用jdeprscan+jdeps做静态扫描,拿到风险清单;再用 AI 工具做批量语法迁移;最后人工审查第三层行为变更。
总结
迁移层次 | AI 表现 | 是否需要人工 |
|---|---|---|
语法升级 | 优秀 | 走代码审查即可 |
API 替换 | 良好 | 需要检查接口兼容性 |
行为变更 | 不稳定 | 必须人工判断 |
JDK 8→21 迁移这件事,AI 最好的角色是"高级助手"而不是"主力"。它能显著加速第一层和第二层,让你从"这个项目迁移要三个月"变成"语法升级两周搞定,剩下时间做兼容测试"。
最难的部分从来不是改代码,而是确保改了之后系统还能正常跑。
所以别让 AI 全自动跑一个几百万行的项目。找个模块,让它处理第一层和第二层,你跑一遍测试,看看效果——这样逐步建立信任,再铺开到整个项目
