ホームページ  >  記事  >  Java  >  Java ナレッジポイントの要約: JDK19 仮想スレッド

Java ナレッジポイントの要約: JDK19 仮想スレッド

WBOY
WBOY転載
2022-10-09 14:46:162518ブラウズ

この記事では、java に関する関連知識をお届けします。主に jdk19 の仮想スレッドに関する関連内容を紹介します。仮想スレッドとは、Go 言語におけるゴルーチンや Erlang 言語によるプロセスです。同様の実装方法は、ユーザーモードのスレッドです。一緒に見てみましょう。皆さんのお役に立てれば幸いです。

Java ナレッジポイントの要約: JDK19 仮想スレッド

推奨学習: 「

Java ビデオ チュートリアル

はじめに
#仮想スレッドは、Go 言語のゴルーチンや Erlang 言語のプロセスに似た実装を持ち、ユーザー モード スレッドの形式です。

以前は、コンピューター ハードウェアの使用率を向上させるためにプラットフォーム スレッドを共有するために Java でスレッド プールがよく使用されていましたが、この非同期スタイルでは、リクエストの各段階が異なるスレッドで実行される可能性があります。スレッドは、さまざまなリクエストに属するフェーズをインターリーブ方式で実行します。これは Java プラットフォームの設計と矛盾しており、次のような結果になります。

    #スタック トレースは使用可能なコンテキストを提供しません
  • デバッガはリクエスト処理ロジックをステップ実行できません。
  • プロファイラは、操作のコストを呼び出し元に関連付けることができません。
  • 仮想スレッドは、スケーラビリティに影響を与えることなくハードウェアを最適に利用しながら、プラットフォームの設計との互換性を維持します。仮想スレッドは、オペレーティング システムではなく JDK によって提供されるスレッドの軽量実装です。

    仮想スレッドは、特定のオペレーティング システムのスレッドにバインドされていないスレッドです。
  • プラットフォーム スレッドは、オペレーティング システム スレッドの単純なラッパーとして、従来の方法で実装されたスレッドです。

概要Java プラットフォームに仮想スレッドを導入します。仮想スレッドは、高スループットの同時アプリケーションの作成、保守、監視の労力を大幅に軽減する軽量のスレッドです。

目標

    シンプルなリクエストごとに 1 スレッドの方法で記述されたサーバー アプリケーションが、最も多くの機能に近づくことができるようにします。最適なハードウェア使用率によるスケーリング。
  • java.lang.ThreadAPI を使用する既存のコードで、最小限の変更で仮想スレッドを採用できるようにします。
  • 既存の JDK ツールを使用して、仮想スレッドのトラブルシューティング、デバッグ、分析を簡単に実行できます。
#非ターゲット

スレッドのレガシー実装を削除するか、仮想を使用して既存のアプリケーションを移行しますスレッドが目標ではありません。
  • Java の基本的な同時実行モデルを変更します。
  • 私たちの目標は、Java 言語または Java ライブラリで新しいデータ並列構造を提供することではありません。 StreamAPI は、依然として大規模なデータ セットを並列処理するための推奨される方法です。
動機

30 年近くにわたり、Java 開発者は並行サーバー アプリケーションの構成要素としてスレッドに依存してきました。 。各メソッドの各ステートメントはスレッドで実行されます。Java はマルチスレッドであるため、複数の実行スレッドが同時に発生します。

スレッドは Java の同時実行単位であり、他の同様の単位と同時に、またほとんど独立して実行される一連のコードです。

各スレッドは、エラー発生時のコンテキストだけでなく、ローカル変数やメソッド呼び出しの調整を保存するためのスタックを提供します。例外は同じスレッド内のメソッドによってスローおよびキャッチされるため、開発者はスレッドのスタック トレースを使用できます。何が起こったのかを知るために。

スレッドはツールの中核概念でもあります。デバッガーはスレッド メソッドのステートメントを実行し、プロファイラーは複数のスレッドの動作を視覚化してパフォーマンスの理解を支援します。

2 つの同時実行スタイル

リクエストごとのスレッド スタイル

サーバー アプリケーションは通常、同時ユーザーを他のリクエストとは独立して処理します。したがって、アプリケーションがリクエストの継続時間全体にわたってリクエストにスレッドを割り当てることによってリクエストを処理することは理にかなっています。このスレッドオンリクエスト スタイルは、プラットフォームの同時実行単位を使用してアプリケーションの同時実行単位を表すため、理解しやすく、プログラムしやすく、デバッグしやすく、構成も簡単です。
  • サーバー アプリケーションのスケーラビリティは、レイテンシー、同時実行性、スループットに関連するリトルの法則によって決まります。 特定のリクエストの処理が継続する時間 (レイテンシー)、アプリケーションが実行できるリクエストの数同時に処理する処理 (同時実行性) は、到着率 (スループット) に比例して増加する必要があります。
  • たとえば、平均レイテンシーが 50 ミリ秒のアプリケーションが、10 個のリクエストを同時に処理することで、1 秒あたり 200 個のリクエストのスループットを達成すると仮定します。このアプリケーションが 1 秒あたり 2000 リクエストのスループットを達成するには、100 リクエストを同時に処理する必要があります。リクエストの存続期間中、各リクエストが 1 つのスレッドで処理される場合、アプリケーションがそれに追いつくためには、スループットの増加に応じてスレッドの数も増やす必要があります。

  • 残念ながら、JDK はオペレーティング システム (OS) スレッドのラッパーとしてスレッドを実装するため、使用可能なスレッドの数は制限されています。 OS スレッドは高価であるため、あまり多くのスレッドを使用することはできません。そのため、実装はリクエストごとに 1 スレッドのスタイルには適していません。

  • 各リクエストが継続中に 1 つのスレッド、つまり 1 つの OS スレッドを消費する場合、通常は、CPU やネットワーク接続などの他のリソースが使い果たされる前にスレッドの数が使い果たされ、制限が発生します。長い間要因でした。 JDK の現在のスレッド実装では、アプリケーションのスループットが、ハードウェアがサポートできるレベルを大幅に下回るレベルに制限されています。これは、スレッド プールでも発生します。プールは、新しいスレッドの開始に伴う高コストの回避に役立ちますが、スレッドの総数は増加しないためです。

非同期スタイル

ハードウェアを最大限に活用したいと考えている一部の開発者は、リクエストごとのスレッド スタイルを放棄し、代わりにスレッド共有を採用しています。スタイル。

リクエストを 1 つのスレッドで最初から最後まで処理するのではなく、リクエスト処理コードは、I/O 操作が完了するまで待機している間、そのスレッドをプールに返し、スレッドが他のリクエストを処理できるようにします。このきめ細かいスレッド共有 (コードは、I/O の待機中ではなく、計算の実行中にのみスレッドを予約します) により、大量のスレッドを消費することなく、多数の同時操作が可能になります。

これにより、オペレーティング システムのスレッドの不足によって課せられるスループットの制限が解消されますが、コストが高くなります。これには、一連の独立した I/O メソッドを使用する、いわゆる非同期プログラミング スタイルが必要です。 I/O 操作が完了するのを待ち、代わりに後でその完了をコールバックに通知します。専用スレッドがない場合、開発者はリクエスト処理ロジックを小さなステージに分割し、通常はラムダ式の形式で記述し、それらを API を使用してシーケンシャル パイプラインに結合する必要があります (たとえば、CompletableFuture またはいわゆる「」を参照)。 React」「セクシュアリティ」フレームワーク)。したがって、ループや try/catch ブロックなど、言語の基本的な順次合成演算子を放棄します。

非同期スタイルでは、リクエストの各フェーズを異なるスレッドで実行でき、各スレッドは異なるリクエストに属するフェーズをインターリーブ方式で実行します。これは、プログラムの動作を理解する上で重大な影響を及ぼします。

  • スタック トレースは使用可能なコンテキストを提供しません

  • デバッガーはリクエスト処理ロジックをステップ実行できません

  • アナライザーは、操作のコストを呼び出し元に関連付けることができません。

Java のストリーミング API を使用して短いパイプでデータを処理する場合、ラムダ式の結合は管理可能ですが、アプリケーション内のすべてのリクエスト処理コードをこの方法で記述する必要がある場合には、問題が発生します。アプリケーションの同時実行単位 (非同期パイプ) がプラットフォームの同時実行単位ではなくなるため、このプログラミング スタイルは Java プラットフォームと矛盾します。

#比較

Java ナレッジポイントの要約: JDK19 仮想スレッド

##仮想スレッドを使用してスレッドごとに保持する-request style

プラットフォームとの調和を維持しながらアプリケーションをスケーリングするには、スレッドをより効率的に実装して、リクエストごとに 1 スレッドのスタイルを維持するよう努める必要があります。彼らはもっと裕福になれるのです。

言語やランタイムによってスレッド スタックの使用方法が異なるため、オペレーティング システムは OS スレッドをより効率的に実装できません。ただし、Java ランタイムが Java スレッドを実装する方法によっては、Java スレッドとオペレーティング システムのスレッド間の 1 対 1 の対応が損なわれる可能性があります。オペレーティング システムが大量の仮想アドレス空間を限られた量の物理 RAM にマッピングすることでメモリが十分であると錯覚させるのと同じように、Java ランタイムも多数の仮想アドレス空間を物理 RAM にマッピングすることでスレッドが十分であると錯覚させることができます。オペレーティング システムのスレッド数が少ないという幻想。

  • 仮想スレッドは、特定のオペレーティング システムのスレッドにバインドされていないスレッドです。

  • プラットフォーム スレッドは、オペレーティング システム スレッドの単純なラッパーとして、従来の方法で実装されたスレッドです。

スレッドごとのリクエスト スタイルのアプリケーション コードは、リクエスト全体にわたって仮想スレッドで実行できますが、仮想スレッドは CPU で計算を実行するときにのみオペレーティング システム スレッドを使用します。結果は、透過的に実装されることを除いて、非同期スタイルと同じスケーラビリティになります。

仮想スレッドで実行されているコードが Java.* API でブロッキング I/O 操作を呼び出すと、ランタイムは非同期スタイルを実行します。 -オペレーティング システム呼び出しをブロックし、後で再開できるまで仮想スレッドを自動的に一時停止します。

Java 開発者にとって、仮想スレッドは、作成コストが低く、ほぼ無限の数を持つスレッドです。ハードウェアの使用率が最適に近く、高レベルの同時実行が可能になり、それによってスループットが向上する一方、アプリケーションは Java プラットフォームとそのツールのマルチスレッド設計に合わせた状態を維持します。

仮想スレッドの重要性

仮想スレッドは安価で豊富であるため、決して共有すべきではありません (スレッド プールを使用する場合でも)。アプリケーションタスクごとに新しい仮想スレッドを作成します。

結果として、ほとんどの仮想スレッドは存続期間が短く、コール スタックが浅く、単一の HTTP クライアント呼び出しまたは単一の JDBC クエリと同じくらい少ない操作を実行します。対照的に、プラットフォーム スレッドは重量が大きく高価であるため、多くの場合共有する必要があります。これらは存続期間が長く、呼び出しスタックが深く、多くのタスク間で共有される傾向があります。

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

説明

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

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

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

仮想スレッド VS プラットフォーム スレッド

簡単な例

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

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

この例のタスクは、最新のハードウェアが同時に実行する 10,000 個の仮想スレッドを簡単にサポートできる単純なコード (1 秒間スリープする) です。 JDK は舞台裏で、いくつかのオペレーティング システム スレッド (おそらく 1 つ程度) でコードを実行します。

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

逆に、プログラムがプールからプラットフォーム スレッドを取得する ExecutorService (Executors.newFixedThreadPool (200) など) を使用している場合、状況はそれほど改善されません。 ExecutorService は 200 のプラットフォーム スレッドを作成し、10,000 のタスクすべてで共有するため、多くのタスクが同時にではなく順番に実行され、プログラムが完了するまでに長い時間がかかります。このプログラムの場合、200 のプラットフォーム スレッドのプールは 1 秒あたり 200 タスクのスループットしか達成できませんが、仮想スレッドは (十分なウォームアップ後) 1 秒あたり 10,000 タスクのスループットを達成します。さらに、サンプル プログラムの 10000 を 1000000 に変更すると、プログラムは 1,000,000 個のタスクを送信し、同時に実行される 1,000,000 個の仮想スレッドを作成し、(十分なウォームアップの後) 約 1,000,000 タスク/秒のスループットを達成します。

このプログラムのタスクが単にスリープするのではなく 1 秒間の計算 (巨大な配列のソートなど) を実行する場合、プロセッサ コアの数を超えてスレッドの数を増やしても、プロセッサ コアの数を超えても役に立ちません。それは仮想スレッドですか、それともプラットフォーム スレッドですか。

虚拟线程并不是更快的线程ーー它们运行代码的速度并不比平台线程快。它们的存在是为了提供规模(更高的吞吐量) ,而不是速度(更低的延迟) 。它们的数量可能比平台线程多得多,因此根据 Little’s Law,它们能够实现更高吞吐量所需的更高并发性。

换句话说,虚拟线程可以显著提高应用程序的吞吐量,在如下情况时:

  • 并发任务的数量很多(超过几千个)

  • 工作负载不受 CPU 限制,因为在这种情况下,比处理器核心拥有更多的线程并不能提高吞吐量

虚拟线程有助于提高典型服务器应用程序的吞吐量,因为这类应用程序由大量并发任务组成,这些任务花费了大量时间等待。

虚拟线程可以运行平台线程可以运行的任何代码。特别是,虚拟线程支持线程本地变量和线程中断,就像平台线程一样。这意味着处理请求的现有 Java 代码很容易在虚拟线程中运行。许多服务器框架将选择自动执行此操作,为每个传入请求启动一个新的虚拟线程,并在其中运行应用程序的业务逻辑。

下面是一个服务器应用程序示例,它聚合了另外两个服务的结果。假设的服务器框架(未显示)为每个请求创建一个新的虚拟线程,并在该虚拟线程中运行应用程序的句柄代码。然后,应用程序代码创建两个新的虚拟线程,通过与第一个示例相同的 ExecutorService 并发地获取资源:

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);
    }
}

这样的服务器应用程序使用简单的阻塞代码,可以很好地扩展,因为它可以使用大量虚拟线程。

NewVirtualThreadPerTaskExector ()并不是创建虚拟线程的唯一方法。新的 java.lang.Thread.Builder。可以创建和启动虚拟线程。此外,结构化并发提供了一个更强大的 API 来创建和管理虚拟线程,特别是在类似于这个服务器示例的代码中,通过这个 API,平台及其工具可以了解线程之间的关系。

虚拟线程是一个预览 API,默认情况下是禁用的

上面的程序使用 Executors.newVirtualThreadPerTaskExector ()方法,因此要在 JDK 19上运行它们,必须启用以下预览 API:

  • 使用javac --release 19 --enable-preview Main.java编译该程序,并使用 java --enable-preview Main 运行该程序;或者:

  • 在使用源代码启动程序时,使用 java --source 19 --enable-preview Main.java 运行程序; 或者:

  • 在使用 jshell 时,使用 jshell --enable-preview 启动它。

不要共享(pool)虚拟线程

开发人员通常会将应用程序代码从传统的基于线程池的 ExecutorService 迁移到每个任务一个虚拟线程的 ExecutorService。与所有资源池一样,线程池旨在共享昂贵的资源,但虚拟线程并不昂贵,而且从不需要共享它们。

开发人员有时使用线程池来限制对有限资源的并发访问。例如,如果一个服务不能处理超过20个并发请求,那么通过提交给大小为 20 的池的任务将确保执行对该服务的所有访问。因为平台线程的高成本使得线程池无处不在,所以这个习惯用法也变得无处不在,但是开发人员不应该为了限制并发性而将虚拟线程集中起来。应该使用专门为此目的设计的构造(如信号量semaphores)来保护对有限资源的访问。这比线程池更有效、更方便,也更安全,因为不存在线程本地数据从一个任务意外泄漏到另一个任务的风险。

观测

编写清晰的代码并不是故事的全部。对于故障排除、维护和优化来说,清晰地表示正在运行的程序的状态也是必不可少的,JDK 长期以来一直提供调试、概要分析和监视线程的机制。这样的工具对虚拟线程也应该这样做ーー也许要适应它们的大量数据ーー因为它们毕竟是 java.lang.Thread 的实例。

Java 调试器可以单步执行虚拟线程、显示调用堆栈和检查堆栈帧中的变量。JDK Flight Recorder (JFR) 是 JDK 的低开销分析和监视机制,可以将来自应用程序代码的事件(比如对象分配和 I/O 操作)与正确的虚拟线程关联起来。

这些工具不能为以异步样式编写的应用程序做这些事情。在这种风格中,任务与线程无关,因此调试器不能显示或操作任务的状态,分析器也不能告诉任务等待 I/O 所花费的时间。

线程转储( thread dump) 是另一种流行的工具,用于以每个请求一个线程的样式编写的应用程序的故障排除。遗憾的是,通过 jstack 或 jcmd 获得的 JDK 传统线程转储提供了一个扁平的线程列表。这适用于数十或数百个平台线程,但不适用于数千或数百万个虚拟线程。因此,我们将不会扩展传统的线程转储以包含虚拟线程,而是在 jcmd 中引入一种新的线程转储,以显示平台线程旁边的虚拟线程,所有这些线程都以一种有意义的方式进行分组。当程序使用结构化并发时,可以显示线程之间更丰富的关系。

因为可视化和分析大量的线程可以从工具中受益,所以 jcmd 除了纯文本之外,还可以发布 JSON 格式的新线程转储:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

新的线程转储格式列出了在网络 I/O 操作中被阻塞的虚拟线程,以及由上面所示的 new-thread-per-task ExecutorService 创建的虚拟线程。它不包括对象地址、锁、 JNI 统计信息、堆统计信息以及传统线程转储中出现的其他信息。此外,由于可能需要列出大量线程,因此生成新的线程转储并不会暂停应用程序。

下面是这样一个线程转储的示例,它取自类似于上面第二个示例的应用程序,在 JSON 查看器中呈现 :

Java ナレッジポイントの要約: JDK19 仮想スレッド

由于虚拟线程是在 JDK 中实现的,并且不绑定到任何特定的操作系统线程,因此它们对操作系统是不可见的,操作系统不知道它们的存在。操作系统级别的监视将观察到,JDK 进程使用的操作系统线程比虚拟线程少。

调度

为了完成有用的工作,需要调度一个线程,也就是分配给处理器核心执行。对于作为 OS 线程实现的平台线程,JDK 依赖于 OS 中的调度程序。相比之下,对于虚拟线程,JDK 有自己的调度程序。JDK 的调度程序不直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程(这是前面提到的虚拟线程的 M: N 调度)。然后,操作系统像往常一样调度平台线程。

JDK 的虚拟线程调度程序是一个在 FIFO 模式下运行的工作窃取(work-stealing) 的 ForkJoinPool。调度程序的并行性是可用于调度虚拟线程的平台线程的数量。默认情况下,它等于可用处理器的数量,但是可以使用系统属性 jdk.viralThreadScheduler.allelism 对其进行调优。注意,这个 ForkJoinPool 不同于公共池,例如,公共池用于并行流的实现,公共池以 LIFO 模式运行。

  • 虚拟线程无法获得载体(即负责调度虚拟线程的平台线程)的标识。由 Thread.currentThread ()返回的值始终是虚拟线程本身。

  • 载体和虚拟线程的堆栈跟踪是分离的。在虚拟线程中抛出的异常将不包括载体的堆栈帧。线程转储不会显示虚拟线程堆栈中其载体的堆栈帧,反之亦然。

  • 虚拟线程不能使用载体的线程本地变量,反之亦然。

此外,从 Java 代码的角度来看,虚拟线程及其载体平台线程临时共享操作系统线程的事实是不存在的。相比之下,从本机代码的角度来看,虚拟线程及其载体都在同一个本机线程上运行。因此,在同一虚拟线程上多次调用的本机代码可能会在每次调用时观察到不同的 OS 线程标识符。

调度程序当前没有实现虚拟线程的时间共享。分时是对消耗了分配的 CPU 时间的线程的强制抢占。虽然在平台线程数量相对较少且 CPU 利用率为100% 的情况下,分时可以有效地减少某些任务的延迟,但是对于一百万个虚拟线程来说,分时是否有效尚不清楚。

执行

要利用虚拟线程,不必重写程序。虚拟线程不需要或期望应用程序代码显式地将控制权交还给调度程序; 换句话说,虚拟线程不是可协作的。用户代码不能假设如何或何时将虚拟线程分配给平台线程,就像它不能假设如何或何时将平台线程分配给处理器核心一样。

为了在虚拟线程中运行代码,JDK 的虚拟线程调度程序通过将虚拟线程挂载到平台线程上来分配要在平台线程上执行的虚拟线程。这使得平台线程成为虚拟线程的载体。稍后,在运行一些代码之后,虚拟线程可以从其载体卸载。此时平台线程是空闲的,因此调度程序可以在其上挂载不同的虚拟线程,从而使其再次成为载体。

通常,当虚拟线程阻塞 I/O 或 JDK 中的其他阻塞操作(如 BlockingQueue.take ())时,它将卸载。当阻塞操作准备完成时(例如,在套接字上已经接收到字节) ,它将虚拟线程提交回调度程序,调度程序将在运营商上挂载虚拟线程以恢复执行。

虚拟线程的挂载和卸载频繁且透明,并且不会阻塞任何 OS 线程。例如,前面显示的服务器应用程序包含以下代码行,其中包含对阻塞操作的调用:

response.send(future1.get() + future2.get());

这些操作将导致虚拟线程多次挂载和卸载,通常每个 get ()调用一次,在 send (...)中执行 I/O 过程中可能多次挂载和卸载。

JDK 中的绝大多数阻塞操作将卸载虚拟线程,从而释放其载体和底层操作系统线程,使其承担新的工作。但是,JDK 中的一些阻塞操作不会卸载虚拟线程,因此阻塞了其载体和底层 OS 线程。这是由于操作系统级别(例如,许多文件系统操作)或 JDK 级别(例如,Object.wait ())的限制造成的。这些阻塞操作的实现将通过暂时扩展调度程序的并行性来补偿对 OS 线程的捕获。因此,调度程序的 ForkJoinPool 中的平台线程的数量可能会暂时超过可用处理器的数量。可以使用系统属性 jdk.viralThreadScheduler.maxPoolSize 调优调度程序可用的最大平台线程数。

有两种情况下,在阻塞操作期间无法卸载虚拟线程,因为它被固定在其载体上:

  • 当它在同步块或方法内执行代码时,或

  • 当它执行本机方法或外部函数时。

固定并不会导致应用程序不正确,但它可能会妨碍应用程序的可伸缩性。如果虚拟线程在固定时执行阻塞操作(如 I/O 或 BlockingQueue.take () ) ,那么它的载体和底层操作系统线程将在操作期间被阻塞。长时间的频繁固定会通过捕获运营商而损害应用程序的可伸缩性。

调度程序不会通过扩展其并行性来补偿固定。相反,可以通过修改频繁运行的同步块或方法来避免频繁和长时间的固定,并保护潜在的长 I/O 操作来使用 java.util.concurrent.locks.ReentrantLock。不需要替换不常使用的同步块和方法(例如,只在启动时执行)或保护内存操作的同步块和方法。一如既往,努力保持锁定策略的简单明了。

新的诊断有助于将代码迁移到虚拟线程,以及评估是否应该使用 java.util.concurrent lock 替换同步的特定用法:

  • 当线程在固定时阻塞时,会发出 JDK JFR事件。

  • 当线程在固定时阻塞时,系统属性 jdk.tracePinnedThreads 触发堆栈跟踪。使用-Djdk.tracePinnedThreads = full 运行会在线程被固定时打印一个完整的堆栈跟踪,并突出显示保存监视器的本机框架和框架。使用-Djdk.tracePinnedThreads = short 将输出限制为有问题的帧。

内存使用和垃圾回收

虚拟线程的堆栈作为堆栈块对象存储在 Java 的垃圾回收堆中。堆栈随着应用程序的运行而增长和缩小,这既是为了提高内存效率,也是为了容纳任意深度的堆栈(直到 JVM 配置的平台线程堆栈大小)。这种效率支持大量的虚拟线程,因此服务器应用程序中每个请求一个线程的风格可以继续存在。

在上面的第二个例子中,回想一下,一个假设的框架通过创建一个新的虚拟线程并调用 handle 方法来处理每个请求; 即使它在深度调用堆栈的末尾调用 handle (在身份验证、事务处理等之后) ,handle 本身也会产生多个虚拟线程,这些虚拟线程只执行短暂的任务。因此,对于每个具有深层调用堆栈的虚拟线程,都会有多个具有浅层调用堆栈的虚拟线程,这些虚拟线程消耗的内存很少。

通常,虚拟线程所需的堆空间和垃圾收集器活动的数量很难与异步代码的数量相比较。一百万个虚拟线程至少需要一百万个对象,但是共享一个平台线程池的一百万个任务也需要一百万个对象。此外,处理请求的应用程序代码通常跨 I/O 操作维护数据。每个请求一个线程的代码可以将这些数据保存在本地变量中:

  • 这些本地变量存储在堆中的虚拟线程堆栈中

  • 异步代码必须将这些数据保存在从管道的一个阶段传递到下一个阶段的堆对象中

一方面,虚拟线程需要的堆栈帧布局比紧凑对象更浪费; 另一方面,虚拟线程可以在许多情况下变异和重用它们的堆栈(取决于低级 GC 交互) ,而异步管道总是需要分配新对象,因此虚拟线程可能需要更少的分配。

总的来说,每个请求线程与异步代码的堆消耗和垃圾收集器活动应该大致相似。随着时间的推移,我们希望使虚拟线程堆栈的内部表示更加紧凑。

与平台线程堆栈不同,虚拟线程堆栈不是 GC 根,所以它们中包含的引用不会被执行并发堆扫描的垃圾收集器(比如 G1)在 stop-the-world 暂停中遍历。这也意味着,如果一个虚拟线程被阻塞,例如 BlockingQueue.take () ,并且没有其他线程可以获得对虚拟线程或队列的引用,那么线程就可以被垃圾收集ーー这很好,因为虚拟线程永远不会被中断或解除阻塞。当然,如果虚拟线程正在运行,或者它被阻塞并且可能被解除阻塞,那么它将不会被垃圾收集。

当前虚拟线程的一个限制是 G1 GC 不支持大型堆栈块对象。如果虚拟线程的堆栈达到区域大小的一半(可能小到512KB) ,那么可能会抛出 StackOverfloError。

具体变化

java.lang.Thread

  • Thread.Builder, Thread.ofVirtual(), 和 Thread.ofPlatform() 是创建虚拟线程和平台线程的新 API,例如:

Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);

创建一个新的未启动的虚拟线程“ duke”。

  • Thread.startVirtualThread(Runnable) 是创建然后启动虚拟线程的一种方便的方法。

  • Thread.Builder 可以创建线程或 ThreadFactory, 后者可以创建具有相同属性的多个线程。

  • Thread.isVirtual() 测试是否一个线程是一个虚拟的线程。

  • Thread.join 和 Thread.sleep 的新重载接受等待和睡眠时间作为java.time.Duration的实例。

  • 新的 final 方法 Thread.threadId() 返回线程的标识符。现在不推荐使用现有的非 final 方法 Thread.getId() 。

  • Thread.getAllStackTraces() 现在返回所有平台线程的映射,而不是所有线程的映射。

java.lang.Thread API其他方面没有改变。构造器也无新变化。

虚拟线程和平台线程之间的主要 API 差异是:

  • 公共线程构造函数不能创建虚拟线程。

  • 虚拟线程始终是守护进程线程,Thread.setDaemon (boolean)方法不能将虚拟线程更改为非守护进程线程。

  • 虚拟线程有一个固定的 Thread.NORM_PRIORITY 优先级。Thread.setPriority(int)方法对虚拟线程没有影响。在将来的版本中可能会重新讨论这个限制。

  • 虚拟线程不是线程组的活动成员。在虚拟线程上调用时,Thread.getThreadGroup() 返回一个名为“ VirtualThreads”的占位符线程组。The Thread.Builder API 不定义设置虚拟线程的线程组的方法。

  • 使用 SecurityManager 集运行时,虚拟线程没有权限。

  • 虚拟线程不支持 stop(), suspend(), 或 resume()方法。这些方法在虚拟线程上调用时引发异常。

Thread-local variables

虚拟线程支持线程局部变量(ThreadLocal)和可继承的线程局部变量(InheritableThreadLocal) ,就像平台线程一样,因此它们可以运行使用线程局部变量的现有代码。但是,由于虚拟线程可能非常多,所以应该在仔细考虑之后使用线程局部变量。

特别是,不要使用线程局部变量在线程池中共享同一线程的多个任务之间共享昂贵的资源。虚拟线程永远不应该被共享,因为每个线程在其生存期内只能运行一个任务。我们已经从 java.base 模块中移除了许多线程局部变量的使用,以便为虚拟线程做准备,从而减少在使用数百万个线程运行时的内存占用。

此外:

  • The Thread.Builder API 定义了一个在创建线程时选择不使用线程局部变量的方法(a method to opt-out of thread locals when creating a thread)。它还定义了一个方法来选择不继承可继承线程局部变量的初始值( a method to opt-out of inheriting the initial value of inheritable thread-locals)。当从不支持线程局部变量的线程调用时, ThreadLocal.get()返回初始值,ThreadLocal.set(T) 抛出异常。

  • 遗留上下文类加载器( context class loader)现在被指定为像可继承的线程本地一样工作。如果在不支持线程局部变量的线程上调用 Thread.setContextClassLoader(ClassLoader),那么它将引发异常。

Networking

java.net および java.nio.channels パッケージのネットワーク API 実装は、仮想スレッドで動作するようになりました。仮想スレッドでの操作 (ネットワーク接続の確立やソケットからの読み取りなど) はブロックされ、基盤となるプラットフォームが解放されます。他の作業を行うためのスレッド。

割り込みとキャンセルを可能にするために、java.net.Socket、ServerSocket、および DatagramSocket で定義されたブロッキング I/O メソッドは、仮想スレッドで呼び出されたときに割り込み可能として指定されるようになりました。ソケットで割り込みがブロックされました。仮想スレッドは解放されます。スレッドを閉じてソケットを閉じます。

これらのタイプのソケットでのブロック I/O 操作は、InterruptibleChannel から取得した場合には常に割り込み可能であるため、この変更により、これらの API はチャネルから取得した場合のコンストラクターと同じように動作し、動作の一貫性が保たれます。

java.io

java.io パッケージは、バイト ストリームと文字ストリームの API を提供します。これらの API の実装は高度に同期しているため、仮想スレッドで使用するために固定されないように変更する必要があります。

内部的には、バイト指向の入出力ストリームはスレッドセーフとして指定されておらず、読み取りまたは書き込みメソッドでスレッドをブロックしているときに close() を呼び出した場合の予期される動作も指定されていません。ほとんどの場合、複数の同時スレッドから特定の入力ストリームまたは出力ストリームを使用することは意味がありません。文字指向のリーダー/ライターもスレッドセーフとして指定されていませんが、ロック オブジェクトをサブクラスに公開します。修正されるだけでなく、これらのクラスの同期に関する問題と不一致があります。たとえば、InputStreamReader と OutputStreamWriter で使用されるストリーム デコーダとエンコーダは、ロック オブジェクトではなくストリーム オブジェクトで同期します。

固定を防ぐために、現在の実装は次のとおりです:

  • BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、PrintStream、および PrintWriter は、次の場合に明示的なロックの代わりに明示的なロックを使用するようになりました。直接モニターを使用します。これらのクラスがサブクラス化されると、以前と同様に同期されます。

  • InputStreamReader および OutputStreamWriter によって使用されるストリーム デコーダおよびエンコーダは、それを囲んでいる InputStreamReader または OutputStreamWriter と同じロックを使用するようになりました。

さらに一歩進んで、これらの不要なロックをすべて削除することは、この記事の範囲を超えています。

さらに、BufferedOutputStream、BufferedWriter、および OutputStreamWriter のストリーム エンコーダーによって使用されるバッファーの初期サイズが小さくなり、ヒープ内に多数のストリームまたはライターがある場合 (仮想サーバーが 100 万ある場合)、メモリ使用量が削減されます。スレッドの場合、各スレッドにはソケット接続にバッファ ストリームがあり、この状況が発生する可能性があります。

推奨学習: 「Java ビデオ チュートリアル

以上がJava ナレッジポイントの要約: JDK19 仮想スレッドの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はjuejin.imで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。