Java Virtual Threads: 実践ガイド

1. 概要

Virtual threadsはJava 21(JEP 444)で正式化された機能で、同期的なコード(blocking style)を書きながらも、non-blocking I/Oと同等のスループットを実現できます。このガイドはコンセプトからvirtual threadの作成方法、platform threadとのパフォーマンス比較、Spring Bootへの統合まで、段階的に解説します。最後に、いつvirtual threadsを使うべきか、いつ使うべきでないかが理解できるでしょう。

2. 従来のPlatform Threadの問題

JavaのplatformスレッドはOSスレッドと1対1でマッピングされます。OSスレッドはメモリを消費し(通常は1MBのスタック)、作成とコンテキストスイッチにも高いコストがかかります。そのため、数万のリクエストを同時処理するアプリケーション(例えばI/Oを待つサーバー)は、作成できるスレッドプールのサイズによって制限されることが多いです。

ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
    executor.submit(() -> {
        try {
            Thread.sleep(1000); // I/O呼び出しをシミュレート
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}
executor.shutdown();

プール200スレッドで10,000タスクを投入すると、タスクはキューで待機し、各スレッドは一度に1つのタスクのみ処理できます。ほとんどの時間は待機(blocked)であり、計算ではないのに、全体の完了時間は大幅に延長されます。

3. Virtual Threadとは

Virtual threadはJVMが管理するスレッド(OSスレッドではなく)で、コード実行時にのみplatformスレッド(carrier threadと呼びます)に「マウント」されます。virtual threadがblocking操作(I/O、Thread.sleep、ロック…)を呼ぶと、JVMは自動的にそれをcarrier threadから「アンマウント」し、carrier threadが別のvirtual threadを処理できるようにします。

その結果、リソースを枯渇させることなく、数百万のvirtual threadを作成できます。一方、コードは従来のblocking方式で書くことができます。CompletableFutureやreactiveライブラリは必要ありません。

3.1. Virtual Threadの基本的な作成

Thread vThread = Thread.ofVirtual()
    .name("order-processor")
    .start(() -> {
        System.out.println("実行先: " + Thread.currentThread());
    });
vThread.join();

Thread.ofVirtual()はvirtual threadを設定するbuilderを返します。.start()は即座にスレッドを作成・実行します。出力されるスレッド情報は以下のような形式です:

実行先: VirtualThread[#21,order-processor]/runnable@ForkJoinPool-1-worker-1

@ForkJoinPool-...の部分に注目してください。これがこのvirtual threadを「担っている」carrier threadです。

4. Virtual Thread用のExecutor

実アプリケーションでvirtual threadsを使う最も一般的な方法は、ExecutorServiceを通じて利用することです。

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<String>> futures = new ArrayList<>();
    for (int i = 0; i < 10_000; i++) {
        int id = i;
        futures.add(executor.submit(() -> {
            Thread.sleep(1000); // I/O呼び出しをシミュレート(DB、HTTP…)
            return "タスク " + id + " 完了";
        }));
    }
    for (Future<String> f : futures) {
        f.get();
    }
}

newVirtualThreadPerTaskExecutor()はタスクごとに新しいvirtual threadを作成し、従来のスレッドプールのようにスレッドを再利用しません。virtual threadの作成コストは非常に低いため(数百バイト、1MBではなく)、このアプローチは完全に合理的です。try-with-resourcesブロックはexecutorが確実に閉じられ、ブロックを抜ける際にタスクが完了するのを待つことを保証します。

セクション2の例(200 platform threadのプール)と比較すると、このコードは約1秒で全10,000タスクをほぼ同時に完了します(プール制限がないため)。従来のようにキューで何ラウンドも待つ必要はありません。

5. 実践的なテストによるパフォーマンス比較

class VirtualThreadBenchmarkTest {

    @Test
    void virtualThreadsShouldHandleManyBlockingTasksFast() throws InterruptedException {
        int taskCount = 10_000;
        CountDownLatch latch = new CountDownLatch(taskCount);
        Instant start = Instant.now();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < taskCount; i++) {
                executor.submit(() -> {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    } finally {
                        latch.countDown();
                    }
                });
            }
        }

        latch.await();
        Duration elapsed = Duration.between(start, Instant.now());

        assertThat(elapsed).isLessThan(Duration.ofSeconds(5));
    }
}

このテストは10,000タスクを作成し、各タスクは100ms待機します。順序実行すれば1000秒かかりますが、virtual threadsを使えば全体が数秒で完了します。タスクが真に並列に実行されるからです。CountDownLatchはassertする前に全タスクが完了するのを待つために使用されます。

注: 実際のベンチマークでは、時間だけでなくJFR(Java Flight Recorder)を使ってCPUとメモリも測定すべきです。実行時間はテスト環境に依存するためです。

6. 落とし穴:synchronized使用時のピニング

virtual threadがsynchronizedブロック内にある場合、carrier threadからアンマウントされません。この現象はピニングと呼ばれます。この場合、synchronized内のblocking I/Oはcarrier thread全体を占有し、virtual threadのメリットが失われます。

public class InventoryService {

    private final Object lock = new Object();

    public void reserveStock(String sku) {
        synchronized (lock) {
            callExternalInventoryApi(sku); // synchronized内のblocking I/O -> ピニング!
        }
    }
}

synchronized (lock)行は、callExternalInventoryApiの呼び出し全体の間、carrier threadを保有します。これはI/O操作であるにもかかわらずです。何千ものvirtual threadが同時にピンされた場合、carrier thread pool(デフォルトではCPUコア数に等しい)はすぐに枯渇します。

解決策:synchronizedをjava.util.concurrent.locks.ReentrantLockに置き換えてください。これはvirtual threadとよく互換性があります(ピニングを引き起こしません)。

public class InventoryService {

    private final ReentrantLock lock = new ReentrantLock();

    public void reserveStock(String sku) {
        lock.lock();
        try {
            callExternalInventoryApi(sku);
        } finally {
            lock.unlock();
        }
    }
}

ReentrantLockにより、blocking I/Oに遭遇すると、virtual threadは正常にアンマウントでき、virtual threadの「軽量」な本質を維持できます。

7. Virtual ThreadのSpring Bootへの統合

Spring Boot 3.2以降、1つのプロパティを有効にするだけで、Spring Web(Tomcat)が各リクエストに対してvirtual threadを使用します。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring.threads.virtual.enabled=true

このプロパティはTomcatのvirtualThreadExecutorを有効にします(Spring Bootがサポートする他のexecutorも同様)。到着するHTTPリクエストは、固定されたplatform threadプール(Tomcatのデフォルトは200スレッド)を共有する代わりに、独立したvirtual threadで処理されます。

@RestController
@RequestMapping("/orders")
class OrderController {

    private final OrderService orderService;

    OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        return orderService.fetchOrderWithDetails(id); // DB呼び出し+他のAPI呼び出し、通常のblocking方式
    }
}

コントローラーコードに変更は不要です。従来のblocking方式で書き続けることができます。abやk6のようなツールで負荷テストを実施すると、I/O遅延が高いエンドポイント(DB呼び出し、他のサービス呼び出し)でのスループットが著しく増加することに気付くでしょう。Tomcatがもはやデフォルトの200スレッドに制限されないからです。

8. Virtual Threadを使うべきではない場合

  • CPU-bound task:タスクが主に計算(I/O待ちなし)である場合、virtual threadは利益をもたらしません。carrier threadの数はCPUコア数によって制限されています。ForkJoinPoolまたはparallelStream()の方が適切です。
  • 重いThreadLocalを使用するコード:virtual threadはThreadLocalと互換性がありますが、何百万ものvirtual threadを作成し、各スレッドが大きなThreadLocalを保持する場合、かなりのメモリを消費します。Java 21のプレビュー機能であるScopedValueへの置き換えを検討してください。
  • synchronizedを多用する古いライブラリ:セクション6で説明したように、ピニングは気付かないうちにパフォーマンスを低下させる可能性があります。
  • 制限されたコネクションプール:virtual threadは多くのリクエストを並列に作成するのに役立ちますが、DBコネクションプールが20接続だけの場合、そこでボトルネックが発生します。対応するプールサイズを増やすか、高い並行性に対応したコネクションプールを使用する必要があります。

注: virtual threadはスレッド数の問題を解決し、個々のタスクの処理速度の問題を解決するものではありません。CPU-boundコードを高速化することは期待しないでください。

9. 結論

Virtual threadsは、シンプルなblockingコードを書きながら、数千の同時リクエストに対応できるスケーラビリティを提供します。特にWebサービスやマイクロサービスなど、I/O-boundアプリケーションに適しています。executorを変更するか、Spring Bootでプロパティを有効にするだけです。ロジックをreactiveなスタイルで書き直す必要はありません。ただし、synchronizedによるピニングの落とし穴に注意し、virtual threadはCPU-boundタスクには役立たないことを忘れないでください。

javavirtual-threadsconcurrencyspring-bootjava21