Java 21 新機能まとめ

公開日: 2025-12-01 21:00 14985文字 75 min read

issyuu avatar

issyuu

静かな湖面、揺れる湖心小築(Lakeheart Retreat)、静かな水辺で紡ぐ思考と日常の歌。

Java 21(LTS)の 15 個の JEP を、そのまま実行できるサンプルコードと実行結果で解説する。仮想スレッド・Scoped Values・構造化並行性は実測データで掘り下げ、各機能の JDK 27 までの「その後」も整理した。

はじめに

Java 21 は 2023 年 9 月にリリースされた LTS(長期サポート)版である。仮想スレッドの正式化をはじめ、レコードパターンや switch のパターンマッチングなど、Java の書き方そのものを変える機能がまとめて入った。

本記事では、Java 21 の 15 個の JEP を 実際に動かしながら 見ていく。サンプルコードはすべて 1 ファイルで完結しており、外部ライブラリも不要である。コピーしてそのまま java コマンドで実行できる。

一方、Java 21 でプレビューだった機能の中には、その後に仕様が変わったもの、取り下げられたものもある。そこで各機能の最後に 「その後」 として、JDK 27(2026 年 9 月 GA)までの動きを補足した。「その後」の内容は、2026 年 10 月に追記したものである。

検証環境とサンプルの実行方法

  • JDK:OpenJDK 21(21.0.12)
  • OS / CPU:macOS(Apple Silicon、8 コア)
  • 実行はソースファイルを直接起動する方式(JEP 330)。コンパイルは不要である。
java RecordPatternsDemo.java                              # 正式機能
java --enable-preview --source 21 ScopedValueDemo.java    # プレビュー機能
  • サンプルコードは GitHub の smile-lxy/java-new-features にもまとめてある。コメントは日本語・中国語・英語の 3 言語で書いてあり、本記事には日本語のコメントだけを載せた。
  • 各サンプルの先頭コメント(// 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 API3rd プレビューJDK 22 で正式化
443Unnamed Patterns and VariablesプレビューJDK 22 で正式化
444Virtual Threads正式JDK 24 で synchronized によるピン留めを解消
445Unnamed Classes and Instance Main Methodsプレビュー名称を変えて JDK 25 で正式化
446Scoped ValuesプレビューJDK 25 で正式化
448Vector API6th インキュベーターJDK 27 でも 12th インキュベーター
449Windows 32-bit x86 ポートの非推奨化非推奨JDK 24 で削除
451エージェントの動的ロード禁止の準備警告を出力―
452Key Encapsulation Mechanism API正式―
453Structured ConcurrencyプレビューAPI を刷新し、JDK 27 でも 7th プレビュー

これらは大きく 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 で形ごとに処理を分ける。この 3 つが 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;
    }

    // ネストしたレコードを一度に分解する
    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 + レコードパターン + 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 に到達できない(支配される)として、コンパイルエラーになる。

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"]

もう 1 つの改善点は 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 で 5th プレビュー)。

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. 使わないラムダ引数
        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'

SQL の組み立てに STR を使ってはいけない。 出力の最後の行のとおり、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!

入門者向けの機能に見えるが、ちょっとしたスクリプトや検証コードを 1 ファイルで書いてそのまま実行できるのは、実務でも便利だ。

その後(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() は新しいコレクションを作るのではなく、元のコレクションを逆向きに見せるビュー を返す。データの実体は 1 つなので、ビューへの変更は元のコレクションにも反映される。

flowchart LR
    subgraph DATA["データの実体(1 つだけ)"]
        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 アプリケーションの多くは「1 リクエスト = 1 スレッド」(thread-per-request)で書かれている。このモデルは読みやすく、デバッグもしやすい。ただし、Java のスレッドは OS スレッドと 1 対 1 なので、同時に扱えるリクエスト数が OS スレッドの数で頭打ちになる。

しかも、典型的なリクエスト処理は、DB や外部 API の応答を 待っている時間 がほとんどだ。

1 リクエストの処理時間の内訳(イメージ、単位:ms)

%%{init: {"gantt": {"leftPadding": 110}}}%%
gantt
    %% gantt:request
    dateFormat x
    axisFormat %L
    section リクエスト
    パラメータ処理 :active, 0, 5
    DB(待ち) :crit, 5, 60
    外部 API(待ち) :crit, 60, 140
    レスポンス生成 :active, 140, 150

CPU を使っているのは「パラメータ処理」と「レスポンス生成」だけで、「待ち」と書いた区間では、スレッドは何もせずに待っている。それでもスレッドは、1 本あたり MB 単位のスタックを確保したまま、OS スレッドを占有し続ける。

この問題に対する従来の答えは、CompletableFuture や Reactor などのリアクティブプログラミングだった。ただし、コードが複雑になり、スタックトレースも追いにくくなる。仮想スレッドは、同期的で素直なコードのまま この問題を解決する。

仕組み:M
スケジューリング

仮想スレッドは JVM が管理する軽量なスレッドで、実体は ヒープ上のオブジェクト である。実際に CPU で動くときだけ、キャリアスレッド(デフォルトでは ForkJoinPool のプラットフォームスレッド)に載せられる。多数(M 本)の仮想スレッドを、少数(N 本)のキャリアスレッドで動かすので、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[OS スレッド]
    C2 --- O2[OS スレッド]
    classDef vt fill:#e53935,color:#fff,stroke:#b71c1c

ポイントは、仮想スレッドがブロッキング I/O(Thread.sleep、ソケットの読み書き、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 で書けば、ブロックを抜けるときに全タスクの完了を待ってくれる。

実測① スケジューリングを観察する

仮想スレッド 4 本を、キャリアスレッド 2 本で動かしてみる。キャリアスレッドの数は、システムプロパティ 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 に載っている。仮想スレッドは特定の OS スレッドに縛られない。
  • 全体の所要時間は約 9.6 s:4 本の計算時間の合計は 19.1 s で、キャリア 2 本で分担すれば理論値は約 9.55 s になる。ほぼ理論どおりの時間で終わっている。

最後の点は、裏を返せば CPU を使う処理は、仮想スレッドにしても速くならない ということだ。仮想スレッドが効くのは、待ち時間(I/O)が支配的な処理である。

実測② スレッドプールとの違い

同じ「1 s 待つタスク 5 個」を、スレッドプールと仮想スレッドで実行してみる。

// 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

1 s 待つタスク 5 個(実測、単位: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 本でも同時に待てる。

実測③ スループット

では、タスクの数を増やすとどうなるか。1 s ブロックするタスクを 10,000 個 実行して比べる。

// 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

1 s ブロックするタスク 10,000 個の所要時間(ms)

xychart-beta
    x-axis ["固定プール(200 スレッド)", "仮想スレッド"]
    y-axis "ms" 0 --> 55000
    bar [50199, 1069]

固定プールは同時に 200 個しか処理できないので、10,000 ÷ 200 = 50 回分の待ち時間がかかる。仮想スレッドは 10,000 個すべてが同時に待てるので、約 1 s で終わった。約 47 倍の差である。

実測④ メモリ:1 本あたりのコスト

プラットフォームスレッド 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 本
1 本あたり(OS の確保仮想メモリ)約 2.05 MB約 90〜120 B
1 本あたり(Java ヒープ)約 370 B約 1.0〜1.7 KB

表の値は 7 回実行した結果の範囲である。仮想スレッドのヒープ使用量は実行のたびにばらつく(1.0〜1.7 KB)。これは、計測値に Thread オブジェクトや待機中のスタック(スタックチャンク)だけでなく、GC がまだ回収していないオブジェクトも含まれるためだ。プラットフォームスレッドの値はほぼ一定だった。

プラットフォームスレッドは、OS スレッドごとにスタック領域(macOS / Linux の既定で 1〜2 MB 程度)を確保する。仮想スレッドは、スタックを必要な分だけヒープ上に持つので、桁違いに小さい。

「確保仮想メモリ(committed virtual)」は OS に予約したアドレス空間の量で、実際に物理メモリを消費した量(RSS)とは一致しない。あくまで「OS に対してどれだけ要求しているか」の目安として見てほしい。

ただし、小さいとはいえ 無限ではない。ヒープを 128 MB に絞って、10 s sleep する仮想スレッドを際限なく作り続けてみる。

// 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
スレッド数の上限OS スレッド数・メモリで制限プールサイズで固定実質メモリのみ
生成コスト高いプールで再利用して低減非常に小さい
ブロッキング I/OOS スレッドごと止まるプールが枯渇しうるキャリアを解放して待つ
向いている処理少数の長時間処理・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 ブロックの中でブロッキング処理をすると、仮想スレッドがキャリアから降りられない。これを「ピン留め」と呼ぶ。キャリアを 1 本にして、ロックを持ったまま 1 s sleep するタスクを 2 つ実行してみる。

// 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) {
        }
    }

    // ロックを持ったまま 1 s ブロックするタスクを 2 つ実行する(ロックはタスクごとに別物なので競合はない)
    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 の合計時間を 2 等分して描いている。)

ロックはタスクごとに別物なので、本来ロックの競合はない。それでも synchronized 版は、1 本しかないキャリアがピン留めされるため、2 つのタスクが 直列に 実行されて 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") した値がその後ずっと残り、呼び出し先にも伝わっている。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 で並行処理を書くと、この構造が崩れる。サブタスクは呼び出し元とは無関係に動き続け、片方が失敗しても、もう片方は誰にも止められないまま 走り続ける。構造化並行性は、複数のサブタスクを 1 つの処理単位(スコープ) にまとめ、関数呼び出しと同じ構造を並行処理にも持ち込む。

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 の間 動き続けた。実際のシステムなら、その間、DB コネクションなどのリソースを握り続けることになる。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 でも 7th プレビュー(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 回あたり 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・3rd プレビュー)

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

Arena を try-with-resources で使うと、スコープを抜けた時点でヒープ外メモリが確実に解放される。解放後のメモリにアクセスすると例外になるので、C のようなダングリングポインタは起きない。

その後(2026 年 10 月追記):JDK 22 で正式化された(JEP 454)。正式版では一部のメソッド名が変わっており、allocateUtf8String は allocateFrom、allocateArray は allocate になった。ネイティブ呼び出しは「制限付きメソッド」なので、警告を出さないには --enable-native-access を付ける。

Vector API(JEP 448・6th インキュベーター)

CPU の SIMD 命令を使ったベクトル演算を、Java から明示的に書くための API である。1 命令で複数の要素(レーン)をまとめて計算する。

// 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;

    // c[i] = a[i] * a[i] + b[i] を SIMD でまとめて計算する
    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)では、float を 4 つずつまとめて計算している。レーン数は CPU が対応する SIMD 命令によって変わる(AVX-512 なら 16)。

その後(2026 年 10 月追記):Valhalla プロジェクト(値オブジェクト)の完成を待っているため、JDK 27 でも 12th インキュベーター のままである。

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-bit x86 ポートの非推奨化(JEP 449)

32-bit Windows 向けのビルドが、将来削除する前提で非推奨になった。Java 21 の時点ではまだビルドできるが、警告が出る。

その後(2026 年 10 月追記):JDK 24 で削除され(JEP 479)、JDK 25 では 32-bit x86 ポート自体が削除された(JEP 503)。

エージェントの動的ロード禁止の準備(JEP 451)

実行中の JVM に、後からエージェント(バイトコードを書き換えるツール)をアタッチすると、警告が出る ようになった。将来はデフォルトで禁止される予定である。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
読み込み中...