Java Virtual Thread(仮想スレッド)とProject Loomとは?作り方・仕組み・落とし穴を実務目線で解説
Virtual Thread(仮想スレッド)は、Project Loomから生まれたJavaの軽量スレッドで、Java 21(2023年9月・LTS)で正式機能になりました(Java 19・20ではプレビュー段階)。2026年時点の最新LTSはJava 25で、後述するpinning問題の解消(Java 24)やスコープ値の正式化(Java 25)など、登場後も実務で使える形へ改良が続いています。この記事では、仮想スレッドの作り方・従来スレッドとの違い・キャリアスレッドの仕組み・Spring Bootでの有効化に加え、本番で踏みやすい落とし穴(pinningやCPUバウンド)まで、実装コード付きで整理します。
まとめ:この記事の結論
- 仮想スレッドはI/O待ちが多い処理向けの機能。1リクエスト=1スレッドの素直な書き方のまま、数万〜数百万スレッドを捌ける。
- 作り方は
Thread.startVirtualThread(...)かExecutors.newVirtualThreadPerTaskExecutor()の2択が基本。既存のRunnable/ExecutorServiceの書き方をほぼ変えずに移行できる。 - 正体は「OSスレッド(キャリアスレッド)に乗り換えながら走るユーザーレベルのスレッド」。ブロッキング時に自動で退避(アンマウント)してキャリアを解放するのが速さの源。
- Spring Boot 3.2以降は
spring.threads.virtual.enabled=trueの1行で有効化できる。 - CPUバウンドな処理には効果がない。またJava 23以前は
synchronized内のブロックでpinningが起きたが、Java 24(JEP 491)でこの制約は解消された。
Project Loomとは:仮想スレッドを含む3つの成果物
「java loom」で検索すると仮想スレッドの記事ばかり出るが、Project LoomはJVMの並行処理を作り直すOpenJDKのプロジェクトの総称で、成果物は仮想スレッドだけではない。まず全体像を押さえると、個々の機能の位置づけが明確になる。
Loomが解決する課題:スレッド枯渇問題
従来のJavaスレッド(プラットフォームスレッド)はOSスレッドと1対1で対応し、1本あたり数百KB〜1MB程度のスタックを確保する。そのためサーバー1台で作れるのは現実的に数千本が上限で、I/O待ちのリクエストが増えるとスレッドプールが枯渇し、それ以上の同時接続を捌けなくなる。これを避けるために非同期・リアクティブなコード(CompletableFutureのチェーンやリアクティブストリーム)を書くと、今度はスタックトレースが追えずデバッグが難しくなる、というトレードオフがあった。Loomはこの「スレッドは高価だから節約せよ」という前提そのものを崩し、スレッドを安価な資源に変えることを狙っている。
Loomが提供する3機能と、それぞれのJEPの状況
| 機能 | 役割 | 状況(2026年時点) |
|---|---|---|
| Virtual Thread | 大量の軽量スレッドを供給 | Java 21で正式化(JEP 444) |
| Structured Concurrency | 複数スレッドを1つの単位で束ねて制御 | プレビュー継続(Java 25でJEP 505/API刷新) |
| Scoped Value | ThreadLocalに代わる不変の値伝播 | Java 25で正式化(JEP 506) |
仮想スレッドが「大量のスレッドを生む」役割なら、Structured Concurrencyは「生んだスレッドを正しく束ねて、エラー時にまとめて中断・後始末する」役割を担う。両者は補完関係にあり、実務では仮想スレッドを土台に構造化並行を組み合わせる方向へ進んでいる。仮想スレッドは大量生成できるがゆえに、値の共有にThreadLocalを使うとメモリを圧迫しやすい。その置き換えとしてJava 25で正式化されたのがScoped Valueで、スコープ内で不変・自動破棄・子スレッドへ継承という性質を持つ。仮想スレッドそのものの入門は本記事で扱い、用語の定義から確認したい場合は仮想スレッドの概要と基本的な定義についても参照してほしい。
仮想スレッドの作り方:3つの生成方法
作り方は用途で使い分ける。単発で走らせるならstartVirtualThread、名前付けや未開始での生成が要るならThread.ofVirtual()、大量のタスクを投げるなら専用のExecutorServiceを使う。
単発生成:startVirtualThreadとThread.ofVirtual
// 生成と同時に開始する最短の書き方
Thread.startVirtualThread(() -> System.out.println("Hello Virtual Thread"));
// Builder経由なら、名前付けや「未開始で生成」ができる
Thread vt = Thread.ofVirtual()
.name("worker-1")
.start(() -> doWork());
vt.join(); // プラットフォームスレッドと同じAPIで待てる
戻り値は通常のThread型で、join()やinterrupt()もそのまま使える。Runnableを渡す点も従来どおりなので、既存のスレッド処理をほぼ書き換えずに移行できる。
大量タスク生成:newVirtualThreadPerTaskExecutor
// タスクごとに新しい仮想スレッドを割り当てるExecutor
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // I/O待ちを模擬
return null;
});
}
} // try-with-resourcesのclose()で全タスク完了までブロックする
従来の固定サイズスレッドプールと違い、プールサイズの設計が不要になる点が大きい。プールは「同時に走れるスレッド数」を絞るための仕組みだが、仮想スレッドは安価なのでタスク数ぶん作ってよい。同時実行数を制限したい場合はプールで縛るのではなくSemaphoreで流量を制御する、という設計に変わる。
仮想スレッドと従来スレッドの違い・動く仕組み
見た目のAPIはThreadで共通だが、内部の動き方は根本的に異なる。ここを理解しておくと、後述の落とし穴の理由も腑に落ちる。
プラットフォームスレッドとの違い
| 観点 | プラットフォームスレッド | 仮想スレッド |
|---|---|---|
| OSスレッドとの対応 | 1対1 | 多対少(乗り換える) |
| スタック | 固定確保(数百KB〜) | ヒープ上で可変・必要時に伸長 |
| 現実的な上限 | 数千本 | 数百万本 |
| 生成コスト | 高い(OS関与) | 低い(JVM内で完結) |
| スケジューリング | OS | JVM内のForkJoinPool |
仮想スレッドはスタックフレームをヒープに置き、必要な分だけ伸ばす。だからプラットフォームスレッドが数千本で頭打ちになるメモリでも、仮想スレッドなら数百万本を同居させられる。
キャリアスレッドとマウント・アンマウントの仕組み
仮想スレッドは、実行時にプラットフォームスレッド(これをキャリアスレッドと呼ぶ)へ「マウント」されて走る。ネットワークやファイルのI/Oでブロックすると、仮想スレッドはキャリアから「アンマウント」されて退避し、空いたキャリアは別の仮想スレッドを走らせる。I/Oが完了すると、いずれかの空きキャリアに再度マウントされて処理を再開する。この乗り換えが自動で行われるため、開発者は同期的な素直なコードを書きながらノンブロッキング相当のスループットを得られる。
キャリアを供給するのは専用のForkJoinPoolで、その並列度(=同時に走れるキャリア数)は既定でCPUコア数に一致する。必要ならシステムプロパティjdk.virtualThreadScheduler.parallelismで調整できる。なお仮想スレッドとキャリアの対応は固定されない(アフィニティを持たない)ため、特定のOSスレッドに紐づく前提のコードは避ける必要がある。軽量スレッドという発想自体は目新しいものではなく、歴史的背景はグリーンスレッドとは何か?基本的な定義とその誕生背景を解説で補える。
Spring Bootでの仮想スレッド有効化
Webアプリで最も効果が出るのがリクエスト処理への適用だ。Spring Boot 3.2以降は、次の1行で組み込みTomcat・Jetty・Undertowのリクエスト処理スレッドを仮想スレッドに切り替えられる。
// application.properties
spring.threads.virtual.enabled=true
この設定を入れると、Spring MVCのリクエストは1本ずつ仮想スレッドで処理され、従来のTomcatスレッドプール(既定200本)の枯渇を気にせずI/O待ちの多いエンドポイントを捌ける。@Asyncやスケジューリング実行のタスクエグゼキュータも仮想スレッドベースに切り替わる。注意点として、これはブロッキングI/Oを前提とした通常のSpring MVCで効果を発揮する設計であり、すでにSpring WebFluxとは何か:概要と基本的な特徴で紹介しているリアクティブスタックを採用済みなら、仮想スレッドへ無理に載せ替える必要はない。どのSpring Bootバージョンで使えるかはSpring Boot 4とは?最新バージョンの変更点も確認してほしい。
仮想スレッドの落とし穴と、向かないケース
仮想スレッドは万能ではない。ここを押さえずに全面適用すると、期待した性能が出ないどころか逆効果になる場面がある。
pinning(ピン留め)とJava 24での解消
pinningとは、本来アンマウントできるはずのブロッキングで仮想スレッドがキャリアから外れず、キャリアを占有し続けてしまう現象を指す。Java 21〜23ではsynchronizedブロック・メソッドの中でI/Oブロックするとpinningが発生し、キャリアが解放されないため、極端な場合はすべてのキャリアが占有されてスレッド枯渇やデッドロックに至った。回避策として当時はsynchronizedをReentrantLockへ置き換えることが推奨されていた。
この制約はJava 24のJEP 491でJVMのモニタ実装が仮想スレッド対応に書き換えられ、解消された。synchronized内でブロックしてもアンマウントできるようになったため、ReentrantLockへの機械的な置き換えはもはや必須ではない。ただしクラスのロード中やクラス初期化子の中でブロックする場合など、ネイティブフレームが絡む一部のケースでは依然としてpinningが残る。検出にはJFRイベントjdk.VirtualThreadPinnedを使う(Java 23以前で使われた-Djdk.tracePinnedThreadsはJava 24で廃止された)。
CPUバウンドな処理・スレッドプール流用は避ける
仮想スレッドはプリエンプティブ(強制横取り)ではない。CPUを使い続ける処理の間は仮想スレッドがキャリアから外れないため、キャリア数(=CPUコア数)を超える計算処理を同時に走らせても意味がない。画像変換や暗号計算のようなCPUバウンドな処理には効果がなく、こうした用途は従来どおり固定サイズのプールで処理すべきだ。また、仮想スレッドを従来型スレッドプール(newFixedThreadPoolなど)にセットして使うのも誤りで、プールが同時実行数を絞ってしまい仮想スレッドの利点が消える。仮想スレッドは「プールしない・タスクごとに使い捨てる」のが正しい使い方だと理解しておきたい。
よくある質問(FAQ)
Virtual ThreadはどのバージョンのJavaから使えますか?
プレビューはJava 19・20、正式機能になったのはJava 21(LTS・2023年9月)からです。本番採用するならJava 21以降、pinningの制約まで解消された環境を求めるならJava 24以降が目安です。
Project LoomとVirtual Threadは同じ意味ですか?
異なります。Project LoomはOpenJDKのプロジェクト名で、その成果物の1つがVirtual Threadです。ほかにStructured Concurrency(プレビュー継続)とScoped Value(Java 25で正式化)が含まれます。
従来の非同期・リアクティブ処理はもう不要になりますか?
すべてが置き換わるわけではありません。仮想スレッドはブロッキングI/Oを同期的に書ける点が利点で、既存のリアクティブ資産を無理に載せ替える必要はありません。CPUバウンドな並列計算にも仮想スレッドは向きません。
ThreadLocalは仮想スレッドで使えますか?
使えますが、仮想スレッドは大量に生成されるためThreadLocalの値がメモリを圧迫しやすくなります。Java 25で正式化されたScoped Valueが、不変で自動破棄される代替として推奨されます。
synchronizedは使わない方がよいですか?
Java 24(JEP 491)以降はsynchronized内のブロックでもpinningが起きないため、無理にReentrantLockへ置き換える必要はありません。Java 23以前を使う場合のみ、I/Oを伴うクリティカルセクションはReentrantLockを検討してください。