前言
Java 21 是 2023 年 9 月发布的 LTS(长期支持)版本。虚拟线程正式发布,record 模式、switch 模式匹配等改变 Java 写法的特性也一起到来。
本文 一边实际运行,一边 介绍 Java 21 的 15 个 JEP。所有示例代码都是单个文件,不依赖任何外部库,复制下来就能直接用 java 命令运行。
另一方面,Java 21 中处于预览阶段的特性,有的之后改了设计,有的被撤回了。所以每个特性的最后都附上 「后续进展」,补充到 JDK 27(2026 年 9 月 GA)为止的变化。「后续进展」的内容是 2026 年 10 月补记的。
验证环境与示例的运行方法
- JDK:OpenJDK 21(21.0.12)
- 操作系统 / CPU:macOS(Apple Silicon,8 核)
- 直接运行源文件(JEP 330),不需要先编译。
java RecordPatternsDemo.java # 正式特性
java --enable-preview --source 21 ScopedValueDemo.java # 预览特性- 示例代码也放在 GitHub 的 smile-lxy/java-new-features 上。代码注释用日语、中文、英语三种语言写成,本文只保留中文注释。
- 每个示例开头的注释(
// Run:)写有运行命令。 - 输出中的时间、线程 ID(如
#23)、内存大小,会随运行环境和每次运行而变化。
全景:Java 21 的 15 个 JEP
| JEP | 特性 | Java 21 中的状态 | 后续进展(至 JDK 27,2026 年 10 月补记) |
|---|---|---|---|
| 430 | String Templates | 预览 | JDK 23 撤回 |
| 431 | Sequenced Collections | 正式 | ― |
| 439 | Generational ZGC | 正式 | JDK 23 默认使用分代模式,JDK 24 删除非分代模式 |
| 440 | Record Patterns | 正式 | ― |
| 441 | Pattern Matching for switch | 正式 | 基本类型模式仍在预览 |
| 442 | Foreign Function & Memory API | 第 3 次预览 | JDK 22 正式发布 |
| 443 | Unnamed Patterns and Variables | 预览 | JDK 22 正式发布 |
| 444 | Virtual Threads | 正式 | JDK 24 解决了 synchronized 导致的固定(pinning) |
| 445 | Unnamed Classes and Instance Main Methods | 预览 | 改名后于 JDK 25 正式发布 |
| 446 | Scoped Values | 预览 | JDK 25 正式发布 |
| 448 | Vector API | 第 6 次孵化 | JDK 27 仍是第 12 次孵化 |
| 449 | 弃用 Windows 32 位 x86 移植版 | 弃用 | JDK 24 删除 |
| 451 | 为禁止动态加载代理做准备 | 输出警告 | ― |
| 452 | Key Encapsulation Mechanism API | 正式 | ― |
| 453 | Structured Concurrency | 预览 | API 重新设计,JDK 27 仍是第 7 次预览 |
这些特性大致可以分成 4 类。
flowchart LR
J21["Java 21"] --> L["语言特性"]
J21 --> C["集合"]
J21 --> P["并发"]
J21 --> R["运行时 · API"]
L --> L1["Record Patterns(440)"]
L --> L2["switch 模式匹配(441)"]
L --> L3["Unnamed Patterns(443)"]
L --> L4["String Templates(430)"]
L --> L5["Instance Main Methods(445)"]
C --> C1["Sequenced Collections(431)"]
P --> P1["Virtual Threads(444)"]
P --> P2["Scoped Values(446)"]
P --> P3["Structured Concurrency(453)"]
R --> R1["Generational ZGC(439)"]
R --> R2["FFM API(442)"]
R --> R3["Vector API(448)"]
R --> R4["KEM API(452)等"]
classDef star fill:#fde68a,stroke:#b45309,color:#78350f
class P1,P2,P3 star
本文按 语言特性 → 集合 → 并发 → 运行时 · API 的顺序介绍。其中并发的 3 个特性(黄色)关系紧密,会结合实测数据详细讲解。
语言特性:「拆开」数据来处理
Java 21 语言特性的共同主题是 面向数据编程:用 record 声明「数据的形状」,用模式拆开这个形状,再用 switch 按形状分别处理。Java 21 把这三者凑齐了。
Record Patterns(JEP 440)
在 instanceof 和 switch 中,可以 在检查类型的同时,把 record 拆成各个组成部分。
if (obj instanceof Point) {
Point p = (Point) obj; // 先强制转换
return p.x() + p.y(); // 再用访问器取值
}if (obj instanceof Point(int x, int y)) { // 类型检查 + 解构
return x + y;
}真正发挥威力的是处理 嵌套数据 的时候。模式可以照着数据结构的样子来写,只把需要的部分取到变量里。
flowchart TB
R["Rectangle(…, …)"] --> UL["upperLeft:ColoredPoint(…, …)"]
R --> LR["lowerRight:ColoredPoint ➜ 变量 lr"]
UL --> P["p:Point ➜ 变量 p"]
UL --> C["c:Color ➜ 变量 c"]
style C fill:#bbdefb,stroke:#1565c0,color:#0d47a1
把上图直接写成代码,就是下面这个模式。
if (obj instanceof Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr)) {
return c; // 只取出左上角点的颜色
}加上 when 守卫,「左上角是蓝色的矩形」这类条件也能在 switch 里声明式地写出来。
// JEP 440: Record Patterns
// Run: java RecordPatternsDemo.java
public class RecordPatternsDemo {
enum Color { RED, GREEN, BLUE }
record Point(int x, int y) {}
record ColoredPoint(Point p, Color c) {}
record Rectangle(ColoredPoint upperLeft, ColoredPoint lowerRight) {}
// 以前的写法:类型检查 → 强制转换 → 访问器
static int sumOld(Object obj) {
if (obj instanceof Point) {
Point p = (Point) obj;
return p.x() + p.y();
}
return 0;
}
// Java 21:类型检查和解构一步完成
static int sumNew(Object obj) {
if (obj instanceof Point(int x, int y)) {
return x + y;
}
return 0;
}
// 一次性解构嵌套的 record
static Color upperLeftColor(Object obj) {
if (obj instanceof Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr)) {
return c;
}
throw new IllegalArgumentException("not a rectangle");
}
// switch + record 模式 + when 守卫
static String describe(Object obj) {
return switch (obj) {
case Point(int x, int y) when x == y -> "point on the diagonal (" + x + ", " + y + ")";
case Point(int x, int y) -> "point (" + x + ", " + y + ")";
case Rectangle(ColoredPoint(Point p, Color c), ColoredPoint lr) when c == Color.BLUE ->
"rectangle with a blue upper-left corner (x=" + p.x() + ")";
case Rectangle r -> "rectangle";
default -> "unknown";
};
}
public static void main(String[] args) {
Point point = new Point(1, 2);
Rectangle blue = new Rectangle(
new ColoredPoint(new Point(0, 0), Color.BLUE),
new ColoredPoint(new Point(10, 10), Color.RED));
Rectangle green = new Rectangle(
new ColoredPoint(new Point(5, 5), Color.GREEN),
new ColoredPoint(new Point(10, 10), Color.RED));
System.out.println("sumOld(point) = " + sumOld(point));
System.out.println("sumNew(point) = " + sumNew(point));
System.out.println("upperLeftColor(blue) = " + upperLeftColor(blue));
System.out.println("describe(Point(3,3)) = " + describe(new Point(3, 3)));
System.out.println("describe(point) = " + describe(point));
System.out.println("describe(blue) = " + describe(blue));
System.out.println("describe(green) = " + describe(green));
System.out.println("describe(\"text\") = " + describe("text"));
}
}sumOld(point) = 3
sumNew(point) = 3
upperLeftColor(blue) = BLUE
describe(Point(3,3)) = point on the diagonal (3, 3)
describe(point) = point (1, 2)
describe(blue) = rectangle with a blue upper-left corner (x=0)
describe(green) = rectangle
describe("text") = unknown注意 case Point(int x, int y) when x == y 写在 case Point(int x, int y) 前面。switch 的模式从上到下依次匹配,所以条件更严格的要写在前面。如果顺序反过来,后面的 case 永远匹配不到(被前面的 case「支配」),会导致编译错误。
Pattern Matching for switch(JEP 441)
以前的 switch 只能和「常量」比较。Java 21 可以在 case 中写 任意类型的模式。
flowchart LR
O["switch (o)"] --> A{"case Integer i<br/>when i < 0"}
A -- 匹配 --> A1["负整数"]
A -- 不匹配 --> B{"case Integer i<br/>when i == 0"}
B -- 匹配 --> B1["零"]
B -- 不匹配 --> C{"case Integer i"}
C -- 匹配 --> C1["正整数"]
C -- 不匹配 --> D{"case String s ..."}
D -- 不匹配 --> E["default"]
另一个改进是 对 null 的处理。以前的 switch 遇到 null 会直接抛出 NullPointerException,所以必须先做检查。Java 21 可以用 case null 处理。
再配合 sealed 接口,穷尽性检查 就会生效:只要 case 覆盖了所有实现类型,就不需要 default。以后新增实现类型时,所有漏写对应 case 的 switch 都会编译报错,避免漏改。
// JEP 441: Pattern Matching for switch
// Run: java SwitchPatternDemo.java
public class SwitchPatternDemo {
// 以前的写法:一连串的 instanceof
static String formatOld(Object o) {
if (o instanceof Integer) {
Integer i = (Integer) o;
return "int " + i;
} else if (o instanceof Long) {
Long l = (Long) o;
return "long " + l;
} else if (o instanceof String) {
String s = (String) o;
return "String " + s;
}
return "unknown";
}
// Java 21:在 case 中直接写类型模式
static String formatNew(Object o) {
return switch (o) {
case Integer i -> "int " + i;
case Long l -> "long " + l;
case String s -> "String " + s;
default -> "unknown";
};
}
// 以前的写法:不先检查 null 就会抛出 NullPointerException
static String answerOld(String s) {
if (s == null) {
return "Oops";
}
return switch (s) {
case "YES", "NO" -> "Known";
default -> "Other";
};
}
// Java 21:可以用 case null 处理
static String answerNew(String s) {
return switch (s) {
case null -> "Oops";
case "YES", "NO" -> "Known";
default -> "Other";
};
}
// when 守卫
static String classify(Object o) {
return switch (o) {
case Integer i when i < 0 -> "negative integer";
case Integer i when i == 0 -> "zero";
case Integer i -> "positive integer";
case String s when s.isBlank() -> "blank string";
case String s -> "string (" + s.length() + " chars)";
default -> "other";
};
}
// 与 sealed 配合时不需要 default(由编译器保证穷尽)
sealed interface Shape permits Circle, Square {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Square q -> q.side() * q.side();
};
}
public static void main(String[] args) {
for (Object o : new Object[] {1, 2L, "abc", 3.14}) {
System.out.printf("%-6s old=%-12s new=%s%n", o, formatOld(o), formatNew(o));
}
System.out.println("---");
for (String s : new String[] {"YES", "yes", null}) {
System.out.printf("%-5s old=%-6s new=%s%n", s, answerOld(s), answerNew(s));
}
System.out.println("---");
for (Object o : new Object[] {-5, 0, 42, " ", "Java", 1.0}) {
System.out.printf("%-6s -> %s%n", "\"" + o + "\"", classify(o));
}
System.out.println("---");
System.out.printf("area(Circle(1)) = %.4f%n", area(new Circle(1)));
System.out.printf("area(Square(2)) = %.4f%n", area(new Square(2)));
}
}1 old=int 1 new=int 1
2 old=long 2 new=long 2
abc old=String abc new=String abc
3.14 old=unknown new=unknown
---
YES old=Known new=Known
yes old=Other new=Other
null old=Oops new=Oops
---
"-5" -> negative integer
"0" -> zero
"42" -> positive integer
" " -> blank string
"Java" -> string (4 chars)
"1.0" -> other
---
area(Circle(1)) = 3.1416
area(Square(2)) = 4.0000后续进展(2026 年 10 月补记):从 JDK 23 开始,把 int 等基本类型当作模式使用的「Primitive Types in Patterns, instanceof, and switch」一直在预览中(JDK 27 是第 5 次预览)。
Unnamed Patterns and Variables(JEP 443・预览)
可以 用 _ 表示不使用的变量和模式成分。这样能在代码里明确表达「这个值是故意不用的」,也不会再有未使用变量的警告。
// JEP 443: Unnamed Patterns and Variables (preview in Java 21)
// Run: java --enable-preview --source 21 UnnamedVariablesDemo.java
import java.util.List;
public class UnnamedVariablesDemo {
record Point(int x, int y) {}
record Line(Point from, Point to) {}
public static void main(String[] args) {
// 1. 模式中不使用的部分
Object obj = new Line(new Point(1, 2), new Point(3, 4));
if (obj instanceof Line(Point(int x, _), _)) {
System.out.println("x of the start point = " + x);
}
// 2. 不使用的循环变量
int count = 0;
for (var _ : List.of("a", "b", "c")) {
count++;
}
System.out.println("number of elements = " + count);
// 3. 不使用的异常变量
try {
Integer.parseInt("abc");
} catch (NumberFormatException _) {
System.out.println("not a number");
}
// 4. 不使用的 lambda 参数
List.of(1, 2, 3).forEach(_ -> System.out.print("*"));
System.out.println();
}
}x of the start point = 1
number of elements = 3
not a number
***后续进展(2026 年 10 月补记):JDK 22 正式发布(JEP 456),现在不加 --enable-preview 也能使用。
String Templates(JEP 430・预览)
在 STR."..." 中用 \{表达式} 嵌入值的字符串插值特性。嵌入的位置一目了然,不用像 String.format 那样数参数的顺序,也没有 MessageFormat 里 ''(单引号转义)这样的坑。
// JEP 430: String Templates (preview in Java 21, withdrawn in JDK 23)
// Run: java --enable-preview --source 21 StringTemplatesDemo.java
import java.text.MessageFormat;
public class StringTemplatesDemo {
public static void main(String[] args) {
String name = "xyz";
int age = 18;
String a = "Hello, " + name + "! Next year you'll be " + (age + 1) + ".";
String b = new StringBuilder().append("Hello, ").append(name)
.append("! Next year you'll be ").append(age + 1).append(".").toString();
String c = String.format("Hello, %s! Next year you'll be %d.", name, age + 1);
String d = MessageFormat.format("Hello, {0}! Next year you''ll be {1}.", name, age + 1);
String e = STR."Hello, \{name}! Next year you'll be \{age + 1}.";
System.out.println("+ operator : " + a);
System.out.println("StringBuilder : " + b);
System.out.println("String.format : " + c);
System.out.println("MessageFormat : " + d);
System.out.println("STR : " + e);
// 也可以和文本块一起使用(可以嵌入任意表达式)
int x = 3, y = 4;
String report = STR."""
x = \{x}
y = \{y}
x * y = \{x * y}
""";
System.out.print(report);
// 注意:STR 只是原样拼接,不做任何转义
String user = "' OR '1'='1";
System.out.println(STR."SELECT * FROM users WHERE name = '\{user}'");
}
}+ operator : Hello, xyz! Next year you'll be 19.
StringBuilder : Hello, xyz! Next year you'll be 19.
String.format : Hello, xyz! Next year you'll be 19.
MessageFormat : Hello, xyz! Next year you'll be 19.
STR : Hello, xyz! Next year you'll be 19.
x = 3
y = 4
x * y = 12
SELECT * FROM users WHERE name = '' OR '1'='1'不要用 STR 拼接 SQL。 正如输出的最后一行所示,STR 只是把值原样拼接起来,不做任何转义,这段代码对 SQL 注入毫无防备。SQL 仍然应该用 PreparedStatement 的占位符来构造。
JEP 430 原本的设想,是通过 STR 以外的「模板处理器」,把值 安全地嵌入 SQL、JSON 等内容中。
后续进展(2026 年 10 月补记):JDK 22 再次预览,但对语法和设计的讨论一直没有结论,JDK 23 把整个特性撤回了。现在的 JDK 已经无法编译 STR."..."。目前比较现实的做法是使用 String.format、"...".formatted(...) 和文本块。重新设计的方向还没有定论。
Unnamed Classes and Instance Main Methods(JEP 445・预览)
不再需要 class 声明,也不需要 public static void main(String[] args)。直接在文件里写方法和字段,就能运行。
// JEP 445: Unnamed Classes and Instance Main Methods (preview in Java 21)
// Run: java --enable-preview --source 21 InstanceMainDemo.java
// 不用写 class 声明,直接写字段和方法
String greeting = "Hello";
String greet(String name) {
return greeting + ", " + name + "!";
}
void main() {
System.out.println(greet("World"));
}Hello, World!看起来是面向初学者的特性,但能把小脚本或验证代码写在一个文件里直接运行,在实际工作中也很方便。
后续进展(2026 年 10 月补记):改名为「Compact Source Files and Instance Main Methods」,JDK 25 正式发布(JEP 512)。新增了用于标准输入输出的 java.lang.IO 类,java.base 模块中的类也不需要 import 就能使用。
void main() {
IO.println("Hello, World!");
}Sequenced Collections(JEP 431)
统一处理「第一个」和「最后一个」
Java 21 之前,取集合首尾元素的方法因类型而异,各不相同。
| 类型 | 第一个 | 最后一个 | 倒序 |
|---|---|---|---|
List | list.get(0) | list.get(list.size() - 1) | Collections.reverse(list)(会修改原列表) |
Deque | getFirst() | getLast() | descendingIterator() |
LinkedHashSet | iterator().next() | 循环到最后 | 先复制到 List 再反转 |
LinkedHashMap | entrySet().iterator().next() | 循环到最后 | 复制后再反转 |
它们都有「有顺序」这个共同特点,却没有一个类型来表示它。Java 21 在 现有的类型层次之间 加入了 3 个表示这一特点的接口(绿色)。
flowchart TB
Collection --> Set
Collection --> SC["SequencedCollection"]
Collection --> Queue
Set --> SS["SequencedSet"]
SC --> SS
SC --> List
SC --> Deque
Queue --> Deque
SS --> SortedSet
Set --> SortedSet
SortedSet --> NavigableSet
SS --> LHS["LinkedHashSet"]
Map --> SM["SequencedMap"]
SM --> SortedMap
SortedMap --> NavigableMap
SM --> LHM["LinkedHashMap"]
classDef added fill:#c8f7c5,stroke:#2e7d32,color:#1b5e20
class SC,SS,SM added
新增的方法如下。它们大多是 default 方法,所以与现有的实现类保持兼容。
interface SequencedCollection<E> extends Collection<E> {
SequencedCollection<E> reversed();
default void addFirst(E e);
default void addLast(E e);
default E getFirst();
default E getLast();
default E removeFirst();
default E removeLast();
}
interface SequencedSet<E> extends SequencedCollection<E>, Set<E> {
SequencedSet<E> reversed();
}
interface SequencedMap<K, V> extends Map<K, V> {
SequencedMap<K, V> reversed();
default Map.Entry<K, V> firstEntry();
default Map.Entry<K, V> lastEntry();
default Map.Entry<K, V> pollFirstEntry();
default Map.Entry<K, V> pollLastEntry();
default V putFirst(K k, V v);
default V putLast(K k, V v);
default SequencedSet<K> sequencedKeySet();
default SequencedCollection<V> sequencedValues();
default SequencedSet<Map.Entry<K, V>> sequencedEntrySet();
}与以前写法的对比
// JEP 431: Sequenced Collections
// Run: java SequencedCollectionsDemo.java
import java.util.*;
public class SequencedCollectionsDemo {
public static void main(String[] args) {
list();
deque();
set();
map();
reversedIsView();
}
static void list() {
System.out.println("=== List ===");
List<Integer> list = new ArrayList<>(List.of(0, 1, 2));
// 以前的写法
System.out.println("get(0) / get(size()-1) : " + list.get(0) + " / " + list.get(list.size() - 1));
// Java 21
System.out.println("getFirst() / getLast() : " + list.getFirst() + " / " + list.getLast());
System.out.println("reversed() : " + list.reversed() + " original: " + list);
// 以前的 Collections.reverse 会直接修改原列表
List<Integer> copy = new ArrayList<>(list);
Collections.reverse(copy);
System.out.println("Collections.reverse : " + copy + " (the copy itself was modified)");
}
static void deque() {
System.out.println("=== Deque ===");
Deque<Integer> deque = new ArrayDeque<>(List.of(0, 1, 2));
// 以前的写法:用 descendingIterator 倒序取出
List<Integer> rev = new ArrayList<>();
deque.descendingIterator().forEachRemaining(rev::add);
System.out.println("descendingIterator : " + rev);
// Java 21
System.out.println("reversed() : " + deque.reversed() + " original: " + deque);
}
static void set() {
System.out.println("=== LinkedHashSet ===");
LinkedHashSet<Integer> set = new LinkedHashSet<>(List.of(0, 1, 2));
// 以前的写法:最后一个元素只能循环到底才能拿到
Integer last = null;
for (Integer x : set) last = x;
System.out.println("iterator().next() / loop : " + set.iterator().next() + " / " + last);
// Java 21
System.out.println("getFirst() / getLast() : " + set.getFirst() + " / " + set.getLast());
System.out.println("reversed() : " + set.reversed());
// 加到最前面(已存在的元素会移到最前面)
set.addFirst(9);
System.out.println("addFirst(9) : " + set);
}
static void map() {
System.out.println("=== LinkedHashMap ===");
LinkedHashMap<Integer, String> map = new LinkedHashMap<>();
map.putLast(0, "zero");
map.putLast(1, "one");
map.putLast(2, "two");
System.out.println("map : " + map);
System.out.println("firstEntry / lastEntry : " + map.firstEntry() + " / " + map.lastEntry());
System.out.println("reversed() : " + map.reversed());
// 已存在的键会移到最前面
map.putFirst(1, "ONE");
System.out.println("putFirst(1, \"ONE\") : " + map);
System.out.println("pollLastEntry() : " + map.pollLastEntry() + " remaining: " + map);
}
static void reversedIsView() {
System.out.println("=== reversed() is a view ===");
Deque<Integer> deque = new ArrayDeque<>(List.of(0, 1, 2));
SequencedCollection<Integer> view = deque.reversed();
// 倒序视图的开头 = 原集合的末尾
view.addFirst(9);
System.out.println("after view.addFirst(9) : view=" + view + " original=" + deque);
}
}=== List ===
get(0) / get(size()-1) : 0 / 2
getFirst() / getLast() : 0 / 2
reversed() : [2, 1, 0] original: [0, 1, 2]
Collections.reverse : [2, 1, 0] (the copy itself was modified)
=== Deque ===
descendingIterator : [2, 1, 0]
reversed() : [2, 1, 0] original: [0, 1, 2]
=== LinkedHashSet ===
iterator().next() / loop : 0 / 2
getFirst() / getLast() : 0 / 2
reversed() : [2, 1, 0]
addFirst(9) : [9, 0, 1, 2]
=== LinkedHashMap ===
map : {0=zero, 1=one, 2=two}
firstEntry / lastEntry : 0=zero / 2=two
reversed() : {2=two, 1=one, 0=zero}
putFirst(1, "ONE") : {1=ONE, 0=zero, 2=two}
pollLastEntry() : 2=two remaining: {1=ONE, 0=zero}
=== reversed() is a view ===
after view.addFirst(9) : view=[9, 2, 1, 0] original=[0, 1, 2, 9]LinkedHashSet 的 addFirst 和 LinkedHashMap 的 putFirst,在元素 已经存在时会把它移到最前面。这是保持插入顺序的集合特有的行为。
注意:reversed() 返回的不是副本,而是「视图」
reversed() 不会创建新集合,而是返回一个 把原集合倒过来看的视图。数据本身只有一份,所以对视图的修改也会反映到原集合上。
flowchart LR
subgraph DATA["数据本体(只有一份)"]
direction LR
D0["0"] --- D1["1"] --- D2["2"] --- D9["9"]
end
ORIG["deque<br/>从头到尾"] -.-> DATA
VIEW["deque.reversed()<br/>从尾到头地看"] -.-> DATA
ADD["view.addFirst(9)<br/>= 加到本体的末尾"] --> D9
style D9 fill:#fde68a,stroke:#b45309,color:#78350f
这在内存效率上是优点,但想要「倒序的副本」时,需要像 new ArrayList<>(list.reversed()) 这样显式复制。名字虽然相似,Collections.reverse 会 修改 原列表,而 reversed() 返回的是 不修改原集合的视图。迁移时要留意两者的差别。
虚拟线程(JEP 444)
这是 Java 21 中影响最大的特性。
为什么需要虚拟线程
很多 Web 应用都按「一个请求 = 一个线程」(thread-per-request)的方式编写。这种模型好读,也好调试。但 Java 线程和操作系统线程是一一对应的,所以能同时处理的请求数会被操作系统线程的数量卡住。
而且,典型的请求处理大部分时间都在 等待 数据库或外部 API 的响应。
一次请求的耗时构成(示意,单位:ms)
%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
%% gantt:request
dateFormat x
axisFormat %L
section 请求
参数处理 :active, 0, 5
数据库(等待) :crit, 5, 60
外部 API(等待) :crit, 60, 140
生成响应 :active, 140, 150
真正用到 CPU 的只有「参数处理」和「生成响应」,在标着「等待」的时间里,线程什么也没做。可即便如此,线程仍然保留着每个 MB 级别的栈,一直占着操作系统线程。
过去对这个问题的回答是 CompletableFuture、Reactor 等响应式编程。但代码会变复杂,栈追踪也难以看懂。虚拟线程则 保持同步、直白的写法,就能解决这个问题。
原理:M 调度
虚拟线程是由 JVM 管理的轻量级线程,本体是 堆上的对象。只有真正需要 CPU 时,才会被放到 载体线程(carrier thread,默认是 ForkJoinPool 的平台线程)上运行。用少量(N 个)载体线程运行大量(M 个)虚拟线程,所以叫 M
flowchart TB
subgraph JVM["JVM"]
subgraph HEAP["堆"]
V1((VT1)):::vt
V2((VT2)):::vt
V3((VT3)):::vt
V4((VT4)):::vt
V5((VT5)):::vt
V6((...)):::vt
end
subgraph POOL["ForkJoinPool"]
C1[载体线程 1]
C2[载体线程 2]
end
V1 -- 挂载 --> C1
V3 -- 挂载 --> C2
end
C1 --- O1[操作系统线程]
C2 --- O2[操作系统线程]
classDef vt fill:#e53935,color:#fff,stroke:#b71c1c
关键在于虚拟线程进入阻塞 I/O(Thread.sleep、socket 读写、BlockingQueue.take 等)那一刻发生的事。
sequenceDiagram
participant VT as 虚拟线程
participant C as 载体线程
participant IO as I/O
VT->>C: 挂载(把栈放上去)
C->>C: 计算
C->>IO: I/O 请求(阻塞)
Note over VT,C: 卸载:把栈存回堆上,<br/>载体线程转去运行其他虚拟线程
IO-->>VT: I/O 完成
VT->>C: 重新挂载(挂到任意一个空闲的载体线程)
C->>C: 继续执行
JDK 内部的阻塞操作已经改写为支持虚拟线程,所以 应用代码只要照常同步地写,等待期间载体线程就会被释放出来。
创建方法
虚拟线程仍然是 Thread 类,有 4 种创建方式。
// JEP 444: Virtual Threads - four ways to create a virtual thread
// Run: java VirtualThreadCreateDemo.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
public class VirtualThreadCreateDemo {
public static void main(String[] args) throws Exception {
Runnable task = () -> System.out.println(" " + Thread.currentThread());
System.out.println("1. Thread.startVirtualThread");
Thread.startVirtualThread(task).join();
System.out.println("2. Thread.ofVirtual().name(...).start");
Thread.ofVirtual().name("my-virtual").start(task).join();
System.out.println("3. ThreadFactory");
ThreadFactory factory = Thread.ofVirtual().name("worker-", 0).factory();
Thread t = factory.newThread(task);
t.start();
t.join();
System.out.println("4. Executors.newVirtualThreadPerTaskExecutor");
// 离开 try-with-resources 时,close() 会等待所有任务完成
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(task);
executor.submit(task);
}
System.out.println("For comparison: a platform thread");
Thread.ofPlatform().start(task).join();
System.out.println("isVirtual = " + Thread.ofVirtual().unstarted(task).isVirtual());
}
}1. Thread.startVirtualThread
VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1
2. Thread.ofVirtual().name(...).start
VirtualThread[#26,my-virtual]/runnable@ForkJoinPool-1-worker-1
3. ThreadFactory
VirtualThread[#27,worker-0]/runnable@ForkJoinPool-1-worker-1
4. Executors.newVirtualThreadPerTaskExecutor
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-2
For comparison: a platform thread
Thread[#31,Thread-0,5,main]
isVirtual = true虚拟线程的 toString() 格式是 VirtualThread[#ID]/状态@载体线程名,可以看出 它现在挂在哪个载体线程上。实际工作中一般用第 4 种 ExecutorService。另外,ExecutorService 从 Java 19 开始实现了 AutoCloseable,用 try-with-resources 写的话,离开代码块时会等待所有任务完成。
实测①:观察调度
用 2 个载体线程运行 4 个虚拟线程。载体线程的数量可以用系统属性 jdk.virtualThreadScheduler.parallelism / maxPoolSize 限制。每个虚拟线程交替执行「计算(占用 CPU)」和「I/O(用 Thread.sleep 代替)」。
// JEP 444: Virtual Threads - run 4 virtual threads on 2 carrier threads and watch them mount / unmount
// Run: java VirtualThreadScheduleDemo.java
import java.time.Duration;
import java.util.concurrent.CountDownLatch;
public class VirtualThreadScheduleDemo {
static final long START = System.nanoTime();
// 按 [计算 ms, I/O ms, 计算 ms, ...] 的顺序执行
static final long[][] PLANS = {
{2500, 500, 1000, 1500, 1300},
{1800, 1000, 3800, 1500, 600},
{2000, 500, 2500},
{800, 500, 2800},
};
public static void main(String[] args) throws InterruptedException {
// 把载体线程限制为 2 个(必须在创建第一个虚拟线程之前设置)
System.setProperty("jdk.virtualThreadScheduler.parallelism", "2");
System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "2");
CountDownLatch done = new CountDownLatch(PLANS.length);
for (int i = 0; i < PLANS.length; i++) {
int id = i + 1;
long[] plan = PLANS[i];
Thread.startVirtualThread(() -> {
log(id, "start");
for (int step = 0; step < plan.length; step++) {
if (step % 2 == 0) {
log(id, "compute " + plan[step] + " ms");
compute(plan[step]);
} else {
log(id, "I/O " + plan[step] + " ms (unmount)");
sleep(plan[step]);
log(id, "I/O done (remount)");
}
}
log(id, "done");
done.countDown();
});
}
done.await();
}
// 持续占用 CPU(虚拟线程一直挂载在载体线程上)
static void compute(long ms) {
long end = System.nanoTime() + ms * 1_000_000;
double sink = 0;
while (System.nanoTime() < end) {
sink += Math.sqrt(sink + 1);
}
}
// 代替阻塞 I/O(虚拟线程会被卸载)
static void sleep(long ms) {
try {
Thread.sleep(Duration.ofMillis(ms));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static synchronized void log(int id, String message) {
long elapsed = (System.nanoTime() - START) / 1_000_000;
// toString() 的格式是「VirtualThread[#id]/状态@载体线程名」
String carrier = Thread.currentThread().toString().replaceAll(".*@", "");
System.out.printf("%5d ms %-23s VT-%d %s%n", elapsed, carrier, id, message);
}
} 4 ms ForkJoinPool-1-worker-2 VT-2 start
12 ms ForkJoinPool-1-worker-1 VT-1 start
14 ms ForkJoinPool-1-worker-1 VT-1 compute 2500 ms
14 ms ForkJoinPool-1-worker-2 VT-2 compute 1800 ms
1817 ms ForkJoinPool-1-worker-2 VT-2 I/O 1000 ms (unmount)
1821 ms ForkJoinPool-1-worker-2 VT-3 start
1821 ms ForkJoinPool-1-worker-2 VT-3 compute 2000 ms
2515 ms ForkJoinPool-1-worker-1 VT-1 I/O 500 ms (unmount)
2516 ms ForkJoinPool-1-worker-1 VT-4 start
2517 ms ForkJoinPool-1-worker-1 VT-4 compute 800 ms
3317 ms ForkJoinPool-1-worker-1 VT-4 I/O 500 ms (unmount)
3318 ms ForkJoinPool-1-worker-1 VT-2 I/O done (remount)
3318 ms ForkJoinPool-1-worker-1 VT-2 compute 3800 ms
3821 ms ForkJoinPool-1-worker-2 VT-3 I/O 500 ms (unmount)
3822 ms ForkJoinPool-1-worker-2 VT-1 I/O done (remount)
3822 ms ForkJoinPool-1-worker-2 VT-1 compute 1000 ms
4823 ms ForkJoinPool-1-worker-2 VT-1 I/O 1500 ms (unmount)
4826 ms ForkJoinPool-1-worker-2 VT-4 I/O done (remount)
4826 ms ForkJoinPool-1-worker-2 VT-4 compute 2800 ms
7118 ms ForkJoinPool-1-worker-1 VT-2 I/O 1500 ms (unmount)
7119 ms ForkJoinPool-1-worker-1 VT-3 I/O done (remount)
7119 ms ForkJoinPool-1-worker-1 VT-3 compute 2500 ms
7627 ms ForkJoinPool-1-worker-2 VT-4 done
7627 ms ForkJoinPool-1-worker-2 VT-1 I/O done (remount)
7627 ms ForkJoinPool-1-worker-2 VT-1 compute 1300 ms
8928 ms ForkJoinPool-1-worker-2 VT-1 done
8928 ms ForkJoinPool-1-worker-2 VT-2 I/O done (remount)
8928 ms ForkJoinPool-1-worker-2 VT-2 compute 600 ms
9528 ms ForkJoinPool-1-worker-2 VT-2 done
9619 ms ForkJoinPool-1-worker-1 VT-3 done把这份日志的时间戳原样画成时间线,就是下图。
4 个虚拟线程 × 2 个载体线程(实测,单位:s)
%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
%% gantt:schedule
dateFormat x
axisFormat %S
tickInterval 1second
section VT-1
计算 :active, 14, 2515
I/O :2515, 3015
等待 :crit, 3015, 3822
计算 :active, 3822, 4823
I/O :4823, 6323
等待 :crit, 6323, 7627
计算 :active, 7627, 8928
section VT-2
计算 :active, 14, 1817
I/O :1817, 2817
等待 :crit, 2817, 3318
计算 :active, 3318, 7118
I/O :7118, 8618
等待 :crit, 8618, 8928
计算 :active, 8928, 9528
section VT-3
等待 :crit, 0, 1821
计算 :active, 1821, 3821
I/O :3821, 4321
等待 :crit, 4321, 7119
计算 :active, 7119, 9619
section VT-4
等待 :crit, 0, 2516
计算 :active, 2517, 3317
I/O :3317, 3817
等待 :crit, 3817, 4826
计算 :active, 4826, 7627
这张图是根据上面的运行日志这样画出来的:
- 计算:从
compute ○○ ms那一行的时间,到该线程下一行日志的时间(挂在载体线程上) - I/O:从
I/O ○○ ms (unmount)那一行的时间开始,长度为指定的 sleep 时间(离开了载体线程) - 等待:从 I/O 结束到出现
I/O done (remount)那一行。I/O 已经结束,但正在等空闲的载体线程。VT-3、VT-4 开头的等待,是从程序启动到出现start那一行
从中可以看出:
- 始终只用了 2 个载体线程:任何时刻,处于「计算」中的虚拟线程最多只有 2 个。
- I/O 期间会让出载体线程:VT-2 在 1.8 s 进入 I/O 的那一刻,正在等待的 VT-3 就在这个载体线程(
worker-2)上启动了。 - 重新挂载时,载体线程可能会换:VT-2 在
worker-2上启动,I/O 之后挂到了worker-1上。虚拟线程不绑定在某个操作系统线程上。 - 总耗时约 9.6 s:4 个线程的计算时间合计 19.1 s,由 2 个载体线程分担,理论值约 9.55 s。实际几乎和理论值一致。
最后一点反过来说,就是 占用 CPU 的处理,改成虚拟线程也不会变快。虚拟线程真正有效的,是等待(I/O)占主导的处理。
实测②:与线程池的区别
把同样的「5 个各等待 1 s 的任务」,分别交给线程池和虚拟线程执行。
// JEP 444: Virtual Threads - limits of a thread pool vs virtual threads on a single carrier
// Run: java ThreadPoolVsVirtualDemo.java
import java.time.Duration;
import java.util.concurrent.*;
public class ThreadPoolVsVirtualDemo {
static final long START = System.nanoTime();
static void log(String message) {
String thread = Thread.currentThread().isVirtual()
? "virtual@" + Thread.currentThread().toString().replaceAll(".*@", "")
: Thread.currentThread().getName();
System.out.printf("%5d ms %-32s %s%n", (System.nanoTime() - START) / 1_000_000, thread, message);
}
static Runnable task(int n) {
return new Runnable() {
public void run() {
log("task " + n + " start");
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException ignored) {
}
log("task " + n + " end");
}
public String toString() {
return "task " + n;
}
};
}
public static void main(String[] args) throws Exception {
System.out.println("=== 1. Fixed pool (1 thread), 5 tasks of 1 s ===");
try (ExecutorService pool = Executors.newFixedThreadPool(1)) {
for (int i = 0; i < 5; i++) pool.execute(task(i));
}
System.out.println("=== 2. Bounded queue (1 thread, queue of 2), 5 tasks ===");
ThreadPoolExecutor bounded = new ThreadPoolExecutor(1, 1, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(2),
(r, executor) -> log("[REJECTED] " + r + " (queue is full)"));
for (int i = 0; i < 5; i++) bounded.execute(task(i));
bounded.shutdown();
bounded.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("=== 3. Virtual threads (1 carrier), 5 tasks of 1 s ===");
// 必须在创建第一个虚拟线程之前设置
System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "1");
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 5; i++) executor.execute(task(i));
}
}
}=== 1. Fixed pool (1 thread), 5 tasks of 1 s ===
1 ms pool-1-thread-1 task 0 start
1012 ms pool-1-thread-1 task 0 end
1013 ms pool-1-thread-1 task 1 start
2016 ms pool-1-thread-1 task 1 end
2017 ms pool-1-thread-1 task 2 start
3023 ms pool-1-thread-1 task 2 end
3023 ms pool-1-thread-1 task 3 start
4025 ms pool-1-thread-1 task 3 end
4026 ms pool-1-thread-1 task 4 start
5027 ms pool-1-thread-1 task 4 end
=== 2. Bounded queue (1 thread, queue of 2), 5 tasks ===
5031 ms pool-2-thread-1 task 0 start
5034 ms main [REJECTED] task 3 (queue is full)
5035 ms main [REJECTED] task 4 (queue is full)
6037 ms pool-2-thread-1 task 0 end
6038 ms pool-2-thread-1 task 1 start
7044 ms pool-2-thread-1 task 1 end
7044 ms pool-2-thread-1 task 2 start
8047 ms pool-2-thread-1 task 2 end
=== 3. Virtual threads (1 carrier), 5 tasks of 1 s ===
8053 ms virtual@ForkJoinPool-1-worker-1 task 0 start
8055 ms virtual@ForkJoinPool-1-worker-1 task 1 start
8056 ms virtual@ForkJoinPool-1-worker-1 task 2 start
8057 ms virtual@ForkJoinPool-1-worker-1 task 3 start
8057 ms virtual@ForkJoinPool-1-worker-1 task 4 start
9060 ms virtual@ForkJoinPool-1-worker-1 task 0 end
9060 ms virtual@ForkJoinPool-1-worker-1 task 1 end
9061 ms virtual@ForkJoinPool-1-worker-1 task 2 end
9061 ms virtual@ForkJoinPool-1-worker-1 task 3 end
9062 ms virtual@ForkJoinPool-1-worker-1 task 4 end5 个各等待 1 s 的任务(实测,单位:s)
%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
%% gantt:pool
dateFormat x
axisFormat %S
tickInterval 1second
section 固定线程池
task 0 :active, 1, 1012
task 1 :active, 1013, 2016
task 2 :active, 2017, 3023
task 3 :active, 3023, 4025
task 4 :active, 4026, 5027
section 虚拟线程
task 0 :active, 0, 1007
task 1 :active, 2, 1007
task 2 :active, 3, 1008
task 3 :active, 4, 1008
task 4 :active, 4, 1009
- 固定线程池(1 个线程):只有 1 个线程,5 个任务 每次 1 s、依次 执行,共用 5 s。
- 有界队列:队列(长度 2)满了以后,多出来的任务 3、4 被拒绝。用线程池时,「池的大小」「队列长度」「溢出时的策略」都得不断调优。
- 虚拟线程(1 个载体线程):明明都运行在同一个
worker-1上,5 个等待却重叠在一起,约 1 s 就全部结束了。等待期间会让出载体线程,所以即使只有 1 个载体线程,也能同时等待。
实测③:吞吐量
那么任务数量增加后会怎样?我们运行 10,000 个各阻塞 1 s 的任务 来比较。
// JEP 444: Virtual Threads - 10,000 tasks that block for 1 s: fixed thread pool vs virtual threads
// Run: java ThroughputDemo.java
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class ThroughputDemo {
static void run(String label, ExecutorService executor) {
long start = System.nanoTime();
try (executor) {
IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
}));
}
// close() 等所有任务完成后再计时
System.out.printf("%-28s %6d ms%n", label, (System.nanoTime() - start) / 1_000_000);
}
public static void main(String[] args) {
run("newFixedThreadPool(200)", Executors.newFixedThreadPool(200));
run("newVirtualThreadPerTask", Executors.newVirtualThreadPerTaskExecutor());
}
}newFixedThreadPool(200) 50306 ms
newVirtualThreadPerTask 1071 ms10,000 个各阻塞 1 s 的任务的耗时(ms)
xychart-beta
x-axis ["固定线程池(200 个线程)", "虚拟线程"]
y-axis "ms" 0 --> 55000
bar [50306, 1071]
固定线程池同时只能处理 200 个任务,所以要花 10,000 ÷ 200 = 50 轮的等待时间。虚拟线程可以让 10,000 个任务同时等待,约 1 s 就结束了,相差约 47 倍。
实测④:内存——每个线程的开销
分别启动 2,000 个平台线程和 200,000 个虚拟线程,让它们处于等待状态,测量内存的增长。
// JEP 444: Virtual Threads - memory per platform thread vs per virtual thread
// Run: java MemoryCostDemo.java
import com.sun.management.OperatingSystemMXBean;
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class MemoryCostDemo {
static final int PLATFORM_COUNT = 2_000;
static final int VIRTUAL_COUNT = 200_000;
public static void main(String[] args) throws Exception {
System.out.println("Java " + System.getProperty("java.version")
+ ", " + Runtime.getRuntime().availableProcessors() + " CPU cores");
measure("Platform", PLATFORM_COUNT, Thread.ofPlatform());
measure("Virtual ", VIRTUAL_COUNT, Thread.ofVirtual());
}
static void measure(String label, int count, Thread.Builder builder) throws Exception {
gc();
long heapBefore = usedHeap();
long osBefore = committedVirtualMemory();
CountDownLatch ready = new CountDownLatch(count);
CountDownLatch release = new CountDownLatch(1);
List<Thread> threads = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
threads.add(builder.start(() -> {
ready.countDown();
try {
// 让所有线程保持等待状态
release.await();
} catch (InterruptedException ignored) {
}
}));
}
ready.await();
gc();
long heapDelta = usedHeap() - heapBefore;
long osDelta = committedVirtualMemory() - osBefore;
System.out.printf("[%s] %,d threads: heap +%s (%s each), OS committed virtual memory +%s (%s each)%n",
label, count, format(heapDelta), format(heapDelta / count),
format(osDelta), format(osDelta / count));
release.countDown();
for (Thread t : threads) t.join();
}
static long usedHeap() {
return ManagementFactory.getMemoryMXBean().getHeapMemoryUsage().getUsed();
}
static long committedVirtualMemory() {
var os = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
return os.getCommittedVirtualMemorySize();
}
static void gc() throws InterruptedException {
for (int i = 0; i < 3; i++) {
System.gc();
Thread.sleep(300);
}
}
static String format(long bytes) {
if (Math.abs(bytes) < 1024) return bytes + " B";
if (Math.abs(bytes) < 1024 * 1024) return String.format("%.2f KB", bytes / 1024.0);
if (Math.abs(bytes) < 1024L * 1024 * 1024) return String.format("%.2f MB", bytes / 1024.0 / 1024);
return String.format("%.2f GB", bytes / 1024.0 / 1024 / 1024);
}
}Java 21.0.12.1, 8 CPU cores
[Platform] 2,000 threads: heap +719.57 KB (368 B each), OS committed virtual memory +4.00 GB (2.05 MB each)
[Virtual ] 200,000 threads: heap +336.66 MB (1.72 KB each), OS committed virtual memory +17.88 MB (93 B each)| 项目 | 平台线程 | 虚拟线程 |
|---|---|---|
| 启动数量 | 2,000 个 | 200,000 个 |
| 每个(操作系统提交的虚拟内存) | 约 2.05 MB | 约 90〜120 B |
| 每个(Java 堆) | 约 370 B | 约 1.0〜1.7 KB |
表中的值是运行 7 次的范围。虚拟线程的堆占用每次都有波动(1.0〜1.7 KB),因为测量值里除了 Thread 对象和等待中的栈(栈块,stack chunk),还包含 GC 尚未回收的对象。平台线程的值几乎不变。
平台线程会为每个操作系统线程预留栈空间(macOS / Linux 默认约 1〜2 MB)。虚拟线程只在堆上按需保存栈,所以 小了好几个数量级。
「提交的虚拟内存(committed virtual)」是向操作系统预留的地址空间大小,与实际占用的物理内存(RSS)并不相同。请把它当作「向操作系统申请了多少」的参考。
不过,再小也 不是无限的。把堆限制在 128 MB,不断创建 sleep 10 s 的虚拟线程,看看会怎样。
// JEP 444: Virtual Threads - what happens if you keep creating them without a limit
// Run: java -Xmx128m VirtualThreadOomDemo.java
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
public class VirtualThreadOomDemo {
static volatile boolean outOfMemory = false;
// 预留一块内存,用于最后输出结果
static byte[] reserve = new byte[8 * 1024 * 1024];
public static void main(String[] args) {
// OOM 也可能发生在虚拟线程内部,所以用异常处理器来捕获
Thread.Builder builder = Thread.ofVirtual().uncaughtExceptionHandler((t, e) -> {
if (e instanceof OutOfMemoryError) outOfMemory = true;
});
Runnable sleep10s = () -> {
try {
Thread.sleep(Duration.ofSeconds(10));
} catch (InterruptedException ignored) {
}
};
List<Thread> hold = new ArrayList<>();
long start = System.nanoTime();
try {
while (!outOfMemory) {
hold.add(builder.start(sleep10s));
}
} catch (OutOfMemoryError e) {
outOfMemory = true;
}
long elapsed = (System.nanoTime() - start) / 1_000_000;
// 先释放预留的内存,再输出
reserve = null;
System.out.printf("OutOfMemoryError after creating %,d threads (%,d ms)%n", hold.size(), elapsed);
}
}OutOfMemoryError after creating 173,940 threads (882 ms)在所有线程都还活着(sleep 中)的状态下,创建到约 17 万个时堆就耗尽了(多次运行在 17 万〜20 万个之间)。虚拟线程虽然占内存很小,但如果设计上「不限制同时存在的线程数」,迟早会内存不足。
线程 / 线程池 / 虚拟线程的对比
| 项目 | Thread(平台线程) | Thread Pool | Virtual Threads |
|---|---|---|---|
| 线程数上限 | 受操作系统线程数和内存限制 | 由池大小固定 | 基本只受内存限制 |
| 创建开销 | 高 | 通过复用降低 | 非常小 |
| 阻塞 I/O | 连同操作系统线程一起阻塞 | 池可能被耗尽 | 释放载体线程后等待 |
| 适合的场景 | 少量长时间处理、CPU 密集型 | 通用(需要调整池大小) | 大量 I/O 密集型任务 |
| 是否应该池化 | 一般为了复用而池化 | 以池化为前提 | 不池化(每个任务新建一个) |
| 与现有 API 的兼容性 | ― | ― | 直接沿用 Thread API |
使用时的注意事项
1. 不要池化,用 Semaphore 限制并发数
虚拟线程很方便,正确的用法是 每个任务新建一个,用完即弃。如果要限制并发数(比如访问外部 API 的同时连接数),不要用线程池,而是用 Semaphore。
private static final Semaphore PERMITS = new Semaphore(50);
String callExternalApi() throws InterruptedException {
PERMITS.acquire();
try {
return client.call();
} finally {
PERMITS.release();
}
}2. 注意固定(pinning)(JDK 21〜23)
在 JDK 21 中,如果在 synchronized 块里执行阻塞操作,虚拟线程就无法离开载体线程,这叫做「固定」(pinning)。我们只用 1 个载体线程,运行 2 个持有锁并 sleep 1 s 的任务。
// JEP 444: Virtual Threads - pinning caused by synchronized (JDK 21-23)
// Run: java -Djdk.tracePinnedThreads=short PinningDemo.java
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;
public class PinningDemo {
static void sleep1s() {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException ignored) {
}
}
// 运行 2 个持有锁并阻塞 1 s 的任务(每个任务用各自的锁,所以没有锁竞争)
static void run(String label, Runnable body) {
long start = System.nanoTime();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.execute(body);
executor.execute(body);
}
System.out.printf("%-14s %5d ms%n", label, (System.nanoTime() - start) / 1_000_000);
}
public static void main(String[] args) {
// 只用 1 个载体线程,让差异更明显
System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "1");
run("synchronized", () -> {
Object lock = new Object();
synchronized (lock) {
// JDK 21:连同载体线程一起被固定
sleep1s();
}
});
run("ReentrantLock", () -> {
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 可以卸载
sleep1s();
} finally {
lock.unlock();
}
});
}
}VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
PinningDemo.lambda$main$0(PinningDemo.java:43) <== monitors:1
synchronized 2018 ms
ReentrantLock 1020 ms1 个载体线程、持锁等待 1 s 的 2 个任务(根据 PinningDemo 的结果绘制,单位:s)
%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
%% gantt:pinning
dateFormat x
axisFormat %S.%L
tickInterval 500millisecond
section synchronized
任务 1 :crit, 0, 1009
任务 2 :crit, 1009, 2018
section ReentrantLock
任务 1 :active, 0, 1020
任务 2 :active, 0, 1020
(图中两个任务的分界,是把 synchronized 的总耗时平分画出来的。)
每个任务用的是各自的锁,本来不存在锁竞争。但 synchronized 版本中,唯一的载体线程被固定住了,两个任务只能 串行 执行,花了 2 s。加上 -Djdk.tracePinnedThreads=short,会输出发生固定的位置(reason:MONITOR)。
在 JDK 21〜23 中使用虚拟线程时,把包含 I/O 的加锁区间换成 ReentrantLock 是常规做法。
后续进展(2026 年 10 月补记):JDK 24 的 JEP 491 解决了 synchronized 导致的固定。从 JDK 24 开始,即使在 synchronized 中阻塞,虚拟线程也会被卸载(调用本地方法期间等少数情况仍会固定)。
3. 其他
- CPU 密集型处理不会变快:如实测①所示。并行计算仍然应该使用
ForkJoinPool或并行流。 - 不要往 ThreadLocal 里放大对象:虚拟线程会大量创建,以往每个线程各自缓存一份的做法会占用大量内存。下面介绍的 Scoped Values 可以作为替代。
Scoped Values(JEP 446・预览)
ThreadLocal 的问题
请求 ID、当前登录用户这类「处理上下文」,为了不靠参数层层传递来共享,过去一直使用 ThreadLocal。但 ThreadLocal 有这些问题:
- 随时都能被改写:很难追查值是在哪里变的
- 生命周期太长:忘了
remove(),在线程池中值会残留到下一个请求(还可能导致信息泄露) - 传给子线程的开销大:
InheritableThreadLocal会复制值,和大量虚拟线程配合不好
ThreadLocal 的值和线程同生共死,而 ScopedValue 的值和作用域(代码块)同生共死。
flowchart TB
subgraph TL["ThreadLocal"]
direction LR
T1["set("a")"] --> T2["set("b")<br/>任何地方都能覆盖"] --> T3["忘了 remove()<br/>就会残留到下一个处理"]
end
subgraph SV["ScopedValue"]
direction LR
S1["where(SV, "a").run(..."] --> S2["只有在里面能看到 a"] --> S3["...) 离开后<br/>自动消失"]
end
TL ~~~ SV
style T2 fill:#ffcdd2,stroke:#c62828,color:#7f1d1d
style T3 fill:#ffcdd2,stroke:#c62828,color:#7f1d1d
style S3 fill:#c8e6c9,stroke:#2e7d32,color:#1b5e20
用法
ScopedValue 是 由代码结构决定值的有效范围(作用域) 的机制。它没有 set(),想换值时就 嵌套一个作用域。离开作用域后值会恢复原样,所以值的有效范围就是代码的缩进本身。
flowchart LR
subgraph S1["where(SV, "a").run(...)"]
A1["SV.get() → a"]
subgraph S2["where(SV, "b").run(...)"]
B1["SV.get() → b"]
end
A2["SV.get() → a(恢复原值)"]
A1 --> S2 --> A2
end
OUT["作用域之外:isBound() = false"]
S1 --> OUT
按和 ThreadLocal 相同的流程改值,比较一下两者的行为。
// JEP 446: Scoped Values (preview in Java 21)
// Run: java --enable-preview --source 21 ScopedValueDemo.java
import java.util.concurrent.StructuredTaskScope;
public class ScopedValueDemo {
static final ThreadLocal<String> TL = new ThreadLocal<>();
static final ScopedValue<String> SV = ScopedValue.newInstance();
public static void main(String[] args) throws Exception {
System.out.println("=== 1. ThreadLocal: a value you set stays from then on ===");
TL.set("a");
threadLocalFlow();
TL.remove();
System.out.println("=== 2. ScopedValue: the value exists only inside the scope ===");
ScopedValue.where(SV, "a").run(ScopedValueDemo::scopedValueFlow);
System.out.println(" outside the scope: isBound = " + SV.isBound());
System.out.println("=== 3. The binding is immutable, but the object's contents are not ===");
mutableObject();
System.out.println("=== 4. Inherited automatically by StructuredTaskScope subtasks ===");
inheritance();
}
static void threadLocalFlow() {
System.out.println(" " + TL.get()); // a
// 如果在这里改掉……
TL.set("b");
System.out.println(" " + TL.get()); // b
// 被调用的方法里也一直是 b
print();
}
static void print() {
System.out.println(" " + TL.get());
}
static void scopedValueFlow() {
System.out.println(" " + SV.get()); // a
// 只有在内层作用域里是 b
ScopedValue.where(SV, "b").run(() -> System.out.println(" " + SV.get()));
// 离开后又变回 a
System.out.println(" " + SV.get());
}
// 故意设计成可变的类
static class Context {
String name;
Context(String name) { this.name = name; }
public String toString() { return "Context{name=" + name + "}"; }
}
static final ScopedValue<Context> CTX = ScopedValue.newInstance();
static void mutableObject() {
ScopedValue.where(CTX, new Context("a")).run(() -> {
System.out.println(" before: " + CTX.get());
// 修改对象的内容
CTX.get().name = "b";
System.out.println(" after : " + CTX.get() + " <- visible to everyone in the same scope");
});
}
static void inheritance() throws Exception {
TL.set("REQ-123");
ScopedValue.where(SV, "REQ-123").run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var a = scope.fork(() -> describe("fork-A"));
var b = scope.fork(() -> describe("fork-B"));
scope.join().throwIfFailed();
System.out.println(" " + a.get());
System.out.println(" " + b.get());
} catch (Exception e) {
throw new RuntimeException(e);
}
});
TL.remove();
}
static String describe(String name) {
return name + ": ScopedValue=" + SV.get() + ", ThreadLocal=" + TL.get();
}
}=== 1. ThreadLocal: a value you set stays from then on ===
a
b
b
=== 2. ScopedValue: the value exists only inside the scope ===
a
b
a
outside the scope: isBound = false
=== 3. The binding is immutable, but the object's contents are not ===
before: Context{name=a}
after : Context{name=b} <- visible to everyone in the same scope
=== 4. Inherited automatically by StructuredTaskScope subtasks ===
fork-A: ScopedValue=REQ-123, ThreadLocal=null
fork-B: ScopedValue=REQ-123, ThreadLocal=null对比 1 和 2,区别一目了然。ThreadLocal 中途 set("b") 之后,值就一直保留下来,连被调用的方法看到的也是 b。ScopedValue 中,b 只在内层作用域里有效,离开后就恢复成 a。
如 3 所示,绑定本身不可变,但对象的内容是可变的。如果绑定的是可变对象,对内容的修改会被共享同一作用域的所有代码看到。放进 ScopedValue 的,原则上应该是 record 等不可变对象。
传递给子线程
如 4 所示,ScopedValue 的绑定会 自动、无需复制地 传给下一节介绍的结构化并发中 StructuredTaskScope.fork() 创建的子线程。而 ThreadLocal 不会被传递(null)。
注意,用 Thread.startVirtualThread 等方式 单独 创建的线程不会自动继承绑定。这种情况下,需要在子线程里重新用 ScopedValue.where(...) 绑定。
后续进展(2026 年 10 月补记):JDK 25 正式发布(JEP 506)。正式发布前,runWhere / callWhere 已被删除(JDK 24),统一为 ScopedValue.where(key, value).run(...) / .call(...) 的写法。从 Java 21 起就用 where(...).run(...) 来写,迁移会更轻松。
Structured Concurrency(JEP 453・预览)
「给并发也加上结构」
调用一个函数时,被调用的函数一定会比调用方先结束。正因为有这种「嵌套结构」,异常才能传回调用方,也能通过栈追踪看清处理流程。
但用 ExecutorService 写并发代码,这种结构就被打破了。子任务的运行与调用方毫无关系,即使其中一个失败,另一个也没人去停,会一直跑下去。结构化并发把多个子任务归入 一个处理单元(作用域),让并发代码也具有和函数调用相同的结构。
flowchart TB
H["handle()(作用域的所有者)"] --> U["fork:findUser()"]
H --> O["fork:fetchOrder()"]
U -. 失败 .-> H
H -. 自动取消其余任务 .-> O
style H fill:#e3f2fd,stroke:#1565c0,color:#0d47a1
style U fill:#ffcdd2,stroke:#c62828,color:#7f1d1d
作用域的完整生命周期如下。
sequenceDiagram
participant Owner as 调用方(作用域的所有者)
participant Scope as StructuredTaskScope
participant A as 子任务 A(虚拟线程)
participant B as 子任务 B(虚拟线程)
Owner->>Scope: try (var scope = new ShutdownOnFailure())
Owner->>Scope: fork(A)
Scope->>A: 在新的虚拟线程中开始
Owner->>Scope: fork(B)
Scope->>B: 在新的虚拟线程中开始
Owner->>Scope: join()(等待)
A-->>Scope: 失败!
Scope->>B: shutdown → 中断(interrupt)
Scope-->>Owner: 从 join() 返回
Owner->>Scope: throwIfFailed() → 抛出异常
Owner->>Scope: close()(离开 try):保证所有子任务都已结束
每次 fork() 都会在 新的虚拟线程 中执行子任务。虚拟线程占内存很小,每个任务新建一个线程也没问题。这是有了虚拟线程才能成立的设计。
与 ExecutorService 的对比
并发执行 findUser(200 ms 后失败)和 fetchOrder(耗时 3 s),比较 ExecutorService 版和 StructuredTaskScope 版的表现。第 4 部分还试了 ShutdownOnSuccess:同时询问多台服务器,只使用最先成功的结果。
// JEP 453: Structured Concurrency (preview in Java 21)
// Run: java --enable-preview --source 21 StructuredConcurrencyDemo.java
import java.util.concurrent.*;
import java.util.function.Supplier;
public class StructuredConcurrencyDemo {
static long t0;
static void log(String message) {
System.out.printf("%5d ms %s%n", (System.nanoTime() - t0) / 1_000_000, message);
}
record User(String id, String name) {}
record Order(String userId, int count) {}
static User findUser(String id) throws InterruptedException {
Thread.sleep(200);
if (id.equals("bad")) throw new IllegalStateException("user not found");
return new User(id, "Alice");
}
static Order fetchOrder(String id) throws InterruptedException {
try {
Thread.sleep(3_000);
log("fetchOrder: finished");
return new Order(id, 3);
} catch (InterruptedException e) {
log("fetchOrder: interrupted");
throw e;
}
}
// ExecutorService + Future(取消要由调用方自己负责)
static String withFuture(ExecutorService pool, String id) throws Exception {
Future<Order> order = pool.submit(() -> fetchOrder(id));
Future<User> user = pool.submit(() -> findUser(id));
// 即使 user 失败,order 也不会停下来
return user.get().name() + " / " + order.get().count() + " orders";
}
// StructuredTaskScope.ShutdownOnFailure
static String withScope(String id) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<Order> order = scope.fork(() -> fetchOrder(id));
Supplier<User> user = scope.fork(() -> findUser(id));
// 任何一个失败,就中断其余任务并抛出异常
scope.join().throwIfFailed();
return user.get().name() + " / " + order.get().count() + " orders";
}
}
// StructuredTaskScope.ShutdownOnSuccess:只用最先成功的结果
static String fastest() throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> { Thread.sleep(300); return "server A"; });
scope.fork(() -> { Thread.sleep(100); return "server B"; });
scope.fork(() -> { Thread.sleep(200); throw new IllegalStateException("server C is down"); });
scope.join();
return scope.result();
}
}
public static void main(String[] args) throws Exception {
System.out.println("=== 1. Success ===");
t0 = System.nanoTime();
log("StructuredTaskScope: " + withScope("u1"));
System.out.println("=== 2. findUser fails: ExecutorService + Future ===");
t0 = System.nanoTime();
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
try {
withFuture(pool, "bad");
} catch (ExecutionException e) {
log("caller: failure detected -> " + e.getCause().getMessage());
}
}
// close() 会等待剩下的任务结束
log("pool closed");
System.out.println("=== 3. findUser fails: StructuredTaskScope ===");
t0 = System.nanoTime();
try {
withScope("bad");
} catch (ExecutionException e) {
log("caller: failure detected -> " + e.getCause().getMessage());
}
log("scope closed");
System.out.println("=== 4. Use the first success ===");
t0 = System.nanoTime();
log("result: " + fastest());
}
}=== 1. Success ===
3011 ms fetchOrder: finished
3032 ms StructuredTaskScope: Alice / 3 orders
=== 2. findUser fails: ExecutorService + Future ===
207 ms caller: failure detected -> user not found
3006 ms fetchOrder: finished
3008 ms pool closed
=== 3. findUser fails: StructuredTaskScope ===
205 ms fetchOrder: interrupted
206 ms caller: failure detected -> user not found
206 ms scope closed
=== 4. Use the first success ===
107 ms result: server B差别最明显的是 失败的时候(第 2 和第 3 部分)。
findUser 失败时的表现(实测,单位:s)
%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
%% gantt:cancel
dateFormat x
axisFormat %S.%L
tickInterval 500millisecond
section ExecutorService
findUser(失败) :crit, 0, 207
fetchOrder(继续运行) :active, 0, 3006
section Structured
findUser(失败) :crit, 0, 206
fetchOrder(被中断) :done, 0, 205
ExecutorService 版中,调用方发现失败之后,fetchOrder 仍然 为了没人要的结果跑了 3 s。在真实系统中,这段时间它会一直占着数据库连接等资源。写上 Future.cancel() 就能停下来,但忘了写也没人会发现。
StructuredTaskScope 版中,一旦失败,其余子任务就 被自动中断,作用域在 206 ms 就结束了。
第 4 部分的 ShutdownOnSuccess 忽略了故障的 server C,返回了最先成功的 server B(100 ms)的结果,较慢的 server A 被自动取消。用 ExecutorService 实现同样的效果,需要用 ExecutorCompletionService 取出最先完成的结果,再手动 cancel() 其余任务。
| 方面 | ExecutorService + Future | StructuredTaskScope |
|---|---|---|
| 写法 | 命令式 | 命令式(try-with-resources) |
| 失败时的取消 | 手动(容易忘写) | 自动 |
| 线程泄漏 | 可能发生 | 结构上不会发生 |
| 子任务的生命周期 | 与调用方无关 | 限定在作用域内 |
| 线程转储 | 看不出父子关系 | 能看出父子关系 |
后续进展:API 重新设计
后续进展(2026 年 10 月补记):结构化并发的 API 还在不断调整,JDK 27 仍是第 7 次预览(JEP 533)。从 JDK 25 开始,不再使用 ShutdownOnFailure / ShutdownOnSuccess 子类,而是改成 StructuredTaskScope.open() 加「Joiner(完成策略)」 的组合。
// JDK 26 及以后的预览 API(在 JDK 21 上无法运行;方法名以 JDK 26 为准)
try (var scope = StructuredTaskScope.open()) { // 默认策略:任一子任务失败,整体就失败
Subtask<User> user = scope.fork(() -> findUser(id));
Subtask<Order> order = scope.fork(() -> fetchOrder(id));
scope.join(); // 如果失败就抛出异常
return new Response(user.get(), order.get());
}
// 「最先成功的那个」用 Joiner 指定
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulOrThrow())) {
scope.fork(() -> callServerA());
scope.fork(() -> callServerB());
return scope.join();
}预览期间,Joiner 的方法名和异常类型每个版本都在变。如果要用在生产代码里,最好等正式发布。不过「在作用域里 fork,用 join 等待,离开作用域时一切都已结束」这个思路没有变。
运行时 · API
Generational ZGC(JEP 439)
ZGC 是把停顿时间控制在 1 ms 以内的低延迟 GC。Java 21 为它加入了 分代(Young / Old) 管理。
大多数对象很快就不再需要(分代假说)。所以新对象放在年轻代,频繁地只回收年轻代(Minor);存活下来的对象移到老年代,偶尔集中回收(Major)。不必每次都扫描整个堆,CPU 和内存的效率都会提高。
flowchart LR
NEW["new 出来的对象"] --> Y["年轻代(Young)<br/>频繁回收:Minor"]
Y -- "大部分在这里被回收" --> GONE["释放"]
Y -- "存活下来的" --> O["老年代(Old)<br/>偶尔回收:Major"]
style Y fill:#c8e6c9,stroke:#2e7d32,color:#1b5e20
style O fill:#ffe0b2,stroke:#e65100,color:#7c2d12
用一个大量创建短命对象的程序,比较分代和非分代两种模式。
// JEP 439: Generational ZGC
// Run (generational) : java -XX:+UseZGC -XX:+ZGenerational -Xmx1g GenerationalZgcDemo.java
// Run (non-generational): java -XX:+UseZGC -Xmx1g GenerationalZgcDemo.java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;
public class GenerationalZgcDemo {
public static void main(String[] args) {
// 只保留最近的 5 万个对象(很快就不再需要的短命对象)
byte[][] recent = new byte[50_000][];
// 极少数对象会一直存活到最后
List<byte[]> longLived = new ArrayList<>();
long start = System.nanoTime();
for (int i = 0; i < 30_000_000; i++) {
recent[i % recent.length] = new byte[1024];
if (i % 500 == 0) longLived.add(new byte[1024]);
}
System.out.printf("allocated ~%d GB, long-lived %,d, elapsed %,d ms%n",
30_000_000L * 1024 / 1024 / 1024 / 1024, longLived.size(), (System.nanoTime() - start) / 1_000_000);
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
System.out.printf(" %-20s count %4d, total %,6d ms%n", gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
}
}
}java -XX:+UseZGC -XX:+ZGenerational -Xmx1g GenerationalZgcDemo.java
allocated ~28 GB, long-lived 60,000, elapsed 6,156 ms
ZGC Minor Cycles count 69, total 1,350 ms
ZGC Minor Pauses count 210, total 1 ms
ZGC Major Cycles count 9, total 587 ms
ZGC Major Pauses count 42, total 0 ms
java -XX:+UseZGC -Xmx1g GenerationalZgcDemo.java
allocated ~28 GB, long-lived 60,000, elapsed 7,203 ms
ZGC Cycles count 68, total 2,236 ms
ZGC Pauses count 204, total 2 ms分代模式下,绝大多数回收(69 次)是只针对年轻代的 Minor,针对整个堆的 Major 只有 9 次。停顿(Pauses)方面,分代模式 210 次合计 1 ms,非分代模式 204 次合计 2 ms,每次都远低于 1 ms。多次运行下来,分代模式的总耗时更短(这次分代约 6.2 s,非分代约 7.2 s;但每次运行的差异很大)。
后续进展(2026 年 10 月补记):JDK 23 起默认使用分代模式(JEP 474),JDK 24 删除了非分代模式(JEP 490)。现在只用 -XX:+UseZGC 就是分代 ZGC。
Foreign Function & Memory API(JEP 442・第 3 次预览)
不使用 JNI,只用 Java 代码就能安全地 调用本地库函数、操作堆外内存的 API。不再需要编写 JNI 那样的 C 胶水代码。
flowchart LR
subgraph JAVA["Java"]
MH["MethodHandle<br/>strlen"]
AR["Arena<br/>(管理内存的生命周期)"]
end
L["Linker<br/>(转换调用约定)"]
subgraph NATIVE["本地代码"]
LIB["libc 的 strlen()"]
MEM["堆外内存<br/>MemorySegment"]
end
MH --> L --> LIB
AR -- "分配 / 释放" --> MEM
LIB -. "读取" .-> MEM
// JEP 442: Foreign Function & Memory API (third preview in Java 21)
// Run: java --enable-preview --source 21 --enable-native-access=ALL-UNNAMED ForeignFunctionDemo.java
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
public class ForeignFunctionDemo {
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
// 找到 C 标准库的 size_t strlen(const char *s),把它变成 Java 的方法句柄
MethodHandle strlen = linker.downcallHandle(
linker.defaultLookup().find("strlen").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));
// 离开作用域时释放内存
try (Arena arena = Arena.ofConfined()) {
// 在堆外分配一个 C 字符串
MemorySegment str = arena.allocateUtf8String("Hello, FFM!");
long len = (long) strlen.invokeExact(str);
System.out.println("strlen(\"Hello, FFM!\") = " + len);
// 在堆外内存中写入并读取一个 int 数组
MemorySegment ints = arena.allocateArray(ValueLayout.JAVA_INT, 5);
for (int i = 0; i < 5; i++) ints.setAtIndex(ValueLayout.JAVA_INT, i, i * i);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 5; i++) sb.append(ints.getAtIndex(ValueLayout.JAVA_INT, i)).append(' ');
System.out.println("off-heap int[] = " + sb.toString().trim());
}
}
}strlen("Hello, FFM!") = 11
off-heap int[] = 0 1 4 9 16用 try-with-resources 管理 Arena,离开作用域时堆外内存一定会被释放。访问已释放的内存会抛出异常,所以不会出现 C 语言那样的悬空指针。
后续进展(2026 年 10 月补记):JDK 22 正式发布(JEP 454)。正式版改了部分方法名:allocateUtf8String 改为 allocateFrom,allocateArray 改为 allocate。调用本地代码属于「受限方法」,想不输出警告,需要加上 --enable-native-access。
Vector API(JEP 448・第 6 次孵化)
在 Java 中显式编写利用 CPU SIMD 指令进行向量运算的 API。一条指令同时计算多个元素(lane)。
// JEP 448: Vector API (sixth incubator in Java 21)
// Run: java --add-modules jdk.incubator.vector VectorApiDemo.java
import jdk.incubator.vector.FloatVector;
import jdk.incubator.vector.VectorSpecies;
public class VectorApiDemo {
static final VectorSpecies<Float> SPECIES = FloatVector.SPECIES_PREFERRED;
// 用 SIMD 批量计算 c[i] = a[i] * a[i] + b[i]
static void vectorized(float[] a, float[] b, float[] c) {
int i = 0;
int upper = SPECIES.loopBound(a.length);
for (; i < upper; i += SPECIES.length()) {
var va = FloatVector.fromArray(SPECIES, a, i);
var vb = FloatVector.fromArray(SPECIES, b, i);
va.mul(va).add(vb).intoArray(c, i);
}
// 剩余的元素用普通(标量)方式处理
for (; i < a.length; i++) {
c[i] = a[i] * a[i] + b[i];
}
}
public static void main(String[] args) {
float[] a = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
float[] b = {10, 10, 10, 10, 10, 10, 10, 10, 10, 10};
float[] c = new float[a.length];
vectorized(a, b, c);
System.out.println("lanes per operation = " + SPECIES.length());
System.out.println(java.util.Arrays.toString(c));
}
}WARNING: Using incubator modules: jdk.incubator.vector
lanes per operation = 4
[11.0, 14.0, 19.0, 26.0, 35.0, 46.0, 59.0, 74.0, 91.0, 110.0]在本环境(Apple Silicon)中,每次计算 4 个 float。lane 的数量取决于 CPU 支持的 SIMD 指令(AVX-512 是 16 个)。
后续进展(2026 年 10 月补记):由于要等 Valhalla 项目(值对象)完成,JDK 27 仍是第 12 次孵化。
Key Encapsulation Mechanism API(JEP 452)
利用公钥密码,与通信对方 安全地共享同一个密钥(共享密钥) 的标准 API。它也是把抗量子密码(如 ML-KEM)引入 Java 的基础。
sequenceDiagram
participant R as 接收方
participant S as 发送方
R->>R: 生成密钥对(公钥、私钥)
R->>S: 发送公钥
S->>S: encapsulate():生成共享密钥和封装数据
S->>R: 只发送封装数据
R->>R: decapsulate(封装数据):用私钥取出同一个共享密钥
Note over R,S: 之后用共享密钥(如 AES)加密通信
// JEP 452: Key Encapsulation Mechanism API
// Run: java KemDemo.java
import javax.crypto.KEM;
import javax.crypto.SecretKey;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.HexFormat;
public class KemDemo {
public static void main(String[] args) throws Exception {
// 接收方:生成密钥对,把公钥交给发送方
KeyPair receiverKeys = KeyPairGenerator.getInstance("X25519").generateKeyPair();
// 发送方:用公钥生成「共享密钥」和「封装数据(加密后的密钥信息)」
KEM kem = KEM.getInstance("DHKEM");
KEM.Encapsulated enc = kem.newEncapsulator(receiverKeys.getPublic()).encapsulate();
SecretKey senderKey = enc.key();
// 发给接收方的只有封装数据
byte[] capsule = enc.encapsulation();
// 接收方:用私钥打开封装数据,取出同一个共享密钥
SecretKey receiverKey = kem.newDecapsulator(receiverKeys.getPrivate()).decapsulate(capsule);
HexFormat hex = HexFormat.of();
System.out.println("encapsulation (" + capsule.length + " bytes): " + hex.formatHex(capsule).substring(0, 32) + "...");
System.out.println("sender's shared key : " + hex.formatHex(senderKey.getEncoded()));
System.out.println("receiver's shared key: " + hex.formatHex(receiverKey.getEncoded()));
System.out.println("equal: " + java.util.Arrays.equals(senderKey.getEncoded(), receiverKey.getEncoded()));
}
}encapsulation (32 bytes): a114f4e68a0dd6fa1b062c9d8ae291eb...
sender's shared key : 52a6bb4ad3e477af9c59f5f37b7fdee934fe5f26dec2abc0da8749c347854389
receiver's shared key: 52a6bb4ad3e477af9c59f5f37b7fdee934fe5f26dec2abc0da8749c347854389
equal: true可以看到,发送方和接收方没有在网络上传送共享密钥本身,却拿到了同一个密钥。
弃用 Windows 32 位 x86 移植版(JEP 449)
面向 32 位 Windows 的构建被标记为弃用,计划在将来删除。Java 21 中还可以构建,但会给出警告。
后续进展(2026 年 10 月补记):JDK 24 中被删除(JEP 479),JDK 25 进一步删除了整个 32 位 x86 移植版(JEP 503)。
为禁止动态加载代理做准备(JEP 451)
在运行中的 JVM 上动态挂载代理(agent,改写字节码的工具)时,会输出警告。将来默认会禁止这种做法。如果 Mockito 等库或性能分析工具触发了这个警告,可以在启动时用 -javaagent 指定代理,或用 -XX:+EnableDynamicAgentLoading 显式允许。
总结:Java 21 项目该用哪些特性
最后,从「用在现有项目上」的角度整理一下。
| 类别 | 特性 | 判断 |
|---|---|---|
| 可以直接用 | Record Patterns、switch 模式匹配、Sequenced Collections | 正式特性,在新写的代码中积极使用 |
| 收益大、需验证 | 虚拟线程 | 以 I/O 为主的服务端处理收益很大。JDK 21〜23 要注意固定问题,先做压力测试再引入 |
| 运维上有效 | Generational ZGC | 对延迟要求严格的话,值得验证 |
| 先观望 | Scoped Values、Structured Concurrency、Unnamed 等 | 在 Java 21 中是预览特性。先学习其思路,正式发布后再用于生产 |
| 不要用 | String Templates | 已被撤回 |
特别是虚拟线程,在 Spring Boot(3.2 及以上)中只需设置 spring.threads.virtual.enabled=true 就能启用。最大的魅力是现有的同步代码 几乎原封不动 就能获得扩展性。但它只对 I/O 等待占主导的处理有效,同时也带来了固定、并发数控制等新的注意点。不要「打开开关就完事」,而是要一边实测一边引入。
参考资料
- JDK 21 项目页面
- JEP 444: Virtual Threads
- JEP 446: Scoped Values (Preview) / JEP 506: Scoped Values
- JEP 453: Structured Concurrency (Preview) / JEP 533: Structured Concurrency (Seventh Preview)
- JEP 431: Sequenced Collections
- JEP 440: Record Patterns / JEP 441: Pattern Matching for switch
- JEP 439: Generational ZGC
- JEP 442: Foreign Function & Memory API (Third Preview)
- JEP 452: Key Encapsulation Mechanism API
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 512: Compact Source Files and Instance Main Methods
- JDK 23 发布说明(String Templates 的撤回)