从JDK8升级到JDK21的完整迁移指南:17个需要注意的变化
2026/4/30大约 12 分钟
从JDK8升级到JDK21的完整迁移指南:17个需要注意的变化
适读人群:正在或计划将项目从JDK8升级到JDK17/21的Java团队 | 阅读时长:约25分钟
开篇故事
2023年,我们把一个运行了8年的核心服务从JDK8升级到JDK21。这个项目有200多个Maven模块,依赖了300多个第三方库,代码量大约80万行。
这次升级花了两个月。期间遭遇的问题大概可以分三类:
- 直接编译报错:约20%的问题,修起来最简单
- 运行时异常:约50%,需要在测试中覆盖
- 性能变化:约30%,有好有坏,需要基准测试验证
今天把我们遇到的17个关键变化整理出来,每个都给出具体的代码对比和解决方案。如果你也在做JDK升级,这篇文章应该能省你几周时间。
一、迁移前的准备工作
1.1 评估工具
# 工具1:jdeps - 依赖分析(JDK自带)
# 找出使用了哪些内部API
jdeps --multi-release 21 \
--jdk-internals \
--class-path '*.jar' \
your-app.jar
# 工具2:Migration Tool from JetBrains (IntelliJ IDEA)
# Project Structure -> Platform Settings -> SDKs
# 切换到JDK21,看哪些红色报错
# 工具3:OpenRewrite(自动代码迁移)
# maven插件,可以自动修复一些常见的迁移问题
# 工具4:jdeprscan - 检查废弃API使用
jdeprscan --release 21 your-app.jar1.2 分阶段迁移策略
推荐迁移路径:JDK8 → JDK11(LTS)→ JDK17(LTS)→ JDK21(LTS)
一步跨越太多版本风险高,分两步更稳:
1. JDK8 → JDK11:解决基础兼容性问题
2. JDK11 → JDK21:使用新特性,进一步优化二、17个关键变化详解
变化1:内部API访问被禁止(JDK9-17逐步收紧)
// 问题:使用了sun.misc、com.sun.*等内部API
// JDK9开始警告,JDK17+直接报InaccessibleObjectException
// 旧代码(JDK8可用):
import sun.misc.Unsafe;
Unsafe unsafe = Unsafe.getUnsafe(); // JDK9+报错
// 新代码方案1:寻找官方替代
// Unsafe.compareAndSwapInt → VarHandle.compareAndSet(JDK9+)
import java.lang.invoke.*;
VarHandle varHandle = MethodHandles.lookup()
.findVarHandle(MyClass.class, "myField", int.class);
varHandle.compareAndSet(obj, expected, newValue);
// 新代码方案2:如果必须用Unsafe,通过反射获取(有warning)
java.lang.reflect.Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true); // 需要--add-opens,不推荐
Unsafe unsafe = (Unsafe) field.get(null);
// 临时解决(迁移期):
// JVM启动参数:
// --add-opens java.base/sun.misc=ALL-UNNAMED
// 但这只是过渡方案,需要在下一个版本前修复变化2:反射访问私有字段受限
// 问题:通过反射访问私有字段/方法,JDK9+报InaccessibleObjectException
// 旧代码:
Field field = SomeClass.class.getDeclaredField("privateField");
field.setAccessible(true); // JDK9-16有警告,JDK17+可能报错
// 真正的问题:很多框架内部这么做
// Spring反射访问、Hibernate实体、Jackson序列化等
// 解决方案(按优先级):
// 1. 升级框架到支持JDK17的版本(Spring 5.3+, Hibernate 5.6+, Jackson 2.12+)
// 2. 在module-info.java中opens对应包
// 3. 添加--add-opens启动参数(临时)
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
// Maven Surefire插件配置(测试时):
/*
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
</argLine>
</configuration>
</plugin>
*/变化3:字符串编码变化(JDK9 Compact Strings)
// JDK9引入Compact Strings:
// 纯Latin-1字符串用byte[]存储(每字符1字节)
// 包含非Latin-1字符用char[]存储(每字符2字节)
// 影响:
// 1. 字符串内存占用显著减少(约50%对于ASCII字符串)
// 2. 内部编码发生变化
// 旧代码(有时候有人这么做):
// 通过反射获取String内部char[](非常不推荐)
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);
char[] chars = (char[]) valueField.get(str); // JDK9+会ClassCastException!
// JDK9+内部是byte[]不是char[]
// 正确做法:使用官方API
char[] chars = str.toCharArray(); // 永远正确
byte[] bytes = str.getBytes(StandardCharsets.UTF_8); // 获取字节
// 性能变化:
// 字符串拼接、比较在JDK9+更快(尤其ASCII场景)
// 通常不需要额外改代码,性能会自动提升变化4:CMS GC被移除(JDK14废弃,JDK15移除)
# 旧JVM参数:
-XX:+UseConcMarkSweepGC # JDK14报警告,JDK15报错
# 解决:切换到G1或ZGC
# G1(默认,推荐):
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# ZGC(超低延迟):
-XX:+UseZGC变化5:日期时间API升级(推荐使用java.time)
// JDK8引入java.time,但JDK8之前的项目通常还用java.util.Date
// 旧代码(不推荐继续使用,但还能工作):
java.util.Date oldDate = new java.util.Date();
java.util.Calendar cal = java.util.Calendar.getInstance();
cal.add(Calendar.DAY_OF_MONTH, 7);
// 新代码(推荐):
import java.time.*;
LocalDateTime now = LocalDateTime.now();
LocalDateTime nextWeek = now.plusDays(7);
ZonedDateTime zoned = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
// 转换旧Date到新API:
Instant instant = oldDate.toInstant();
LocalDateTime ldt = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
// 常用操作对比:
// 获取当前毫秒时间戳
long ms1 = System.currentTimeMillis(); // 两者都可以
long ms2 = Instant.now().toEpochMilli();
// 格式化
String s1 = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
String s2 = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));变化6:废弃的finalize()方法
// JDK9废弃,JDK18废弃Object.finalize()
// 预计未来版本会移除
// 旧代码:
class OldResource {
@Override
protected void finalize() throws Throwable { // 废弃!
cleanup();
super.finalize();
}
}
// 新代码:使用try-with-resources + AutoCloseable
class NewResource implements AutoCloseable {
@Override
public void close() {
cleanup(); // 明确释放资源
}
private void cleanup() { /* ... */ }
}
// 使用:
try (var resource = new NewResource()) {
resource.use();
} // 自动调用close()
// 或者使用Cleaner(JDK9+,替代finalize的正确方式)
import java.lang.ref.Cleaner;
class CleanableResource {
private static final Cleaner cleaner = Cleaner.create();
private final Cleaner.Cleanable cleanable;
CleanableResource() {
this.cleanable = cleaner.register(this, new CleanupAction());
}
public void close() {
cleanable.clean();
}
static class CleanupAction implements Runnable {
@Override
public void run() {
System.out.println("Cleaning up...");
}
}
}变化7:ThreadGroup的废弃
// JDK16废弃了ThreadGroup的部分方法
// JDK21虚拟线程出现后,ThreadGroup的作用更小
// 旧代码:
ThreadGroup group = new ThreadGroup("myGroup");
Thread t = new Thread(group, () -> {}, "myThread");
group.interrupt(); // 中断组内所有线程(已废弃)
group.stop(); // 危险!已废弃
// 新代码:使用ExecutorService管理线程
ExecutorService executor = Executors.newCachedThreadPool();
executor.submit(() -> doWork());
executor.shutdownNow(); // 取消所有任务
// 或者JDK21虚拟线程:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> doWork());
scope.join();
}变化8:多版本JAR(MRJAR)的支持
// JDK9引入,允许同一JAR包含不同JDK版本的实现
// 目录结构:
// META-INF/versions/9/com/example/Feature.class <- JDK9+使用
// META-INF/versions/17/com/example/Feature.class <- JDK17+使用
// com/example/Feature.class <- JDK8使用
// 这对第三方库很重要:
// 如果升级到JDK17,库可能自动使用新实现
// 通常是透明的,但有时行为会变化
// 检查:
jar --list --file=library.jar | grep versions/变化9:Records、Sealed Classes等新语法(编译器变化)
// JDK8项目迁移到JDK17+后可以使用新语法
// 逐步迁移:先让代码在新JDK上编译运行(不用新特性)
// 然后逐步用新特性重构
// 例如:DTO类从Lombok换成Record
// 旧代码(Lombok,JDK8):
// @Data @Builder @AllArgsConstructor @NoArgsConstructor
// class UserDTO { private String name; private String email; }
// 新代码(JDK16+ Record):
record UserDTO(String name, String email) {}
// 注意:Record不能作为JPA Entity(需要可变+无参构造)变化10:新的HTTP Client(JDK11)
// 旧代码:HttpURLConnection(JDK1.1,很难用)
URL url = new URL("https://api.example.com/users");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
int responseCode = conn.getResponseCode();
// ...
// 新代码(JDK11 HttpClient):
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Content-Type", "application/json")
.GET()
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
// 异步版本:
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(System.out::println)
.join();变化11:String新方法(JDK11+)
// JDK11新增的String方法,迁移后可以直接使用
// isBlank()
String s = " ";
// 旧:s.trim().isEmpty()
// 新:s.isBlank()
// strip() vs trim()
// trim() 只去除ASCII空白(0x20)
// strip() 去除Unicode空白(更完整)
" hello ".strip() // "hello"
" hello ".stripLeading() // "hello "
" hello ".stripTrailing() // " hello"
// lines()(JDK11)
String multiline = "line1\nline2\nline3";
multiline.lines().forEach(System.out::println);
// repeat()(JDK11)
"ha".repeat(3) // "hahaha"
// indent()(JDK12)
"hello\nworld".indent(4) // " hello\n world\n"
// formatted()(JDK15,等同于String.format())
"Hello %s, you are %d".formatted("Alice", 30);变化12:Optional新方法
// JDK9新增:ifPresentOrElse, stream, or
// JDK10新增:无
// JDK11新增:isEmpty
Optional<String> opt = Optional.of("hello");
// JDK9: ifPresentOrElse
opt.ifPresentOrElse(
s -> System.out.println("Found: " + s),
() -> System.out.println("Not found")
);
// JDK9: or
Optional<String> result = opt.or(() -> Optional.of("default"));
// JDK9: stream
long count = opt.stream().count(); // 0或1
// JDK11: isEmpty
opt.isEmpty(); // 比!isPresent()更语义清晰变化13:NullPointerException消息改善(JDK14)
// JDK14引入Helpful NullPointerExceptions(JEP 358)
// JDK15默认开启(不需要参数)
// 旧JDK的NPE信息:
// java.lang.NullPointerException(什么都不说)
// JDK14+的NPE信息:
// Cannot invoke "String.length()" because "str" is null
// Cannot read field "name" because "user" is null
// 示例:
class User { String name; }
User user = null;
int len = user.name.length();
// JDK14+异常信息:Cannot read field "name" because "user" is null
// 这在生产排查时非常有价值
// 不需要任何代码改动,升级JDK自动获得变化14:instanceof改进(JDK16)
// JDK16: Pattern Matching for instanceof
// 旧代码:
if (obj instanceof String) {
String s = (String) obj; // 冗余强转
System.out.println(s.length());
}
// 新代码:
if (obj instanceof String s) { // 一步搞定
System.out.println(s.length());
}
// 在equals方法中特别有用:
@Override
public boolean equals(Object o) {
// 旧:
// if (!(o instanceof Point)) return false;
// Point p = (Point) o;
// return x == p.x && y == p.y;
// 新:
return o instanceof Point p && x == p.x && y == p.y;
}变化15:弃用的Runtime.exec()用法
// Runtime.exec(String command) 在JDK18警告
// 存在路径注入等安全问题
// 旧代码:
Process p = Runtime.getRuntime().exec("ls -la /tmp"); // 警告
// 新代码(推荐):
Process p = new ProcessBuilder("ls", "-la", "/tmp")
.redirectErrorStream(true)
.start();
// 更安全:明确分离命令和参数
String[] cmd = {"find", "/tmp", "-name", "*.log"};
Process p2 = Runtime.getRuntime().exec(cmd); // 数组形式更安全
// 最推荐(JDK9+):
var process = new ProcessBuilder(List.of("ls", "-la", "/tmp"))
.directory(new File("/"))
.start();
try (var reader = new BufferedReader(new InputStreamReader(process.inputStream()))) {
reader.lines().forEach(System.out::println);
}
process.waitFor();变化16:集合工厂方法(JDK9)
// JDK9引入List.of(), Set.of(), Map.of()(不可变集合)
// 旧代码:
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); // 可变
List<String> immutable = Collections.unmodifiableList(
Arrays.asList("a", "b", "c")); // 不可变
// 新代码:
List<String> list = List.of("a", "b", "c"); // 不可变(简洁)
Set<String> set = Set.of("a", "b", "c"); // 不可变,无重复
Map<String, Integer> map = Map.of("a", 1, "b", 2); // 不可变
// 注意:List.of返回的是不可变列表!
// list.add("d"); // UnsupportedOperationException
// JDK10新增List.copyOf()
List<String> copy = List.copyOf(existingList); // 不可变拷贝
// JDK16新增Stream.toList()
List<String> fromStream = Stream.of("a", "b").toList(); // 不可变变化17:虚拟线程(JDK21,最重要的新特性)
// 参见第396期详细讲解
// 这里只列迁移要点
// 迁移要点1:用Executors.newVirtualThreadPerTaskExecutor替代固定线程池
// 旧:ExecutorService es = Executors.newFixedThreadPool(200);
// 新:ExecutorService es = Executors.newVirtualThreadPerTaskExecutor();
// 迁移要点2:synchronized → ReentrantLock(避免Pinning)
// 迁移要点3:ThreadLocal注意清理
// 迁移要点4:Spring Boot 3.2+配置:spring.threads.virtual.enabled=true三、完整迁移检查清单代码
import java.util.*;
import java.util.stream.*;
/**
* JDK8→JDK21迁移问题检查清单生成器
*/
public class MigrationChecklist {
record CheckItem(String category, String issue, String severity, String solution) {}
static final List<CheckItem> CHECKLIST = List.of(
new CheckItem("内部API", "使用sun.misc.Unsafe", "HIGH",
"用VarHandle或官方API替代"),
new CheckItem("内部API", "使用com.sun.*私有API", "HIGH",
"寻找官方替代,或用--add-opens临时解决"),
new CheckItem("反射", "setAccessible(true)访问JDK内部", "HIGH",
"添加--add-opens或升级相关框架"),
new CheckItem("GC", "使用-XX:+UseConcMarkSweepGC", "MEDIUM",
"切换到G1GC或ZGC"),
new CheckItem("废弃API", "使用finalize()", "MEDIUM",
"改用try-with-resources或Cleaner"),
new CheckItem("废弃API", "使用ThreadGroup.stop/interrupt", "MEDIUM",
"改用ExecutorService"),
new CheckItem("序列化", "Java序列化安全漏洞", "LOW",
"考虑改用JSON/Protobuf等"),
new CheckItem("性能", "未使用G1/ZGC", "LOW",
"考虑切换GC以提升性能"),
new CheckItem("新特性", "未使用Record/Sealed/Pattern Matching", "INFO",
"逐步使用新语法提升代码质量"),
new CheckItem("新特性", "未使用虚拟线程", "INFO",
"IO密集型服务考虑使用虚拟线程")
);
public static void main(String[] args) {
System.out.println("JDK8 → JDK21 迁移检查清单");
System.out.println("=".repeat(60));
var byCategory = CHECKLIST.stream()
.collect(Collectors.groupingBy(CheckItem::category));
byCategory.forEach((category, items) -> {
System.out.println("\n【" + category + "】");
items.forEach(item ->
System.out.printf(" [%s] %s%n 解决方案:%s%n",
item.severity(), item.issue(), item.solution()));
});
// 优先级排序
System.out.println("\n\n按优先级排序的待办:");
CHECKLIST.stream()
.sorted(Comparator.comparingInt(item ->
switch (item.severity()) {
case "HIGH" -> 1;
case "MEDIUM" -> 2;
case "LOW" -> 3;
default -> 4;
}
))
.forEach(item ->
System.out.printf("[%s] %s: %s%n",
item.severity(), item.category(), item.issue()));
}
}四、踩坑实录
坑1:Spring 5.x + JDK17的兼容问题
# 问题:Spring 5.x部分版本在JDK17下有反射访问问题
# 症状:启动时出现WARNING或者InaccessibleObjectException
# 解决:升级Spring Boot到3.x(基于Spring 6)
# Spring Boot 3.x要求JDK17+
# 或者在Spring Boot 2.7.x上添加:
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
# 如果用Spring Boot 3.2+,还可以启用虚拟线程:
# spring.threads.virtual.enabled=true坑2:Mockito反射问题
# Mockito内部使用反射,JDK17+可能有问题
# Mockito 4.x → 5.x 解决了大多数JDK17问题
# build.gradle或pom.xml:
# 确保使用支持JDK17的Mockito版本
# mockito-core 4.6.1+ 支持JDK17
# 还是有问题?添加:
--add-opens org.mockito/org.mockito=ALL-UNNAMED坑3:lombok和JDK21的兼容性
# Lombok通过注解处理器生成代码,版本需要匹配
# JDK21 需要 Lombok 1.18.30+
# 建议使用最新版本的Lombok
# 如果遇到问题:
# gradle/maven中指定 lombok 版本为最新稳定版坑4:JDK17模块系统导致的测试失败
# 单元测试比生产代码更容易触发模块访问问题
# (测试代码通常会访问private/internal类)
# Maven Surefire 统一配置:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0</version>
<configuration>
<argLine>
--add-opens java.base/java.lang=ALL-UNNAMED
--add-opens java.base/java.util=ALL-UNNAMED
--add-opens java.base/java.io=ALL-UNNAMED
--add-opens java.base/java.lang.reflect=ALL-UNNAMED
</argLine>
</configuration>
</plugin>坑5:JVM默认行为变化导致的性能差异
# JDK17+的G1 GC默认参数有变化
# 如果从JDK8直接跳到JDK21,GC行为可能有显著变化
# JDK8默认:-XX:+UseParallelGC
# JDK9+默认:-XX:+UseG1GC
# 建议:升级后重新做GC基准测试
# 不要假设"JDK更新了性能一定更好"
# 先验证,再上生产五、总结与延伸
5.1 迁移时间预估
| 项目规模 | 代码量 | 预计迁移时间 |
|---|---|---|
| 小型(单模块) | < 10万行 | 1-2天 |
| 中型(10-50模块) | 10-50万行 | 1-2周 |
| 大型(50+模块) | > 50万行 | 1-3个月 |
5.2 最推荐的迁移顺序
第1步(必做,可能有编译错误):
- 移除对sun.*、com.sun.*内部API的依赖
- 升级所有第三方库到支持JDK17的版本
- 修复废弃API使用(finalize等)
第2步(必做,可能有运行时错误):
- 更新JVM参数(移除CMS等废弃GC参数)
- 添加必要的--add-opens
- 运行完整测试套件
第3步(可选,用新特性优化):
- DTO类改用Record
- 使用Text Blocks改进多行字符串
- 使用Pattern Matching简化类型检查
- IO密集型服务使用虚拟线程