実際の問題:ピーク時にスレッドプールが詰まるとき
昨年末、毎秒約50,000リクエストを処理するJava Spring Bootサービスの最適化に携わりました。症状はよく見る話です:CPUは余裕がある、RAMも逼迫していない、それなのにレイテンシが急騰し、Tomcatが“No threads available”を繰り返す。スレッドプールを増やすとヒープが悲鳴を上げはじめます——デフォルトのプラットフォームスレッドは1本あたり約512KB〜1MBのスタックを消費するからです。
根本的な問題はCPU不足ではありません。スレッド・パー・リクエストモデルは本質的にI/Oバウンドのワークロードに向いていないのです——スレッドが費やす時間の80%はデータベースの応答待ちかダウンストリームAPIのレスポンス待ちで、実際には何もしていません。
Javaにおける3つの並行処理アプローチの比較
1. Platform Threads——シンプルだがスケールしない
このモデルはJava 1.0から存在します。各リクエストに専用のOSスレッドを割り当て、コードは順次実行され、読みやすく、デバッグも直感的です。しかしOSスレッドはリソースを消費します——生成・破棄が遅く、コンテキストスイッチにもコストがかかり、カーネルによって数に上限が設けられています。
// 従来のスレッドプール
ExecutorService executor = Executors.newFixedThreadPool(200);
Future<String> future = executor.submit(() -> {
String dbResult = queryDatabase(); // ~50msブロック
String apiResult = callExternalApi(); // ~100msブロック
return dbResult + apiResult;
});
200スレッドで平均レイテンシ150msとなると、最大スループットはわずか約1,300 req/sです。2,000スレッドに増やすと、スタックだけで約2GBのRAMを消費します——アプリケーションのヒープはそれとは別です。
2. Reactive Programming(WebFlux / Project Reactor)
Reactive Programmingは2018年頃から、このスケーラビリティ問題への答えとして注目を集めました。ノンブロッキングI/Oとイベントループにより、わずかなスレッドでより多くのリクエストを処理できます。
// Spring WebFluxによるReactive実装
@GetMapping("/data")
public Mono<ResponseData> getData(@RequestParam String id) {
return webClient.get()
.uri("/external/{id}", id)
.retrieve()
.bodyToMono(ExternalData.class)
.flatMap(ext -> repository.findByKey(ext.getKey()))
.map(entity -> new ResponseData(entity));
}
スループットは高く、メモリ効率も良好です——しかし代償は小さくありません。同期コードなら15分で終わるバグのデバッグに、reactiveチェーンでは2日かかったこともあります。reactiveのスタックトレースはlambda$0やonNextだらけで、どこでエラーが発生したかわかりません。さらに重要なのは:コードベース全体をreactiveで統一しなければならない——どこか1カ所でもブロッキング処理があるとイベントループ全体が止まります。
3. Virtual Threads(Project Loom)——Java 21で正式リリース
Virtual ThreadsはOSではなくJVMが管理する軽量スレッドです。数百万個作成しても問題ありません——スタックはヒープ上に格納され、OSレベルのメモリ割り当ては行われません。
動作の仕組みはこうです:virtual threadがI/OによってブロックされるとJVMはそのスレッドを自動的にOSスレッドからアンマウントし、そのOSスレッドを別のvirtual threadに割り当てます。I/Oが完了するとJVMは再びvirtual threadをマウントして処理を継続します。プラットフォームスレッドにはこのマウント/アンマウントの仕組みはなく、スレッドがブロックされればOSスレッドもブロックされます。
// Virtual Thread——通常のブロッキングコードとして書ける
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> future = executor.submit(() -> {
String dbResult = queryDatabase(); // JVMが自動処理、OSスレッドをブロックしない
String apiResult = callExternalApi(); // 同様
return dbResult + apiResult;
});
System.out.println(future.get());
}
コードの見た目はプラットフォームスレッドとまったく同じです。しかし内部のスケジューリングはJVMがすべて処理します——コールバックも、チェーンも、reactiveの考え方も不要です。
メリット・デメリットの分析:どのアプローチを選ぶべきか
実務経験から整理しました:
- Platform Threads:同時実行数が少ない場合(同時500リクエスト未満)やCPUバウンドのワークロード(計算処理、画像処理など)に適しています。I/O処理が多い場合は避けましょう。
- Reactive:チームがreactiveの考え方に慣れており、極めて高いスループットが必要で、完全新規の開発である場合に適しています。レガシーコードベースのreactiveへの移行はほぼ全面書き直しになります。
- Virtual Threads:I/OバウンドサービスのREST APIやDB・ダウンストリームを呼ぶマイクロサービスに最も適しています。コードを書き直さずにスループットを向上させたい場合に特に有効で、プラットフォームスレッドからの移行もほとんど手間がかかりません。
シンプルな判断基準:サービスが待機(DBクエリ、HTTP呼び出し、ファイルI/O)に多くの時間を費やしているなら、Virtual Threadsが今すぐ適用できる最も現実的な答えです。
Virtual Threadsの実践的な導入ガイド
ステップ1:Java 21以上を確認する
java -version
# 必要:openjdk 21.0.x以上
# SDKMANを使用する場合
sdk install java 21.0.3-tem
sdk use java 21.0.3-tem
ステップ2:スタンドアロン——フレームワーク不要
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
// 100,000個のvirtual threadを作成——プラットフォームスレッドで試すとOOMになる
try (ExecutorService vte = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
final int taskId = i;
vte.submit(() -> {
Thread.sleep(1000); // simulate I/O wait
System.out.println("Task " + taskId + " done on: " + Thread.currentThread());
return null;
});
}
} // 自動シャットダウンと終了待機
}
}
ステップ3:Spring Boot 3.2+との統合
Spring Boot 3.2はvirtual threadをネイティブサポートしており、設定1行で有効化できます:
# application.yml
spring:
threads:
virtual:
enabled: true
より細かく制御したい場合は、手動でBeanを定義することもできます:
@Configuration
public class ThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadTomcatCustomizer() {
return protocolHandler -> protocolHandler
.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
// @Asyncタスク用
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(
Executors.newVirtualThreadPerTaskExecutor());
}
}
Spring Boot 3.2未満またはSpringを使わない場合は、サーバーに直接executorを設定します:
// HttpServer(JDK組み込み)の場合
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
ステップ4:Pinning——避けるべき落とし穴
Virtual threadがピン留め(キャリアスレッドからアンマウントできない状態)になるケースは2つあります:synchronizedブロック内と、ネイティブメソッドの呼び出し時です。この点を見落として、virtual threadを有効にしたのにパフォーマンスが改善されないと疑問に思う人が多いです。
// BAD——synchronizedがvirtual threadをピン留めし、利点がすべて失われる
public synchronized String getFromCache(String key) {
return cache.get(key); // ここでブロックされると、キャリアスレッドもブロックされる
}
// GOOD——synchronizedの代わりにReentrantLockを使用
private final ReentrantLock lock = new ReentrantLock();
public String getFromCache(String key) {
lock.lock();
try {
return cache.get(key);
} finally {
lock.unlock();
}
}
PinningはJVMフラグで検出できます:
java -Djdk.tracePinnedThreads=full -jar your-app.jar
virtual threadがピン留めされるたびに完全なスタックトレースが出力されます——これを使って正確に特定し修正できます。
ステップ5:スレッドローカル変数——慎重に扱う
数百万のvirtual threadは潜在的に数百万のThreadLocalインスタンスを意味します。ThreadLocalに大きなデータを持つと、スケール時にメモリリークになりやすい。新しいユースケースにはScopedValue(Java 21プレビュー)の検討をお勧めします:
// ScopedValue——virtual thread対応のThreadLocal代替
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
// スコープに値をセット
ScopedValue.where(CURRENT_USER, user).run(() -> {
processRequest(); // CURRENT_USER.get()はこのスコープ内でuserを返す
});
ベンチマーク結果と実際の数値
400スレッドのプラットフォームスレッドプールからvirtual threadに移行した後の、本番環境での計測結果(k6を使ったロードテスト):
- スループット:約2,600 req/sから約47,000 req/sに向上(ワークロードの大半はDBクエリ+ダウンストリームHTTP)
- P99レイテンシ:同一負荷で3.2sから180msに改善
- ヒープ使用量:400本のプラットフォームスレッドのスタックがなくなり約40%削減
- 移行工数:2時間未満、ビジネスロジックへの変更なし
デプロイ前のデバッグやconfig検証の際、JSON configの断片やAPIレスポンスのフォーマットを素早く確認するのにtoolcraft.appをよく使っています——具体的にはtoolcraft.app/ja/tools/developer/json-formatterです。拡張機能のインストールより断然便利で、特にサーバーにSSHしながらレスポンスフォーマットをすばやく確認したいときに重宝します。
Virtual Threadsはencryptionや画像リサイズのようなCPUバウンドタスクの代替にはなりません——ボトルネックはI/OではなくCPUにあるからです。しかし一般的なWebサービスのほとんど(CRUD、APIゲートウェイ、DBを呼ぶマイクロサービス)において、これはJava 21へのアップグレード時に実施できる最もシンプルで最大の効果をもたらす変更といえるでしょう。

