JDK 21 和 JDK 25 是两个相邻的 LTS,中间隔着 22、23、24 三个非 LTS 版本。多数 LTS 到 LTS 的升级都是”安静”的:你的代码大概率原样能跑。
但 21 到 25 这两年是”预览转正 + 移除清理”集中发生的窗口,有几类坑是编译器和 JVM 不会替你兜底的:预览 API 改了形状、遗留线程 API 被删、sun.misc.Unsafe 开始打印警告、Security Manager 彻底关死、虚拟线程在 synchronized 里的行为被重写。
这篇文章是一份可执行的迁移清单:每条结论都在本机的 JDK 21(21.0.8)和 JDK 25(25+37-LTS)上分别编译或运行过,原始输出贴在对应的 ```txt 块里。凡是没在本机验证过的,都单独标注”未实测”。

实验环境与取证方式

本机是 Apple M1 Pro(10 核 arm64,macOS),装了 JDK 21.0.8 和 JDK 25 两个版本,路径如下:

1
2
3
4
5
6
7
8
9
$ /Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home/bin/java -version
java version "21.0.8" 2025-07-15 LTS
Java(TM) SE Runtime Environment (build 21.0.8+12-LTS-250)
Java HotSpot(TM) 64-Bit Server VM (build 21.0.8+12-LTS-250, mixed mode, sharing)

$ /Library/Java/JavaVirtualMachines/jdk-25.jdk/Contents/Home/bin/java -version
java version "25" 2025-09-16 LTS
Java(TM) SE Runtime Environment (build 25+37-LTS-3491)
Java HotSpot(TM) 64-Bit Server VM (build 25+37-LTS-3491, mixed mode, sharing)

全文只用 JDK 自带的 java / javac / jar / jdeps / jdeprscan / jcmd / jfr,没有安装任何额外软件;压测程序都是自己写的。验证手法统一为两条:

  1. 同一份源码分别丢给两个 javac 编译,比较报错原文;
  2. 同一个 jar / 同一个程序分别用两个 java 运行,比较输出或耗时。

所有实验都在 /tmp/jdk-migration 下进行:源码放 src/,编译产物按版本分到 b21/(JDK 21)与 b25/(JDK 25),预览编译产物在 b21p/b25p/。下面命令里的 -cp b21-cp b25 就是这个意思;少数输出里带 file:/private/tmp/jdk-migration/b25/ 的绝对路径,也是真实打印出来的。

本机 JDK 的输出是中文 locale(zh_CN),所以下面编译错误是中文的;同一份 JDK 在英文 locale 下会打印内容相同、措辞为英文的信息,不影响判断。

语言与 API:从预览到正式

这一节每个特性都给一份源码,然后对照它在两个 JDK 上的命运。核心规律是:预览特性不会长期停留在”预览”,要么转正、要么被删除、要么以改过的形状继续预览——三种情况在这两年里都出现了(Scoped Values 转正、Gatherers 在 21 上压根不存在、Structured Concurrency 改形状后仍预览),所以凡是依赖 --enable-preview 的代码都要逐个过一遍。

未命名变量 _:JDK 21 预览,JDK 22 转正

未命名变量与未命名模式由 JEP 443(JDK 21 预览)提出、JEP 456(JDK 22 正式)定稿,两者都用下划线 _ 表示”这个位置必须声明但我不使用它”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

public class Unnamed {

public static void main(String[] args) {
AtomicInteger hits = new AtomicInteger();
List<String> items = List.of("alpha", "beta", "gamma");

items.forEach(_ -> hits.incrementAndGet());

try {
Integer.parseInt("not-a-number");
} catch (NumberFormatException _) {
hits.incrementAndGet();
}

int loops = 0;
for (String _ : items) {
loops++;
}
System.out.println("hits=" + hits.get() + ", loops=" + loops);
}
}

JDK 21 上,不加 --enable-preview 直接编译,第一条 _ 就被拦下:

1
2
3
4
5
6
$ jdk-21/bin/javac -d b21 src/Unnamed.java
src/Unnamed.java:10: 错误: 未命名变量 是预览功能,默认情况下禁用。
items.forEach(_ -> hits.incrementAndGet());
^
(请使用 --enable-preview 以启用 未命名变量)
1 个错误

JDK 21 加 --enable-preview 能编译运行;JDK 25 上什么都不用加,直接通过:

1
2
3
4
5
6
7
8
9
$ jdk-21/bin/javac --release 21 --enable-preview -d b21p src/Unnamed.java
注: src/Unnamed.java 使用 Java SE 21 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
$ jdk-21/bin/java --enable-preview -cp b21p Unnamed
hits=4, loops=3

$ jdk-25/bin/javac -d b25 src/Unnamed.java
$ jdk-25/bin/java -cp b25 Unnamed
hits=4, loops=3

升级动作:删掉 --enable-preview 即可。如果你的构建里还带着这个开关,25 上它会变成纯粹的噪音。

Stream Gatherers:JDK 21 上根本不存在

Stream.gather(...)java.util.stream.GatherersJEP 461(JDK 22 预览)提出,JEP 473(JDK 23 二次预览)打磨,JEP 485(JDK 24)定稿。注意它的起点是 JDK 22——JDK 21 连预览都没有,这是与 _ 不同的地方:在 21 上加 --enable-preview 也救不回来。

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.List;
import java.util.stream.Gatherers;
import java.util.stream.Stream;

public class GathererDemo {

public static void main(String[] args) {
List<List<Integer>> windows = Stream.of(1, 2, 3, 4, 5, 6, 7)
.gather(Gatherers.windowFixed(3))
.toList();
System.out.println("windows=" + windows);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ jdk-21/bin/javac --release 21 --enable-preview -d b21p src/GathererDemo.java
src/GathererDemo.java:2: 错误: 找不到符号
import java.util.stream.Gatherers;
^
符号: 类 Gatherers
位置: 程序包 java.util.stream
src/GathererDemo.java:9: 错误: 找不到符号
.gather(Gatherers.windowFixed(3))
^
符号: 变量 Gatherers
位置: 类 GathererDemo
2 个错误

$ jdk-25/bin/javac -d b25 src/GathererDemo.java
$ jdk-25/bin/java -cp b25 GathererDemo
windows=[[1, 2, 3], [4, 5, 6], [7]]

升级动作:这类代码在 21 上根本编译不过,所以不存在”从 21 升到 25 后坏掉”的风险;它是升到 25 之后新增可用的能力。如果要让同一份源码兼容 21 和 25,Gatherers 的对应逻辑得自己在 21 上实现(例如用 Stream.iterator 或手写收集器),没有别的办法。

Scoped Values:JDK 21 预览,JDK 25 转正

Scoped Values 由 JEP 446(JDK 21 预览)提出,历经 JEP 464(22)、JEP 481(23)、JEP 487(24)多次预览,最终由 JEP 506(JDK 25)定稿——官网头部的 Status: Closed / DeliveredRelease: 25 可查。

1
2
3
4
5
6
7
8
9
public class Scoped {

private static final ScopedValue<String> USER = ScopedValue.newInstance();

public static void main(String[] args) {
ScopedValue.where(USER, "alice").run(() -> System.out.println("user=" + USER.get()));
System.out.println("stillBound=" + USER.isBound());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ jdk-21/bin/javac -d b21 src/Scoped.java
src/Scoped.java:3: 错误: ScopedValue 是预览 API,默认情况下处于禁用状态。
private static final ScopedValue<String> USER = ScopedValue.newInstance();
^
(请使用 --enable-preview 以启用预览 API)
src/Scoped.java:3: 错误: ScopedValue 是预览 API,默认情况下处于禁用状态。
private static final ScopedValue<String> USER = ScopedValue.newInstance();
^
(请使用 --enable-preview 以启用预览 API)
src/Scoped.java:6: 错误: ScopedValue 是预览 API,默认情况下处于禁用状态。
ScopedValue.where(USER, "alice").run(() -> System.out.println("user=" + USER.get()));
^
(请使用 --enable-preview 以启用预览 API)
3 个错误

$ jdk-21/bin/javac --release 21 --enable-preview -d b21p src/Scoped.java
注: src/Scoped.java 使用 Java SE 21 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
$ jdk-21/bin/java --enable-preview -cp b21p Scoped
user=alice
stillBound=false

$ jdk-25/bin/javac -d b25 src/Scoped.java
$ jdk-25/bin/java -cp b25 Scoped
user=alice
stillBound=false

升级动作:和 _ 一样,删掉 --enable-preview 即可。JEP 506 相对 JDK 24 预览只改了一处:ScopedValue.orElse 不再接受 null 参数。如果你在 21/24 上用了 orElse(null) 这类写法,25 上要换成显式的空值处理。

Foreign Function & Memory API:JDK 21 预览,JDK 22 转正

FFM API(java.lang.foreign)由 JEP 442(JDK 21 预览)提出,JEP 454(JDK 22)定稿。用同一个调 getpid 的程序看两版差异:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.lang.foreign.Arena;
import java.lang.foreign.FunctionDescriptor;
import java.lang.foreign.Linker;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.SymbolLookup;
import java.lang.foreign.ValueLayout;
import java.lang.invoke.MethodHandle;

public class FfmCall {

public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
try (Arena arena = Arena.ofConfined()) {
SymbolLookup libc = linker.defaultLookup();
MemorySegment getpid = libc.find("getpid").orElseThrow();
MethodHandle handle = linker.downcallHandle(getpid, FunctionDescriptor.of(ValueLayout.JAVA_INT));
System.out.println("getpid=" + (int) handle.invokeExact());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ jdk-21/bin/javac -d b21 src/FfmCall.java
src/FfmCall.java:1: 错误: Arena 是预览 API,默认情况下处于禁用状态。
import java.lang.foreign.Arena;
^
(请使用 --enable-preview 以启用预览 API)
[中间省略:每个 java.lang.foreign 的导入与使用点各报一次]
src/FfmCall.java:16: 错误: FunctionDescriptor 是预览 API,默认情况下处于禁用状态。
MethodHandle handle = linker.downcallHandle(getpid, FunctionDescriptor.of(ValueLayout.JAVA_INT));
^
(请使用 --enable-preview 以启用预览 API)
14 个错误

$ jdk-21/bin/java --enable-preview -cp b21p FfmCall
WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: java.lang.foreign.Linker::downcallHandle has been called by the unnamed module
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for this module

getpid=75464

$ jdk-25/bin/java -cp b25 FfmCall
WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: java.lang.foreign.Linker::downcallHandle has been called by FfmCall in an unnamed module (file:/private/tmp/jdk-migration/b25/)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

getpid=75442

这里同时暴露了两个层面的差异:

  1. 预览转正:21 上 Linker 是预览 API,不加 --enable-preview 编不过;25 上直接编译运行。
  2. 受限方法警告在升级后被加强JEP 472,JDK 24 的”Integrity by Default”准备步骤):两版都会在首次调用 downcallHandle 时打印警告,但 25 多了一行 Restricted methods will be blocked in a future release unless native access is enabled,并标出了具体的调用类名。

升级动作:删 --enable-preview;然后为所有用到 FFM/JNI 的模块显式声明 --enable-native-access=<module>,否则升 25 后日志里会持续出现这类警告,而且未来的 JDK 会直接禁止。

Structured Concurrency:到 JDK 25 仍是预览,且 API 换了形状

结构化并发是这一批里唯一升上去反而要改代码的预览特性:从 JEP 453(JDK 21 预览)一路预览到 JEP 505(JDK 25 第五次预览),到 JDK 25 依然是预览特性,仍需要 --enable-preview。JEP 505 还把创建方式从公开构造器改成了静态工厂方法:JDK 21 里 new StructuredTaskScope.ShutdownOnFailure() 这种写法在 25 上直接编译失败。

JDK 21 时代的写法(在 21 上能用,25 上编译失败):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.concurrent.StructuredTaskScope;

public class StructuredOld {

public static void main(String[] args) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var user = scope.fork(() -> "user-1");
var order = scope.fork(() -> "order-9");
scope.join();
scope.throwIfFailed();
System.out.println(user.get() + " / " + order.get());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ jdk-21/bin/javac --release 21 --enable-preview -d b21p src/StructuredOld.java
注: src/StructuredOld.java 使用 Java SE 21 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
$ jdk-21/bin/java --enable-preview -cp b21p StructuredOld
user-1 / order-9

$ jdk-25/bin/javac --release 25 --enable-preview -d b25p src/StructuredOld.java
src/StructuredOld.java:6: 错误: 找不到符号
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
^
符号: 类 ShutdownOnFailure
位置: 接口 StructuredTaskScope
注: src/StructuredOld.java 使用 Java SE 25 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
1 个错误

JDK 25 的写法(用静态工厂 open(),21 上编译失败):

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.concurrent.StructuredTaskScope;

public class StructuredNew {

public static void main(String[] args) throws Exception {
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> "user-1");
var order = scope.fork(() -> "order-9");
scope.join();
System.out.println(user.get() + " / " + order.get());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ jdk-25/bin/javac --release 25 --enable-preview -d b25p src/StructuredNew.java
注: src/StructuredNew.java 使用 Java SE 25 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
$ jdk-25/bin/java --enable-preview -cp b25p StructuredNew
user-1 / order-9

$ jdk-21/bin/javac --release 21 --enable-preview -d b21p src/StructuredNew.java
src/StructuredNew.java:6: 错误: 找不到符号
try (var scope = StructuredTaskScope.open()) {
^
符号: 方法 open()
位置: 类 StructuredTaskScope
注: src/StructuredNew.java 使用 Java SE 21 的预览功能。
注: 有关详细信息,请使用 -Xlint:preview 重新编译。
1 个错误

升级动作:不要用结构化并发来做需要跨 21/25 双版本编译的代码——它仍是预览,API 在 25 又改了一次(open() 工厂 + Joinerfork 返回 Subtask 而非 Future)。已经写了的 21 风格代码要在 25 上改成 StructuredTaskScope.open(),并且继续保留 --enable-preview

JDK 25 新增转正的两项,以及一项”21 就已正式、不用动”的

还有两个 JDK 25 定稿的语法糖,属于”升级后可选用”,顺手验证了一下:

灵活构造器体(JEP 513,JDK 25)允许在显式 super(...) 之前写语句,省掉了以前必须抽静态辅助方法的那步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class FlexCtor {

static class Base {
final String name;
Base(String name) { this.name = name; }
}

static class Child extends Base {
Child(String raw) {
String normalized = raw == null ? "anon" : raw.trim();
super(normalized);
}
}

public static void main(String[] args) {
System.out.println(new Child(" bob ").name);
}
}
1
2
3
4
5
6
7
8
9
$ jdk-21/bin/javac --release 21 -d b21 src/FlexCtor.java
src/FlexCtor.java:11: 错误: 对super的调用必须是构造器中的第一个语句
super(normalized);
^
1 个错误

$ jdk-25/bin/javac -d b25 src/FlexCtor.java
$ jdk-25/bin/java -cp b25 FlexCtor
bob

紧凑源文件(JEP 512,JDK 25)允许顶层 void main() 无显式类:

1
2
3
void main() {
System.out.println("compact source file");
}
1
2
3
4
5
6
7
8
9
$ jdk-21/bin/java src/HelloSimple.java
src/HelloSimple.java:1: 错误: 未命名类 是预览功能,默认情况下禁用。
void main() {
^
(请使用 --enable-preview 以启用 未命名类)
1 个错误

$ jdk-25/bin/java src/HelloSimple.java
compact source file

反向的确认:record 解构与 switch 模式匹配(JEP 440 / JEP 441)在 JDK 21 已经正式,21 到 25 之间没有变化,同一份代码两版都直接编译运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class PatternDemo {

record Point(int x, int y) { }

static String classify(Object o) {
return switch (o) {
case Point(int x, int y) when x == y -> "diagonal " + x;
case Point(int x, int y) -> "point " + x + "," + y;
case String s -> "string " + s;
default -> "other";
};
}

public static void main(String[] args) {
System.out.println(classify(new Point(3, 3)));
System.out.println(classify(new Point(1, 2)));
System.out.println(classify("hi"));
}
}
1
2
3
4
5
6
7
8
9
$ jdk-21/bin/java -cp b21 PatternDemo
diagonal 3
point 1,2
string hi

$ jdk-25/bin/java -cp b25 PatternDemo
diagonal 3
point 1,2
string hi

行为变化:虚拟线程在 synchronized 内的固定

这是 21 到 25 之间运行时行为变化最大的一处,而且是”代码一行不改、性能突然变好”的类型。

同一份程序:JDK 21 固定载体,JDK 25 解除

在 JDK 21 里,虚拟线程一旦进入 synchronized 代码块就无法从载体线程上卸载,遇到 Thread.sleep 这种阻塞会连载体一起卡住。JDK 24 的 JEP 491Status: Closed / DeliveredRelease: 24)重写了 synchronized 的实现,让虚拟线程在 synchronized 方法/语句里、以及 Object.wait() 时都能正常卸载。下面用”每任务独立监视器 + synchronized 内 sleep”来放大这个差异:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;

public class Pinning {

private static final int TASKS = 64;
private static final Duration HOLD = Duration.ofMillis(500);

public static void main(String[] args) throws Exception {
System.out.println("cpus=" + Runtime.getRuntime().availableProcessors());
System.out.println("synchronized elapsed=" + runSync() + "ms");
System.out.println("reentrant-lock elapsed=" + runLock() + "ms");
}

private static long runSync() throws Exception {
long start = System.nanoTime();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < TASKS; i++) {
executor.submit(() -> {
Object monitor = new Object(); // 每个任务独占监视器,彼此不竞争
synchronized (monitor) {
hold();
}
});
}
}
return (System.nanoTime() - start) / 1_000_000;
}

private static long runLock() throws Exception {
long start = System.nanoTime();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < TASKS; i++) {
executor.submit(() -> {
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
hold();
} finally {
lock.unlock();
}
});
}
}
return (System.nanoTime() - start) / 1_000_000;
}

private static void hold() {
try {
Thread.sleep(HOLD);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

本机是 10 核,虚拟线程调度器默认并行度等于核心数(10)。64 个任务各睡 500ms:JDK 21 上 synchronized 分支会固定载体,64 个任务只能被 10 个载体分批消化,大约 7 批 × 500ms ≈ 3.5 秒;ReentrantLock 分支不固定,理论上 500ms 左右。跑了三轮,结果稳定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
--- JDK 21 ---
cpus=10
synchronized elapsed=3550ms
reentrant-lock elapsed=511ms
cpus=10
synchronized elapsed=3550ms
reentrant-lock elapsed=501ms
cpus=10
synchronized elapsed=3547ms
reentrant-lock elapsed=509ms

--- JDK 25 ---
cpus=10
synchronized elapsed=516ms
reentrant-lock elapsed=511ms
cpus=10
synchronized elapsed=520ms
reentrant-lock elapsed=511ms
cpus=10
synchronized elapsed=519ms
reentrant-lock elapsed=501ms

reentrant-lock 两版几乎一样(约 505~511ms),排除了环境差异;差异集中在 synchronized 分支:21 约 3550ms,25 约 520ms,接近一个数量级。结论:如果你在 JDK 21 上因为固定问题把热点 synchronized 换成了 ReentrantLock,JDK 25 上不必再这么做了;JEP 491 的正文也明确说,已迁移到 ReentrantLock 的代码无需迁回。

-Djdk.tracePinnedThreads 在 JDK 24 起不再生效

JDK 21 用 -Djdk.tracePinnedThreads=full 可以在固定发生时打印栈。JEP 491 正文有一节专门说这个系统属性”no longer needed”,并明确:从 JDK 24 起移除该属性,命令行上设置它不会有任何效果。实测对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class PinTrace {

public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = Thread.ofVirtual().start(() -> {
Object monitor = new Object();
synchronized (monitor) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
}
for (Thread t : threads) {
t.join();
}
System.out.println("pin trace run done");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
$ jdk-21/bin/java -Djdk.tracePinnedThreads=full -cp b21 PinTrace
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2 reason:MONITOR
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
java.base/java.lang.Thread.sleep(Thread.java:507)
PinTrace.lambda$main$0(PinTrace.java:10) <== monitors:1
java.base/java.lang.VirtualThread.run(VirtualThread.java:329)
pin trace run done

$ jdk-25/bin/java -Djdk.tracePinnedThreads=full -cp b25 PinTrace
pin trace run done

21 上打印了 9 行(固定栈),25 上只打印程序自己的那一行 pin trace run done,属性完全失效。

用什么替代:JFR 的 jdk.VirtualThreadPinned

JEP 491 保留并增强了 JFR 事件 jdk.VirtualThreadPinned(剩下的固定场景,如 native 回调、类加载等,仍会被记录)。用同一个 PinTrace 程序各录 6 秒,对比事件数:

1
2
3
4
5
6
7
8
9
$ jdk-21/bin/java -XX:StartFlightRecording=filename=pin21.jfr,settings=default,duration=6s -cp b21 PinTrace
[0.258s][info][jfr,startup] Started recording 1. The result will be written to:
/private/tmp/jdk-migration/out/pin21.jfr
pin trace run done

$ jdk-25/bin/java -XX:StartFlightRecording=filename=pin25.jfr,settings=default,duration=6s -cp b25 PinTrace
[0.229s][info][jfr,startup] Started recording 1. The result will be written to:
/private/tmp/jdk-migration/out/pin25.jfr
pin trace run done
1
2
3
4
5
$ jdk-21/bin/jfr summary pin21.jfr | grep VirtualThread
jdk.VirtualThreadPinned 4 56

$ jdk-25/bin/jfr summary pin25.jfr | grep VirtualThread
jdk.VirtualThreadPinned 0 0

同一份程序:JDK 21 录到 4 次固定事件,JDK 25 录到 0 次。升级后排查固定,请改用 jfr 事件而不是那个已失效的系统属性。

移除与降级:逐项核对官方源

这一节只写能在 openjdk.org 的 JEP 页面或官方 API 文档里核实的条目,并明确区分”编译期报错 / 运行时警告 / 仅行为变化”。

Security Manager:永久禁用(JEP 486,JDK 24,直接报错)

JEP 486Release: 24)接续 JDK 17 的 JEP 411(弃用),把 Security Manager 永久禁用:System.setSecurityManager 永远抛 UnsupportedOperationExceptionSystem.getSecurityManager 永远返回 null-Djava.security.manager 只能设为 disallow

1
2
3
4
5
6
7
8
9
10
11
public class SecMgr {

public static void main(String[] args) {
try {
System.setSecurityManager(new SecurityManager());
System.out.println("security manager installed");
} catch (Throwable t) {
System.out.println("install failed: " + t.getClass().getName());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ jdk-21/bin/java -cp b21 SecMgr
install failed: java.lang.UnsupportedOperationException

$ jdk-21/bin/java -Djava.security.manager=allow -cp b21 SecMgr
WARNING: A terminally deprecated method in java.lang.System has been called
WARNING: System::setSecurityManager has been called by SecMgr (file:/private/tmp/jdk-migration/b21/)
WARNING: Please consider reporting this to the maintainers of SecMgr
WARNING: System::setSecurityManager will be removed in a future release
security manager installed

$ jdk-25/bin/java -cp b25 SecMgr
install failed: java.lang.UnsupportedOperationException

$ jdk-25/bin/java -Djava.security.manager=allow -cp b25 SecMgr
Error occurred during initialization of VM
java.lang.Error: A command line option has attempted to allow or enable the Security Manager. Enabling a Security Manager is not supported.
at java.lang.System.initPhase3(java.base@25/System.java:1969)

21 上还能靠 -Djava.security.manager=allow 强行装回(伴随”即将删除”的警告);25 上连 JVM 都起不来,直接在初始化阶段报 Error。这属于启动即报错级别。升级动作:删除所有 -Djava.security.manager=allowsetSecurityManager 调用;依赖 Security Manager 做沙箱的历史组件必须换成进程级/容器级隔离。

sun.misc.Unsafe 内存访问方法:JDK 23 弃用、JDK 24 起运行时警告

sun.misc.Unsafe 的内存访问方法由 JEP 471(JDK 23)标记为”待删除”,JEP 498(JDK 24)起在首次调用时打印运行时警告。用反射拿到 theUnsafe 后读写字段与堆外内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import java.lang.reflect.Field;
import sun.misc.Unsafe;

public class UnsafeDemo {

private static final Unsafe UNSAFE;

static {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
UNSAFE = (Unsafe) field.get(null);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}

public static void main(String[] args) {
Holder holder = new Holder();
long offset;
try {
offset = UNSAFE.objectFieldOffset(Holder.class.getDeclaredField("value"));
} catch (NoSuchFieldException e) {
throw new IllegalStateException(e);
}
UNSAFE.putInt(holder, offset, 42);
System.out.println("value=" + holder.value);

long address = UNSAFE.allocateMemory(4);
try {
UNSAFE.putInt(address, 7);
System.out.println("offHeap=" + UNSAFE.getInt(address));
} finally {
UNSAFE.freeMemory(address);
}
}

static class Holder {
int value;
}
}
1
2
3
4
5
6
7
8
9
10
11
$ jdk-21/bin/java -cp b21 UnsafeDemo
value=42
offHeap=7

$ jdk-25/bin/java -cp b25 UnsafeDemo
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by UnsafeDemo (file:/private/tmp/jdk-migration/b25/)
WARNING: Please consider reporting this to the maintainers of class UnsafeDemo
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
value=42
offHeap=7

21 上静默;25 上首次调用打印 4 行警告。这属于运行时警告,不是报错——程序照常跑。JDK 24 同时引入开关 --sun-misc-unsafe-memory-access=allow|warn|debug|deny

1
2
3
4
5
6
7
8
9
10
$ jdk-25/bin/java --sun-misc-unsafe-memory-access=deny -cp b25 UnsafeDemo
Exception in thread "main" java.lang.UnsupportedOperationException: objectFieldOffset
at jdk.unsupported/sun.misc.Unsafe.beforeMemoryAccessSlow(Unsafe.java:1822)
at jdk.unsupported/sun.misc.Unsafe.beforeMemoryAccess(Unsafe.java:1787)
at jdk.unsupported/sun.misc.Unsafe.objectFieldOffset(Unsafe.java:905)
at UnsafeDemo.main(UnsafeDemo.java:22)

$ jdk-21/bin/java --sun-misc-unsafe-memory-access=deny -cp b21 UnsafeDemo
Unrecognized option: --sun-misc-unsafe-memory-access=deny
Error: Could not create the Java Virtual Machine.

升级动作:优先迁到标准替代(字段/数组访问用 VarHandle,堆外内存用 FFM API);迁移完成前,把用到 Unsafe 的第三方库升级到最新版本(多数网络/序列化库已跟进),并留意启动日志里的警告。JNI 那半边(JEP 472 对 JNI 的首次使用警告)与本项同源,但本机没有用 JDK 自带工具构建 .dylib 的条件,未实测,仅按 JEP 472 正文记录其存在。

Thread 遗留方法:一部分早于 21 就废了,一部分在 22/23 被删

Thread.stop()JDK 20 起就无条件抛 UnsupportedOperationException——比 JDK 21 还早,所以它不属于 21→25 的变化,两版行为完全一致。这可以从官方 API 文档的措辞变化看出来:JDK 19 的 stop() 只在”虚拟线程上”抛 UOE,JDK 20 起改为 “always”:

1
2
3
4
5
# JDK 19 的 Thread.stop() javadoc(节选)
UnsupportedOperationException - if invoked on a virtual thread

# JDK 20 / 21 / 25 的 Thread.stop() javadoc(节选)
Throws: UnsupportedOperationException - always

ThreadGroup.stop()Thread.countStackFrames() 这类方法是在 21 之后被真正删除的(这些删减走的是 JDK bug 流程,没有独立 JEP 编号)。用官方 API 文档的”方法是否存在”矩阵来定位首次移除版本:

1
2
3
4
5
6
7
8
                              JDK 21   JDK 22   JDK 23   JDK 24   JDK 25
Thread.countStackFrames() 有 无 无 无 无 <- JDK 22 移除
ThreadGroup.stop() 有 有 无 无 无 <- JDK 23 移除
Thread.suspend() 有 有 无 无 无 <- JDK 23 移除
Thread.resume() 有 有 无 无 无 <- JDK 23 移除
ThreadGroup.suspend() 有 有 无 无 无 <- JDK 23 移除
ThreadGroup.resume() 有 有 无 无 无 <- JDK 23 移除
Thread.stop() 有 有 有 有 有 <- 未删除,仅"待删除"

(这张表不是背出来的:对 docs.oracle.com 上各版本的 Thread / ThreadGroup javadoc 页做方法存在性检查(例如在页面 HTML 里统计 id="suspend()" 是否出现),就能定位某个方法是在哪个版本消失的。)

实测编译差异。下面这个类只调用了那两个已被删除的方法,JDK 21 上只有”待删除”警告、JDK 25 上直接编译失败:

1
2
3
4
5
6
7
public class RemovedApi {

public static void main(String[] args) {
Thread.currentThread().getThreadGroup().stop();
System.out.println(Thread.currentThread().countStackFrames());
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ jdk-21/bin/javac --release 21 -d b21 src/RemovedApi.java
src/RemovedApi.java:4: 警告: [removal] ThreadGroup 中的 stop() 已过时, 且标记为待删除
Thread.currentThread().getThreadGroup().stop();
^
src/RemovedApi.java:5: 警告: [removal] Thread 中的 countStackFrames() 已过时, 且标记为待删除
System.out.println(Thread.currentThread().countStackFrames());
^
2 个警告

$ jdk-25/bin/javac --release 25 -d b25 src/RemovedApi.java
src/RemovedApi.java:4: 错误: 找不到符号
Thread.currentThread().getThreadGroup().stop();
^
符号: 方法 stop()
位置: 类 ThreadGroup
src/RemovedApi.java:5: 错误: 找不到符号
System.out.println(Thread.currentThread().countStackFrames());
^
符号: 方法 countStackFrames()
位置: 类 Thread
2 个错误

如果旧代码是已经编译好的 jar(比如第三方库),升级后是运行时炸,而不是编译炸。下面这段在 JDK 21 上能完整编译的遗留代码(包含四个已被降级或删除的方法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Legacy {

public static void main(String[] args) throws Exception {
Runtime.getRuntime().runFinalization();

Thread worker = new Thread(() -> { });
worker.start();
worker.join();

try {
worker.stop();
System.out.println("stop() returned normally");
} catch (Throwable t) {
System.out.println("stop() threw " + t.getClass().getName());
}

ThreadGroup group = Thread.currentThread().getThreadGroup();
try {
group.stop();
System.out.println("ThreadGroup.stop() returned normally");
} catch (Throwable t) {
System.out.println("ThreadGroup.stop() threw " + t.getClass().getName());
}

try {
System.out.println("countStackFrames()=" + Thread.currentThread().countStackFrames());
} catch (Throwable t) {
System.out.println("countStackFrames() threw " + t.getClass().getName());
}

System.out.println("legacy run on " + Runtime.version());
}
}

用 JDK 21 编译它(只有 4 条”待删除”警告)、打成 jar,然后分别在两个 JVM 上跑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ jdk-21/bin/javac --release 21 -d b21 src/Legacy.java
src/Legacy.java:4: 警告: [removal] Runtime 中的 runFinalization() 已过时, 且标记为待删除
Runtime.getRuntime().runFinalization();
^
src/Legacy.java:11: 警告: [removal] Thread 中的 stop() 已过时, 且标记为待删除
worker.stop();
^
src/Legacy.java:19: 警告: [removal] ThreadGroup 中的 stop() 已过时, 且标记为待删除
group.stop();
^
src/Legacy.java:26: 警告: [removal] Thread 中的 countStackFrames() 已过时, 且标记为待删除
System.out.println("countStackFrames()=" + Thread.currentThread().countStackFrames());
^
4 个警告

$ jdk-21/bin/java -cp jar/legacy-demo.jar Legacy
stop() threw java.lang.UnsupportedOperationException
ThreadGroup.stop() threw java.lang.UnsupportedOperationException
countStackFrames() threw java.lang.UnsupportedOperationException
legacy run on 21.0.8+12-LTS-250

$ jdk-25/bin/java -cp jar/legacy-demo.jar Legacy
stop() threw java.lang.UnsupportedOperationException
ThreadGroup.stop() threw java.lang.NoSuchMethodError
countStackFrames() threw java.lang.NoSuchMethodError
legacy run on 25+37-LTS-3491

同一个 jar:21 上是”已删除”级别的 UnsupportedOperationException(还能 catch 住继续跑),25 上变成 NoSuchMethodError——这是 Error 不是 Exceptioncatch (Throwable) 之外的代码会被直接打穿。升级动作:这类方法只能逐个替换(stop → 协作式取消标志/interruptcountStackFramesStackWalker)。

32 位 x86 端口移除(JEP 501/503、JEP 479,未实测)

JEP 501(JDK 24)弃用 32 位 x86 端口,JEP 503(JDK 25)将其移除;Windows 的 32 位 x86 端口则由 JEP 479(JDK 24)移除。三条 JEP 的 Release 字段分别为 24 / 25 / 24。本机是 arm64,没有 32 位 x86 的 JDK 可跑,这一项未实测,仅按 JEP 页面记录:如果你还在维护 32 位 x86 的部署目标,JDK 25 没有对应构建,需要在 24 或更早就开始规划迁到 64 位。

工具链自检:jdeps / jdeprscan / jcmd

升级前用三个自带工具做体检,比读 release notes 更贴近你自己的代码。

jdeps:查 JDK 内部 API 依赖

对含 sun.misc.Unsafe 引用的 jar 跑 jdeps --jdk-internals,会点出”JDK internal API”并给替换建议:

1
2
3
4
5
6
7
8
9
10
11
$ jdk-25/bin/jdeps --jdk-internals jar/unsafe-demo.jar
unsafe-demo.jar -> jdk.unsupported
UnsafeDemo -> sun.misc.Unsafe JDK internal API (jdk.unsupported)

警告: 不支持 JDK 内部 API, 它们专用于通过不兼容方式来
删除或更改的 JDK 实现, 可能会损坏您的应用程序。
请修改您的代码, 消除与任何 JDK 内部 API 的相关性。

JDK 内部 API 建议的替换
---------- -----
sun.misc.Unsafe See https://openjdk.org/jeps/260

jdeps -summary 则给模块级依赖概览:

1
2
3
$ jdk-25/bin/jdeps -summary jar/unsafe-demo.jar
unsafe-demo.jar -> java.base
unsafe-demo.jar -> jdk.unsupported

两个用法互斥(-summary/-verbose 不能和 -jdkinternals 同时用),分开跑即可。如果 jar 里没有任何内部 API,--jdk-internals 就不输出”内部 API”表,只显示模块依赖——注意看上面 unsafe 的输出来自专门的 demo jar,纯粹的 Legacy jar 是干净的。

jdeprscan:查”已弃用且待删除”的 API,但别被它骗了

jdeprscan --for-removal 只扫 forRemoval=true 的 API。对同一个 jar,用哪个 JDK 的工具、哪个 --release,结果不一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ jdk-21/bin/jdeprscan --release 21 --for-removal jar/legacy-demo.jar
Jar 文件 jar/legacy-demo.jar:
class Legacy 使用已过时的方法 java/lang/Runtime::runFinalization()V (forRemoval=true)
class Legacy 使用已过时的方法 java/lang/Thread::stop()V (forRemoval=true)
class Legacy 使用已过时的方法 java/lang/ThreadGroup::stop()V (forRemoval=true)
class Legacy 使用已过时的方法 java/lang/Thread::countStackFrames()I (forRemoval=true)
class SecMgr 使用已过时的类 java/lang/SecurityManager (forRemoval=true)
class SecMgr 使用已过时的方法 java/lang/System::setSecurityManager(Ljava/lang/SecurityManager;)V (forRemoval=true)

$ jdk-25/bin/jdeprscan --release 21 --for-removal jar/legacy-demo.jar
Jar 文件 jar/legacy-demo.jar:
class Legacy 使用已过时的方法 java/lang/Runtime::runFinalization()V (forRemoval=true)
class Legacy 使用已过时的方法 java/lang/Thread::stop()V (forRemoval=true)
class SecMgr 使用已过时的类 java/lang/SecurityManager (forRemoval=true)
class SecMgr 使用已过时的方法 java/lang/System::setSecurityManager(Ljava/lang/SecurityManager;)V (forRemoval=true)

同一个 jar,JDK 21 的 jdeprscan 扫出 6 条,JDK 25 的 jdeprscan(哪怕加 --release 21)只扫出 4 条。少掉的 ThreadGroup::stopcountStackFrames 恰恰是 25 上会 NoSuchMethodError 的那两个——因为这两个方法在工具所在 JDK 里已经不存在了,jdeprscan 无法解析,于是”静默跳过”。结论:jdeprscan 的”扫描通过/变干净”不能证明代码能跑在新 JDK 上,它只负责查”仍存在但已弃用”的 API;查”已删除”的唯一可靠手段是用目标 JDK 编译一次。顺带,jdeprscan --list 的条目总数也能佐证这个”数据库随 JDK 走”的事实:

1
2
3
4
5
6
7
8
9
$ jdk-21/bin/jdeprscan --list | wc -l
681
$ jdk-21/bin/jdeprscan --list --for-removal | wc -l
103

$ jdk-25/bin/jdeprscan --list | wc -l
720
$ jdk-25/bin/jdeprscan --list --for-removal | wc -l
132

jcmd:运行时观测(版本、堆、默认参数、线程 dump)

让同一个 LongRunning 程序(打印 pid 后休眠)在两版上跑起来,用 jcmd 观测:

1
2
3
4
5
6
7
8
9
$ jdk-21/bin/jcmd 75817 VM.version
75817:
Java HotSpot(TM) 64-Bit Server VM version 21.0.8+12-LTS-250
JDK 21.0.8

$ jdk-25/bin/jcmd 75863 VM.version
75863:
Java HotSpot(TM) 64-Bit Server VM version 25+37-LTS-3491
JDK 25.0.0

GC.heap_info 能直接确认默认 GC,并且两版输出格式并不完全一致(25 少了 Metaspace 两行、total 拆成 reserved/committed):

1
2
3
4
5
6
7
8
9
10
11
$ jdk-21/bin/jcmd 75817 GC.heap_info
75817:
garbage-first heap total 264192K, used 4467K [0x0000000700000000, 0x0000000800000000)
region size 2048K, 2 young (4096K), 0 survivors (0K)
Metaspace used 520K, committed 640K, reserved 1114112K
class space used 54K, committed 128K, reserved 1048576K

$ jdk-25/bin/jcmd 75863 GC.heap_info
75863:
garbage-first heap total reserved 4194304K, committed 264192K, used 5249K [0x0000000700000000, 0x0000000800000000)
region size 2048K, 2 young (4096K), 0 survivors (0K)

两版都支持 Thread.dump_to_file -format=json(输出的 JSON 按 threadContainers 分组,含 <root>ForkJoinPool-1/jdk.internal.vm.SharedThreadContainer 等容器):

1
2
3
$ jdk-21/bin/jcmd 75817 Thread.dump_to_file -format=json threads21.json
75817:
Created /private/tmp/jdk-migration/out/threads21.json

如果升级后要看某个运行中进程有没有设置已移除的参数,用 VM.flags -all 过滤(下一节会用到)。

GC 与启动参数

默认 GC 没变:G1 仍是服务器环境默认

两版的 -XX:+PrintFlagsFinalUseG1GC 都是 true {product} {ergonomic}GC.heap_info 也都显示 garbage-first heap

1
2
3
4
5
6
7
8
9
10
11
$ jdk-21/bin/java -XX:+PrintFlagsFinal -version | grep -E 'UseG1GC|UseZGC|UseParallelGC|ZGenerational'
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseZGC = false {product} {default}
bool ZGenerational = false {product} {default}

$ jdk-25/bin/java -XX:+PrintFlagsFinal -version | grep -E 'UseG1GC|UseZGC|UseParallelGC|UseCompactObjectHeaders'
bool UseCompactObjectHeaders = false {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseParallelGC = false {product} {default}
bool UseZGC = false {product} {default}

G1 成为默认 GC 是 JDK 9 的 JEP 248Release: 9),至今未变。这里有个容易误读的点:JEP 523Release: 27)要把 G1 变成”所有环境”的默认,它在 JDK 27 才生效——也就是说在 JDK 25 里,非服务器(client)环境仍可能用别的收集器。绝大多数服务端部署不受影响,这里不展开。

被移除/新引入的参数(每条都实测过)

ZGC 非分代模式移除(JEP 474 / 490)。ZGC 的分代模式在 JDK 23 成为默认(JEP 474),非分代模式在 JDK 24 被移除(JEP 490)。-XX:-ZGenerational 这个参数在 21 上被接受、在 25 上被忽略并警告:

1
2
3
4
5
6
$ jdk-21/bin/java -XX:-ZGenerational -version
java version "21.0.8" 2025-07-15 LTS

$ jdk-25/bin/java -XX:-ZGenerational -version
Java HotSpot(TM) 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
java version "25" 2025-09-16 LTS

升级动作:如果你用了 ZGC,删掉 -XX:-ZGenerational(或显式 -XX:+ZGenerational,它现在就是默认);25 上是警告 + 忽略,不会崩,但参数已无意义。

压缩对象头(JEP 450 / 519)。压缩对象头在 JDK 24 是实验特性(JEP 450),JDK 25 转为产品特性(JEP 519)。开关 -XX:+UseCompactObjectHeaders 在 21 上不存在、在 25 上直接可用(不需要 -XX:+UnlockExperimentalVMOptions):

1
2
3
4
5
6
7
$ jdk-21/bin/java -XX:+UseCompactObjectHeaders -version
Unrecognized VM option 'UseCompactObjectHeaders'
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.

$ jdk-25/bin/java -XX:+UseCompactObjectHeaders -version
java version "25" 2025-09-16 LTS

这是可选的省内存能力,默认关闭(上面 PrintFlagsFinal 里是 = false),升级后可评估启用。

AOT 缓存参数(JEP 483 / 514 / 515)。JDK 24 的 JEP 483 引入 AOT 类加载与链接(-XX:AOTMode-XX:AOTConfiguration),JDK 25 的 JEP 514 补齐命令行体验(-XX:AOTCache-XX:AOTCacheOutput)。这些参数在 21 上完全不存在:

1
2
3
4
5
6
7
8
9
$ jdk-25/bin/java -XX:+PrintFlagsFinal -version | grep -iE 'AOTMode|AOTCache|AOTConfiguration|AOTClassLinking'
ccstr AOTCache = {product} {default}
ccstr AOTCacheOutput = {product} {default}
bool AOTClassLinking = false {product} {default}
ccstr AOTConfiguration = {product} {default}
ccstr AOTMode = {product} {default}

$ jdk-21/bin/java -XX:+PrintFlagsFinal -version | grep -iE 'AOTMode|AOTCache|AOTConfiguration|AOTClassLinking'
(无输出)

这是加速启动的可选项,对迁移本身没有破坏性;如果 21 的脚本里带了这些参数,那 21 上早就报错了,反过来不成立。

--release 支持范围:21 与 25 一致(都是 8 起)。用下面这个三行类做探针:

1
2
3
4
5
6
public class Hello8 {

public static void main(String[] args) {
System.out.println("hello");
}
}

--release 7 两版都直接拒绝,--release 8 两版都只警告”已过时”而仍可用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ jdk-21/bin/javac --release 7 -d b21 src/Hello8.java
错误: 不支持发行版本 7
用法: javac <选项> <源文件>
使用 --help 可列出可能的选项

$ jdk-25/bin/javac --release 7 -d b25 src/Hello8.java
错误: 不支持发行版本 7
用法: javac <选项> <源文件>
使用 --help 可列出可能的选项

$ jdk-21/bin/javac --release 8 -d b21 src/Hello8.java
警告: [options] 源值 8 已过时,将在未来发行版中删除
警告: [options] 目标值 8 已过时,将在未来发行版中删除
警告: [options] 要隐藏有关已过时选项的警告, 请使用 -Xlint:-options。
3 个警告

$ jdk-25/bin/javac --release 8 -d b25 src/Hello8.java
警告: [options] 源值 8 已过时,将在未来发行版中删除
警告: [options] 目标值 8 已过时,将在未来发行版中删除
警告: [options] 要隐藏有关已过时选项的警告, 请使用 -Xlint:-options。
3 个警告

也就是说”源码版本 8 被移除”这件事在 25 还没发生,不必急着改 --release 8。已移除的部分是另外一回事(比如上面说的 JDK 22/23 删掉的 API,它们在低 --release 下同样编不过)。

升级前自查清单(可复制)

把上面的验证压缩成一条可复制的流程,全部只用 JDK 自带工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1) 用目标 JDK 编译全部源码——这一步能抓到"找不到符号"级别的移除
/path/to/jdk-25/bin/javac --release 25 -d /tmp/build25 $(find src -name '*.java')

# 2) 扫"仍存在但已弃用"的 API;注意结果随工具所在 JDK 变化,别当安全网
/path/to/jdk-25/bin/jdeprscan --release 25 --for-removal app.jar

# 3) 查 JDK 内部 API 依赖
/path/to/jdk-25/bin/jdeps --jdk-internals app.jar

# 4) 找出脚本里已被移除/改名/新增的 JVM 参数
/path/to/jdk-25/bin/java -XX:+PrintFlagsFinal -version \
| grep -E 'UseG1GC|ZGenerational|UseCompactObjectHeaders|AOTMode|AOTCache'

# 5) 运行时观测:默认 GC、线程 dump(升级后也用它替代 jdk.tracePinnedThreads)
jcmd <pid> GC.heap_info
jcmd <pid> VM.flags -all
jcmd <pid> Thread.dump_to_file -format=json threads.json

先编译(步骤 1),再看 jdeprscan(步骤 2):已删除的 API 不会出现在扫描结果里,只跑 jdeprscan 会得到”干净”的假象。

总表

项目 JDK 21 现状 JDK 25 现状 升级动作 验证方式
未命名变量 _(JEP 443→456) 预览,需 --enable-preview 正式 --enable-preview 编译 + 运行对照
Stream Gatherers(JEP 461/473→485) 不存在,加预览也编不过 正式(Stream.gather 无(21 上写不出来),升后可用 编译对照
Scoped Values(JEP 446→506) 预览 正式 --enable-preview;改掉 orElse(null) 编译 + 运行对照
FFM API(JEP 442→454) 预览 正式 --enable-preview;声明 --enable-native-access 编译 + 运行对照
Structured Concurrency(JEP 453→505) 预览(new ShutdownOnFailure() 仍预览,API 改为 open() 改写法,继续保留 --enable-preview 双向编译对照
灵活构造器体(JEP 513) 报”super 必须是第一个语句” 正式 可用(可选) 编译对照
紧凑源文件(JEP 512) 预览 正式 可用(可选) java x.java 对照
record/switch 模式匹配(JEP 440/441) 已正式 已正式 运行对照(一致)
虚拟线程 synchronized 固定(JEP 491) 固定载体 解除 无需再换 ReentrantLock 同程序耗时:3550ms vs 520ms
-Djdk.tracePinnedThreads 有效 移除、无效果 改用 JFR jdk.VirtualThreadPinned 9 行 vs 1 行;JFR 4 次 vs 0 次
Security Manager(JEP 486) allow 可装回 永久禁用 删属性与 setSecurityManager 启动对照(25 直接 Error
sun.misc.Unsafe 内存访问(JEP 471/498) 静默 首次调用警告;可 deny 迁 VarHandle/FFM 运行输出对照
Thread.stop() 抛 UOE(JDK 20 起) 抛 UOE 无(21 前就废了) 两版运行一致
ThreadGroup.stop() / countStackFrames() 弃用警告,能编 已删除 换协作取消 / StackWalker 编译”找不到符号”;旧 jar 运行 NoSuchMethodError
32 位 x86 端口(JEP 501/503、479) 无构建 迁 64 位 未实测(arm64),文档核实
默认 GC G1(服务器环境) G1(服务器环境) PrintFlagsFinal + GC.heap_info
ZGC 非分代模式(JEP 474/490) -XX:-ZGenerational 可用 移除,忽略并警告 删参数 -version 对照
压缩对象头(JEP 450/519) 参数不存在 产品特性(默认关) 可选启用 -version 对照
AOT 参数(JEP 483/514/515) 不存在 AOTMode/AOTCache 可选启用 PrintFlagsFinal 对照
javac --release 8 可用(警告) 可用(警告) 编译对照

总结

  • 21 到 25 的升级主体是删开关_、Scoped Values、FFM 都从”预览”转正,对应的 --enable-preview 可以全删;只有 Structured Concurrency 到 25 仍是预览,而且 API 从 new ShutdownOnFailure() 换成了 open(),是这一批里唯一”升了反而要改代码”的预览特性。
  • 行为变化最大的一处是虚拟线程的固定:JEP 491 在 24 解除了 synchronized / Object.wait() 的固定,同一个”独占监视器 + synchronized 内 sleep”程序,21 约 3550ms、25 约 520ms;-Djdk.tracePinnedThreads 随之失效,改看 JFR 的 jdk.VirtualThreadPinned(同程序 4 次 → 0 次)。
  • 移除类里要分清三档:编译期报错ThreadGroup.stopcountStackFrames 在 22/23 被删,javac 报”找不到符号”)、运行时警告sun.misc.Unsafe 内存访问在 24 起首次调用打印 4 行警告,可 deny)、启动即报错(Security Manager 在 24 永久禁用,-Djava.security.manager=allow 直接起不来)。
  • Thread.stop() 属于”早于 21 就废了”的那类:JDK 20 起永远抛 UOE,21 和 25 行为一致,不是这次迁移的新坑;真正的新坑是旧 jar 里 ThreadGroup.stop/countStackFrames 从 21 的 UOE 变成 25 的 NoSuchMethodErrorError,会打穿 catch (Exception))。
  • GC 与参数:默认 GC 还是 G1({ergonomic}garbage-first heap);-XX:-ZGenerational 被移除(忽略 + 警告),新增 UseCompactObjectHeaders 和一批 AOT 参数;--release 8 仍在支持范围内。
  • 工具链结论:jdeps --jdk-internals 查内部 API,jdeprscan --for-removal 查”仍存在但已弃用”的 API——后者会漏报已删除的方法(ThreadGroup::stopcountStackFrames 在 25 的扫描结果里凭空消失),所以先编译、后扫描,别把 jdeprscan 当安全网。

参考资料

系列索引:Java 系列,语言特性与运行时的长文集