Java 19でようやくプレビュー版公開された JEP 425 Virtual Threads の要約


はじめに

Project Loom で進められていた JEP 425 Virtual Threads が Java 19 でようやくプレビュー公開されました。待ちに待ってました。

JEP のテキストは長くて読みにくいので、要約したものです。といっても、それなりの長さになってしまいました。


概要

Java プラットフォームに仮想スレッドを導入します(現在はレビュー版)。 仮想スレッドは、高スループットの並列アプリケーションに関わる労力を劇的に削減する軽量スレッドです。


目標

  • 1リクエスト-1スレッド方式のシンプルなサーバアプリケーションを、ほぼ最適なハードウェア使用率でスケーリング可能とする
  • java.lang.Thread API の最小限の変更で仮想スレッドを導入する
  • 既存の JDK ツールにて、仮想スレッドのトラブルシューティング、デバッグ、およびプロファイリングを容易に行えるようにする


非目標

  • 従来のスレッドの実装を削除したり移行させることが目標ではない
  • Java の基本的な並行処理モデルを変更することが目標ではない
  • Java 言語やライブラリに新しいデータ並列化構造を提供することが目的ではない


動機

スレッドは、Javaの並行処理の単位であり、約30年に渡り、並列サーバーアプリケーションで利用されてきました。

スレッドは、ローカル変数を格納し、メソッド呼び出しを調整するためのスタックと、物事がうまくいかないときのコンテキストを提供します。 デバッガでは、スレッドのメソッド内のステートメントをステップ実行できますし、プロファイラでは複数のスレッドの挙動を可視化します。

スレッドは、並列処理の単位であると同時に、各種ツールの中心的な概念でもあります。


1リクエスト-1スレッド方式

サーバアプリケーションにおいて、1つのリクエストを1つのスレッドに割り当てて管理するのは、理解しやすくプログラミングも簡単です。

管理が容易な一方、この方式はスケーラビリティ面で問題に直面する場合が多くなります。 リトルの法則により、あるリクエスト処理時間(=待ち時間)において、アプリケーションが同時に処理するリクエストの数(=同時実行性)は、到着率(=スループット)に比例して増加しなければなりません。つまり、スループットの増加にはスレッドの数を増加させる必要があります。

JDK はスレッドをOSスレッドのラッパーとして実装しており、プラットフォームの並行処理単位とアプリケーションの並行処理単位が同じになります。 OSスレッドはコストが高く、利用可能なスレッドの数は限られているため、CPUやネットワーク接続などのリソースが枯渇する前に、スレッド数が制限要因となりスループットが達成できないことが多くなります。


非同期方式

1つのスレッドで最初から最後までリクエストを処理するのではなく、I/O待ちの間はスレッドをプールに戻し、1つのスレッドが複数のリクエストを処理することで、少数のスレッドで多くの同時処理を行うことができます。

これには、いわゆる非同期プログラミングスタイルが必要で、OSのスレッドの不足によるスループットの制限をなくす一方、高い代償を払うことになります。 非同期型では、リクエストの各ステージは異なるスレッドで実行される可能性があるため、スタックトレースは有用なコンテキストを提供しません。デバッガはリクエスト処理ロジックをステップスルーできず、プロファイラは操作のコストとその呼び出し元を関連付けることができません。


仮想スレッドによる1リクエスト-1スレッド方式

Javaランタイムにより、多数の仮想スレッドを少数のOSスレッドマッピングできれば、1リクエスト1スレッド方式を維持したまま、より多くのスレッドを使用できるようになります。

仮想スレッドは、特定のOSスレッドに結びつかない java.lang.Thread のインスタンスです。 アプリケーションは、リクエストの全期間にわたって1つの仮想スレッドで処理されます。 仮想スレッドで実行されているコードがブロッキングI/Oオペレーションを呼び出すと、ランタイムはノンブロッキングOSコールを実行し、後で再開できるまで仮想スレッドを自動的にサスペンドさせます。

その結果、OSスレッドを消費するのは、CPUで計算を行う間だけとなり、非同期スタイルと同じスケーラビリティを、透過的に達成することができます。 仮想スレッドは、Javaプラットフォームの設計と調和したまま、ハードウェアを最適に利用することができます。


説明

仮想スレッドは、OSではなく、JDK によって提供される軽量なスレッドの実装です。 これはユーザーモードスレッドの一種であり、Goの goルーチン や Erlangの プロセス などと同種です。

仮想スレッドは java.lang.Thread のインスタンスで、基礎となるOSスレッド上でJavaコードを実行しますが、コードの全生涯にわたってOSスレッドに割り当てられるわけではありません。

多くの仮想スレッドが同じOSスレッド上でJavaコードを実行し、事実上それを共有することができるのです。 プラットフォームスレッドが貴重なOSスレッドを占有するのに対して、仮想スレッドの数は、OSスレッドの数よりはるかに大きくすることができます。


仮想スレッドとプラットフォームスレッドの使い分け

以下は、1万個のタスクに対して仮想スレッドを作成するプログラム例です。

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

この例は、最近のハードウェアで容易く動作します(裏側では、JDKが少数のOSスレッドでコードを実行します)。

Executors.newVirtualThreadPerTaskExecutor() ではなく、プラットフォームスレッドを作成する Executors.newCachedThreadPool() を使ったとすると、状況は大きく変わります。 OS スレッドの数が1万個にもなると、プログラムがクラッシュする可能性があります。

さらに、Executors.newFixedThreadPool(200) のようなスレッドプールを使う場合でも状況はあまり良くなりません。 200個に制限されたプラットフォームスレッドが処理を行うため、プログラムの完了に長い時間がかかることになります(200タスク/秒のスループットしか達成できません)。

ただし、このプログラムのタスクが、単にスリープするのではなく、1秒間計算を行う(例えば、巨大な配列をソートする)ようなケースでは、プロセッサコアの数以上にスレッドの数を増やしても、それが仮想スレッドであろうとプラットフォームスレッドであろうと意味はありません。

仮想スレッドはコードを高速に実行(低レイテンシ)するわけではなく、スケール(高スループット)を実現するために存在します。

仮想スレッドは次のような、典型的なサーバーアプリケーションのスループットを大幅に向上させます。

  • 同時実行タスクの数が多い(数千以上)
  • 作業負荷がCPUに依存していない(I/O待ちに依存している)

仮想スレッドは、プラットフォームスレッドが実行可能なあらゆるコードを実行することができます。 プラットフォームスレッドと同様に、スレッドローカル変数とスレッドの中断をサポートします。 つまり、リクエストを処理する既存の 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);
    }
}

このようなサーバーアプリケーションは、単純なブロッキングコードで、多数の仮想スレッドを使用できるため、うまくスケールすることができます。


仮想スレッドの有効化

仮想スレッドはプレビューAPIであり、デフォルトでは無効になっています。 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


仮想スレッドにスレッドプールは不要

スレッドプールは、他のリソースプールと同様に、高価なリソースを共有するためのものですが、仮想スレッドは高価ではないので、スレッドをプールする必要は決してありません。 限られたリソースへのアクセスを保護するためにスレッドプールを使う場合でも、セマフォのようなその目的のために設計された構造を使用すべきです。 これはスレッドプールよりも効果的で、スレッドローカルデータがあるタスクから別のタスクに誤って漏れる危険性がないため、より安全です。


仮想スレッドの監視

JDKは長い間、スレッドをデバッグ、プロファイル、監視するメカニズムを提供してきました。 このようなツールは、トラブルシューティング、メンテナンス、最適化といった点で、仮想スレッドにも不可欠です。

  • Javaデバッガは、仮想スレッドを通過し、コールスタックを表示し、スタックフレーム内の変数を検査することができます
  • JDK Flight Recorder(JFR)は、JDK の低オーバーヘッドのプロファイリングとモニタリングのメカニズムで、アプリケーションコードからのイベント(オブジェクトの割り当てや I/O 操作など)を正しい仮想スレッドに関連付けることができます

非同期スタイルで書かれたアプリケーションにおいては、これらのツールを有効活用できません。 デバッガはタスクの状態を表示したり操作したりできず、プロファイラもタスクがI/O待ちをしている時間を知ることはできません。

スレッドダンプについては、jstackまたはjcmdで取得した場合にスレッドのフラットリストを出力します。 これは、数十または数百のプラットフォームスレッドには適していますが、数千または数百万の仮想スレッドには不向きです。 多くのスレッドを視覚化し、分析できるように、 jcmd は、プレーンテキストに加えて、JSON フォーマットで新しいスレッドダンプを出力できるようになります。

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

新しいスレッドダンプ形式は、ネットワークI/O操作でブロックされた仮想スレッドと、上記のタスク毎に新しいスレッドを作成する ExecutorService で作成された仮想スレッドを出力します。 オブジェクトアドレス、ロック、JNI統計、ヒープ統計など、従来のスレッドダンプに表示される情報は含まれません。


仮想スレッドのスケジューリング

OSスレッドとして実装されているプラットフォームスレッドでは、スレッドのスケジューリングはOSのスケジューラに依存します。

一方、仮想スレッドについては、JDKは独自のスケジューラを備えています。 JDKの仮想スレッドスケジューラは、FIFOモードで動作する work-stealing 型 ForkJoinPool で、仮想スレッドをプラットフォームスレッドに M:N で割り当てます。

スケジューラの並列度は、仮想スレッドのスケジューリングのために利用可能なプラットフォームスレッドの数になります。 デフォルトは利用可能なプロセッサの数ですが、システムプロパティ jdk.virtualThreadScheduler.parallelism で調整することができます。

スケジューラが仮想スレッドを割り当てるプラットフォーム・スレッドは、仮想スレッドのキャリアと呼ばれます。 Javaコードから見ると、実行中の仮想スレッドは、現在のキャリアから論理的に独立しています。 そのため、Thread.currentThread() が返す値は常に仮想スレッド自身となります。

キャリアと仮想スレッドのスタックトレースは分かれており、仮想スレッドでスローされた例外は、キャリアのスタックフレームを含みません。 スレッドダンプでは、仮想スレッドのスタックにキャリアのスタックフレームは表示されません。 キャリアのスレッドローカル変数は仮想スレッドでは利用できず、その逆も同様です。

一方、ネイティブコードから見ると、仮想スレッドとそのキャリアは同じネイティブスレッド上で動作します。 そのため、同じ仮想スレッド上で複数回呼び出されたネイティブコードでは、呼び出すたびに異なるOSスレッド識別子が観測される可能性があります。

スケジューラは現在、仮想スレッドに対するタイムシェアリングを実装していません。 タイムシェアリングは、割り当てられたCPU時間を消費したスレッドを強制的に先取りすることです。 比較的少数のプラットフォームスレッドがあり、CPU使用率が100%の場合、タイムシェアリングはいくつかのタスクのレイテンシを減らすのに効果的ですが、100万の仮想スレッドで時間共有が同じように効果的であるかは明らかではありません。


仮想スレッドの実行

JDKの仮想スレッドスケジューラは、プラットフォームスレッドに仮想スレッドをマウントすることで、プラットフォームスレッド上で実行するように仮想スレッドを割り当てます。

これにより、プラットフォームスレッドは仮想スレッドのキャリアとなります。 その後、いくつかのコードを実行した後、仮想スレッドはそのキャリアからアンマウントすることができます。 アンマウントによりプラットフォーム・スレッドが空くので、スケジューラは別の仮想スレッドをそこにマウントして、再びキャリアにすることができます。

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

仮想スレッドのマウントとアンマウントは、OSのスレッドをブロックすることなく、頻繁かつ透過的に行われます。 例えば、先ほどのサーバーアプリケーションには以下のようなコードがあり、ブロッキング操作の呼び出しが含まれています。

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

これらの操作により、仮想スレッドは何度もマウントとアンマウントを繰り返します。 典型的には get() を呼び出すたびに 1 回、場合によっては send(...) で I/O を実行する間に複数回行われるかもしれません。

JDKのブロック操作の大部分は、仮想スレッドをアンマウントして、OSスレッドを解放ます。 しかし、いくつかのブロッキング操作は、仮想スレッドをアンマウントしないため、そのキャリアとなるOSスレッドをブロックします。 これは、OSレベル(多くのファイルシステム操作など)またはJDKレベル(Object.wait()など)のいずれかの制限によるものです。

これらのブロッキング処理の実装は、スケジューラの並列性を一時的に拡張することで、OSスレッドを補うことになります。 その結果、スケジューラの ForkJoinPool 内のプラットフォームスレッド数が、一時的に利用可能なプロセッサの数を超えることがあります。 スケジューラが利用できるプラットフォームスレッドの最大数は、システムプロパティ jdk.virtualThreadScheduler.maxPoolSize で調整できます。

仮想スレッドがキャリアに固定され、ブロック操作中にアンマウントできないケースには以下の2があります。

  • 同期ブロックまたはメソッドの内部でコードを実行する場合(将来のリリースで、この制限事項は回避される可能性があります)
  • ネイティブメソッドまたは外部関数を実行する場合

仮想スレッドがキャリアに固定(Pinning:ピン留め)されることでアプリケーションが不正になることはありませんが、スケーラビリティの妨げになる可能性があります。

頻繁に実行され、潜在的に長いI/O操作を守る同期ブロックまたはメソッドを、java.util.concurrent.locks.ReentrantLock を使用するように修正することによって、ピン留めの問題を回避できます。 頻度が低い同期ブロックやメソッド、メモリ内の操作を保護する同期ブロックやメソッドは、置き換える必要はありません。 常に、ロックポリシーをシンプルかつ明確に保つのが肝要です。

診断機能によって、コードを仮想スレッドに移行する際や、synchronized の特定の使用を java.util.concurrent ロックに置き換える必要があるかどうかを評価できます。

  • ピン留め中にスレッドがブロックされると、JDK Flight Recorder(JFR)イベントが発行されます
  • システムプロパティの jdk.tracePinnedThreads は、スレッドがピン留めによってブロックされた場合にスタックトレースを開始します。-Djdk.tracePinnedThreads=full を指定して実行すると、ピン留め中にスレッドがブロックされたときに、ネイティブフレームとモニターを保持するフレームをハイライトした完全なスタックトレースが出力されます。-Djdk.tracePinnedThreads=short で実行すると、問題のあるフレームのみの出力に制限されます。


メモリの使用とガベージコレクションとの相互作用

仮想スレッドのスタックは、スタック・チャンク・オブジェクトとしてJavaヒープに格納されます。 スタックは、メモリ効率と任意の深さのスタック(JVMの設定されたプラットフォームのスレッドスタックサイズまで)を収容するために、アプリケーションの実行中に成長したり縮小したりしています。 この効率性により、多数の仮想スレッドの継続的な実行が可能となっています。

仮想スレッドが必要とするヒープスペースとガベージコレクタの量は、一般に、非同期コードのそれと比較することは困難ですが、 全体として、スレッド単位のリクエストと非同期コードのヒープ消費とガベージコレクタの動作は、ほぼ同じであるべきです。

プラットフォームのスレッドスタックとは異なり、仮想スレッドスタックはGCルートではありません。 仮想スレッドスタックに含まれる参照は、同時ヒープスキャンを実行するG1などのガベージコレクタによって、ストップザワールドポーズでトラバースされることはありません。 これはまた、仮想スレッドが例えば BlockingQueue.take() でブロックされ、他のスレッドが仮想スレッドまたはキューへの参照を取得できない場合、そのスレッドはガベージコレクションされうることを意味します(仮想スレッドは決して中断またはブロック解除できないので、これは良いことです)。 もちろん、仮想スレッドが実行中であったり、ブロックされ、ブロックが解除される可能性がある場合には、仮想スレッドはガベージコレクションされることはありません。

仮想スレッドの現在の限界は、G1 GCが巨大なスタック・チャンク・オブジェクトをサポートしないことです。 もし仮想スレッドのスタックが領域サイズの半分に達すると、それは 512KB のように小さくなる可能性があり、StackOverflowError が投げられる可能性があります。


変更点の詳細

java.lang.Thread

  • Thread.Builder, Thread.ofVirtual(), Thread.ofPlatform() は,仮想スレッドとプラットフォームスレッドを生成するための新しいAPIです。以下は"duke" という名前の新しい未始動仮想スレッドを作成します
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
  • Thread.startVirtualThread(Runnable) は、仮想スレッドを作成して起動します
  • Thread.Builder は、スレッドまたは ThreadFactory を作成し、同一のプロパティを持つ複数のスレッドを作成します
  • Thread.isVirtual() は、スレッドが仮想スレッドであるかどうかをテストします
  • Thread.joinThread.sleep の新しいオーバーロードは、待ち時間とスリープ時間を java.time.Duration で指定できます
  • Thread.threadId() は、スレッドの識別子を返します(既存のThread.getId()は、現在非推奨です)
  • Thread.getAllStackTraces() は、すべてのスレッドではなく、すべてのプラットフォーム スレッドのマップを返すようになります

仮想スレッドとプラットフォームスレッドの主なAPIの違いは以下のとおりです。

  • パブリックな Thread コンストラクタは仮想スレッドを作成することができません
  • 仮想スレッドは常にデーモンスレッドです。Thread.setDaemon(boolean) メソッドは、仮想スレッドを非デーモンスレッドに変更することはできません
  • 仮想スレッドは Thread.NORM_PRIORITY という固定の優先度を持ちます。Thread.setPriority(int) メソッドは、仮想スレッドに影響を与えません。この制限は、将来のリリースで再検討される可能性があります。
  • 仮想スレッドは、スレッドグループのアクティブなメンバーではありません。仮想スレッド上で呼び出されると、Thread.getThreadGroup()VirtualThreads という名前のプレースホルダ・スレッド・グループを返します。Thread.Builder APIは、仮想スレッドのスレッドグループを設定するメソッドを定義していません。
  • 仮想スレッドは、SecurityManager が設定された状態で実行されている場合、パーミッションを持ちません。
  • 仮想スレッドは、stop()suspend()resume()メソッドをサポートしません。これらのメソッドは、仮想スレッド上で呼び出されると例外をスローします。

Thread-local variables

仮想スレッドは、プラットフォームスレッドと同様に ThreadLocalInheritableThreadLocal をサポートします。 ただし、仮想スレッドは非常に数が多いため、スレッドローカルは慎重な検討の上で使用する必要があります。

  • Thread.Builder API は、スレッドを作成するときにスレッドロカールをオプトアウトするメソッドを定義しています。また、継承可能なスレッドロカールの初期値を継承しないようにするメソッドも定義しています。スレッドロカールをサポートしないスレッドから呼び出された場合、ThreadLocal.get() は初期値を返し、ThreadLocal.set(T) は例外をスローします
  • レガシーコンテキストクラスローダーは、継承可能なスレッドローカルのように動作するように指定されるようになりました。Thread.setContextClassLoader(ClassLoader) がスレッドローカルをサポートしていないスレッドで呼び出された場合、例外がスローされます

スコープローカル変数がスレッドローカルのより良い代替品になる場合もあります。

java.util.concurrent

ロック機能をサポートするプリミティブ API である java.util.concurrent.LockSupport が、仮想スレッドをサポートするようになりました。 仮想スレッドをパーキングすると、基盤となるプラットフォーム・スレッドが他の作業を行うために解放され、仮想スレッドのパーキングを解除すると、そのスレッドが続行するようにスケジュールされます。

Executors.newThreadPerTaskExecutor(ThreadFactory)Executors.newVirtualThreadPerTaskExecutor() は、タスクごとに新しいスレッドを生成する ExecutorService を作成します。これらのメソッドにより、スレッドプールおよび ExecutorService を使用する既存のコードとの移行および相互運用が可能になります。

ExecutorService は AutoCloseable を拡張し、try-with-resource 構造でこの API を使用できるようになりました。

Future は、完了したタスクの結果や例外を取得するメソッドと、タスクの状態を取得するメソッドを定義するようになりました。 これらの追加を組み合わせると、Futureオブジェクトをストリームの要素として使用し、Futureのストリームをフィルタリングして完了したタスクを見つけ、それをマッピングして結果のストリームを取得することが容易になります。

Networking

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

java.net.SocketServerSocketDatagramSocket で定義されたブロッキングI/Oメソッドは、仮想スレッドで呼び出されると中断とキャンセルが可能になりました。ソケットでブロックされている仮想スレッドに割り込むとソケットがクローズされます。

java.io

java.io パッケージのAPI実装は同期化が進んでおり、仮想スレッドで使用する際にはピン留めを回避する変更が必要です。

バイト指向の入出力ストリームはスレッドセーフではなく、スレッドが読み取りまたは書き込みメソッドでブロックされている間に close() が呼び出されたときに期待される動作が指定されていません。 ほとんどのシナリオでは、複数の同時実行スレッドから特定の入出力ストリームを使用することは意味を成しません。

文字指向のリーダ・ライタもスレッドセーフではありませんが、サブクラスのためにロックオブジェクトを公開します。 例えば、InputStreamReaderOutputStreamWriter が使用するストリーム・デコーダとエンコーダは、ロックオブジェクトではなく、ストリームオブジェクト上で同期がとられます。

ピン止めを防ぐため、現在、実装は以下のようになっています。

BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriterPrintStream、および PrintWriter は、直接使用する場合、明示的にロックを使用するようになりました。これらのクラスは、サブクラス化されると従来どおり同期化されます。

InputStreamReaderOutputStreamWriter で使用されるストリーム・デコーダとエンコーダは、InputStreamReader または OutputStreamWriter を包含するものと同じロックを使用するようになりました。

さらに進んで、このしばしば必要とされるロックをすべて排除することは、この JEP の範囲外となります。

Java Native Interface (JNI)

JNIは、オブジェクトが仮想スレッドであるかどうかをテストするための新しい関数、IsVirtualThread が追加されました。

Debugging (JVM TI, JDWP, and JDI)

JVM Tool Interface (JVM TI), Java Debug Wire Protocol (JDWP), Java Debug Interface (JDI) はすべて、仮想スレッドをサポートするようになりました。

JVM TIに対する更新は以下となります。

  • jthread(すなわち、ThreadオブジェクトへのJNI参照)を使って呼び出されるほとんどの関数は、仮想スレッドへの参照で呼び出すことができます。少数の関数、すなわちPopFrame、ForceEarlyReturn、StopThread、AgentStartFunction、およびGetThreadCpuTimeは、仮想スレッドではサポートされていません。SetLocal* 関数は、ブレークポイントまたはシングルステップのイベントで中断された仮想スレッドの最上位フレームにローカル変数を設定する場合に限定されます

  • GetAllThreads および GetAllStackTraces 関数は、すべてのスレッドではなく、すべてのプラットフォームスレッドを返すように指定されるようになりました

  • 初期の VM スタートアップ中またはヒープ反復中にポストされるものを除いて、すべてのイベントは、仮想スレッドのコンテキストで呼び出されるイベントコールバックを持つことができます

  • サスペンド/レジュームの実装により、デバッガによる仮想スレッドのサスペンドとレジュームが可能になり、仮想スレッドがマウントされたときにプラットフォームスレッドをサスペンドすることができるようになりました

  • 新しいケイパビリティである can_support_virtual_threads は、エージェントが仮想スレッドの開始と終了のイベントをより細かく制御できるようにします

  • 新しい関数は、仮想スレッドの一括停止と再開をサポートします

既存のJVM TIエージェントは、ほとんど以前のように動作しますが、仮想スレッドでサポートされていない関数を呼び出した場合、エラーに遭遇する可能性があります。 これらは、仮想スレッドを認識しないエージェントが、仮想スレッドを使用するアプリケーションで使用される場合に発生します。 プラットフォームスレッドのみを含む配列を返すように GetAllThreads を変更したことは、一部のエージェントにとって問題となるかもしれません。ThreadStart および ThreadEnd イベントを有効にしている既存のエージェントは、これらのイベントをプラットフォームスレッドに制限する機能がないため、パフォーマンスの問題が発生する可能性があります。

JDWP の更新は次のとおりです。

  • 新しいコマンドにより、デバッガはスレッドが仮想スレッドであるかどうかをテストすることができます。
  • EventRequestコマンドの新しいモディファイアにより、デバッガーはスレッドの開始と終了イベントをプラットフォームスレッドに制限できるようになりました。

JDI に対する更新は次のとおりです。

  • com.sun.jdi.ThreadReference の新しいメソッドは、スレッドが仮想スレッドであるかどうかをテストします。

  • com.sun.jdi.request.ThreadStartRequest および com.sun.jdi.request.ThreadDeathRequest の新しいメソッドは、リクエストに対して生成されるイベントをプラットフォームスレッドに制限しています。

前述のとおり、仮想スレッドはスレッドグループ内のアクティブなスレッドとはみなされません。 そのため、JVM TI 関数 GetThreadGroupChildren、JDWP コマンド ThreadGroupReference/Children、JDI メソッド com.sun.jdi.ThreadGroupReference.threads() が返すスレッドリストは、プラットフォームス レッドのみを含んでいます。

JDK Flight Recorder (JFR)

JFR は、いくつかの新しいイベントによって仮想スレッドをサポートしています。

  • jdk.VirtualThreadStart および jdk.VirtualThreadEnd は、仮想スレッドの開始と終了を示します。これらのイベントはデフォルトでは無効になっています

  • jdk.VirtualThreadPinned は、仮想スレッドがピン留めされたまま、つまりプラットフォームスレッドを解放せずにパークされたことを示します (説明を参照してください)。このイベントはデフォルトで有効になっており、閾値は 20 ミリ秒です

  • jdk.VirtualThreadSubmitFailed は、仮想スレッドの起動またはパーク解除に失敗したことを示し、リソースの問題が原因である可能性があります。このイベントはデフォルトで有効になっています

Java Management Extensions (JMX)

java.lang.management.ThreadMXBean は、プラットフォーム・スレッドの監視および管理のみをサポートします。findDeadlockedThreads() メソッドは、デッドロックに陥ったプラットフォームスレッドのサイクルを検出しますが、デッドロックに陥った仮想スレッドのサイクルは検出しません。

com.sun.management.HotSpotDiagnosticsMXBean の新しいメソッドは、上記の新スタイルのスレッドダンプを生成します。このメソッドは、ローカルまたはリモートの JMX ツールから、プラットフォーム MBeanServer を介して間接的に呼び出すこともできます。

java.lang.ThreadGroup

java.lang.ThreadGroup はスレッドをグループ化するためのレガシー API ですが、現代のアプリケーションではほとんど使われておらず、仮想スレッドをグループ化するのにも適していません。 現在では非推奨となります。非推奨となる suspend()resume()stop() メソッドは、常に例外を投げます。


代替手段

  • 非同期APIに依存し続ける。非同期APIは同期APIとの統合が難しく、同じI/O操作の2つの表現という分裂した世界を作り出し、トラブルシューティング、監視、デバッグ、プロファイリングの目的でプラットフォームがコンテキストとして使用できる一連の統一された概念と操作を提供できません。

  • スタックレス・コルーチン(async/await)を追加する。これらは、ユーザーモードのスレッドよりも実装が簡単で、一連の操作のコンテキストを表す統一的な構造を提供します。しかし、この構成はスレッドとは別の新しいものであり、スレッド用に設計されたAPIとコルーチン用に設計されたAPIの間で世界を二分し、プラットフォームとそのツールのすべてのレイヤーに、新しいスレッドのような構造を導入する必要があります。 これは、エコシステムの採用に時間がかかり、ユーザーモードのスレッドのようにエレガントでプラットフォームと調和したものにはならないでしょう。

  • java.lang.Threadとは無関係に,ユーザモードのスレッドを表現する新しいパブリッククラスを導入する。これは、Threadクラスが25年以上にわたって蓄積してきた不要な荷物を捨てる機会になるでしょう。私たちは、このアプローチのいくつかのバリエーションを検討し、プロトタイプを作成しましたが、どの場合も、既存のコードをどのように実行するかという問題に悩まされました。主な問題は、Thread.currentThread() が、直接的または間接的に、既存のコードに広く使用されていることです。このメソッドは、現在の実行スレッドを表すオブジェクトを返さなければなりません。もしユーザモードスレッドを表す新しいクラスを導入した場合、 currentThread() は Thread のように見えるがユーザモードスレッドオブジェクトに委ねるある種のラッパーオブジェクトを返さなければならないでしょう。

現在の実行スレッドを表すオブジェクトが2つあると混乱するので、結局、古いThread APIを維持することは大きなハードルではないと判断しました。 currentThread()などの一部のメソッドを除いて、開発者がThread APIを直接使うことはほとんどなく、ExecutorServiceなどの上位のAPIを使ってやり取りすることがほとんどです。Thread クラスや、ThreadGroupなどの関連クラスから、不要なメソッドを非推奨にしたり削除したりすることで、時間をかけて不要な荷物を取り除いていく予定です。


リスクと前提条件

本提案の主なリスクは、既存のAPIとその実装の変更に伴う互換性の問題です。

  • java.io.BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter, PrintStream, および PrintWriter クラスで使用される内部ロックプロトコルの変更は、入出力メソッドが呼び出されたストリーム上で同期することを想定しているコードに影響を与える可能性があります。また、java.io.Reader または java.io.Writer を拡張し、これらの API によって公開されるロック・オブジェクトを使用するコードにも影響しません。
  • java.lang.ThreadGroup は、スレッドグループの破棄を許可しなくなり、デーモンスレッドグループの概念をサポートしなくなりました。また、その suspend(), resume(), stop() メソッドは常に例外を投げます。

java.lang.Thread を継承するコードに影響を与える可能性のある、いくつかのソース互換性のない API 変更と、1つのバイナリ互換性のない変更があります

  • 既存のソース ファイル内のコードが Thread を拡張し、そのサブクラス内のメソッドが新しい Thread メソッドのいずれかと競合する場合、そのファイルは変更せずにコンパイルすることができません
  • Thread.Builder はネストされたインタフェースとして追加されます
  • スレッドの識別子を返す最終メソッドとして Thread.threadId() が追加されました。

既存のコードと仮想スレッドや新しい API を利用する新しいコードを混在させた場合、プラットフォームスレッドと仮想スレッドの間にいくつかの動作の違いが見られることがあります

  • Thread.setPriority(int) メソッドは仮想スレッドに影響を与えません(常に Thread.NORM_PRIORITY の優先度となる)。
  • Thread.setDaemon(boolean) メソッドは仮想スレッドに影響を与えません(常にデーモンスレッドである)
  • Thread.stop()suspend()resume()メソッドは、仮想スレッド上で起動されるとUnsupportedOperationExceptionをスローします。
  • Thread API は、スレッドローカル変数をサポートしないスレッドの作成をサポートします。ThreadLocal.set(T) および Thread.setContextClassLoader(ClassLoader) は、スレッドローカルをサポートしないスレッドのコンテキストで起動すると、UnsupportedOperationException をスローします
  • Thread.getAllStackTraces() は、すべてのスレッドのマップではなく、すべてのプラットフォームスレッドのマップを返すようになりました
  • java.net.Socket, ServerSocket, DatagramSocket によって定義されたブロッキング I/O メソッドは、仮想スレッドのコンテキストで呼び出された場合、割り込み可能になりました。ソケット操作でブロックされているスレッドが中断された場合、既存のコードが壊れる可能性があります
  • 仮想スレッドは ThreadGroup のアクティブなメンバではありません。仮想スレッドで Thread.getThreadGroup() を呼び出すと、空のダミー VirtualThreads グループが返されます
  • SecurityManagerが設定された状態で実行されている場合、仮想スレッドにはパーミッションがありません
  • JVM TIでは、GetAllThreadsおよびGetAllStackTraces関数は、仮想スレッドを返しません。ThreadStartイベントとThreadEndイベントを有効にする既存のエージェントは、イベントをプラットフォームスレッドに制限する機能がないため、パフォーマンスの問題が発生する可能性があります
  • java.lang.management.ThreadMXBean APIは、プラットフォームスレッドの監視と管理をサポートしますが、仮想スレッドはサポートしません。
  • XX:+PreserveFramePointerフラグは、仮想スレッドのパフォーマンスに重大な悪影響を及ぼします