12 仮想スレッド

仮想スレッドは、高スループットの同時アプリケーションの書込み、メンテナンスおよびデバッグ作業を削減する軽量スレッドです。

ノート:

これはプレビュー機能です。プレビュー機能は、設計、仕様および実装が完了したが、永続的でない機能です。プレビュー機能は、将来のJava SEリリースで、異なる形式で存在することもあれば、まったく存在しないこともあります。プレビュー機能が含まれているコードをコンパイルして実行するには、追加のコマンド行オプションを指定する必要があります。『Preview Language and VM Features』を参照してください。

仮想スレッドの背景情報は、JEP 425を参照してください。

スレッドは、スケジュールできる処理の最小単位です。これは、他の同様のユニットと同時に、多くの場合は独立して実行されます。これは java.lang.Threadのインスタンスです。スレッドには、プラットフォーム・スレッドと仮想スレッドの2種類があります。

プラットフォーム・スレッドとは

プラットフォーム・スレッドは、オペレーティング・システム(OS)スレッドを囲むthinラッパーとして実装されます。プラットフォーム・スレッドは、基礎となるOSスレッド上でJavaコードを実行します。また、プラットフォーム・スレッドはプラットフォーム・スレッドの存続期間中ずっとOSスレッドを保持します。このため、使用可能なプラットフォーム・スレッドの数はOSスレッドの数に制限されます。

通常、プラットフォーム・スレッドには、オペレーティング・システムによって保守される大きなスレッド・スタックおよびその他のリソースがあります。プラットフォーム・スレッドはスレッド・ローカル変数をサポートします。

プラットフォーム・スレッドは、すべてのタイプのタスクの実行に適していますが、リソースに制限がある場合があります。

仮想スレッドとは

プラットフォーム・スレッドと同様に、仮想スレッドjava.lang.Threadのインスタンスです。ただし、仮想スレッドは特定のOSスレッドに関連付けられません。それにもかかわらず仮想スレッドはOSスレッド上でコードを実行します。ただし、仮想スレッドで実行されているコードがブロッキングI/O操作をコールすると、Javaランタイムは、再開できるまで仮想スレッドを一時停止します。一時停止された仮想スレッドに関連付けられていたOSスレッドは解放され、他の仮想スレッドの操作を実行できます。

仮想スレッドは、仮想メモリーと同様の方法で実装されます。大容量のメモリーをシミュレートするため、オペレーティング・システムは大きな仮想アドレス空間を限られた量のRAMにマップします。同様に、多数のスレッドをシミュレートするため、Javaランタイムは多数の仮想スレッドを少数のOSスレッドにマップします。

プラットフォーム・スレッドとは異なり、通常、仮想スレッドのコール・スタックは浅く、1つのHTTPクライアント・コールまたは1つのJDBC問合せとして実行されます。仮想スレッドでもスレッド・ローカル変数がサポートされますが、1つのJVMが数百万個の仮想スレッドをサポートする場合があるため、このような変数の使用は慎重に検討してください。

仮想スレッドが適しているのは、ほとんどの時間をブロックされて(多くの場合、I/O操作の完了を待機して)費やすタスクの実行です。ただし、長時間実行されるCPU集約型の操作は対象ではありません。

仮想スレッドを使用する理由

仮想スレッドは、高スループットの同時アプリケーションで使用します。特に、時間のほとんどを待機に費やす大量の同時タスクで構成されるアプリケーションです。サーバー・アプリケーションは高スループット・アプリケーションの一例です。通常、ブロッキングI/O操作(リソースのフェッチなど)を実行する多くのクライアント・リクエストを処理するためです。

仮想スレッドは高速スレッドではありません。つまりプラットフォーム・スレッドよりも速くコードが実行されることはありません。速度(低レイテンシ)ではなく、スケール(高スループット)を提供するために存在します。

仮想スレッドの作成と実行

Thread APIおよびThread.Builder APIは、プラットフォーム・スレッドと仮想スレッドの両方を作成する方法を提供します。java.util.concurrent.Executorsクラスは、各タスクに対して新しい仮想スレッドを開始するExecutorServiceを作成するメソッドも定義します。

ThreadクラスおよびThread.Builderインタフェースを使用した仮想スレッドの作成

Thread.ofVirtual()メソッドをコールして、仮想スレッドを作成するためのThread.Builderのインスタンスを作成します。

次の例では、メッセージを出力する仮想スレッドを作成して開始します。これはjoinメソッドをコールして、仮想スレッドが終了するまで待機します。(これにより、出力れたメッセージをメイン・スレッドが終了する前に確認できます。)

Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello"));
thread.join();

Thread.Builderインタフェースを使用すると、一般的なThreadプロパティ(スレッドの名前など)のスレッドを作成できます。Thread.Builder.OfPlatformサブインタフェースによってプラットフォーム・スレッドが作成され、Thread.Builder.OfVirtualによって仮想スレッドが作成されます。

次の例では、Thread.Builderインタフェースを使用してMyThreadという名前の仮想スレッドを作成します:

        try {
            Thread.Builder builder = Thread.ofVirtual().name("MyThread");
            Runnable task = () -> {
                System.out.println("Running thread");
            };
            Thread t = builder.start(task);
            System.out.println("Thread t name: " + t.getName());
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

次の例では、Thread.Builderを使用して2つの仮想スレッドを作成して開始します:

public class CreateNamedThreadsWithBuilders {
    
    public static void main(String[] args) {
        
        try {
            Thread.Builder builder =
                Thread.ofVirtual().name("worker-", 0);

            Runnable task = () -> {
                System.out.println("Thread ID: " +
                    Thread.currentThread().threadId());
            };            

            // name "worker-0"
            Thread t1 = builder.start(task);   
            t1.join();
            System.out.println(t1.getName() + " terminated");

            // name "worker-1"
            Thread t2 = builder.start(task);   
            t2.join();  
            System.out.println(t2.getName() + " terminated");
            
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

この例では、次のような出力が表示されます:

Thread ID: 21
worker-0 terminated
Thread ID: 24
worker-1 terminated

Executors.newVirtualThreadPerTaskExecutor()メソッドを使用した仮想スレッドの作成と実行

エグゼキュータを使用すると、アプリケーションの他の部分からスレッド管理と作成を分離できます。

次の例では、Executors.newVirtualThreadPerTaskExecutor()メソッドを使用してExecutorServiceを作成します。ExecutorService.submit(Runnable)がコールされるたびに、新しい仮想スレッドが作成され、タスクを実行するために開始されます。このメソッドは、Futureのインスタンスを戻します。メソッドFuture.get()は、スレッドのタスクが完了するまで待機します。したがって、この例では、仮想スレッドのタスクが完了したときにメッセージが出力されます。

        try (ExecutorService myExecutor =
            Executors.newVirtualThreadPerTaskExecutor()) {
            Future<?> future =
                myExecutor.submit(() -> System.out.println("Running thread"));
            future.get();
            System.out.println("Task completed");
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }   

マルチスレッド・クライアント・サーバーの例

次の例は、2つのクラスで構成されています。EchoServerは、ポートでリスニングし、接続ごとに新しい仮想スレッドを開始するサーバー・プログラムです。EchoClientは、サーバーに接続し、コマンドラインに入力されたメッセージを送信するクライアント・プログラムです。

EchoClientはソケットを作成し、EchoServerへの接続を取得します。これは標準入力ストリームでユーザーからの入力を読み取り、そのテキストをソケットに書き込むことでEchoServerに転送します。EchoServerは、ソケットを介して、その入力をEchoClientにエコーします。EchoClientは、サーバーから戻されたデータを読み取って表示します。EchoServerは、仮想スレッド(クライアント接続ごとに1つのスレッド)を介して複数のクライアントに同時にサービスを提供できます。

public class EchoServer {
    
    public static void main(String[] args) throws IOException {
         
        if (args.length != 1) {
            System.err.println("Usage: java EchoServer <port>");
            System.exit(1);
        }
         
        int portNumber = Integer.parseInt(args[0]);
        try (
            ServerSocket serverSocket =
                new ServerSocket(Integer.parseInt(args[0]));
        ) {                
            while (true) {
                Socket clientSocket = serverSocket.accept();
                // Accept incoming connections
                // Start a service thread
                Thread.ofVirtual().start(() -> {
                    try (
                        PrintWriter out =
                            new PrintWriter(clientSocket.getOutputStream(), true);
                        BufferedReader in = new BufferedReader(
                            new InputStreamReader(clientSocket.getInputStream()));
                    ) {
                        String inputLine;
                        while ((inputLine = in.readLine()) != null) {
                            System.out.println(inputLine);
                            out.println(inputLine);
                        }
                    
                    } catch (IOException e) { 
                        e.printStackTrace();
                    }
                });
            }
        } catch (IOException e) {
            System.out.println("Exception caught when trying to listen on port "
                + portNumber + " or listening for a connection");
            System.out.println(e.getMessage());
        }
    }
}
public class EchoClient {
    public static void main(String[] args) throws IOException {
        if (args.length != 2) {
            System.err.println(
                "Usage: java EchoClient <hostname> <port>");
            System.exit(1);
        }
        String hostName = args[0];
        int portNumber = Integer.parseInt(args[1]);
        try (
            Socket echoSocket = new Socket(hostName, portNumber);
            PrintWriter out =
                new PrintWriter(echoSocket.getOutputStream(), true);
            BufferedReader in =
                new BufferedReader(
                    new InputStreamReader(echoSocket.getInputStream()));
        ) {
            BufferedReader stdIn =
                new BufferedReader(
                    new InputStreamReader(System.in));
            String userInput;
            while ((userInput = stdIn.readLine()) != null) {
                out.println(userInput);
                System.out.println("echo: " + in.readLine());
                if (userInput.equals("bye")) break;
            }
        } catch (UnknownHostException e) {
            System.err.println("Don't know about host " + hostName);
            System.exit(1);
        } catch (IOException e) {
            System.err.println("Couldn't get I/O for the connection to " +
                hostName);
            System.exit(1);
        } 
    }
}

仮想スレッドのスケジュールおよび固定された仮想スレッド

プラットフォーム・スレッドがいつ実行されるかは、オペレーティング・システムによってスケジュールされます。ただし、仮想スレッドがいつ実行されるかはJavaランタイムによってスケジュールされます。Javaランタイムが仮想スレッドをスケジュールするとき、仮想スレッドをプラットフォーム・スレッドに割り当てます(すなわちマウントします)。その後、オペレーティング・システムがそのプラットフォーム・スレッドを通常どおりスケジュールします。このプラットフォーム・スレッドはキャリアと呼ばれます。いくつかのコードを実行した後で、仮想スレッドをそのキャリアからマウント解除することができます。通常これが発生するのは、仮想スレッドがブロッキングI/O操作を実行するときです。仮想スレッドがキャリアからマウント解除されると、キャリアは解放されます。これは、Javaランタイム・スケジューラが別の仮想スレッドをそのキャリアにマウントできることを意味します。

仮想スレッドがキャリアに固定されている場合、ブロッキング操作中にマウント解除することはできません。仮想スレッドが固定されるのは次の状況です:

  • 仮想スレッドが、synchronizedブロックまたはメソッド内でコードを実行します
  • 仮想スレッドが、nativeメソッドまたは外部関数実行します(外部関数およびメモリーAPIを参照)

固定してアプリケーションが正しくなくなることはありませんが、スケーラビリティが低下する可能性があります。よく実行されるsynchronizedブロックまたはメソッドを修正し、潜在的に長時間になるI/O操作をjava.util.concurrent.locks.ReentrantLockによって保護することで、長期にわたる頻繁な固定を回避してみてください。

仮想スレッドのデバッグ

仮想スレッドがスレッドであることに変わりありません。デバッガーはプラットフォーム・スレッドのようにスレッドをステップ処理することができます。Java Flight Recorderとjcmdツールには、アプリケーションの仮想スレッドの観察に役立つ追加機能があります。

仮想スレッドのJava Flight Recorderイベント

Java Flight Recorder (JFR)は、仮想スレッドに関連する次のイベントを出力できます:

  • jdk.VirtualThreadStartおよびjdk.VirtualThreadEndは、仮想スレッドが開始したときと終了したときを示します。これらのイベントはデフォルトでは無効です。
  • jdk.VirtualThreadPinnedは、仮想スレッドが固定された(およびキャリア・スレッドが解放されなかった)ことを示します。このイベントは、20ミリ秒のしきい値でデフォルトで有効になっています。
  • jdk.VirtualThreadSubmitFailedは、おそらくリソースの問題が原因で、仮想スレッドの開始またはパーキング解除が失敗したことを示します。仮想スレッドをパーキングすると、基礎となるキャリア・スレッドが他の作業をできるように解放されます。仮想スレッドをパーキング解除すると、仮想スレッドの続行がスケジュールされます。このイベントはデフォルトで有効です。

jdk.VirtualThreadStartイベントおよびjdk.VirtualThreadEndイベントを有効にするには、JDK Mission ControlまたはカスタムJFR構成を使用します。『Java Platform, Standard Edition Flight Recorder APIプログラマーズ・ガイド』Flight Recorderの構成を参照してください。

これらのイベントを出力するには、次のコマンドを実行します(recording.jfrは記録するためのファイル名です):

jfr print --events jdk.VirtualThreadStart,jdk.VirtualThreadEnd,jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed recording.jfr

jcmdスレッド・ダンプでの仮想スレッドの表示

プレーン・テキストとJSON形式のスレッド・ダンプを作成できます:

jcmd <PID> Thread.dump_to_file -format=text <file>
jcmd <PID> Thread.dump_to_file -format=json <file>

JSON形式は、この形式を受け入れるデバッグ・ツールにとって最適です。

jcmdスレッド・ダンプには、ネットワークI/O操作でブロックされている仮想スレッドと、ExecutorServiceインタフェースによって作成された仮想スレッドがリスト表示されます。これには、オブジェクト・アドレス、ロック、JNI統計、ヒープ統計、および従来のスレッド・ダンプに表示されるその他の情報は含まれません。

仮想スレッドに関する実際的なアドバイス

仮想スレッドは低コストで大量に使用でき、通常、多くのプログラミング技術で使用されます。プラットフォーム・スレッドは、高コストで重量があるため、適用できなくなったり、推奨されなくなったりしているためです。

仮想スレッドをプールしない

スレッド・プールは、事前に構成されたプラットフォーム・スレッドのグループです。これらのスレッドは使用可能になったときに再使用されます。決まった数のスレッドが含まれるスレッド・プールもあれば、必要に応じて新しいスレッドが作成されるスレッド・プールもあります。

仮想スレッドをプールしません。アプリケーション・タスクごとに1つ作成します。仮想スレッドは存続期間が短く、コール・スタックは深くありません。追加のオーバーヘッド、つまりスレッド・プールの機能は必要ありません。

制限されたリソースにセマフォを使用

セマフォによって、物理リソースまたは論理リソースにアクセスできるスレッドの数を制限します。同時実行性を制限する必要がある場合は、(スレッド・プールではなく)セマフォを使用します。たとえば、制限されたリソース(データベースへのリクエストなど)に指定した数のスレッドのみがアクセスできるようにする場合です。

固定の回避

仮想スレッドのスケジュールおよび固定された仮想スレッドで説明したように、固定するとアプリケーションのスケーラビリティが低下することがあります。よく実行されるsynchronizedブロックまたはメソッドを修正し、潜在的に長時間になるI/O操作をjava.util.concurrent.locks.ReentrantLockによって保護することで、長期にわたる頻繁な固定を回避します。複数スレッドが共有するリソースへのアクセスが、同期モードでの暗黙ロックと同様の方法で制御されます。

スレッド・ローカル変数の使用について確認

アプリケーション内に多数の仮想スレッドが存在することがあるため、スレッド・ローカル変数の使用は慎重に検討してください。