Java 21で劇的進化!『Virtual Threads』(Project Loom)徹底解説とExecutors.newVirtualThreadPerTaskExecutor活用術

Java

Java 21で劇的進化!『Virtual Threads』(Project Loom)とは?

Java 21で正式導入されたVirtual Threadsは、Project Loomとして長年開発されてきた機能で、Javaアプリケーションの並行処理能力を根本から変革します。従来のプラットフォームスレッドの限界を打ち破り、数百万規模の軽量スレッドを効率的に扱うことを可能にし、高負荷なI/Oバウンド処理を持つシステムのスケーラビリティを飛躍的に向上させます。

本記事では、Virtual Threadsの基本概念から、そのメリット、そして特に現場で役立つExecutors.newVirtualThreadPerTaskExecutorを用いた具体的な活用方法までを、動くサンプルコードを交えて徹底解説します。

従来のスレッドモデルの課題

Javaの伝統的なスレッド(プラットフォームスレッド)は、オペレーティングシステム(OS)のスレッドに直接マップされます。これは信頼性をもたらす一方で、いくつかの課題を抱えていました。

  • リソース消費: OSスレッドは比較的多くのメモリを消費し、OSカーネルが管理するため、作成できる数に限りがあります。
  • コンテキストスイッチのオーバーヘッド: スレッド間の切り替え(コンテキストスイッチ)はOSレベルで行われ、一定のパフォーマンスコストが発生します。
  • ブロッキングI/Oの非効率性: データベースアクセスやネットワーク通信などのI/O処理は、完了するまでスレッドをブロックします。多数のI/O処理を並行して行う場合、多くのプラットフォームスレッドが必要となり、システムリソースを圧迫し、スケーラビリティの限界に達しやすくなります。

これらの課題を解決するため、非同期プログラミング(CompletableFutureなど)が利用されてきましたが、コードの複雑性が増すというデメリットがありました。

Virtual Threads (仮想スレッド) の概要

Virtual Threadsは、これらの課題に対するJavaの答えです。これらは「軽量なユーザーモードスレッド」であり、OSスレッドに直接マップされません。代わりに、少数のプラットフォームスレッド(キャリアスレッドと呼ばれる)上で多数の仮想スレッドが実行されます。

  • 軽量性: 仮想スレッドは非常に軽量であり、数百万単位で作成してもシステムリソースをほとんど消費しません。
  • ブロッキングI/Oの効率化: 仮想スレッドがI/O処理でブロックされると、キャリアスレッドから「アンマウント」され、別の仮想スレッドがキャリアスレッド上で実行されます。I/O処理が完了すると、仮想スレッドはキャリアスレッドに「マウント」され直され、実行を再開します。これにより、ブロッキング処理がスレッドリソースを占有することがなくなり、効率的なリソース利用が可能になります。
  • コードの簡潔さ: 仮想スレッドは、従来のプラットフォームスレッドと同じjava.lang.Thread APIを使用するため、既存の同期的なコードを非同期プログラミングの複雑さに悩まされることなく、そのまま高並行な処理に適用できます。

Virtual Threadsの生成方法

Virtual Threadsは、主に以下の2つの方法で生成・実行できます。

  • Thread.ofVirtual().start(): 最もシンプルな方法で、新しい仮想スレッドを生成してすぐに実行します。
  • Executors.newVirtualThreadPerTaskExecutor(): ExecutorServiceインターフェースを実装しており、タスクごとに新しい仮想スレッドを生成して実行します。Webサーバーやマイクロサービスなど、多数のリクエストを並行処理する際に非常に強力なツールとなります。

実践!Executors.newVirtualThreadPerTaskExecutor の使い方

ここでは、Executors.newVirtualThreadPerTaskExecutor を使って、多数の並行タスクを実行する例を見ていきましょう。外部API呼び出しやデータベースアクセスなど、I/Oバウンドな処理をシミュレートするため、短い遅延処理を挟みます。

まず、従来のプラットフォームスレッドで同じ処理を行った場合のコードと実行時間を見てみましょう。

import java.util.concurrent.*;
import java.util.stream.IntStream;

public class PlatformThreadsExample {

    private static final int NUM_TASKS = 1000; // 実行するタスク数
    private static final int TASK_DELAY_MS = 100; // 各タスクの遅延時間 (ms)

    public static void main(String[] args) throws InterruptedException {
        System.out.println("--- Platform Threads Example ---");
        long startTime = System.currentTimeMillis();

        // 固定サイズのプラットフォームスレッドプール
        // NUM_TASKSが非常に大きい場合、スレッド作成でOutOfMemoryErrorやシステムリソース枯渇の可能性
        try (ExecutorService executor = Executors.newFixedThreadPool(100)) { // 例として100スレッド
            IntStream.range(0, NUM_TASKS).forEach(i ->
                executor.submit(() -> {
                    try {
                        // 外部API呼び出しやDBアクセスをシミュレート
                        Thread.sleep(TASK_DELAY_MS);
                        // System.out.println("Task " + i + " completed by " + Thread.currentThread().getName() + " (Virtual: " + Thread.currentThread().isVirtual() + ")");
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                })
            );
        } // try-with-resources が executor.shutdown() と awaitTermination() を自動で処理

        long endTime = System.currentTimeMillis();
        System.out.println("All " + NUM_TASKS + " tasks completed in " + (endTime - startTime) + " ms");
    }
}

このコードでは、1000個のタスクを100個のプラットフォームスレッドからなる固定スレッドプールで実行しています。各タスクは100msの遅延を伴います。実行時間は、タスク数とスレッドプールサイズ、各タスクの遅延によって変動しますが、例えば1000タスク、各100ms遅延、100スレッドプールの場合、約1秒程度で完了します(100ms * (1000/100) = 1000ms)。しかし、タスク数がさらに増えたり、スレッドプールサイズを大きくしすぎると、OSリソースの限界にぶつかります。

次に、Executors.newVirtualThreadPerTaskExecutor を使用したバージョンを見てみましょう。

import java.util.concurrent.*;
import java.util.stream.IntStream;

public class VirtualThreadsExample {

    private static final int NUM_TASKS = 10000; // 実行するタスク数 (プラットフォームスレッドより大幅に増やせる)
    private static final int TASK_DELAY_MS = 100; // 各タスクの遅延時間 (ms)

    public static void main(String[] args) throws InterruptedException {
        System.out.println("--- Virtual Threads Example ---");
        long startTime = System.currentTimeMillis();

        // タスクごとに新しい仮想スレッドを生成するExecutorService
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, NUM_TASKS).forEach(i ->
                executor.submit(() -> {
                    try {
                        // 外部API呼び出しやDBアクセスをシミュレート
                        Thread.sleep(TASK_DELAY_MS);
                        // System.out.println("Task " + i + " completed by " + Thread.currentThread().getName() + " (Virtual: " + Thread.currentThread().isVirtual() + ")");
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                })
            );
        } // try-with-resources が executor.shutdown() と awaitTermination() を自動で処理

        long endTime = System.currentTimeMillis();
        System.out.println("All " + NUM_TASKS + " tasks completed in " + (endTime - startTime) + " ms");
    }
}

このコードでは、Executors.newVirtualThreadPerTaskExecutor() を使用しています。注目すべきは、NUM_TASKS を10000に増やしても、プラットフォームスレッド版とほぼ同等か、それ以上のパフォーマンスを発揮できる点です。各タスクはそれぞれ独立した仮想スレッドで実行されますが、これらの仮想スレッドは少数のキャリアスレッドに効率的にマッピングされるため、OSリソースを圧迫することなく、高い並行性を実現できます。

実行結果を見ると、10000タスクをそれぞれ100ms遅延させても、完了までの時間はプラットフォームスレッド100個で1000タスクを実行した場合と大きく変わらないか、むしろ高速になる場合もあります。これは、仮想スレッドがI/Oブロッキング時にキャリアスレッドを解放し、他の仮想スレッドに利用させることで、CPUリソースを最大限に活用しているためです。

Virtual Threadsの活用シーンと注意点

Virtual Threadsは非常に強力ですが、その特性を理解して適切に活用することが重要です。

  • 活用シーン:
    • I/Oバウンドなアプリケーション: Webサーバー、マイクロサービス、データベースクライアントなど、ネットワークI/OやディスクI/Oがボトルネックになりやすいアプリケーションで特に効果を発揮します。
    • 多数の並行リクエスト処理: 同時に多数のクライアントからのリクエストを処理する必要があるシステム。
    • 既存コードのモダナイゼーション: 既存の同期的なブロッキングI/Oコードを、大きな変更なしに高並行処理に対応させたい場合。
  • 注意点:
    • CPUバウンドな処理には不向き: 仮想スレッドはI/Oブロッキング時の効率化が主な目的です。CPUを大量に消費する計算処理(例: 大規模な数値計算、画像処理)には、従来のプラットフォームスレッドや適切なスレッドプールの方が適している場合があります。CPUバウンドな処理で多数の仮想スレッドを使用すると、コンテキストスイッチのオーバーヘッドが増加し、かえってパフォーマンスが低下する可能性があります。
    • スレッドプールの概念の変化: Executors.newVirtualThreadPerTaskExecutor は「タスクごとに新しい仮想スレッドを生成する」ため、従来の固定サイズスレッドプールのようにスレッドを再利用するという概念とは異なります。スレッドリークの心配はほとんどありませんが、大量のタスクを一度に投入する際には、メモリ使用量やガベージコレクションの頻度を監視することをお勧めします。
    • スレッドローカルの利用: 仮想スレッドでもスレッドローカル変数(ThreadLocal)は利用できますが、多数の仮想スレッドを作成する場合、スレッドローカルに大量のデータを保持するとメモリを消費しすぎる可能性があります。

まとめ

Java 21で導入されたVirtual Threads (Project Loom) は、Javaの並行処理モデルに革命をもたらす画期的な機能です。軽量な仮想スレッドにより、I/Oバウンドなアプリケーションにおいて、従来のプラットフォームスレッドの限界をはるかに超えるスケーラビリティとパフォーマンスを実現します。

特に Executors.newVirtualThreadPerTaskExecutor を活用することで、既存の同期的なコードをほとんど変更することなく、高並行な処理へと簡単に移行できます。CPUバウンドな処理には注意が必要ですが、Webサービスやマイクロサービス開発に携わるJavaエンジニアにとって、Virtual Threadsは間違いなく習得すべき重要な技術となるでしょう。

この新しい強力なツールを使いこなし、より高性能でスケーラブルなJavaアプリケーションを開発していきましょう!

コメント

タイトルとURLをコピーしました