Java 21 新特性总结

发布于 2025-12-01 21:00 14105 字 71 min read

issyuu avatar

issyuu

静水深流,湖心小筑(Lakeheart Retreat),在静谧水畔凝结思考,谱写日常的歌。

用可以直接运行的示例代码和运行结果,讲解 Java 21(LTS)的 15 个 JEP。虚拟线程、Scoped Values、结构化并发用实测数据深入分析,并整理了各特性到 JDK 27 为止的后续进展。

前言

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 月补记)
430String Templates预览JDK 23 撤回
431Sequenced Collections正式―
439Generational ZGC正式JDK 23 默认使用分代模式,JDK 24 删除非分代模式
440Record Patterns正式―
441Pattern Matching for switch正式基本类型模式仍在预览
442Foreign Function & Memory API第 3 次预览JDK 22 正式发布
443Unnamed Patterns and Variables预览JDK 22 正式发布
444Virtual Threads正式JDK 24 解决了 synchronized 导致的固定(pinning)
445Unnamed Classes and Instance Main Methods预览改名后于 JDK 25 正式发布
446Scoped Values预览JDK 25 正式发布
448Vector API第 6 次孵化JDK 27 仍是第 12 次孵化
449弃用 Windows 32 位 x86 移植版弃用JDK 24 删除
451为禁止动态加载代理做准备输出警告―
452Key Encapsulation Mechanism API正式―
453Structured 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 之前,取集合首尾元素的方法因类型而异,各不相同。

类型第一个最后一个倒序
Listlist.get(0)list.get(list.size() - 1)Collections.reverse(list)(会修改原列表)
DequegetFirst()getLast()descendingIterator()
LinkedHashSetiterator().next()循环到最后先复制到 List 再反转
LinkedHashMapentrySet().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

调度。虚拟线程可以在堆上创建几十万到几百万个,而载体线程的数量默认与 CPU 核数相同。

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 end

5 个各等待 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 ms

10,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 PoolVirtual 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 ms

1 个载体线程、持锁等待 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(&quot;a&quot;)"] --> T2["set(&quot;b&quot;)<br/>任何地方都能覆盖"] --> T3["忘了 remove()<br/>就会残留到下一个处理"]
    end
    subgraph SV["ScopedValue"]
        direction LR
        S1["where(SV, &quot;a&quot;).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, &quot;a&quot;).run(...)"]
        A1["SV.get() → a"]
        subgraph S2["where(SV, &quot;b&quot;).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 + FutureStructuredTaskScope
写法命令式命令式(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 等待占主导的处理有效,同时也带来了固定、并发数控制等新的注意点。不要「打开开关就完事」,而是要一边实测一边引入。

参考资料

© 2020 - 2026 issyuu @Lakeheart Retreat
Powered by theme astro-koharu · Inspired by Shoka
加载中...