はじめに
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 月追記) |
|---|---|---|---|
| 430 | String Templates | プレビュー | JDK 23 で取り下げ |
| 431 | Sequenced Collections | 正式 | ― |
| 439 | Generational ZGC | 正式 | JDK 23 で世代別がデフォルト、JDK 24 で非世代別モードを削除 |
| 440 | Record Patterns | 正式 | ― |
| 441 | Pattern Matching for switch | 正式 | プリミティブ型パターンがプレビュー中 |
| 442 | Foreign Function & Memory API | 3rd プレビュー | JDK 22 で正式化 |
| 443 | Unnamed Patterns and Variables | プレビュー | JDK 22 で正式化 |
| 444 | Virtual Threads | 正式 | JDK 24 で synchronized によるピン留めを解消 |
| 445 | Unnamed Classes and Instance Main Methods | プレビュー | 名称を変えて JDK 25 で正式化 |
| 446 | Scoped Values | プレビュー | JDK 25 で正式化 |
| 448 | Vector API | 6th インキュベーター | JDK 27 でも 12th インキュベーター |
| 449 | Windows 32-bit x86 ポートの非推奨化 | 非推奨 | JDK 24 で削除 |
| 451 | エージェントの動的ロード禁止の準備 | 警告を出力 | ― |
| 452 | Key Encapsulation Mechanism API | 正式 | ― |
| 453 | Structured 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") = unknowncase 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 以前、コレクションの先頭・末尾を取り出す方法は型ごとにバラバラだった。
| 型 | 先頭 | 末尾 | 逆順 |
|---|---|---|---|
List | list.get(0) | list.get(list.size() - 1) | Collections.reverse(list)(元のリストを書き換える) |
Deque | getFirst() | getLast() | descendingIterator() |
LinkedHashSet | iterator().next() | ループで最後まで回す | 一度 List にコピーして反転 |
LinkedHashMap | entrySet().iterator().next() | ループで最後まで回す | コピーして反転 |
「順序を持つ」という共通の性質があるのに、それを表す型がなかった。Java 21 では、この性質を表す 3 つのインターフェースが 既存の型階層の間に 追加された(緑色)。
flowchart TB
Collection --> Set
Collection --> SC["SequencedCollection"]
Collection --> Queue
Set --> SS["SequencedSet"]
SC --> SS
SC --> List
SC --> Deque
Queue --> Deque
SS --> SortedSet
Set --> SortedSet
SortedSet --> NavigableSet
SS --> LHS["LinkedHashSet"]
Map --> SM["SequencedMap"]
SM --> SortedMap
SortedMap --> NavigableMap
SM --> LHM["LinkedHashMap"]
classDef added fill:#c8f7c5,stroke:#2e7d32,color:#1b5e20
class SC,SS,SM added
追加されたメソッドは次のとおりである。ほとんどが default メソッドなので、既存の実装クラスとの互換性は保たれる。
interface SequencedCollection<E> extends Collection<E> {
SequencedCollection<E> reversed();
default void addFirst(E e);
default void addLast(E e);
default E getFirst();
default E getLast();
default E removeFirst();
default E removeLast();
}
interface SequencedSet<E> extends SequencedCollection<E>, Set<E> {
SequencedSet<E> reversed();
}
interface SequencedMap<K, V> extends Map<K, V> {
SequencedMap<K, V> reversed();
default Map.Entry<K, V> firstEntry();
default Map.Entry<K, V> lastEntry();
default Map.Entry<K, V> pollFirstEntry();
default Map.Entry<K, V> pollLastEntry();
default V putFirst(K k, V v);
default V putLast(K k, V v);
default SequencedSet<K> sequencedKeySet();
default SequencedCollection<V> sequencedValues();
default SequencedSet<Map.Entry<K, V>> sequencedEntrySet();
}従来の書き方との比較
// JEP 431: Sequenced Collections
// Run: java SequencedCollectionsDemo.java
import java.util.*;
public class SequencedCollectionsDemo {
public static void main(String[] args) {
list();
deque();
set();
map();
reversedIsView();
}
static void list() {
System.out.println("=== List ===");
List<Integer> list = new ArrayList<>(List.of(0, 1, 2));
// 従来
System.out.println("get(0) / get(size()-1) : " + list.get(0) + " / " + list.get(list.size() - 1));
// Java 21
System.out.println("getFirst() / getLast() : " + list.getFirst() + " / " + list.getLast());
System.out.println("reversed() : " + list.reversed() + " original: " + list);
// 従来の Collections.reverse は元のリストを書き換える
List<Integer> copy = new ArrayList<>(list);
Collections.reverse(copy);
System.out.println("Collections.reverse : " + copy + " (the copy itself was modified)");
}
static void deque() {
System.out.println("=== Deque ===");
Deque<Integer> deque = new ArrayDeque<>(List.of(0, 1, 2));
// 従来:逆順は descendingIterator で取り出す
List<Integer> rev = new ArrayList<>();
deque.descendingIterator().forEachRemaining(rev::add);
System.out.println("descendingIterator : " + rev);
// Java 21
System.out.println("reversed() : " + deque.reversed() + " original: " + deque);
}
static void set() {
System.out.println("=== LinkedHashSet ===");
LinkedHashSet<Integer> set = new LinkedHashSet<>(List.of(0, 1, 2));
// 従来:最後の要素はループで最後まで回すしかない
Integer last = null;
for (Integer x : set) last = x;
System.out.println("iterator().next() / loop : " + set.iterator().next() + " / " + last);
// Java 21
System.out.println("getFirst() / getLast() : " + set.getFirst() + " / " + set.getLast());
System.out.println("reversed() : " + set.reversed());
// 先頭に追加(すでにあれば先頭へ移動)
set.addFirst(9);
System.out.println("addFirst(9) : " + set);
}
static void map() {
System.out.println("=== LinkedHashMap ===");
LinkedHashMap<Integer, String> map = new LinkedHashMap<>();
map.putLast(0, "zero");
map.putLast(1, "one");
map.putLast(2, "two");
System.out.println("map : " + map);
System.out.println("firstEntry / lastEntry : " + map.firstEntry() + " / " + map.lastEntry());
System.out.println("reversed() : " + map.reversed());
// 既存のキーは先頭へ移動
map.putFirst(1, "ONE");
System.out.println("putFirst(1, \"ONE\") : " + map);
System.out.println("pollLastEntry() : " + map.pollLastEntry() + " remaining: " + map);
}
static void reversedIsView() {
System.out.println("=== reversed() is a view ===");
Deque<Integer> deque = new ArrayDeque<>(List.of(0, 1, 2));
SequencedCollection<Integer> view = deque.reversed();
// 逆順ビューの先頭 = 元の末尾
view.addFirst(9);
System.out.println("after view.addFirst(9) : view=" + view + " original=" + deque);
}
}=== List ===
get(0) / get(size()-1) : 0 / 2
getFirst() / getLast() : 0 / 2
reversed() : [2, 1, 0] original: [0, 1, 2]
Collections.reverse : [2, 1, 0] (the copy itself was modified)
=== Deque ===
descendingIterator : [2, 1, 0]
reversed() : [2, 1, 0] original: [0, 1, 2]
=== LinkedHashSet ===
iterator().next() / loop : 0 / 2
getFirst() / getLast() : 0 / 2
reversed() : [2, 1, 0]
addFirst(9) : [9, 0, 1, 2]
=== LinkedHashMap ===
map : {0=zero, 1=one, 2=two}
firstEntry / lastEntry : 0=zero / 2=two
reversed() : {2=two, 1=one, 0=zero}
putFirst(1, "ONE") : {1=ONE, 0=zero, 2=two}
pollLastEntry() : 2=two remaining: {1=ONE, 0=zero}
=== reversed() is a view ===
after view.addFirst(9) : view=[9, 2, 1, 0] original=[0, 1, 2, 9]LinkedHashSet の addFirst や LinkedHashMap の putFirst は、すでに存在する要素なら先頭へ移動する。挿入順を保持するコレクションならではの動きである。
注意:reversed() はコピーではなく「ビュー」
reversed() は新しいコレクションを作るのではなく、元のコレクションを逆向きに見せるビュー を返す。データの実体は 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
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 end1 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 ms1 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 Pool | Virtual Threads |
|---|---|---|---|
| スレッド数の上限 | OS スレッド数・メモリで制限 | プールサイズで固定 | 実質メモリのみ |
| 生成コスト | 高い | プールで再利用して低減 | 非常に小さい |
| ブロッキング I/O | OS スレッドごと止まる | プールが枯渇しうる | キャリアを解放して待つ |
| 向いている処理 | 少数の長時間処理・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("a")"] --> T2["set("b")<br/>どこからでも上書き"] --> T3["remove() を忘れると<br/>次の処理に残る"]
end
subgraph SV["ScopedValue"]
direction LR
S1["where(SV, "a").run(..."] --> S2["中でだけ a が見える"] --> S3["...) を抜けると<br/>自動で消える"]
end
TL ~~~ SV
style T2 fill:#ffcdd2,stroke:#c62828,color:#7f1d1d
style T3 fill:#ffcdd2,stroke:#c62828,color:#7f1d1d
style S3 fill:#c8e6c9,stroke:#2e7d32,color:#1b5e20
使い方
ScopedValue は、値を束縛できる範囲(スコープ)をコードの構造で決める 仕組みである。set() は存在せず、値を変えたいときは 入れ子のスコープ を作る。スコープを抜ければ元の値に戻るので、値の有効範囲がコードのインデントそのものになる。
flowchart LR
subgraph S1["where(SV, "a").run(...)"]
A1["SV.get() → a"]
subgraph S2["where(SV, "b").run(...)"]
B1["SV.get() → b"]
end
A2["SV.get() → a(元に戻る)"]
A1 --> S2 --> A2
end
OUT["スコープの外:isBound() = false"]
S1 --> OUT
ThreadLocal と同じ流れで値を変えて、挙動を比べてみる。
// JEP 446: Scoped Values (preview in Java 21)
// Run: java --enable-preview --source 21 ScopedValueDemo.java
import java.util.concurrent.StructuredTaskScope;
public class ScopedValueDemo {
static final ThreadLocal<String> TL = new ThreadLocal<>();
static final ScopedValue<String> SV = ScopedValue.newInstance();
public static void main(String[] args) throws Exception {
System.out.println("=== 1. ThreadLocal: a value you set stays from then on ===");
TL.set("a");
threadLocalFlow();
TL.remove();
System.out.println("=== 2. ScopedValue: the value exists only inside the scope ===");
ScopedValue.where(SV, "a").run(ScopedValueDemo::scopedValueFlow);
System.out.println(" outside the scope: isBound = " + SV.isBound());
System.out.println("=== 3. The binding is immutable, but the object's contents are not ===");
mutableObject();
System.out.println("=== 4. Inherited automatically by StructuredTaskScope subtasks ===");
inheritance();
}
static void threadLocalFlow() {
System.out.println(" " + TL.get()); // a
// ここで書き換えると……
TL.set("b");
System.out.println(" " + TL.get()); // b
// 呼び出し先も b のまま
print();
}
static void print() {
System.out.println(" " + TL.get());
}
static void scopedValueFlow() {
System.out.println(" " + SV.get()); // a
// 内側のスコープでだけ b
ScopedValue.where(SV, "b").run(() -> System.out.println(" " + SV.get()));
// 抜けると a に戻る
System.out.println(" " + SV.get());
}
// わざと可変にしたクラス
static class Context {
String name;
Context(String name) { this.name = name; }
public String toString() { return "Context{name=" + name + "}"; }
}
static final ScopedValue<Context> CTX = ScopedValue.newInstance();
static void mutableObject() {
ScopedValue.where(CTX, new Context("a")).run(() -> {
System.out.println(" before: " + CTX.get());
// 中身を書き換える
CTX.get().name = "b";
System.out.println(" after : " + CTX.get() + " <- visible to everyone in the same scope");
});
}
static void inheritance() throws Exception {
TL.set("REQ-123");
ScopedValue.where(SV, "REQ-123").run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var a = scope.fork(() -> describe("fork-A"));
var b = scope.fork(() -> describe("fork-B"));
scope.join().throwIfFailed();
System.out.println(" " + a.get());
System.out.println(" " + b.get());
} catch (Exception e) {
throw new RuntimeException(e);
}
});
TL.remove();
}
static String describe(String name) {
return name + ": ScopedValue=" + SV.get() + ", ThreadLocal=" + TL.get();
}
}=== 1. ThreadLocal: a value you set stays from then on ===
a
b
b
=== 2. ScopedValue: the value exists only inside the scope ===
a
b
a
outside the scope: isBound = false
=== 3. The binding is immutable, but the object's contents are not ===
before: Context{name=a}
after : Context{name=b} <- visible to everyone in the same scope
=== 4. Inherited automatically by StructuredTaskScope subtasks ===
fork-A: ScopedValue=REQ-123, ThreadLocal=null
fork-B: ScopedValue=REQ-123, ThreadLocal=null1 と 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 + Future | StructuredTaskScope |
|---|---|---|
| 書き方 | 命令的 | 命令的(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 16Arena を 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 待ちが支配的な処理に限られ、ピン留めや同時実行数の制御といった新しい注意点もある。「設定をオンにして終わり」にせず、実測で確かめながら導入するのがよい。
参考資料
- JDK 21 プロジェクトページ
- JEP 444: Virtual Threads
- JEP 446: Scoped Values (Preview) / JEP 506: Scoped Values
- JEP 453: Structured Concurrency (Preview) / JEP 533: Structured Concurrency (Seventh Preview)
- JEP 431: Sequenced Collections
- JEP 440: Record Patterns / JEP 441: Pattern Matching for switch
- JEP 439: Generational ZGC
- JEP 442: Foreign Function & Memory API (Third Preview)
- JEP 452: Key Encapsulation Mechanism API
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 512: Compact Source Files and Instance Main Methods
- JDK 23 リリースノート(String Templates の取り下げ)