外部APIやDBへの応答待ちが多いアプリケーションでは、CPUに余裕があっても、要求を処理するスレッドが待機で埋まることがあります。Java 21の仮想スレッドは、このような処理で、タスクごとにスレッドを割り当てる書き方を保ちながら、多数のタスクを並行して扱うための仕組みです。
ただし、仮想スレッドを増やしても、SQLの実行や外部APIそのものが速くなるわけではありません。この記事ではJDK 21を基準に、基本的な実行例、DB接続数と受付量の制御、ピン留め、タイムアウトと再送の注意点を整理します。
Java 21の仮想スレッドは何を改善するのか
プラットフォームスレッドと仮想スレッドの関係
プラットフォームスレッドは、通常、一つのOSスレッドに対応します。これに対して仮想スレッドは、JDKがスケジュールする軽量なスレッドです。Java 21では正式な機能として利用できます。
仮想スレッドのコードを実行するときは、キャリアと呼ばれるプラットフォームスレッドに載せて実行します。対応するI/O処理などで待機に入ると、仮想スレッドをキャリアから外し、そのキャリアを別の仮想スレッドの実行に使えます。待機が終わると、再びキャリアに載って処理を続けます。
この仕組みにより、待機中のタスク一つにつきOSスレッドを占有し続ける構成を減らせます。ただし、JDK 21ですべてのブロッキング処理がキャリアを解放するわけではありません。後述するピン留めや、利用ライブラリの実装も確認対象です。
処理時間の短縮とスループットの向上を区別する
一つの外部API呼び出しに必要な待ち時間は、仮想スレッドへ置き換えるだけでは短くなりません。改善を狙うのは、主に多数の要求を同時に扱うときのスループットです。スループットは、単位時間あたりに完了できる処理件数を指します。
たとえば、従来のスレッドプールで空きスレッドを待っていた要求が、仮想スレッドでは先にI/O待ちへ進める場合があります。このとき受付から完了までの時間が改善する可能性はありますが、下流サービスが飽和していれば、待つ場所が変わるだけです。単一処理の速さと、混雑時に処理をさばく能力を分けて評価します。
仮想スレッドの使いどころはI/O待ちが多い処理
外部APIやDBへの待機を伴う多数の独立したタスク
仮想スレッドの採用候補になるのは、ネットワーク通信などの待機が多く、同時に扱いたい独立したタスクが多数ある処理です。外部サービスから情報を取得する要求や、DBへの問い合わせを含む要求が例になります。
同期的なメソッド呼び出しで処理を順に書ける点も特徴です。結果を待つコードをすべてコールバックへ書き換えなくても、タスクごとの処理の流れを表現できます。ただし、JDBCドライバーやHTTPクライアント内部の待機方法、接続プール、共有ロックによって効果は変わります。
また、並行に実行してよいかは業務上の依存関係で判断します。前の処理結果を必要とする操作や、同じデータを更新する操作を、スレッドを作れるという理由だけで同時実行してはいけません。
CPUバウンドの計算処理を速くする万能策ではない
CPUバウンドとは、待機よりも計算が処理時間の中心になる状態です。画像変換や大量の数値計算などでCPUを使い切っている場合、仮想スレッドを増やしてもCPUの計算能力は増えません。
実行可能なタスクだけを大量に増やすと、スケジューリングやメモリの負担が増えることもあります。CPU中心の処理では、利用可能なCPU資源を踏まえて並列度を決め、アルゴリズムや処理の分割方法を検討します。I/O待ちを減らした結果、今度はCPUがボトルネックになる場合もあります。
newVirtualThreadPerTaskExecutorでタスクを実行する
タスクごとに仮想スレッドを作る最小構成
Executors.newVirtualThreadPerTaskExecutor()は、タスクごとに新しい仮想スレッドを作るExecutorを返します。固定数のスレッドを使い回すプールではなく、作成するスレッド数は非有界です。アプリケーションが安全に受け付けられる要求数を、自動で制限するAPIではありません。
次は、三つのタスクを投入してから結果を取得する完結例です。Thread.sleep()で待機を表しており、ネットワークI/OやDB処理は行いません。
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class VirtualThreadExample {
public static void main(String[] args) {
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 1; i <= 3; i++) {
int taskId = i;
futures.add(executor.submit(() -> {
Thread.sleep(100);
return "task-" + taskId;
}));
}
try {
for (Future<String> future : futures) {
System.out.println(future.get());
}
} catch (InterruptedException ex) {
for (Future<String> future : futures) {
future.cancel(true);
}
Thread.currentThread().interrupt();
} catch (ExecutionException ex) {
for (Future<String> future : futures) {
future.cancel(true);
}
throw new IllegalStateException(
"Task failed", ex.getCause()
);
}
}
}
}この例では、すべてのタスクを投入した後にget()を呼びます。投入直後に毎回get()で完了を待つと、次のタスクを投入する前に待つことになり、複数タスクの待機を重ねられません。
結果の取得・例外処理・Executorの終了を扱う
submit()の戻り値であるFutureを使い、結果と失敗を呼び出し元で扱います。タスク内の例外はget()でExecutionExceptionとして通知され、元の原因はgetCause()から取得できます。結果を確認しないままFutureを捨てると、タスクの失敗を見落としやすくなります。
例では投入順に結果を取得するため、先頭のタスクが遅いと、その後のタスクが完了していても先頭を待ちます。タスク自体の実行順や完了順を保証するコードではありません。
Java 21のExecutorServiceはtry-with-resourcesで扱えます。ブロックを抜けるとclose()が呼ばれ、Executorの終了を待ちます。この待機も含めてライフサイクルを設計し、停止しないI/O処理を残したまま、必ず短時間でブロックを抜けられるとは考えないようにします。
DB接続数と下流サービスへの同時アクセスを制御する
仮想スレッドを増やしてもDB接続プールの上限は増えない
仮想スレッドとDB接続は別の資源です。たとえば接続プールの最大接続数が20なら、多数の仮想スレッドを作っても、そのプールから同時に借りられる接続は最大20本です。接続を借りられないタスクは、接続の返却を待つことになります。
接続数を増やす判断も、DBサーバーの処理能力やロック競合を踏まえて行います。アプリケーションを複数台で動かすなら、各インスタンスのプール上限を合計した接続数も確認します。仮想スレッドで要求を多く進められるようになっても、DBの適切な同時実行数が同じ割合で増えるわけではありません。
仮想スレッドのプール化ではなく共有資源へのアクセスを制限する
仮想スレッドは、タスクごとに作る使い方が基本です。下流への同時アクセス数を抑えるために仮想スレッド自体をプール化するのではなく、制限したい資源の手前で許可数を管理します。外部APIへの同時呼び出しを制限するなら、Semaphoreが選択肢になります。
次のクラスは、同じインスタンスを共有する呼び出し元の間で、処理の同時実行数を制限します。許可の取得には待機時間を設け、取得に成功した場合だけfinallyで返却します。
import java.util.concurrent.Callable;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
public final class DownstreamGate {
private final Semaphore permits;
public DownstreamGate(int maxConcurrent) {
if (maxConcurrent < 1) {
throw new IllegalArgumentException(
"maxConcurrent must be positive"
);
}
permits = new Semaphore(maxConcurrent);
}
public <T> T call(Callable<T> operation) throws Exception {
if (!permits.tryAcquire(200, TimeUnit.MILLISECONDS)) {
throw new TimeoutException("Downstream is busy");
}
try {
return operation.call();
} finally {
permits.release();
}
}
}呼び出しごとにGateを作ると、呼び出し間の制限になりません。また、この制御が共有されるのは同じJVM内です。複数インスタンス全体の上限は、各インスタンスへの配分や別の制御で設計します。コード中の200ミリ秒は許可取得の待機時間であり、取得後の処理時間を制限するものではありません。
同時実行数と、毎秒のリクエスト数も別です。下流が「毎秒何件まで」という制限を持つ場合は、同時実行数の制限に加えてレート制御が必要になります。
待機タスクの増加に備えて受付量とタイムアウトを決める
Semaphoreで下流へのアクセスを絞っても、その手前で待つタスク数は別途増え得ます。仮想スレッドは軽量ですが、要求本文、処理途中のオブジェクト、結果を保持するメモリまで不要になるわけではありません。
受付中の要求数やキュー容量に上限を設け、超過時に待たせるのか拒否するのかを決めます。接続取得、外部通信、SQL実行、要求全体の期限もそろえて設計します。各段階で同じ長さのタイムアウトを設定するだけでは、合計の待ち時間が要求全体の期限を超えることがあります。残り時間を踏まえて後続処理へ渡す方針が必要です。
JDK 21で確認したいピン留めとスレッド依存の設計
synchronizedやネイティブ処理に関わるピン留めを理解する
JDK 21では、synchronizedのブロックやメソッド内で実行している場合、またはネイティブメソッドやforeign functionを実行している場合、仮想スレッドをキャリアから外せない状態になります。これがピン留めです。その状態で長いI/O待ちなどに入ると、キャリアも占有され、仮想スレッドの並行処理能力を下げる原因になります。
ここはJDK 21を対象にした説明です。JDK 24ではJEP 491により、synchronizedのブロックやメソッドが原因のピン留めは解消されています。ネイティブメソッドやforeign functionによるピン留めは別に扱われます。利用するJDKの版に合わせて判断してください。変更点はOracleのJDK 24移行ガイドでも確認できます。
synchronizedが存在するだけで、すべてを書き換える必要はありません。短いメモリ上の処理と、頻繁に実行される長い待機を含む処理を区別します。JDK 21で後者が問題になる場合は、ロック範囲を短くする、I/Oをロック外へ移す、必要に応じてReentrantLockを検討する、といった順に見直します。
ロックを置き換えても、共有資源へのアクセスが直列であること自体は変わりません。排他制御が守っている条件を保ち、キャリアの占有と業務上必要な排他を分けて改善します。
ThreadLocalに高価な資源を保持する設計を見直す
仮想スレッドでもThreadLocalは使えます。ただし、少数のワーカースレッドを使い回す前提で、スレッドごとに高価なオブジェクトをキャッシュする設計は見直しが必要です。タスクごとに仮想スレッドを作ると、その数に応じて初期化やメモリ使用量が増えるためです。
DB接続のような高価で数に限りがある資源は、専用のプールから必要な間だけ借りて返します。また、別スレッドへタスクを投入しただけで、呼び出し元のThreadLocalにある認証情報やトランザクション状態が自動的に引き継がれるとは考えないようにします。利用するフレームワークの伝播方法を確認し、必要な値と処理境界を明示します。
タイムアウト・再送・業務上の重複防止を分けて設計する
待機の打ち切りと処理の停止・取り消しを同一視しない
Future.get(timeout, unit)がタイムアウトしても、タスクは自動ではキャンセルされません。打ち切られるのは、呼び出し元による結果の待機です。
cancel(true)は、実行中のタスクに対して割り込みによる停止を試みますが、処理の即時停止を保証しません。タスクや利用APIが割り込みを適切に扱うことに加え、HTTPクライアントやJDBC側のタイムアウト、キャンセル機能も確認します。
待機のタイムアウト、タスクのキャンセル、DBトランザクションのロールバック、外部APIの副作用の取り消しは別の処理です。仮想スレッドのキャンセルによって、完了済みの更新や外部処理まで元に戻るわけではありません。
また、タイムアウトをcatchしていても、その後にtry-with-resourcesのclose()でタスクの終了を待つ場合があります。呼び出し元が応答を返す期限と、残った処理を停止・回収する仕組みの両方を設計します。
APIの二重処理は冪等キーとDB設計で防ぐ
注文作成APIの応答がタイムアウトしても、サーバーではすでに注文が登録されている可能性があります。この状態で再送された要求を新しい注文として処理する問題は、仮想スレッドを採用しても解消しません。
仮想スレッドはタスクの実行方式、冪等キーは同じ論理操作の再送を識別する仕組みです。再送による重複防止には、キーと入力の照合、DBの一意制約、業務更新と結果記録のトランザクション設計が必要です。Spring Bootでの具体的な設計は、次の記事で整理しています。

導入効果を判断するための確認項目
同じ負荷条件でスループット・応答時間・資源使用量を比べる
比較では、実行方式以外の条件をそろえます。リクエストの到着量、処理内容、DB接続数、下流の応答条件、タイムアウトを変えてしまうと、何による改善か判断しにくくなります。通常時だけでなく、負荷を増やして飽和する付近の挙動も確認します。
| 観点 | 確認する内容 |
|---|---|
| スループット | 単位時間あたりの成功件数と、失敗・拒否・タイムアウトの件数 |
| 応答時間 | 受付から応答までの時間、中央値、p95・p99などの遅い側の分布 |
| CPU | 利用率と、計算処理が新しいボトルネックになっていないか |
| メモリ | ヒープ使用量、プロセス全体のメモリ、GC、待機要求が保持するデータ量 |
| 再現条件 | JDKの配布元・更新版、CPU・メモリ、設定、ウォームアップ、測定時間 |
完了件数だけが増えても、タイムアウトや下流のエラーが大幅に増えていれば、利用者にとっての改善とは限りません。要求の到着量と成功件数を分け、遅い要求や拒否された要求も含めて評価します。
DB接続待ち・下流の制限・ピン留めを確認する
期待した効果が出ない場合は、タスクがどこで時間を使っているかを分けます。DB接続取得待ち、SQL実行、ロック待ち、外部APIの応答待ち、Semaphoreの許可待ちは、それぞれ改善方法が異なります。
JDK 21では、JFRのjdk.VirtualThreadPinnedイベントや、システムプロパティjdk.tracePinnedThreadsによる診断が、ブロッキングに伴うピン留めの調査に使えます。負荷がかかる経路でスタックを確認し、頻繁で長い待機を伴う箇所から見直します。ピン留めの診断だけで、すべての性能問題を把握できるわけではありません。
仕組み、適用範囲、ピン留め、ThreadLocalに関する説明の参照先はJEP 444: Virtual Threadsです。タスクごとの生成とスレッド数が非有界である点は、Java SE 21のExecutors APIを参照してください。終了待機とキャンセルの契約は、ExecutorService APIとFuture APIで確認できます。
まとめ
Java 21の仮想スレッドは、I/O待ちを伴う多数のタスクを、タスクごとのスレッドという書き方で扱うための仕組みです。単一のI/OやCPU計算を速くする機能ではなく、待機中のOSスレッド占有を減らし、並行処理のスループットを改善する用途に向いています。
newVirtualThreadPerTaskExecutor()を使う際は、DB接続数、下流への同時アクセス、受付量、各段階の期限を別に管理します。JDK 21でのピン留めとThreadLocalの使い方を確認し、同じ負荷条件で応答時間と資源使用量を比較してください。タイムアウト後の結果不明や業務上の二重処理には、実行方式とは別に、結果照会と冪等性の設計で備えます。


コメント