JDK21の新機能仮想スレッド

1. 概要

仮想スレッドは、高スループットの同時アプリケーションの作成、保守、監視の労力を大幅に軽減する軽量のスレッドです。また、仮想スレッド内のプログラムは IO を待機している間プラットフォーム スレッドを放棄するため、CPU が過負荷になっていないマルチスレッド プログラムのスループットが飛躍的に向上します。これは本当に素晴らしい機能です。

2. 歴史

仮想スレッドは、JEP 425 によってプレビュー機能として提案され、JDK 19 でリリースされました。フィードバックを取得し、より多くの経験を蓄積する時間を確保するために、JEP 436 はプレビュー機能として仮想スレッドを再度提案し、JDK 20 でリリースします。この JEP では、JDK 21 で仮想スレッドを完成させ、開発者のフィードバックに基づいて JDK 20 に次の変更を加えることが推奨されています。

  • 仮想スレッドは常にスレッド ローカル変数をサポートするようになりました。プレビューの場合のように、スレッドローカル変数を使用して仮想スレッドを作成することはできなくなりました。スレッドローカル変数のサポートが保証されているため、仮想スレッドの使用時により多くの既存のライブラリが変更されないことが保証され、タスク指向のコードを仮想スレッドに移植するのに役立ちます。
  • Thread.Builder API (Executors.newVirtualThreadPerTaskExecutor() を介して作成されるのではなく) を使用して直接作成された仮想スレッドも、ライフサイクル全体にわたってデフォルトで監視されるようになり、「仮想スレッドの監視」セクションを介してアクセスして、新しいスレッド ダンプを監視できるようになりました。

3. 目標

  • シンプルなリクエストごとのスレッド形式で記述されたサーバー アプリケーションを、最適に近いハードウェア使用率で拡張できるようにします。
  • java.lang.Thread API を使用して既存のコードを有効にし、最小限の変更で仮想スレッドを導入できるようにします。
  • 既存の JDK ツールを使用して、仮想スレッドのトラブルシューティング、デバッグ、プロファイリングを簡単に実行できます。

4. 仮想スレッドに含まれないもの

  • 従来のスレッド実装を排除するのではなく、既存のアプリケーションはデフォルトでは仮想スレッドに移行されません。
  • Java の基本的な同時実行モデルは変更されません。
  • Java 言語または Java ライブラリに新しいデータ並列構造は提供されません。ストリーミング API は、依然として大規模なデータ セットを並列処理するための推奨される方法です。
    注: 並列処理は CPU のマルチコア マルチスレッド アーキテクチャを使用し、プログラムは同時に同時に実行されます。同時実行はマクロの観点から同時に実行され、並列実行または交互実行になります。同時実行性の焦点は、CPU の各コアのリソースを利用することであり、IO 待機が発生した場合、プログラムは交互に実行されます。

5. 動機

30 年近くにわたり、Java 開発者はスレッドを利用して同時サーバー アプリケーションを構築してきました。各メソッドの各ステートメントはスレッドで実行され、Java はマルチスレッドであるため、複数のスレッドを同時に実行できます。スレッドは Java の同時実行単位です。順次実行されるコードは単一のスレッドで実行され、同じ構造の他のスレッドと同時に実行され、他の単位からもほとんど独立しています。各スレッドは、ローカル変数を保存しメソッド呼び出しを調整するスタックを提供し、エラーが発生した場合にはコンテキストを提供します。例外は同じスレッド内のメソッドによってスローおよびキャッチされるため、開発者はスレッドのスタック トレースを使用して何が起こったのかを知ることができます。スレッドはツールの中核概念でもあります。デバッガはスレッド メソッドのステートメントをステップ実行し、プロファイラは複数のスレッドの動作を視覚化してパフォーマンスの理解を支援します。

6. リクエストごとのスレッド方式

サーバー アプリケーションは通常、同時ユーザー リクエストを互いに独立して処理するため、アプリケーションがリクエストを処理しているとき、リクエストの継続時間全体にわたってスレッドをそのリクエスト専用にすることができます。このリクエストごとのスレッド割り当ては、アプリケーションの同時実行単位を表すためにオペレーティング システムの同時実行単位を使用するため、理解しやすく、プログラムしやすく、デバッグしやすく、構成も簡単です。

サーバー アプリケーションのスケーラビリティは、レイテンシー、同時実行性、およびスループットを関連付けるリトルの法則によって決まります。 指定されたリクエスト処理時間 (つまり、レイテンシー) に対して、アプリケーションが同時に処理できるリクエストの数 リクエストの数 (つまり、同時実行性)到着率 (つまり、スループット) に比例して増加する必要があります。たとえば、平均レイテンシが 50 ミリ秒のアプリケーションが、10 個のリクエストを同時に処理することで、1 秒あたり 200 個のリクエストのスループットを達成すると仮定します。アプリケーションのスループットを 1 秒あたり 2000 リクエストに拡張するには、100 リクエストを同時に処理する必要があります。リクエストの存続期間中、各リクエストが 1 つのスレッドによって処理される場合、アプリケーションがそれに追いつくためにはスループットの増加に応じてスレッドの数を増やす必要があります。

残念ながら、JDK はオペレーティング システム (OS) スレッドのラッパーとしてスレッドを実装するため、使用可能なスレッドの数は制限されています。オペレーティング システムのスレッドのコストは非常に高いため、スレッドを多すぎることはできません。そのため、スレッドの実装はリクエストごとのスレッド スタイルには適していません。各リクエストが継続中にスレッド (オペレーティング システム スレッド) を消費する場合、多くの場合、CPU やネットワーク接続などの他のリソースが使い果たされる前に、スレッドの数が制限要因になります。JDK の現在のスレッド実装では、アプリケーションのスループットがハードウェアがサポートするレベルを大幅に下回るレベルに制限されています。これは、スレッドがプールされている場合でも発生します。プーリングは、新しいスレッドの開始にかかる高コストの回避に役立ちますが、スレッドの総数は増加しないためです。

7. 非同期モードを使用してスケーラビリティを向上させる

ハードウェアを最大限に活用したいと考える一部の開発者は、リクエストごとにスレッドを使用するアプローチを放棄し、スレッド共有を選択します。リクエストを 1 つのスレッドで最後まで処理するのではなく、リクエスト処理コードは、スレッドが他のリクエストを処理できるように、別の I/O 操作が完了するのを待機している間、そのスレッドをスレッド プールに返します。このきめ細かいスレッド共有 (コードは、I/O 待機中ではなく、計算実行中にのみスレッドを予約します) により、大量のスレッドを消費することなく、多数の同時操作が可能になります。これにより、オペレーティング システムのスレッド不足によって課せられるスループットの制限が解消されますが、コストが高くなります。I/O 操作が完了するのを待たない一連の独立した I/O メソッドを使用する、いわゆる非同期プログラミング スタイルが必要です。代わりに、完了信号が後でコールバックに送信されます。専用スレッドがない場合、開発者はリクエスト処理ロジックを小さなステージ (多くの場合ラムダ式として記述) に分割し、API (たとえば、 CompletableFuture またはいわゆる「リアクティブ」フレームワークを参照) に渡す必要があります。シーケンシャルパイプライン。したがって、ループや try/catch ブロックなど、言語の基本的な順次合成演算子を放棄します。

非同期スタイルでは、リクエストの各フェーズは異なるスレッドで実行され、各スレッドは異なるリクエストに属するフェーズをインターリーブ方式で実行します。これは、プログラムの動作を理解する上で重大な影響を及ぼします。スタック トレースは使用可能なコンテキストを提供できず、デバッガーは要求処理ロジックをステップ実行できず、プロファイラーは操作のコストを呼び出し元に関連付けることができません。Java のストリーミング API を使用して短いパイプラインでデータを処理する場合、ラムダ式を作成できますが、アプリケーション内のすべてのリクエスト処理コードをこの方法で記述する必要がある場合に問題が発生しますアプリケーションの同時実行単位 (非同期パイプライン) がプラットフォームの同時実行単位ではなくなるため、このプログラミング スタイルは Java プラットフォームと互換性がありません。

8. 仮想スレッドを使用してリクエストごとのスレッドコーディングスタイルを保持する

プラットフォームとの一貫性を保ちながらアプリケーションを拡張できるようにするには、リクエストごとのスレッドの処理スタイルを維持するように努める必要があります。これを実現するには、スレッドをより効率的に実装し、サポートできるスレッドの数を増やします。言語やランタイムによってスレッド スタックの使用方法が異なるため、オペレーティング システムはオペレーティング システム スレッドをより効率的に実装できません。ただし、Java ランタイムは、オペレーティング システム スレッドとの 1 対 1 の対応を壊す方法で Java スレッドを実装できます。オペレーティング システムが大量の仮想アドレス空間を限られた量の物理 RAM にマッピングすることで、メモリが豊富であるかのような錯覚を生み出すのと同じように、Java ランタイムは、多数の仮想スレッドを少数の仮想スレッドにマッピングすることによって、豊富なスレッドの錯覚を作り出すことができます。オペレーティング システムのスレッドの数。

仮想スレッドは、特定のオペレーティング システムのスレッドから独立した java.lang.Thread の実装です。対照的に、プラットフォーム スレッドは従来の方法で実装された java.lang.Thread インスタンス オブジェクトであり、オペレーティング システム スレッドの薄いラッパーです。

リクエストごとのスレッド モードのアプリケーション コードは、リクエストの継続時間全体にわたって仮想スレッドで実行できますが、仮想スレッドは CPU で計算を実行している間のみオペレーティング システムのスレッドを消費します。その結果、非同期と同じスケーラビリティが得られますが、透過的な方法で行われます。仮想スレッドで実行されているコードが java.* API でブロッキング I/O 操作を呼び出すと、ランタイムはノンブロッキング オペレーティング システムを実行し、システムを自動的に一時停止します。後で再開できるようになるまで、仮想スレッドを停止し続けます。Java 開発者にとって、仮想スレッドは、作成コストが低く、ほぼ無制限のスレッドにすぎません。ハードウェアの使用率が最適に近く、高い同時実行性、したがって高いスループットが可能になり、仮想スレッドで実装されたアプリケーションは Java プラットフォームおよび関連ツールのマルチスレッド設計と一致します。これは、開発者が仮想スレッドのデバッグのコストを学習し、使用していることを意味します。非常に低いので、簡単に学び、始めることができます。

9. 仮想スレッドの意味

仮想スレッドはオーバーヘッドが低いため、多数のスレッドをサポートするため、決してプールしないでください。アプリケーション タスクごとに新しい仮想スレッドを作成する必要があります。したがって、ほとんどの仮想スレッドは存続期間が短く、呼び出しスタックが浅く、HTTP クライアント呼び出しまたは JDBC クエリを 1 つだけ実行します。それに比べて、プラットフォーム スレッドは高価で容量が大きいため、通常はプールする必要があります。これらは存続期間が長く、呼び出しスタックが深く、複数のタスク間で共有できる傾向があります。

要約すると、仮想スレッドは、利用可能なハードウェアを最適に利用しながら、Java プラットフォームの設計と一致する、信頼性の高いリクエストごとのスレッド スタイルを維持します。仮想スレッドを使用する場合、新しい概念を学ぶ必要はありませんが、現在のスレッドの高コストに応じて開発された習慣を放棄する必要がある場合があります。仮想スレッドは、スケーラビリティを損なうことなくプラットフォーム設計と互換性のある使いやすい API を提供することで、アプリケーション開発者だけでなくフレームワーク設計者も支援します。

10. 説明

現在、JDK 内のすべての java.lang.Thread インスタンスはプラットフォーム スレッドです。プラットフォーム スレッドは、基礎となるオペレーティング システム スレッド上で Java コードを実行し、コードの存続期間全体にわたってオペレーティング システム スレッドをキャプチャします。プラットフォームのスレッド数は、オペレーティング システムのスレッド数によって制限されます。

仮想スレッドは、基礎となるオペレーティング システム スレッド上で Java コードを実行する java.lang.Thread のインスタンスですが、コードの有効期間全体にわたってオペレーティング システム スレッドをキャプチャしません。これは、多くの仮想スレッドが同じオペレーティング システム スレッド上で Java コードを実行でき、実質的にオペレーティング システム スレッドを共有できることを意味します。プラットフォーム スレッドは貴重なオペレーティング システム スレッドを独占しますが、仮想スレッドは独占しません。仮想スレッドの数は、オペレーティング システムのスレッドの数よりもはるかに大きくなる場合があります。

仮想スレッドは、オペレーティング システムではなく JDK によって提供される、スレッドの軽量実装です。これらは、Go のゴルーチンや Erlang のプロセスなど、他のマルチスレッド言語で成功したユーザー モード スレッドの形式です。ユーザー モード スレッドは、オペレーティング システムのスレッドが成熟して普及する前のJava の初期バージョンでは「グリーン スレッド」とさえ呼ばれていました。ただし、Java のグリーン スレッドはすべてオペレーティング システム スレッド (M:1 スケジューリング) を共有し、最終的にはオペレーティング システム スレッド ラッパーとして実装されたプラットフォーム スレッド (1:1 スケジューリング) に追い抜かれました。仮想スレッドは M:N スケジューリングを採用しています。つまり、多数 (M) の仮想スレッドが、少数 (N) のオペレーティング システム スレッド上で実行されるようにスケジュールされます。

11. 仮想スレッドとプラットフォーム スレッドの使用

開発者は、仮想スレッドまたはプラットフォーム スレッドの使用を選択できます。以下は、多数の仮想スレッドを作成するサンプルプログラムです。プログラムは最初に ExecutorService を取得し、送信されたタスクごとに新しい仮想スレッドを作成します。次に、プログラムは 10,000 個のタスクを送信し、すべてのタスクが完了するまで待機します。

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    
    
    IntStream.range(0, 10_000).forEach(i -> {
    
    
        executor.submit(() -> {
    
    
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

この例のタスクは「1 秒間スリープする」単純なコードであり、最新のハードウェアは、そのようなコードを同時に実行する 10,000 の仮想スレッドを簡単にサポートできます。JDK はバックグラウンドで、少数のオペレーティング システム スレッド (おそらく 1 つだけ) でのみコードを実行します。

プログラムが ExecutorService (Executors.newCachedThreadPool() など) を使用してタスクごとに新しいプラットフォーム スレッドを作成する場合、状況は大きく異なります。ExecutorService は 10,000 のプラットフォーム スレッドを作成しようとし、それによって 10,000 のオペレーティング システム スレッドが作成されます。マシンとオペレーティング システムによっては、プログラムがクラッシュする可能性があります。

プログラムが、スレッド プールからプラットフォーム スレッドを取得する ExecutorService (Executors.newFixedThreadPool(200) など) を使用する場合、状況はそれほど改善されません。ExecutorService は、共有する 10,000 タスクすべてに対して 200 のプラットフォーム スレッドを作成します。未実行のタスクは、java.util.concurrent.FutureTask インスタンスとしてキューにキャッシュされます。キューの最大サイズは Integer.MAX_VALUE です。s が作成されますが、プラットフォーム スレッドは減少しますが、タスクが 200 のプラットフォーム スレッドの 1 つによって要求されている限り、このスレッドのコードに IO 待機操作がどれだけ含まれているかに関係なく、このスレッドはプラットフォーム スレッドのリソースを解放する前にタスクを完了します。タスクはキューに入れられ、200 の並列処理方法に従って順次実行され、プログラムが完了するまでに長い時間がかかります。
上記のシナリオでも、仮想スレッド テクノロジが使用されている場合、仮想スレッドは IO を待機している間にプラットフォーム スレッド リソースを解放するため、仮想スレッド タスクが完了するまで仮想スレッドはプラットフォーム スレッドを独占しないことを意味します。スレッドが関与する IO が待機している場合、プラットフォーム スレッドは放棄されてそれぞれが待機するため、プラットフォーム スレッドは CPU 操作を必要とする仮想スレッドを実行できるため、スループットが飛躍的に向上します。200 のプラットフォーム スレッドのプールは 1 秒あたり 200 のタスクしか完了できませんが、仮想スレッドは 1 秒あたり約 10,000 のタスクを完了できます (十分なウォームアップ後)。さらに、サンプル プログラムの 10_000 を 1_000_000 に変更すると、プログラムは 1,000,000 個のタスクを送信し、同時に実行する 1,000,000 個の仮想スレッドを作成し、(十分なウォームアップの後) スループットは 1 秒あたり約 1,000,000 タスクに達します。

このプログラムのタスクが 1 秒で計算 (巨大な配列のソートなど) を実行し、ただスリープするだけではない場合、仮想スレッドであるかどうかに関係なく、プロセッサ コアの数を超えるようにスレッドの数を増やしても役に立ちません。またはプラットフォームのスレッド。仮想スレッドは高速なスレッドではなく、プラットフォーム スレッドよりも高速にコードを実行するわけではありません。これらは、速度 (待ち時間の短縮) ではなく、スケール (スループットの向上) を提供するために存在します。プラットフォーム スレッドよりも多くの仮想スレッドが存在する可能性があるため、リトルの法則によれば、仮想スレッドはより高いスループットに必要な高い同時実行性を提供できます。

別の言い方をすると、仮想スレッドは次の場合にアプリケーションのスループットを大幅に向上させることができます。

  • 同時タスクの数が多く (数千以上)、
  • この場合、プロセッサ コアより多くのスレッドがあってもスループットは向上しないため、ワークロードは CPU に依存しません。

仮想スレッドは、一般的なサーバー アプリケーションのスループットの向上に役立ちます。そのようなアプリケーションは、さまざまな IO 待機の実行にほとんどの時間を費やす多数の同時タスクで構成されているためです。

仮想スレッドは、プラットフォーム スレッドが実行できるあらゆるコードを実行できます。特に、仮想スレッドは、プラットフォーム スレッドと同様に、スレッド ローカル変数とスレッド割り込みをサポートします。これは、リクエストを処理する既存の Java コードを仮想スレッドで簡単に実行できることを意味します。多くのサーバー フレームワークは、これを自動的に実行することを選択し、受信リクエストごとに新しい仮想スレッドを開始し、その中でアプリケーションのビジネス ロジックを実行します。

以下は、他の 2 つのサービスの結果を集約するサーバー アプリケーションの例です。仮想サーバー フレームワークは、リクエストごとに新しい仮想スレッドを作成し、その仮想スレッドでアプリケーションの処理コードを実行します。アプリケーション コードは、最初の例と同様に、同じ ExecutorService を通じてリソースを同時に取得する 2 つの新しい仮想スレッドを作成します。

void handle(Request request, Response response) {
    
    
    var url1 = ...
    var url2 = ...
 
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    
    
        var future1 = executor.submit(() -> fetchURL(url1));
        var future2 = executor.submit(() -> fetchURL(url2));
        response.send(future1.get() + future2.get());
    } catch (ExecutionException | InterruptedException e) {
    
    
        response.fail(e);
    }
}
 
String fetchURL(URL url) throws IOException {
    
    
    try (var in = url.openStream()) {
    
    
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

コードが直接ブロックされるこのようなサーバー アプリケーションは、使用できる仮想スレッドの数が多いため、適切に拡張できます。

Executor.newVirtualThreadPerTaskExecutor() は、仮想スレッドを作成する唯一の方法ではありません。後で説明する新しいjava.lang.Thread.Builder API は、仮想スレッドを作成および開始できます。さらに、構造化された同時実行性は、特にスレッド間の関係がプラットフォームとそのツールに既知であるこのサーバー例のようなコードにおいて、仮想スレッドを作成および管理するためのより強力な API を提供します。

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

開発者は多くの場合、アプリケーション コードを従来のスレッド プール ベースの ExecutorService から、タスクごとに分割された仮想スレッド ExecutorService に移行します。スレッド プールは、他のリソース プールと同様、高価なリソースを共有するように設計されていますが、仮想スレッドは高価ではないため、プールする必要はありません。

開発者は、限られたリソースへの同時アクセスを制限するためにスレッド プールを使用することがあります。たとえば、サービスが 20 を超える同時リクエストを処理できない場合、サイズ 20 のスレッド プールに送信されたタスクを通じてサービスへのすべてのリクエストを処理することでこれを保証できます。このイディオムは、プラットフォーム スレッドのコストが高くスレッド プールが遍在になるため、遍在するようになりましたが、同時実行を制限するために仮想スレッドをプールすることは決してありません。代わりに、セマフォなど、この目的のために特別に設計された構造を使用してください。

開発者はスレッド プールと組み合わせて、スレッド ローカル変数を使用して、同じスレッドを共有する複数のタスク間で高価なリソースを共有することがあります。たとえば、データベース接続の作成にコストがかかる場合は、データベース接続を 1 回だけ開いてスレッド ローカル変数に保存し、後で同じスレッド内の他のタスクで使用できるようにすることができます。コードをスレッド プールの使用からタスクごとに 1 つの仮想スレッドの使用に移行する場合は、仮想スレッドごとに高価なリソースを作成するとパフォーマンスが大幅に低下する可能性があるため、このイディオムを使用するときは注意してください。このようなコードを変更して、多数の仮想スレッド間で高価なリソースを効率的に共有できる別のキャッシュ戦略を使用します。

13. 仮想スレッドのスケジュール設定

有用な作業を実行するには、スレッドをスケジュールする、つまり実行のためにプロセッサ コアに割り当てる必要があります。オペレーティング システムのスレッドとして実装されたプラットフォーム スレッドの場合、JDK はオペレーティング システムのスケジューラに依存します。対照的に、仮想スレッドの場合、JDK には独自のスケジューラがあります。JDK スケジューラは、仮想スレッドをプロセッサに直接割り当てませんが、仮想スレッドをプラットフォーム スレッドに割り当てます (これは、前述した仮想スレッドの M:N スケジューリングです)。その後、オペレーティング システムは通常どおりプラットフォーム スレッドをスケジュールします。

JDK の仮想スレッド スケジューラは、ワーク スチール (特にマルチコア プロセッサや並列コンピューティングにおけるスレッド スケジューリング戦略。この戦略により、スレッドは他の作業 (または「ワーク スチール」) を実行しているプロセッサからスチールすることができます。) です。異なるプロセッサ間でワークロードのバランスをとり、スループットと応答性を最大化します。このメカニズムは、パフォーマンスとリソース使用率の向上に役立ちます。) ForkJoinPool、先入れ先出し(FIFO) モード操作。スケジューラの並列処理とは、仮想スレッドのスケジュールに使用できるプラットフォーム スレッドの数を指します。デフォルトでは、これは使用可能なプロセッサーで使用可能なスレッドの数と等しくなりますが、システム プロパティ jdk.virtualThreadScheduler.Parallelism を介して調整できます。ForkJoinPool は、並列ストリームなどの実装に使用され、後入れ先出し (LIFO) モードで実行される通常のプールとは異なります。

スケジューラによって仮想スレッドに割り当てられたプラットフォーム スレッドは、仮想スレッドのキャリアと呼ばれます。仮想スレッドは、その存続期間中、異なるキャリア上でスケジュールできます。言い換えれば、スケジューラは、仮想スレッドと特定のプラットフォーム スレッドとの間のアフィニティを維持しません。Java コードの観点から見ると、実行中の仮想スレッドは論理的に現在のキャリアから独立しています。

  • 仮想スレッドはキャリアの ID を取得できません。Thread.currentThread() によって返される値は、常に仮想スレッド自体です。
  • スタック トレースは、キャリア スレッドと仮想スレッドに分けられます。仮想スレッドでスローされる例外には、キャリアのスタック フレームは含まれません。スレッド ダンプでは、仮想スレッドのスタック内のキャリアのスタック フレームは表示されません。また、その逆も同様です。
  • 仮想スレッドはキャリアのスレッドローカル変数を使用できず、その逆も同様です。

さらに、仮想スレッドとそのキャリアがオペレーティング システム スレッドを一時的に共有するという事実は、Java コードの観点からは見えません。代わりに、ネイティブ コードの観点から見ると、仮想スレッドとそのキャリアの両方が同じネイティブ スレッド上で実行されます。したがって、同じ仮想スレッド上で複数回呼び出されるネイティブ コードでは、呼び出しごとに異なるオペレーティング システム スレッド識別子が観察される可能性があります。

現在、スケジューラは仮想スレッドのタイムシェアリングを実装していません。タイムシェアリングとは、一定量の CPU 時間を消費したスレッドを強制的にプリエンプションすることを指します。プラットフォーム スレッドの数が比較的少なく、CPU 使用率が 100% の場合、タイム シェアリングは特定のタスクの遅延を効果的に削減できますが、数百万の仮想スレッドではタイム シェアリングの効果は明ら​​かではありません。

14. 仮想スレッドの実行

仮想スレッドを利用するためにプログラムを書き直す必要はありません。仮想スレッドは、アプリケーション コードが明示的にスケジューラに制御を返すことを要求または期待しません。つまり、仮想スレッドは操作可能ではありません。ガベージ コレクターがいつガベージを収集するかを指定できないのと同様に、ユーザー コードは仮想スレッドを制御できません。いつ、どのように制御するかです。プラットフォーム スレッドがプロセッサ コアに割り当てられる方法やタイミングと同様に、スレッドがプラットフォーム スレッドに割り当てられるかどうかは想定できません。

仮想スレッドでコードを実行するには、JDK の仮想スレッド スケジューラが、仮想スレッドをプラットフォーム スレッドにマウントすることによって、実行のために仮想スレッドをプラットフォーム スレッドに割り当てます。このようにして、プラットフォーム スレッドは仮想スレッドのキャリアになります。その後、何らかのコードを実行した後、仮想スレッドをキャリアからアンロードできます。この時点では、プラットフォーム スレッドはアイドル状態であるため、スケジューラはそのプラットフォームに別の仮想スレッドをマウントして、再びキャリアにすることができます。

通常、仮想スレッドは、I/O または JDK 内の他のブロック操作 (BlockingQueue.take() など) をブロックするとアンロードされます。ブロッキング操作を完了する準備ができると (たとえば、ソケットでバイトが受信されると)、仮想スレッドをスケジューラに送り返し、スケジューラは仮想スレッドをキャリアにマウントして実行を継続します。

仮想スレッドは、オペレーティング システムのスレッドをブロックすることなく、頻繁かつ透過的にマウントおよびアンマウントされます。たとえば、前に示したサーバー アプリケーションには、ブロック操作の呼び出しを含む次のコード行が含まれています。

response.send(future1.get() + future2.get());
これらの操作により、仮想スレッドが複数回マウントおよびアンマウントされます (通常は get() が呼び出されるたびに 1 回ずつ行われ、send(…) で実行されます) I/O 中に複​​数回マウントされる可能性があります。

JDK のブロック操作の大部分は仮想スレッドをオフロードし、そのキャリアと基盤となるオペレーティング システム スレッドを解放して新しい作業を処理できるようにします。ただし、JDK の一部のブロッキング操作は仮想スレッドをオフロードしないため、そのキャリアと基礎となるオペレーティング システム スレッドをブロックします。これは、オペレーティング システム レベル (多くのファイル システム操作など) または JDK レベル (Object.wait() など) の制限のためです。これらのブロック操作を実装すると、スケジューラの並列処理を一時的に拡張することで、オペレーティング システム スレッドのキャプチャが補償されます。したがって、スケジューラの ForkJoinPool 内のプラットフォーム スレッドの数が、使用可能なプロセッサの数を一時的に超える可能性があります。スケジューラが使用できるプラットフォーム スレッドの最大数は、システム プロパティ jdk.virtualThreadScheduler.maxPoolSize を通じて調整できます。

仮想スレッドがキャリアに固定されているため、ブロッキング操作中に仮想スレッドをアンロードできない状況は 2 つあります。

  • 同期されたブロックまたはメソッド内でコードを実行するとき、または
  • ローカルメソッドまたは外部関数を実行するとき。

仮想スレッドをキャリアに固定しても、アプリケーションが不正になることはありませんが、スケーラビリティが妨げられる可能性があります。仮想スレッドがキャリアに固定されているときにブロッキング操作 (I/O や BlockingQueue.take() など) を実行すると、そのキャリアと基礎となるオペレーティング システムのスレッドは操作中ブロックされます。頻繁に仮想スレッドを長時間にわたってキャリアに固定すると、仮想スレッドがキャリアを捕捉することになり (これは、仮想スレッドがプラットフォーム スレッドに強く結合していると理解できます)、アプリケーションのスケーラビリティが損なわれます。

スケジューラは、並列処理を拡張することによってキャリア上の仮想スレッドの固定を補償しません。代わりに、頻繁に実行される同期ブロックまたはメソッドを変更し、長時間の I/O 操作の代わりに java.util.concurrent.locks.ReentrantLock を使用することで、頻繁で長いロック キャリアを回避できます。頻繁に使用されない (起動時にのみ実行されるなど)、またはメモリ操作を保護する同期ブロックやメソッドを置き換える必要はありません。いつものように、ロックダウン戦略をシンプルかつ明確に保つよう努めてください。

新しい診断機能は、コードを仮想スレッドに移行するのに役立ち、また、synchronized の特定の使用を java.util.concurrent ロックに置き換えるべきかどうかを評価するのにも役立ちます。

  • JDK Flight Recorder (JFR) イベントは、キャリアのロック中にスレッドがブロックされると生成されます ( 「JDK Flight Recorder 」を参照)。
  • システム プロパティ jdk.tracePinnedThreads は、仮想スレッドがプラットフォーム スレッドをロックするときにスタック トレースをトリガーします。-Djdk.tracePinnedThreads=full を使用すると、スレッドがブロックされたときに完全なスタック トレースが出力され、ローカル フレームとモニターを保持しているフレームが強調表示されます。-Djdk.tracePinnedThreads=short を使用すると、出力が問題のフレームに制限されます。

15. メモリ使用量とガベージコレクション

仮想スレッドのスタックは、スタック ブロック オブジェクトの形式でヒープ メモリに保存されます。仮想スレッドのスタックはアプリケーションの実行に応じて拡大および縮小するため、JVM に構成されたプラットフォーム スレッド スタック サイズと同じ深さのスタックに対応しながらメモリを節約できます。この効率のおかげで、サーバー アプリケーションは多数の仮想スレッドを持つことができ、リクエストごとにスレッドのアプローチを継続できます。

一般に、仮想スレッドに必要なヒープ領域とガベージ コレクター アクティビティの量は、非同期コードの量よりも大きくなります。まず、スレッド自体の消費の観点から見ると、100 万の仮想スレッドには少なくとも 100 万のオブジェクトが必要であり、100 万の共有プラットフォーム スレッド プール タスクにも 100 万のオブジェクトが必要です。内部割り当ての詳細にはいくつかの違いがありますが、たとえば、リクエストごとのスレッドのアプローチで開発されたプログラムは、ヒープ内の仮想スレッド スタックに格納されるローカル変数にデータを保存できますが、非同期コードは、パイプラインのあるステージから次のステージに渡されるヒープ オブジェクトには同じデータが保持されており、仮想スレッドが必要とするヒープ フレーム レイアウトはコンパクト オブジェクトに比べて無駄が多くなりますが、仮想スレッドは多くの場合に使用できます (状況に応じて)。スタックは再利用されますが、非同期パイプラインでは常に新しいオブジェクトを割り当てる必要があるため、仮想スレッドに必要な割り当ては少なくなる場合があります。全体として、この段階では、リクエスト スレッドおよび非同期コードごとのヒープ消費量とガベージ コレクター アクティビティはほぼ同じになるはずです。仮想スレッド スタックの内部表現は、将来的にはよりコンパクトになる可能性があります。

プラットフォーム スレッド スタックとは異なり、仮想スレッド スタックは GC ルートではありません。したがって、ガベージ コレクター (G1 など) が同時ヒープ スキャンを実行するときに、Stop-the-World の一時停止中に、それらに含まれる参照は走査されません。これは、仮想スレッドが BlockingQueue.take() などの操作でブロックされ、他のスレッドが仮想スレッドまたはキューへの参照を取得できない場合、仮想スレッドは決して割り込みされないため、スレッドはガベージ コレクションされることも意味します。またはブロックを解除します。もちろん、仮想スレッドが実行中である場合、またはブロックされており、ブロック解除される可能性がある場合は、ガベージ コレクションされません。

仮想スレッドの現在の制限の 1 つは、G1 GC が巨大なスタック ブロックオブジェクトをサポートしていないことです。仮想スレッドのスタックが領域サイズの半分 (512KB 程度の場合もあります) に達すると、StackOverflowError がスローされる場合があります。

16. スレッドローカル変数

仮想スレッドは、プラットフォーム スレッドと同様に、スレッド ローカル変数 ( ThreadLocal ) および継承可能なスレッド ローカル変数 ( InheritableThreadLocal ) をサポートしているため、スレッド ローカル変数を使用する既存のコードを実行できます。ただし、非常に多くの仮想スレッドが存在する可能性があるため、スレッドローカル変数の使用は慎重に検討する必要があります。

システム プロパティ jdk.traceVirtualThreadLocals を使用すると、仮想スレッドがスレッド ローカル変数の値を設定するときにスタック トレースをトリガーできます。この診断出力は、仮想スレッドを使用するようにコードを移行するときにスレッド ローカル変数を削除するのに役立つ場合があります。スタック トレースをトリガーするには、システム プロパティを true に設定します。デフォルトは false です。

前の

JDK 21 がリリース、新機能の概要と文字列テンプレートの詳細な紹介

おすすめ

転載: blog.csdn.net/xieshaohu/article/details/133101349