Spring Boot 3.2 + Java 21の仮想スレッド(Virtual Threads)で高スループットを実現する方法


「仮想スレッドに切り替えたらスループットが大幅に改善した」という話を聞いて、自分のプロジェクトでも試してみたいと思っていませんか。設定自体は一行で済むのですが、落とし穴もいくつかあります。この記事では、有効化の手順から実測値・注意点まで一通りまとめます。

前提条件を確認する

まず手元の環境が対応しているか確認しましょう。

java --version
# openjdk 21.0.x ...

Spring Boot 3.2以上が必要です。build.gradle または pom.xml でバージョンを確認してください。

// build.gradle
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

Spring Boot 3.2未満では spring.threads.virtual.enabled プロパティ自体が存在しないので、まずはバージョンアップが先決です。

仮想スレッドとは何か

従来のプラットフォームスレッドはOSスレッドと1対1で対応しています。数千スレッドを同時に扱おうとするとメモリとコンテキストスイッチのコストが跳ね上がるので、Tomcatのスレッドプールも通常200前後に制限されていますよね。

Virtual Threads(Project Loom)はJVMが管理する軽量スレッドです。I/O待ちが発生するとJVMが自動でプラットフォームスレッドから切り離し(アンマウント)、別のVirtual Threadにプラットフォームスレッドを割り当てます。結果として、少数のプラットフォームスレッドで大量のリクエストを捌けるようになります。

ただし CPUバウンドな処理 では効果はほとんどありません。DBアクセスや外部API呼び出しのようなI/O待ちが多いサービスでこそ威力を発揮します。

spring.threads.virtual.enabled=true で有効化する

application.properties に一行追加するだけです。

spring.threads.virtual.enabled=true

これを設定すると、TomcatのスレッドプールがVirtual Threads対応のExecutorに切り替わり、リクエストごとに新しいVirtual Threadが生成されます。

@Async で独自Executorを使っている場合や、処理ごとに別のExecutorを定義しているケースでは、そちらも合わせて対応が必要です。

@Bean
public ExecutorService virtualThreadExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

簡易ベンチマークで効果を確認する

I/O待ちを含むエンドポイント(DBアクセスに100ms程度かかるAPI)で wrk を使って計測しました。計測環境はCPU 4コア・JVM -Xmx512m・PostgreSQL接続プール最大20件・Java 21.0.2・Spring Boot 3.2.2です。

# 30秒間、100同時接続で計測
wrk -t4 -c100 -d30s http://localhost:8080/api/users
設定スループットレイテンシ(平均)
従来スレッドプール(200スレッド固定)約450 req/s220ms
仮想スレッド有効約1,200 req/s82ms

I/O待ちでプラットフォームスレッドが解放されるため、接続プール待ちの間も他のリクエストを処理できます。ただし接続プール上限20件・100同時接続という条件ではプール枯渇も性能差の要因となりうるため、この数値はあくまで参考例です。必ず自分のサービスの実際の条件で計測してください。

CPUバウンドなエンドポイントでは差はほぼゼロでした。

ThreadLocalの挙動変化に注意する

Virtual Threadsはスレッドが使い捨てになるため、生存中のスレッド数は同時並行リクエスト数に応じて変化します。ThreadLocal を使っているコードがある場合は注意が必要です。

主な懸念は2点あります。まず **ThreadLocal マップのメモリオーバーヘッド ** です。同時接続数が多くなるほどヒープ使用量が増大します。次に InheritableThreadLocal による値の伝播 です。Virtual Threadが親スレッドから値を引き継いでしまうケースがあります。

特にMDCでは、処理後に明示的なクリアを忘れると不要な値が残り続けます。防御的プログラミングとして finally での MDC.clear() を徹底しましょう。

MDC.put("requestId", requestId);
try {
    // 処理
} finally {
    MDC.clear();
}

Java 21ではより安全な代替として ScopedValue が提案されています。Java 21〜23 はプレビュー段階(--enable-preview 必須)であり、正式機能(Final)となったのは Java 24(JEP 487)です。Java 22・23でも引き続きプレビュー段階にあるため、導入する場合は対象JDKバージョンでのステータスを確認してください。

ピン留め(Pinning)問題の発生条件と診断方法

最もハマりやすい落とし穴です。synchronized ブロックやネイティブメソッドの実行中は、Virtual Threadがプラットフォームスレッドに ピン留め されます。ピン留め中はアンマウントが発生しないため、仮想スレッドの恩恵が得られません。

JFRでピン留めの発生状況を確認できます。

# duration=30s指定で30秒後に自動的にvt.jfrが書き出されます
jcmd <PID> JFR.start duration=30s filename=vt.jfr
jfr print --events jdk.VirtualThreadPinned vt.jfr

# 手動で停止・ダンプしたい場合(長時間計測時)
jcmd <PID> JFR.start name=vtrecord filename=vt.jfr
jcmd <PID> JFR.stop name=vtrecord

jdk.VirtualThreadPinned イベントが頻発しているなら対処が必要です。

synchronizedをReentrantLockに置き換える

対処法はシンプルです。synchronizedReentrantLock に置き換えることでピン留めを解消できます。

// Before
synchronized (this) {
    doSomething();
}

// After
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    doSomething();
} finally {
    lock.unlock();
}

自分のコードは直せますが、使用ライブラリが synchronized に依存している場合はライブラリのバージョンアップが必要です。JedisやMySQLの古いJDBCドライバなどは確認しておきましょう。

@Asyncとの役割の違いと使い分け

@Async を使った非同期処理と仮想スレッドは別レイヤーの話です。混同しやすいので整理しておきましょう。

spring.threads.virtual.enabled=true を設定すると、Spring Boot 3.2は @Async のデフォルトExecutorとして SimpleAsyncTaskExecutorsetVirtualThreads(true) 設定済み)を自動構成します。追加設定なしに @Async メソッドも仮想スレッドで動作します。

ただし @Async に固定サイズのスレッドプールを明示的に指定している場合は、そのプール数で並行度が制限されてしまいます。カスタムExecutorを使う場合は Executors.newVirtualThreadPerTaskExecutor() に差し替えましょう。

@Scheduledとの組み合わせ

@Scheduled を使ったスケジューラーについては、spring.threads.virtual.enabled=true が設定済みであればSpring Boot 3.2の自動構成が同等の設定を行うため、手動でBean定義する必要はありません。

カスタムSchedulerが必要な場合は SimpleAsyncTaskScheduler を使うのが推奨パターンです。

@Bean
public TaskScheduler taskScheduler() {
    SimpleAsyncTaskScheduler scheduler = new SimpleAsyncTaskScheduler();
    scheduler.setVirtualThreads(true);
    return scheduler;
}

スケジューラーで長時間実行するタスクを動かしている場合は、ピン留め問題が起きていないかJFRで確認しておくと安心です。

まとめ

本番投入に踏み切る前に、次の4点を一度確認しておくと安心です。

  • JFRで jdk.VirtualThreadPinned イベントの有無を計測する
  • 使用ライブラリが synchronized に依存していないかチェックする
  • ThreadLocal の利用箇所を見直し、InheritableThreadLocal の挙動とメモリへの影響を確認する
  • まずカナリアリリースで段階的に展開する

RestTemplateやWebClientを使った外部API呼び出しが多いサービスほどI/O待ちの比率が高いので、仮想スレッドの効果が出やすいです。ぜひ計測してみてください。