#chiroito ’s blog

Java を中心とした趣味の技術について

日本語訳:JEP 425 : Virtual Threads (Preview) Part 5

個人的に気になっている Project Loom で Virtual Threads がプレビューされたので仕様である JEP を翻訳してみました。 ボリュームが多いため、いくつかのパートに分けて公開していきます。

原文はこちら:JEP 425: Virtual Threads (Preview)

全パートはこちら

他の案

  • 非同期APIに依存し続けます。非同期APIは同期APIとの統合が難しく、同じI/O操作の2つの表現という分断された世界を作り出し、トラブルシューティング、監視、デバッグ、プロファイリングの目的でプラットフォームが状況に応じて使用できる、一連の操作に対する統一した概念を提供しません。
  • Java言語に構文的なスタックレス・コルーチン(すなわちasync/await)を追加することです。これらはユーザモードスレッドよりも実装が簡単で、一連の操作の状況を表す統一的な構造を提供するでしょう。しかし、この構成は新しく、スレッドとは別物で、多くの点でスレッドと似ていますが、ニュアンス的には異なるものです。スレッド用に設計されたAPIとコルーチン用に設計されたAPIの間で世界を二分し、プラットフォームとそのツールのすべてのレイヤーに新しいスレッドのような構造を導入する必要があるのです。これは、エコシステムが採用するのに時間がかかり、ユーザーモードのスレッドのようにエレガントでプラットフォームと調和したものにはならないでしょう。 構文的コルーチンを採用しているほとんどの言語は、ユーザーモードスレッドを実装できない(例:Kotlin)、従来のセマンティックの保証(例:本質的にシングルスレッドなJavaScript)、または言語固有の技術的制約(例:C++)のためにそうしてきました。これらの制約は、Javaには当てはまりません。
  • java.lang.Threadとは関係なく、ユーザモードのスレッドを表す新しいパブリッククラスを導入します。これは、Threadクラスが25年以上にわたって蓄積してきた不要なお荷物を捨てる機会になるでしょう。私たちは、このアプローチのいくつかのバリエーションを検討し、プロトタイプを作成しましたが、どの場合も、既存のコードをどのように実行するかという問題に直面することになりました。主な問題は、既存のコードで Thread.currentThread() が直接的または間接的に広く使用されていることです(例えば、ロック所有権の決定やスレッドローカル変数など)。このメソッドは、現在の実行スレッドを表すオブジェクトを返さなければなりません。もしユーザモードスレッドを表す新しいクラスを導入した場合、 currentThread() は Thread のように見えるがユーザモードスレッドオブジェクトに委ねるある種のラッパーオブジェクトを返さなければならないでしょう。現在の実行スレッドを表すオブジェクトが2つあると混乱するので、結局、古いThread APIを維持することは大きなハードルではないと判断しました。currentThread()などの一部のメソッドを除いて、開発者がThread APIを直接使うことはほとんどなく、ExecutorServiceなどの上位のAPIを使ってやり取りすることがほとんどです。Thread クラスや、ThreadGroup などの関連クラスから、不要なメソッドを非推奨にしたり削除したりすることで、時間をかけて不要な荷物を取り除いていく予定です。

テスト

  • 既存のテストは、私たちがここで提案する変更が、多数の構成と実行モードにおいて、予期せぬ回帰を引き起こさないことを保証するものです。
  • 私たちは jtreg テストツールを拡張し、既存のテストを仮想スレッドのコンテキストで実行できるようにする予定です。これにより、多くのテストで2つのバージョンを用意する必要がなくなります。
  • 新しいテストでは、すべての新しいAPIと改良されたAPI、および仮想スレッドをサポートするために変更されたすべての領域を検証します。
  • 新しい負荷テストは、信頼性と性能に重要な領域を対象とします。
  • 新しいマイクロベンチマークは、パフォーマンスが重要な部分をターゲットにします。
  • Helidon や Jetty などの既存のサーバーを大規模なテストに使用します。

リスクと前提条件

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

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

プラットフォームスレッドと仮想スレッドの間には、既存のコードと仮想スレッドや新しい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() は、すべてのスレッドの Map ではなく、すべてのプラットフォームス レッドの Map を返すようになりました。
  • 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フラグは、仮想スレッドのパフォーマンスに重大な悪影響を及ぼします。

依存関係

  • JDK 18のJEP 416 (Reimplement Core Reflection with Method Handles)では、VM-native Reflectionの実装が削除されました。これにより、メソッドがリフレクションで呼び出されたときに、仮想スレッドがうまく一時停止するようになりました。
  • JDK 13 の JEP 353 (Reimplement the Legacy Socket API) と JDK 15 の JEP 373 (Reimplement the Legacy DatagramSocket API) は、java.net.Socket、ServerSocket、DatagramSocket の実装を仮想スレッドでの使用に向けて設計した新しい実装と置き換えたものです。
  • JDK 18 の JEP 418 (Internet-Address Resolution SPI) は、ホスト名とアドレス探索のためのサービスプロバイダインターフェースを定義しました。これにより、サードパーティーライブラリーは、ホスト探索中にスレッドを固定化しない代替の java.net.InetAddress 解決手段を実装できます。

日本語訳:JEP 425 : Virtual Threads (Preview) Part 4

個人的に気になっている Project Loom で Virtual Threads がプレビューされたので仕様である JEP を翻訳してみました。 ボリュームが多いため、いくつかのパートに分けて公開していきます。

原文はこちら:JEP 425: Virtual Threads (Preview)

全パートはこちら

詳細な変更点

残りの章では、Javaプラットフォームとその実装において、私たちが提案する変更点について詳しく説明します。

java.lang.Thread

java.lang.Thread APIを以下のように更新します。

  • 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() は、スレッドの識別子を返します。既存の non-final メソッド Thread.getId() は、現在非推奨です。
  • Thread.getAllStackTraces() は、すべてのスレッドではなく、すべてのプラットフォームスレッドのマップを返すようになりました。

java.lang.Thread APIは、それ以外には変更ありません。Threadクラスで定義されたコンストラクタは、以前と同様にプラットフォームスレッドを作成します。新しいパブリック コンストラクタはありません。

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

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

スレッドローカル変数

仮想スレッドは、プラットフォームスレッドと同様にスレッドローカル変数(ThreadLocal)と継承可能なスレッドローカル変数(InheritableThreadLocal)をサポートしているので、スレッドローカルを使用する既存のコードを実行できます。ただし、仮想スレッドは非常に数が多いため、スレッドローカルは慎重に検討した上で使用してください。特に、スレッドプールで同じスレッドを共有する複数のタスクの間で、コストのかかるリソースをプールするためにスレッドローカルを使用しないでください。仮想スレッドは、そのライフタイムを通じて単一のタスクのみを実行することを目的としているため、決してプールしてはいけません。私たちは、何百万ものスレッドで実行する際のメモリフットプリントを減らすために、仮想スレッドの準備のために、java.baseモジュールからスレッドローカルの多くの使用を削除しています。

さらに:

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

スコープローカル変数は、スレッドローカルの代わりとして、より良い使用例であることが証明されるかもしれません。

java.util.concurrent

ロック機能をサポートするプリミティブ API である java.util.concurrent.LockSupport が、仮想スレッドをサポートするようになりました。仮想スレッドを一時停止すると、基盤となるプラットフォーム・スレッドが他の作業を行うために解放され、仮想スレッドの一時停止を解除すると、そのスレッドが続行するようにスケジュールされます。この LockSupport の変更により、これを使用するすべての API(ロック、セマフォ、ブロッキングキューなど)が、仮想スレッドで呼び出されたときに適切に一時停止するようになりました。

さらに:

  • Executors.newThreadPerTaskExecutor(ThreadFactory) および Executors.newVirtualThreadPerTaskExecutor() は、タスクごとに新しいスレッドを生成する ExecutorService を作成します。これらのメソッドにより、スレッドプールやExecutorServiceを使用する既存のコードとの移行や相互運用が可能になります。
  • ExecutorService が AutoCloseable を継承するようになったため、例に書いたように try-with-resource 構造でこの API を使用できるようになりました。
  • Futureは、完了したタスクの結果または例外を取得するメソッドと、タスクの状態を取得するメソッドを定義するようになりました。これらの追加を組み合わせると、Futureオブジェクトをストリームの要素として使うことが容易になります。Futureのストリームをフィルタリングして完了したタスクを見つけ、それをマッピングして結果のストリームを取得できます。これらのメソッドは、構造化された並行処理のために提案されたAPI追加に対しても有用です。

ネットワーク

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

割り込みやキャンセルができるように、java.net.Socket, ServerSocket, DatagramSocketで定義されているブロッキングI/Oメソッドは、仮想スレッドで起動されると割り込みできるように仕様変更されました。ソケット上でブロックされている仮想スレッドに割り込みをかけると、スレッドの停 止が解除され、ソケットが閉じられます。これらのタイプのソケットでInterruptibleChannelから取得したI/O操作をブロックすることは、常に割り込み可能でした。この変更により、これらのAPIのコンストラクタで作成されたときの動作と、チャネルから取得したときの動作が一致するようになりました。

java.io

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

背景として、バイト指向の入出力ストリームはスレッドセーフであることが指定されておらず、スレッドが読み取りまたは書き込みメソッドでブロックされている間にclose()が呼び出された場合に期待される動作が指定されていません。ほとんどのシナリオにおいて、複数の並行スレッドから特定の入出力ストリームを使用することは意味をなさない。文字指向のリーダライタもスレッドセーフであることは指定されていませんが、サブクラスのためにロックオブジェクトを公開します。固定化は別として、これらのクラスでの同期には問題があり、一貫性がありません。 例えば、InputStreamReader と OutputStreamWriter が使用するストリームデコーダとエンコーダは、ロックオブジェクトではなく、ストリームオブジェクト上で同期を取ります。

固定化を防ぐために、現在では以下のような実装になっています。

  • BufferedInputStream, BufferedOutputStream, BufferedReader, BufferedWriter, PrintStream, PrintWriter は、直接使用する場合、モニターではなく、明示的なロックを使用するようになりました。これらのクラスは、サブクラス化されると従来どおり同期化されます。
  • InputStreamReader および OutputStreamWriter によって使用されるストリーム・デコーダおよびエンコーダは、InputStreamReader または OutputStreamWriter が包含するものと同じロックを使用するようになりました。

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

さらに、BufferedOutputStream、BufferedWriter、および OutputStreamWriter のストリームエンコーダーで使用されるバッファの初期サイズが小さくなり、ヒープに多くのストリームやライターがある場合のメモリ使用量を減らしました。このため、ヒープ内に多くのストリームやライターが存在する場合、メモリ使用量を減らすことができます。

Java Native Interface (JNI)

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

それ以外のJNI仕様は変更されていません。

デバッグ

デバッグ・アーキテクチャは、3つのインターフェースから構成されています。JVM Tool Interface (JVM TI), Java Debug Wire Protocol (JDWP), そして Java Debug Interface (JDI) です。この3つのインタフェースはすべて仮想スレッドをサポートするようになりました。

JVM TIのアップデートは

  • jthread(すなわち、ThreadオブジェクトへのJNI参照)で呼び出されるほとんどの関数は、仮想スレッドへの参照で呼び出せます。少数の関数、すなわちPopFrame、ForceEarlyReturn、StopThread、AgentStartFunction、およびGetThreadCpuTimeは、仮想スレッドではサポートされていません。SetLocal* 関数は、ブレークポイントまたはシングルステップのイベントで中断された仮想スレッドの最上位フレームにローカル変数を設定する場合に限定されます。
  • GetAllThreads および GetAllStackTraces 関数は、すべてのスレッドではなく、すべてのプラットフォームスレッドを返すように指定されるようになりました。
  • 初期の VM スタートアップ中またはヒープ反復中に通知されるものを除いて、すべてのイベントは、仮想スレッドのコンテキストで呼び出されるイベントコールバックを持てるようになりました。
  • 中断/再開の実装により、デバッガによる仮想スレッドの中断と再開が可能になり、仮想スレッドがマウントされたときにプラットフォームスレッドを中断できるようになりました。
  • 新しい機能である can_support_virtual_threads は、エージェントが仮想スレッドの開始と終了のイベントをより細かく制御できるようにします。
  • 新しい関数は仮想スレッドの一括停止と再開をサポートします。これらは 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 ですが、最近のアプリケーションではほとんど使われておらず、仮想スレッドをグループ化するのにも適していません。現在では非推奨とし、その地位を低下させ、将来的には構造化された並行処理の一部として新しいスレッド編成構造を導入することを想定しています。

背景として、ThreadGroup APIはJava 1.0から存在しています。これはもともと、グループ内のすべてのスレッドを停止するようなジョブ制御操作を提供することを目的としていました。現代のコードでは、Java 5 で導入された java.util.concurrent パッケージのスレッドプール API を使用することが多くなっています。ThreadGroupは、初期のJavaリリースではアプレットの分離をサポートしていましたが、Javaセキュリティ・アーキテクチャはJava 1.2で大きく進化し、スレッドグループはもはや重要な役割を果たしません。ThreadGroupは、診断目的にも役立つことを意図していましたが、その役割は、java.lang.management APIを含むJava 5で導入された監視・管理機能によって取って代わられました。

ThreadGroupのAPIと実装は、現在ではほとんど無関係であることに加え、いくつかの重大な問題を抱えています。

  • スレッドグループを破棄するための API とメカニズムに不備があります。
  • API は、グループ内のすべての生きているスレッドへの参照を持つことを実装に要求します。これは、スレッド生成、スレッド開始、スレッド終了に同期と競合のオーバーヘッドを追加します。
  • APIは本質的に厳しいenumerate()メソッドを定義しています。
  • API は suspend(), resume(), stop() メソッドを定義していますが、これらは本質的にデッドロックを起こしやすく、安全ではありません。

ThreadGroupは、以下のように指定され、非推奨となり、その地位を低下させられました。

  • スレッドグループを明示的に破棄する機能が削除されました。最終的に非推奨となる destroy() メソッドは何もしません。
  • デーモンスレッドグループの概念が削除されました。最終的に非推奨となる setDaemon(boolean) および isDaemon() メソッドによって設定および取得されるデーモンの状態は無視されます。
  • 実装は、もはやサブグループへの強い参照を保持しません。スレッドグループは、グループ内に生きているスレッドがなく、他にスレッドグループを生かしているものがない場合、ガベージコレクトされる対象になります。
  • 最終的に非推奨となる suspend()、resume()、stop() メソッドは、常に例外を投げます。

つづきはこちら: 日本語訳:JEP 425 : Virtual Threads (Preview) Part 5 - #chiroito ’s blog

日本語訳:JEP 425 : Virtual Threads (Preview) Part 3

個人的に気になっている Project Loom で Virtual Threads がプレビューされたので仕様である JEP を翻訳してみました。 ボリュームが多いため、いくつかのパートに分けて公開していきます。

原文はこちら:JEP 425: Virtual Threads (Preview)

全パートはこちら

説明(Part 2からの続き)

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

スレッドが有用な作業を行うには、スケジューリング、つまりプロセッサコアでの実行を割り当てられる必要があります。OSスレッドとして実装されているプラットフォームスレッドについては、JDKはOS内のスケジューラに依存します。一方、仮想スレッドについては、JDKは独自のスケジューラを備えています。JDKのスケジューラは、仮想スレッドを直接プロセッサに割り当てるのではなく、仮想スレッドをプラットフォームスレッドに割り当てます(これが前述の仮想スレッドのM:Nスケジューリングです)。その後、プラットフォームスレッドは通常通りOSによってスケジューリングされます。

JDKの仮想スレッドスケジューラは、FIFOモードで動作するwork-stealing ForkJoinPoolです。スケジューラの並列度は、仮想スレッドのスケジューリングに利用できるプラットフォーム・スレッドの数です。デフォルトでは利用可能なプロセッサの数と同じですが、システムプロパティ jdk.virtualThreadScheduler.parallelism を使用して調整できます。この ForkJoinPool は、例えば並列ストリームの実装で使用され、LIFO モードで動作する共通プールとは別物であることに注意しましょう。

訳注:work-stealingとは、スレッドは自身が持つキューから仕事を取り出して処理しますが、そのキューが空になったら別のスレッドのキューから取りだす事を言います。

スケジューラが仮想スレッドを割り当てるプラットフォームスレッドを、仮想スレッドのキャリアと呼びます。仮想スレッドは、そのライフタイムを通じて異なるキャリアにスケジューリングされる可能性があります。つまり、スケジューラは仮想スレッドと特定のプラットフォームスレッドとの間の関係を維持しないのです。Javaコードから見ると、実行中の仮想スレッドはその現在のキャリアから論理的に独立しています。

  • 仮想スレッドでは、キャリアの身元は不明です。Thread.currentThread() が返す値は、常に仮想スレッド自身である。
  • キャリアと仮想スレッドのスタックトレースは別々です。仮想スレッドでスローされた例外は、キャリアのスタックフレームを含みません。スレッドダンプでは、仮想スレッドのスタックにキャリアのスタックフレームは表示されず、その逆もまた同様です。
  • キャリアのスレッドローカル変数は仮想スレッドでは使用できません。その逆もまた同様です。

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

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

仮想スレッドの実行

仮想スレッドを利用するために、プログラムを書き換える必要はありません。仮想スレッドは、アプリケーションコードが明示的にスケジューラに制御を戻すことを要求したり、期待したりしません。言い換えれば、仮想スレッドは連携しません。ユーザーコードは、仮想スレッドがいつどのようにプラットフォームスレッドに割り当てられるかについて、プラットフォームスレッドがいつどのようにプロセッサコアに割り当てられるかについて予測するのと同じように、予測してはいけません。

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

訳注、マウント:取り付けること、アンマウント:取り外すこと

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

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

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

これらの操作により、仮想スレッドは複数回マウントとアンマウントを行うことになります。通常、get()の呼び出しごとに1回、場合によってはsend(...)でのI/O実行中に複数回行われます。

JDKのブロック操作の大部分は、仮想スレッドをアンマウントして、そのキャリアと下位のOSスレッドを解放し、新しい仕事を引き受けられるようにします。しかし、JDKの一部のブロック操作では、仮想スレッドをアンマウントしないため、そのキャリアと下位のOSスレッドの両方がブロックされます。これは、OSレベル(例:多くのファイルシステム操作)またはJDKレベル(例:Object.wait())のいずれかの制限によるものです。これらのブロッキング処理の実装は、スケジューラの並列性を一時的に拡張することで、OSスレッドの占有を埋め合わせることになります。その結果、スケジューラの ForkJoinPool 内のプラットフォームスレッド数が、一時的に利用可能なプロセッサの数を超えることがあります。スケジューラが利用できるプラットフォームスレッドの最大数は、システムプロパティ jdk.virtualThreadScheduler.maxPoolSize を使用して調整できます。

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

  1. synchronizedブロックや メソッド内のコードを実行する場合
  2. ネイティブメソッドまたは外部関数を実行する場合

固定化によってアプリケーションが不正になることはありませんが、スケーラビリティの妨げになる可能性があります。仮想スレッドが固定されている間に I/O や BlockingQueue.take() などのブロッキング処理を実行すると、そのキャリアと基盤となる OS スレッドはその処理の間ブロックされます。長い時間頻繁に固定されると、キャリアが占有され、アプリケーションの拡張性が損なわれる可能性があります。

スケジューラは、その並列度を拡大することによって固定化を補うことはしません。頻繁に実行され、長い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 で実行すると、問題のあるフレームのみの出力に制限されます。

将来のリリースでは、上記の最初の制限事項 (同期化された内部での固定化) を取り除けるかもしれません。2番目の制限は、ネイティブコードとの適切な相互作用のために必要です。

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

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

上記の2番目の例では、仮定のフレームワークが新しい仮想スレッドを作成し、handleメソッドを呼び出すことで、各リクエストを処理することを思い出してください。たとえ深いコールスタック(認証、トランザクションなどの後)の最後に handle を呼び出したとしても、handle 自体は短時間のタスクだけを実行する複数の仮想スレッドを生成します。したがって、深いコールスタックを持つ各仮想スレッドに対して、わずかなメモリを消費する浅いコールスタックを持ついくつもの仮想スレッドが存在することになります。

仮想スレッドが必要とするヒープスペースとガベージコレクタの動作量は、一般に、非同期コードのそれと比較することは困難です。100万の仮想スレッドは少なくとも100万のオブジェクトを必要としますが、プラットフォームスレッドのプールを共有する100万のタスクも同じです。さらに、リクエストを処理するアプリケーションコードは、通常、I/O操作にわたってデータを維持します。リクエスト毎のスレッドでは、そのデータをローカル変数に保持できます。ローカル変数はヒープ内の仮想スレッドスタックに格納されます。これに対して、非同期コードは、パイプラインの1つのステージから次のステージに渡されるヒープオブジェクトに、同じデータを保持する必要があります。また、仮想スレッドが必要とするスタックフレームレイアウトは、コンパクトなオブジェクトのレイアウトよりも無駄が多くなります。しかし、一方で、仮想スレッドは多くの状況でスタックを変更したり再利用したりすることができます((低レベルのGC相互作用に依存します)。一方、非同期パイプラインは常に新しいオブジェクトを割り当てる必要があるため、仮想スレッドはより少ない割り当てを求めるかもしれません。全体として、リクエスト毎のスレッドと非同期コードのヒープ消費量とガベージコレクタの動作は、ほぼ同じであるべきです。時間の経過とともに、仮想スレッドスタックの内部表現がよりコンパクトになることが期待されます。

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

仮想スレッドの現在の限界は、G1 GC が巨大なスタックチャンクオブジェクトをサポートしないことです。もし仮想スレッドのスタックがG1 GCの領域サイズの半分(512KB ほど)に達すると、StackOverflowError がスローされるかもしれません。