はじめに
Javaにおける並行処理は、マルチスレッドプログラミングの基本です。しかし、従来のプラットフォームスレッドはOSスレッドと1対1で対応するため、大量のスレッドを生成するとリソース消費が激しく、スケーラビリティに限界がありました。
Virtual Threads(仮想スレッド) は、Java 21で正式導入された軽量スレッドです。数百万単位のスレッドを効率的に扱えるため、スレッド・パー・リクエスト方式のサーバーアプリケーションのスループットを劇的に向上できます。
この記事では、Virtual Threadsの基本概念から、プラットフォームスレッドとの違い、Thread.ofVirtual()やExecutorServiceでの使い方、ブロッキング操作の扱い、Structured Concurrency(構造化並行性)まで、実践的なコード例とともに解説します。
Virtual Threadsとは何か
Virtual Threadsは、JDKが提供する軽量スレッドの実装です。従来のプラットフォームスレッドと同じjava.lang.Threadのインスタンスでありながら、OSスレッドに直接紐付かないため、非常に多くのスレッドを効率的に生成・管理できます。
Virtual Threadsの導入経緯
Virtual Threadsは、Project Loomの成果として以下の経緯を経て正式導入されました。
| バージョン | ステータス | JEP |
|---|---|---|
| Java 19 | プレビュー機能(第1弾) | JEP 425 |
| Java 20 | プレビュー機能(第2弾) | JEP 436 |
| Java 21 | 正式機能 | JEP 444 |
Virtual Threadsが解決する課題
従来のサーバーアプリケーション開発には、以下のような課題がありました。
| 方式 | 課題 |
|---|---|
| スレッド・パー・リクエスト | プラットフォームスレッドはOSスレッドを占有するため、数千程度でリソース枯渇 |
| スレッドプール | プールサイズがボトルネックになり、同時処理数に上限 |
| 非同期プログラミング | CompletableFutureやリアクティブフレームワークはコードが複雑化 |
Virtual Threadsは、シンプルな同期的コードのまま、高いスループットを実現します。
Virtual Threadsの動作原理
flowchart TB
subgraph "プラットフォームスレッド"
PT1["プラットフォーム<br/>スレッド 1"] --> OS1["OSスレッド 1"]
PT2["プラットフォーム<br/>スレッド 2"] --> OS2["OSスレッド 2"]
PT3["プラットフォーム<br/>スレッド 3"] --> OS3["OSスレッド 3"]
end
subgraph "Virtual Threads"
VT1["仮想スレッド 1"]
VT2["仮想スレッド 2"]
VT3["仮想スレッド 3"]
VT4["仮想スレッド 4"]
VT5["仮想スレッド 5"]
VT6["仮想スレッド 6"]
C1["キャリア<br/>スレッド 1"]
C2["キャリア<br/>スレッド 2"]
VT1 -.-> C1
VT2 -.-> C1
VT3 -.-> C1
VT4 -.-> C2
VT5 -.-> C2
VT6 -.-> C2
C1 --> OSA["OSスレッド A"]
C2 --> OSB["OSスレッド B"]
endプラットフォームスレッドが1対1でOSスレッドを占有するのに対し、Virtual Threadsは少数のキャリアスレッド(プラットフォームスレッド)上で多数の仮想スレッドがスケジューリングされます。
プラットフォームスレッドとVirtual Threadsの違い
プラットフォームスレッドとVirtual Threadsの主な違いを理解しておきましょう。
特性の比較
| 特性 | プラットフォームスレッド | Virtual Threads |
|---|---|---|
| OSスレッドとの関係 | 1対1で紐付く | M:Nでマッピング |
| 生成コスト | 高い(約1MB/スレッド) | 非常に低い(約数KB) |
| 同時実行数 | 数千程度が限界 | 数百万も可能 |
| プーリング | 推奨される | 非推奨(都度生成) |
| スケジューリング | OSが行う | JVMが行う |
| デーモンスレッド | 設定可能 | 常にデーモン |
| 優先度 | 設定可能 | 常にNORM_PRIORITY |
| スレッドグループ | 所属する | 仮想グループに所属 |
スループットの違い
以下の例で、10,000個のタスク(各1秒のスリープ)を実行した場合の違いを示します。
|
|
Virtual Threadsでは、ブロッキング操作中にキャリアスレッドを解放するため、少ないOSスレッドで多数のタスクを効率的に処理できます。
Virtual Threadsの作成方法
Virtual Threadsを作成する方法は複数あります。用途に応じて使い分けましょう。
Thread.startVirtualThreadによる作成
最もシンプルな方法は、Thread.startVirtualThread()を使用することです。
|
|
実行結果の例:
Hello from Virtual Thread!
Thread name:
Is virtual: true
Thread.ofVirtualによる作成
Thread.ofVirtual()を使用すると、スレッドに名前を付けたり、開始タイミングを制御したりできます。
|
|
連番名のVirtual Threadsを作成
複数のVirtual Threadsに連番の名前を付ける場合は、name(String prefix, long start)を使用します。
|
|
実行結果の例:
Thread ID: 21, Name: worker-0
Thread ID: 24, Name: worker-1
Thread ID: 27, Name: worker-2
ThreadFactoryによる作成
ThreadFactoryを使用すると、既存のAPIとの互換性を保ちながらVirtual Threadsを使用できます。
|
|
ExecutorServiceでのVirtual Threads活用
実際のアプリケーションでは、ExecutorServiceを使用してVirtual Threadsを管理することが推奨されます。
newVirtualThreadPerTaskExecutorの使用
Executors.newVirtualThreadPerTaskExecutor()は、タスクごとに新しいVirtual Threadを作成するExecutorServiceを返します。
|
|
ファンアウトパターンの実装
複数の外部サービスに並行してリクエストを送信し、結果を集約するファンアウトパターンの例です。
|
|
Virtual Threadsをプーリングしてはいけない理由
プラットフォームスレッドではスレッドプールが一般的ですが、Virtual Threadsではプーリングは非推奨です。
|
|
| プラットフォームスレッド | Virtual Threads |
|---|---|
| プーリング推奨(高コストのため再利用) | プーリング非推奨(低コストのため都度生成) |
| スレッド数で同時実行を制限 | Semaphoreで同時実行を制限 |
| ThreadLocalでリソースをキャッシュ | ThreadLocalのキャッシュは非推奨 |
ブロッキング操作とスケーラビリティ
Virtual Threadsの最大の利点は、ブロッキング操作時にキャリアスレッドを解放し、他のVirtual Threadsが実行できることです。
ブロッキング操作の動作
sequenceDiagram
participant VT1 as 仮想スレッド1
participant VT2 as 仮想スレッド2
participant CT as キャリアスレッド
participant OS as OSスレッド
VT1->>CT: マウント
CT->>OS: 実行
Note over VT1,OS: I/O操作開始
VT1-->>CT: アンマウント(ブロック)
VT2->>CT: マウント
CT->>OS: 実行
Note over VT2,OS: 処理続行
Note over VT1: I/O完了
VT2-->>CT: アンマウント
VT1->>CT: 再マウント
CT->>OS: 実行続行Virtual Threadがブロッキング操作(I/O、Thread.sleep()、BlockingQueue.take()など)を実行すると、JVMスケジューラーはそのVirtual Threadをキャリアスレッドからアンマウントし、別のVirtual Threadをマウントします。
スケーラビリティが向上するケース
Virtual Threadsは以下のケースで特に効果を発揮します。
| ケース | 効果 |
|---|---|
| I/O待ちが多い | ブロッキング中にキャリアを解放 |
| 同時タスク数が多い | 数千以上のタスクを効率的に処理 |
| スレッド・パー・リクエスト | シンプルなコードで高スループット |
逆に、以下のケースではVirtual Threadsの恩恵は限定的です。
| ケース | 理由 |
|---|---|
| CPU集約型の処理 | CPUコア数以上の並列化は効果なし |
| 同時タスク数が少ない | Virtual Threadsのオーバーヘッドが相対的に大きい |
| 非同期コードが既に最適化済み | 移行メリットが小さい |
ピンニングに注意する
Virtual Threadsがキャリアスレッドからアンマウントできない状態を**ピンニング(Pinning)**と呼びます。以下の状況でピンニングが発生します。
synchronizedブロックまたはメソッド内でのブロッキング操作- ネイティブメソッドまたは外部関数の実行中
|
|
ピンニングを検出するには、JVMオプション-Djdk.tracePinnedThreads=fullを使用するか、JDK Flight Recorder(JFR)のjdk.VirtualThreadPinnedイベントを確認します。
Semaphoreによる同時実行数の制限
Virtual Threadsではスレッドプールによる同時実行数制限は不適切です。代わりにSemaphoreを使用します。
Semaphoreの使用例
外部サービスへの同時接続数を10に制限する例です。
|
|
スレッドプールとSemaphoreの等価性
flowchart LR
subgraph "スレッドプールによる制限"
T1[タスク1] --> Q1[キュー]
T2[タスク2] --> Q1
T3[タスク3] --> Q1
Q1 --> P1[プールスレッド1]
Q1 --> P2[プールスレッド2]
end
subgraph "Semaphoreによる制限"
VT1[仮想スレッド1] --> S[Semaphore]
VT2[仮想スレッド2] --> S
VT3[仮想スレッド3] --> S
S --> R[リソース]
endスレッドプールではタスクがキューに入り、プールスレッドが順次処理します。Semaphoreでは、Virtual Threadsがセマフォの許可を待ってブロックします。Virtual Threads自体がタスクを表すため、両者は等価な構造になります。
Structured Concurrency(プレビュー機能)
Structured Concurrency(構造化並行性)は、並行タスクを構造化して管理するためのAPIです。Java 21以降でプレビュー機能として提供されています。
Structured Concurrencyの目的
従来のExecutorServiceを使用した並行処理には以下の問題がありました。
| 問題 | 詳細 |
|---|---|
| スレッドリーク | サブタスクの失敗時に他のサブタスクがキャンセルされない |
| キャンセル伝播の欠如 | 親タスクの中断がサブタスクに伝播しない |
| 可観測性の低下 | タスク間の親子関係がスレッドダンプに現れない |
Structured Concurrencyは、これらの問題を解決し、タスクの親子関係を明確にします。
StructuredTaskScopeの基本的な使い方
|
|
ShutdownOnFailureとShutdownOnSuccess
StructuredTaskScopeには、2つの組み込みポリシーがあります。
| ポリシー | 動作 |
|---|---|
ShutdownOnFailure |
いずれかのサブタスクが失敗したらシャットダウン |
ShutdownOnSuccess |
いずれかのサブタスクが成功したらシャットダウン |
|
|
Structured Concurrencyのメリット
flowchart TB
subgraph "Structured Concurrency"
H[handle] --> S[StructuredTaskScope]
S --> F1[findUser]
S --> F2[fetchOrder]
F1 -.->|成功/失敗| S
F2 -.->|成功/失敗| S
S -.->|結果集約| H
end
subgraph "メリット"
M1[エラー時の自動キャンセル]
M2[親タスク中断の伝播]
M3[スレッドダンプでの可視化]
M4[リソースリークの防止]
endStructured Concurrencyを使用すると、以下のメリットがあります。
- サブタスクの失敗時に他のサブタスクを自動キャンセル
- 親タスクの中断がサブタスクに自動伝播
- スレッドダンプでタスクの階層構造を可視化
try-with-resourcesでリソースリークを防止
従来のスレッドプールからの移行
既存のコードをVirtual Threadsに移行する際のガイドラインを紹介します。
移行パターン
| 既存コード | 移行後 |
|---|---|
Executors.newFixedThreadPool(n) |
Executors.newVirtualThreadPerTaskExecutor() |
Executors.newCachedThreadPool() |
Executors.newVirtualThreadPerTaskExecutor() |
| スレッドプールでの同時実行制限 | Semaphoreでの同時実行制限 |
ThreadLocalでのリソースキャッシュ |
共有リソースの明示的な管理 |
synchronizedでのI/O保護 |
ReentrantLockでの保護 |
移行例:HTTPクライアント
|
|
移行時の注意点
Virtual Threadsへの移行時に注意すべき点をまとめます。
-
ThreadLocalの見直し:Virtual Threadsは大量に生成されるため、ThreadLocalでのリソースキャッシュは逆効果になる可能性があります。
-
synchronizedブロックの確認:長時間のブロッキング操作を含む
synchronizedブロックは、ReentrantLockへの置き換えを検討してください。 -
スレッド数による制限の撤廃:スレッドプールサイズによる同時実行制限は、
Semaphoreに置き換えてください。 -
デバッグとモニタリング:
jcmdの新しいスレッドダンプ形式(JSON)やJFRイベントを活用してください。
|
|
まとめ
Virtual Threadsは、Javaの並行処理プログラミングを大きく変革する機能です。
| ポイント | 内容 |
|---|---|
| 本質 | OSスレッドを直接占有しない軽量スレッド |
| メリット | 同期的なコードのままスケーラビリティを向上 |
| 適用場面 | I/O待ちが多い、同時タスク数が多いサーバーアプリケーション |
| 注意点 | プーリングしない、ピンニングを避ける、ThreadLocalの見直し |
| 将来 | Structured Concurrencyとの組み合わせでさらに強力に |
Virtual Threadsを活用することで、シンプルで保守しやすいコードを維持しながら、高スループットなアプリケーションを構築できます。スレッド・パー・リクエスト方式のサーバーアプリケーションを開発している場合は、ぜひ導入を検討してください。
参考リンク
- JEP 444: Virtual Threads - Virtual Threadsの正式仕様
- Oracle Java Documentation - Virtual Threads - Oracle公式ドキュメント
- JEP 462: Structured Concurrency (Second Preview) - Structured Concurrencyの仕様
- java.lang.Thread API Documentation - Thread APIリファレンス
- java.util.concurrent.Executors API Documentation - Executors APIリファレンス