Java の仮想スレッド:スケーラビリティと複雑性のバランス
従来の並行性の問題
Javaの並行処理といえば、長らくプラットフォームスレッド(OSスレッドのラッパー)が基本でした。1スレッドにつき1〜2MBのメモリを消費し、OSリソースも食う。作りすぎるとOutOfMemoryErrorかコンテキストスイッチの飽和で死にます。
Webアプリで「リクエストごとにスレッドを1つ割り当てる」モデルは直感的でわかりやすいんですが、1万同時リクエストとなるとスケールしません。スレッドが足りなくなるからです。
これを解決するためにリアクティブプログラミング(Project Reactor、RxJava)が登場しました。スループットは確かに改善しますが、コードが複雑になります。デバッグも追いづらい。非同期のコールバック地獄に近い感覚です。
低遅延と高スケーラビリティを両立しつつ、コードは同期処理のまま書きたい。それが仮想スレッドの目的です。
仮想スレッド(VT)とは何か?
Project Loomで導入され、JDK 21(JEP 444)で安定化したのが仮想スレッドです。Goのgoroutineに似た発想で、JVMが管理する軽量スレッドです。
1つあたりのメモリ消費が約1KB。つまり、メモリが許す限り数百万個のスレッドを作れます。既存のThreadコードとの互換性も高く、ドロップインで置き換えられるケースが多いです。
I/O待ちが多い処理(HTTPリクエスト、DBクエリ、ファイル読み書き)のスループットに最適化されています。CPUバウンドな計算処理には向きませんが、それは後で説明します。
JDK 21で安定機能入りし、22〜25で継続的に改善されています。
内部動作:M:N スケジューリング
仮想スレッドの仕組みはM:Nスケジューリングと呼ばれます。M個の仮想スレッドを、N個のプラットフォームスレッド(CPUコア数程度)にマッピングして実行します。
「キャリア」と呼ばれるプラットフォームスレッドが、仮想スレッドを載せて(マウント)実行します。仮想スレッドがI/Oでブロックすると、キャリアから降ろされて(アンマウント)、キャリアは別の仮想スレッドを載せに行きます。I/Oが完了すると、仮想スレッドはスケジューラに戻されて、空いているキャリアに再マウントされます。
つまり、キャリアは常にビジー状態を保てる。CPUの無駄待ちが減るわけです。
最近の改善で重要なのが2つあります。JDK 24(JEP 491)では、synchronizedブロック内で仮想スレッドがピン留めされる問題が解消されました。これまではsynchronizedの中でブロックすると、キャリアから降りられなかったのです。JDK 25(JEP 506)では、ThreadLocalの代替としてスコープ値が導入され、仮想スレッド環境でのメモリリーク防止に効いてきます。
実践的例
仮想スレッドが本領発揮するのはI/Oバウンドな処理です。HTTP、DB、ファイルの読み書きが多いシナリオで最大の効果が出ます。
例 1:シンプル仮想スレッド
Thread.startVirtualThread(() -> {
System.out.println("VT からの挨拶!" + Thread.currentThread());
});例 2:ExecutorService(高並行)
import java.util.concurrent.Executors;
import java.time.Duration;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 10k 同時タスク
IntStream.range(0, 10_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // I/O シミュレーション
// ビジネスロジック
})
);
} // 自動完了待ちプラットフォームスレッドで1万個のタスクを同時に回したら即死ですが、仮想スレッドなら問題なく動きます。newVirtualThreadPerTaskExecutor()がタスクごとに新しい仮想スレッドを作るので、プールサイズを気にする必要がありません。
例 3:Spring Boot + VT(実ベンチマーク)
JDK 24 + Spring Boot + Postgresの構成でベンチマークを取ったところ、仮想スレッドが10k req/sの負荷で、プラットフォームスレッド比で約50%の遅延削減を示しました。
@RestController
public class ApiController {
@GetMapping("/data")
String getData() {
// VT が Tomcat/Undertow 設定で自動処理
return fetchFromDb(); // ブロックだがスケーラブル
}
}Spring Boot 3.2以降なら、設定1行でリクエストハンドラを仮想スレッドで動かせます。コードは普通の同期処理のまま、裏で仮想スレッドがよしなにスケールしてくれます。これが仮想スレッドの最大の売りです。リアクティブのコールバック地獄なしで、高いスループットが得られる。
高度な考慮事項とベストプラクティス
CPU 結合 vs I/O 結合
仮想スレッドは万能ではありません。処理の性質によって使い分ける必要があります。
I/Oバウンドな処理(DB、HTTPリクエスト)は仮想スレッドの独壇場です。newVirtualThreadPerTaskExecutor()で大規模にスケールします。
一方、CPUバウンドな処理(暗号化、ML推論、重い計算)は別です。仮想スレッドでCPUを占有すると、キャリアが他の仮想スレッドを実行できなくなり、「キャリアスタベーション」と呼ばれる問題が起きます。こういう処理は、CPUコア数程度のプラットフォームスレッドプールで回すのが正解です。
問題と解決策
ピン留めについては、JDK 24でsynchronized問題が解消されました。ただしJNIやネイティブメソッドはまだ避けた方が無難です。
ThreadLocalは仮想スレッドでは危険です。スレッド数が数百万になると、ThreadLocalのエントリも数百万できてメモリが爆発します。スコープ値(JDK 25プレビュー)への移行を検討してください。
モニタリングはThread.currentThread().isVirtual()で仮想スレッドかどうかを判定できます。JFR(Java Flight Recorder)はJDK 21から仮想スレッドをサポートしています。ピン留めの検出にもJFRが使えます。
フレームワーク対応も進んでいます。Spring Boot 3.2+、Quarkus、Helidonがネイティブで仮想スレッドをサポートしています。
2025年時点で私が実践しているベストプラクティスをまとめると、Webサーバではデフォルトで仮想スレッドを使う、JFRでピン留めを監視する、ThreadLocalをスコープ値に移行する、CPU集約な処理だけ固定プールに分ける、あたりです。本番投入前にはwrkやabで10万同時接続以上のテストを回すことをお勧めします。
最終まとめ
初心者なら、Thread.startVirtualThread()で始めて、並行処理は全部仮想スレッドに任せればいいと思います。シンプルで十分なスケールが得られます。
慣れてきたら、I/Oバウンドは仮想スレッド、CPUバウンドはプラットフォームスレッドプールと使い分けて、JFRでピン留めを監視する。それが2025年時点での現実的な落とし所です。
仮想スレッドを一言で説明するとしたら、プラットフォームスレッドが「専用エレベータ」だとしたら、仮想スレッドは「空気圧パイプシステム」です。少数の物理パイプで、何千もの軽量カプセルを流せる。
出典:OpenJDK JEPs、Spring Boot ベンチマーク(Reddit/Java 24)、RockTheJVM/RabinaNPatra ガイド(2025)。