Java 仮想スレッドを JDK 26 で実測:限界・ピン留め・競合
sleep するタスク 10 万個を仮想スレッドとプラットフォームスレッドで実測。macOS で落ちる地点、CPU 処理で効かない理由、JEP 491 後のピン留め、競合状態と構造化並行性まで。
シリーズ:Java 講座 5/6(上級)。 ExecutorService は使えるが、「仮想スレッドにすれば何でもスケールする」という話を確かめたことがない方向けです。読み終えると、仮想スレッドが効く場面と効かない場面を数字で説明でき、最初に必ず出会うコンパイルエラー 2 つと「静かに消える例外」を避け、競合状態を 3 通りに直せるようになります。
外部 API や DB の応答待ちが処理時間の大半を占める業務システムでは、仮想スレッドはこの 20 年で最大の変化です。一方、計算ばかりしているバッチには何も効きません。この記事では両方を実測します。また、「synchronized は仮想スレッドをピン留めするので ReentrantLock に置き換えよ」という JDK 21 時代の助言が JDK 26 でまだ正しいのかも確かめました。結論から言うと、その助言はほぼ古くなっています。
実行環境: OpenJDK 26(Temurin 26+35)、Apple Silicon Mac(M1 Pro、8 コア、メモリ 16GB)、macOS。メモリは /usr/bin/time -l の最大常駐セットサイズ(RSS)。特記がなければ 3 回の中央値です。時間には JVM の起動と JIT のウォームアップも含みますが、ここで見る差は 10 倍以上なので結論は変わりません。
sleep するタスク 1 万個・10 万個:プラットフォームスレッドが止まる地点
定番の実験です。1 秒 sleep するタスクを N 個起動し、全部終わるのを待ちます。sleep は遅いネットワーク呼び出しの代わりです。1 つのファイルで 3 種類の実行方式を切り替えます。
import java.time.Duration;
import java.util.concurrent.*;
/** Start N tasks that each sleep 1 s. Usage: java SleepTasks.java <virtual|platform|pool200> <N> */
public class SleepTasks {
public static void main(String[] args) throws Exception {
String mode = args[0];
int n = Integer.parseInt(args[1]);
ExecutorService ex = switch (mode) {
case "virtual" -> Executors.newVirtualThreadPerTaskExecutor();
case "platform" -> Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory());
case "pool200" -> Executors.newFixedThreadPool(200);
default -> throw new IllegalArgumentException(mode);
};
long t0 = System.nanoTime();
try (ex) { // close() waits for all tasks
for (int i = 0; i < n; i++) {
ex.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return null; });
}
}
long ms = (System.nanoTime() - t0) / 1_000_000;
Runtime rt = Runtime.getRuntime();
long usedMb = (rt.totalMemory() - rt.freeMemory()) >> 20;
System.out.printf("%-8s n=%,d wall=%,d ms heapUsed=%d MB%n", mode, n, ms, usedMb);
}
}
| 実行方式 | タスク数 | 実時間 | 最大 RSS |
|---|---|---|---|
| タスクごとにプラットフォームスレッド | 1,000 | 1,078ms | 124MB |
| タスクごとにプラットフォームスレッド | 4,000 | 1,304ms | 261MB |
| タスクごとにプラットフォームスレッド | 10,000 | 異常終了 | – |
| タスクごとに仮想スレッド | 1,000 | 1,024ms | 57MB |
| タスクごとに仮想スレッド | 4,000 | 1,040ms | 71MB |
| タスクごとに仮想スレッド | 10,000 | 1,068ms | 90MB |
| タスクごとに仮想スレッド | 100,000 | 1,791ms | 210MB |
| 200 スレッドの固定プール | 10,000 | 50,375ms(1 回) | 73MB |
仮想スレッド 10 万個は、プラットフォームスレッド 4,000 個より少ないメモリで動きました。200 スレッドの固定プールはメモリを抑える昔ながらの方法ですが、代わりに時間を払います。1 秒のタスク 1 万個を 200 個ずつ処理すると 50 巡で、50,375ms はちょうどその計算どおりです。10 万個でのプール実行は行っていません(同じ計算なら約 500 秒)。
そして、プラットフォームスレッドは 1 万個で遅くなったのではなく、落ちました。
[0.453s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.
[0.453s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-4067"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:1486)
...
at SleepTasks.main(SleepTasks.java:18)
OutOfMemoryError と出ていますが、ヒープには余裕がありました。手がかりは「Thread-4067」です。この Mac で sysctl kern.num_taskthreads を実行すると 4096 と表示されます。macOS のプロセスあたりのスレッド上限で、GC や JIT など JVM 自身のスレッドを足すと、ユーザースレッド 4,000 強で上限に当たります。Linux サーバーでは ulimit -u やコンテナの設定で上限が決まるので数字は変わりますが、メッセージは同じです。このエラーを見たら -Xmx を増やす前にスレッド数を疑ってください。
仮想スレッドはなぜコアを止めずにブロックできるのか
仮想スレッドは OS ではなく JDK がスケジュールする Java オブジェクトです。JDK はそれを、既定ではコア数と同じ数の「キャリアスレッド」からなる ForkJoinPool の上で動かします。仮想スレッドが JDK 対応済みのブロック処理(sleep、ソケット I/O、ReentrantLock、BlockingQueue、JDK 24 以降はモニタも)に入るとアンマウントされ、スタックフレームはヒープへ移り、キャリアは別の仮想スレッドを動かします。上の表で 10 万タスクのときヒープ使用量が約 122MB まで増えたのに RSS が小さいままだったのは、このためです。
仮想スレッドが効かない CPU バウンドの例
ブロックしないタスクには、アンマウントする機会がありません。xorshift を 2 億回回すタスク 64 個で比べました。
static long work(long seed) { // pure CPU: xorshift loop
long x = seed;
for (int i = 0; i < 200_000_000; i++) { x ^= x << 13; x ^= x >>> 7; x ^= x << 17; }
return x;
}
// run(...) はタスク 64 個を投入して結果を合計する。
// 事前に work(1) を 1 回呼び、JIT コンパイルを済ませておく
| 実行方式(64 タスク) | 1 回目 | 2 回目 | 3 回目 | 中央値 |
|---|---|---|---|---|
| シングルスレッド | 25,362ms | 25,622ms | 25,024ms | 25,362ms |
| 8 スレッドの固定プール | 4,385ms | 4,530ms | 4,332ms | 4,385ms |
| タスクごとに仮想スレッド | 4,741ms | 4,191ms | 4,695ms | 4,695ms |
| タスクごとにプラットフォームスレッド | 3,992ms | 4,161ms | 3,920ms | 3,992ms |
並列化した 3 方式はどれもシングルスレッドの約 5.8 倍で、M1 Pro の 8 コア(高性能 6 + 高効率 2)で出せる上限です。仮想スレッドは速くならず、回ごとのぶれ(4,191〜4,741ms)の方が方式の差より大きいくらいです。計算処理はコア数に合わせた固定プールか並列ストリームに任せましょう。そうすれば、I/O 待ちの仮想スレッドが使うキャリアを計算処理が占有することもありません。
JDK 26 のピン留め:JEP 491 で直ったもの、残ったもの
JDK 21 では、synchronized の中でブロックした仮想スレッドは「ピン留め」され、待っている間ずっとキャリアを占有しました。JDK 24 の JEP 491 でモニタについては解消されています。JDK 26 での動きを、キャリアを 1 本だけ(-Djdk.virtualThreadScheduler.parallelism=1)にして確かめました。ピン留めが起きれば全部が直列になります。
// ケース 1:100 タスクが、それぞれ自分のモニタを持ったまま 100ms 眠る
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
Object lock = new Object();
ex.submit(() -> { synchronized (lock) { sleep(100); } });
}
}
// ケース 2:1 タスクが static 初期化子の中で 1 秒眠り、他の 100 タスクは 10ms 眠るだけ
static class Slow { static { sleep(1000); } static void touch() {} }
$ java -Djdk.virtualThreadScheduler.parallelism=1 -cp out Pinning
synchronized + sleep : 115 ms
bystanders while <clinit> blocks: 960 ms
ケース 1 は 115ms でした。ピン留めされていれば 100 × 100ms = 約 10 秒かかるはずなので、モニタはもうピン留めしません。ケース 2 は今も残るピン留めです。1 つの仮想スレッドがクラス初期化子の中で眠っている間、10ms で済むはずの 100 タスクが 960ms 待たされました。キャリアを既定の 8 本に戻すと同じ 100 タスクは 11ms で終わります。キャリアが埋まるまで表に出ないので、本番で初めて気づくタイプの問題です。
-Djdk.tracePinnedThreads=full で追跡しようとしましたが、JDK 26 ではこのプロパティは何もしません。同じ 2 行が出るだけでスタックトレースは出ず、JDK 26 のクラスライブラリ(lib/modules)を検索しても tracePinnedThreads という文字列は見つかりませんでした。JEP 491 と一緒に削除されています。代わりは JFR イベント jdk.VirtualThreadPinned です。今回の JDK には jfr コマンドがなかったので、jdk.jfr.consumer API を使った 10 行ほどのプログラムで記録を読みました。
$ java -Djdk.virtualThreadScheduler.parallelism=1 -XX:StartFlightRecording=filename=pin.jfr,settings=profile -cp out Pinning
$ java Dump.java pin.jfr
jdk.VirtualThreadPinned {
duration = 1.01 s
blockingOperation = "LockSupport.park"
pinnedReason = "VM call to Pinning$Slow.<clinit> on stack"
carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 33)
eventThread = "" (javaThreadId = 134, virtual)
...
記録されたピン留めはクラス初期化子の 1 件だけで、synchronized の中の sleep 100 回は 1 件も出ませんでした。pinnedReason に原因がそのまま書かれるので、以前のスタックトレース出力より調べやすくなっています。
よくある間違い:コンパイルエラー 2 つと、静かに消える例外
最初に書く仮想スレッドのコードは、たいていこうなります。
int count = 0;
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
ex.submit(() -> { Thread.sleep(10); }); // mistake 1
ex.submit(() -> count++); // mistake 2
}
}
Mistakes.java:8: error: unreported exception InterruptedException; must be caught or declared to be thrown
ex.submit(() -> { Thread.sleep(10); }); // mistake 1
^
Mistakes.java:9: error: local variables referenced from a lambda expression must be final or effectively final
ex.submit(() -> count++); // mistake 2
^
2 errors
間違い 1 → 何が起きるか → 直し方。 値を返さないブロックラムダは submit(Runnable) に一致し、Runnable.run() はチェック例外を投げられません。return null; を足すとラムダは Callable として扱われ、例外を投げられるようになります。上の SleepTasks のラムダが return null で終わっているのはこのためです。
間違い 2 → 何が起きるか → 直し方。 ラムダは変数ではなく値をキャプチャします。コンパイラが止めてくれるのはむしろ親切で、ありがちな回避策(static int フィールドにする)はコンパイルが通るうえに結果が間違います。次の節で実測します。
静かに消える例外。 submit() はタスクの例外を Future にしまい込みます。get() を呼ばなければ、例外はどこにも出ません。
main finished, no stack trace above?
Exception in thread "" java.lang.IllegalStateException: boom2
at Swallow.lambda$main$1(Swallow.java:9)
submit() で投入したタスクの boom は何も表示されず、Thread.ofVirtual().start(...) で起動した方だけが既定のハンドラまで届きました。スレッド名が空("")なのにも注目してください。仮想スレッドは既定で名前がないので、ログで追いたいなら Thread.ofVirtual().name("fetch-", 0) のように名前を付けます。
競合状態を実測し、3 通りに直す
1,000 個の仮想スレッドが、共有カウンタをそれぞれ 1 万回増やします。正解は 10,000,000 です。
static int plain;
static volatile int vol;
static final AtomicInteger atomic = new AtomicInteger();
static final LongAdder adder = new LongAdder();
static final ReentrantLock lock = new ReentrantLock();
static int locked;
run("int ++", () -> plain++, () -> plain);
run("volatile ++", () -> vol++, () -> vol);
run("AtomicInteger", atomic::incrementAndGet, atomic::get);
run("LongAdder", adder::increment, adder::sum);
run("ReentrantLock", () -> { lock.lock(); try { locked++; } finally { lock.unlock(); } }, () -> locked);
int ++ total= 7,627,061 16 ms
volatile ++ total= 1,282,858 342 ms
AtomicInteger total= 10,000,000 667 ms
LongAdder total= 10,000,000 25 ms
ReentrantLock total= 10,000,000 341 ms
3 回の実行で、ただの int は 210 万〜290 万回分の更新を失いました。volatile はもっと悪く、結果は 128 万〜260 万でした。volatile は読み書きを他スレッドに見せるだけで、vol++ が「読む・足す・書く」の 3 手順であることは変わらず、毎回メモリまで書きに行くぶん衝突が増えます。Java Silver の問題集で「volatile はアトミックではない」と覚えた方は、ここで数字として確認できます。
3 つの修正はどれも毎回正確でした。時間には各ラムダの JIT ウォームアップが含まれるので順位として読んでください。AtomicInteger は 458〜667ms、ReentrantLock は 341〜371ms、LongAdder は 21〜26ms でした。LongAdder はカウントを複数のセルに分散し、sum() のときに合計するので速いのです。更新が多く読み出しが少ないカウンタには LongAdder、2 つのフィールドをまとめて更新するならロックを使います。
JDK 26 の構造化並行性とスコープ値
StructuredTaskScope は、サブタスクの集まりを 1 つの単位として扱います。1 つが失敗すれば他は取り消され、子が動いている間はスコープを抜けられません。JDK 26 でも まだプレビュー API です。
$ javac Structured.java
Structured.java:2: error: StructuredTaskScope is a preview API and is disabled by default.
import java.util.concurrent.StructuredTaskScope;
^
(use --enable-preview to enable preview APIs)
try (var scope = StructuredTaskScope.open()) { // default: all must succeed
var user = scope.fork(() -> fetch("user", 100, failUser));
var order = scope.fork(() -> fetch("order", 2_000, false));
scope.join(); // throws if any subtask failed
System.out.println(" " + user.get() + " " + order.get());
}
$ javac --release 26 --enable-preview -Xlint:preview Structured.java
Structured.java:20: warning: [preview] StructuredTaskScope is a preview API and may be removed in a future release.
$ java --enable-preview Structured
user@req-42 order@req-42
failUser=false -> 2,013 ms
order cancelled after being interrupted
caught FailedException: java.lang.IllegalStateException: user failed
failUser=true -> 108 ms
user が 100ms で失敗すると、2 秒かかる order は割り込みで取り消され、リクエスト全体は 108ms で返りました。使われない結果を 2 秒待つことはありません。req-42 は呼び出しの周りで束縛した ScopedValue の値で、fork したサブタスクにも引き継がれています。ScopedValue 自体は JDK 26 でフラグなしにコンパイルでき、javac --release 24 ではプレビュー API として拒否、--release 25 では通りました。つまり JDK 25 で正式化されています。スコープ側はプレビューなので、リリースごとに API が変わっています(JDK 21 では new StructuredTaskScope.ShutdownOnFailure() でした)。JDK 26 向けのコードが 27 でそのまま動く保証はありません。
判断表
| 状況 | 使うもの | 理由 |
|---|---|---|
| HTTP や DB を待つタスクが数千個 | newVirtualThreadPerTaskExecutor() |
10 万個の sleep が 1.8 秒・210MB |
| 相手が同時 N 接続までしか受けない | 仮想スレッド + Semaphore(N) |
プールを流量制限に使わない |
| CPU バウンドの計算 | コア数程度の固定プールか並列ストリーム | 仮想スレッドでは速くならなかった |
| 読み出しの少ない共有カウンタ | LongAdder |
正確で 21〜26ms(ロックは 341ms〜) |
| 複数フィールドをまとめて更新 | ReentrantLock か synchronized |
JDK 24 以降モニタはピン留めしない |
| 1 つ失敗したら全部やめたい並列呼び出し | StructuredTaskScope(26 ではプレビュー) |
失敗時 2 秒ではなく 108ms |
| リクエスト単位の文脈情報 | ScopedValue(25 で正式化) |
fork したサブタスクに引き継がれる |
重いオブジェクトを ThreadLocal に保持 |
仮想スレッドでは設計を見直す | プールのスレッド数ではなくタスク数だけ作られる |
正直な限界とバージョンの注記
- 仮想スレッドは Java 21 で正式化されました。
javac --release 20はnewVirtualThreadPerTaskExecutor()をプレビュー API として拒否し、--release 21は通します。それ以外はすべて JDK 26 でのみ実行しています。 - JDK 24 のピン留め変更は 26 で確認しただけです。「JDK 21 なら 10 秒」は計算上の値で、実行していません。
- スレッド 4,096 個の上限は macOS の設定です。Linux サーバーやコンテナでは別の数字で落ちます。
- RSS は JVM 全体の値です。
Runtimeのヒープ値はタスク終了後のスナップショットで、最大値ではありません。 - JMH は使っていません。CPU テストは
work()を 1 回ウォームアップしましたが、競合テストはしていないので順位だけが意味を持ちます。
次の講座では JVM の内部に入り、JIT のウォームアップ、GC、メモリリークを同じやり方で計ります。
前の講座: 第 4 回:ストリーム・ラムダ・Optional · 次の講座: 第 6 回:JIT・GC・メモリ
関連:Python 講座 第 5 回:スレッド・プロセス・asyncio と GIL · VB.NET 講座 第 5 回:Async/Await
よくある質問
仮想スレッドにすると CPU を使う処理も速くなりますか?
なりません。CPU だけを使うタスク 64 個は、8 スレッドの固定プールで 4,385ms、タスクごとの仮想スレッドで 4,695ms でした(3 回の中央値、8 コアの M1 Pro)。仮想スレッドが効くのは待ち時間が長いタスクで、計算だけならコア数が上限です。
JDK 26 でも synchronized の中で仮想スレッドはピン留めされますか?
通常のブロックではされません。JDK 24(JEP 491)以降、synchronized の中で sleep してもアンマウントされます。キャリア 1 本で、自分のモニタを持ったまま 100ms 眠るタスク 100 個が約 115ms で終わりました。ただしクラス初期化子の中のブロックは今もピン留めされ、JFR に jdk.VirtualThreadPinned として記録されました。-Djdk.tracePinnedThreads は JDK 26 にはもうありません。
Java 26 で構造化並行性(StructuredTaskScope)は正式機能ですか?
いいえ。JDK 26 でもプレビュー API で、--enable-preview なしでは javac がエラーにします。一方 ScopedValue はフラグなしでコンパイルでき、--release 24 ではプレビュー扱い、--release 25 では通るので、JDK 25 で正式化されています。