#chiroito ’s blog

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

日本語訳: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 がスローされるかもしれません。

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

個人的に気になっている Project Loom で Virtual Threads がプレビューされたので仕様である JEP を翻訳してみました。 ボリュームが多いため、いくつかのパートに分けて公開していきます。スターやブコメが付くとモチベーションが上がるかもしれません。(上がらないかもしれません)

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

全パートはこちら

説明

今日、JDKのjava.lang.Threadのすべてのインスタンスは、プラットフォームスレッドです。プラットフォームスレッドは、基礎となるOSスレッド上でJavaコードを実行し、コードの全生涯にわたってOSスレッドを捕捉します。プラットフォームスレッドの数は、OSスレッドの数に制限されています。

仮想スレッドは、基盤となるOSスレッド上でJavaコードを実行するjava.lang.Threadのインスタンスですが、コードの全生涯にわたってOSスレッドを捕捉することはありません。つまり、多くの仮想スレッドが同じOSスレッド上でJavaコードを実行することができ、事実上それを共有できます。プラットフォームスレッドが貴重なOSスレッドを独占するのに対して、仮想スレッドはそうではありません。仮想スレッドの数は、OSスレッドの数よりはるかに大きくできます。

仮想スレッドは、OSではなくJDKによって提供されるスレッドの軽量な実装です。これはユーザーモードスレッドの一種で、他のマルチスレッド言語(GoのゴルーチンやErlangのプロセスなど)でも成功したものです。ユーザーモードスレッドは、OSスレッドがまだ成熟しておらず普及していなかったJavaの初期バージョンでは、いわゆる「グリーンスレッド」として機能したこともあります。しかし、Javaのグリーンスレッドはすべて1つのOSスレッドを共有し(M:1スケジューリング)、最終的にはOSスレッドのラッパーとして実装されたプラットフォームスレッドに負けました(1:1スケジューリング)。仮想スレッドはM:Nスケジューリングを採用しており、多数の(M)個の仮想スレッドが少数の(N)個のOSスレッド上で動作するようにスケジューリングされます。

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

開発者は、仮想スレッドとプラットフォームスレッドのどちらを使うかを選択できます。ここでは、大量の仮想スレッドを作成するサンプルプログラムを紹介します。このプログラムは、まず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()は暗黙のうちに呼び出され、待機します。

この例のタスクは、1秒間スリープするという単純なコードで、最近のハードウェアは、このようなコードを同時に実行する1万個の仮想スレッドを簡単にサポートできます。裏側では、JDKは少数のOSスレッド(おそらく1スレッド程度)でコードを実行しています。

このプログラムが、Executors.newCachedThreadPool()のような、各タスクに対して新しいプラットフォームスレッドを作成するExecutorServiceを使用していたら、状況は大きく変わっていたことでしょう。ExecutorService は 10,000 個のプラットフォームスレッドを作成しようとするので、OS スレッドも 10,000 個になり、マシンとオペレーティングシステムによってはプログラムがクラッシュする可能性があります。

もしこのプログラムが、Executors.newFixedThreadPool(200)のようなプールからプラットフォームスレッドを取得するExecutorServiceを使用するなら、状況はあまり良くならないでしょう。このExecutorServiceは、1万個のタスクが共有する200個のプラットフォームスレッドを作成するため、多くのタスクが同時ではなく連続して実行され、プログラムの完了に長い時間がかかることになります。このプログラムでは、200個のプラットフォームスレッドを持つプールでは、200タスク/秒のスループットしか達成できませんが、仮想スレッドでは、(十分なウォームアップの後)約10,000タスク/秒のスループットが達成されます。さらに、このプログラムの10_000を1_000_000に変更すると、100万個のタスクを投入し、100万個の仮想スレッドを生成して同時実行し、(十分なウォームアップの後)約100万タスク/秒のスループットを達成できます。

このプログラムのタスクが、単にスリープするのではなく、1秒間計算を行う(例えば、巨大な配列をソートする)場合、プロセッサコアの数以上にスレッドの数を増やしても、それが仮想スレッドであろうとプラットフォームスレッドであろうと効果はありません。仮想スレッドは高速なスレッドではなく、プラットフォームスレッドよりも高速にコードを実行するわけではありません。仮想スレッドは高速化(低レイテンシ)ではなく、スケール(高スループット)を実現するために存在します。仮想スレッドはプラットフォームスレッドよりも多く存在するため、リトルの法則に従って、高いスループットに必要な高い並行処理が可能になります。

別の言い方をすれば、仮想スレッドは次のような場合にアプリケーションのスループットを大幅に向上させます。

  • 同時実行タスク数が多い(数千以上)。
  • 作業負荷が CPU 拘束でないこと。この場合、プロセッサコア数よりも多くのスレッドがあっても、スループットを向上させることはできないからです。

仮想スレッドは、一般的なサーバーアプリケーションのスループットを向上させるのに役立ちます。なぜなら、そのようなアプリケーションは、多くの時間を待機して過ごす多数の同時実行タスクで構成されているからです。

仮想スレッドは、プラットフォームスレッドが実行可能なあらゆるコードを実行できます。特に、仮想スレッドは、プラットフォームスレッドと同様に、スレッドローカル変数とスレッド割り込みをサポートします。つまり、リクエストを処理する既存の 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を提供し、それによってスレッド間の関係がプラットフォームとそのツールに知られるようになるのです。

仮想スレッドはプレビュー用APIで、デフォルトでは無効

上記のプログラムは Executors.newVirtualThreadPerTaskExecutor() メソッドを使用しているので、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で起動します。

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

開発者は通常、アプリケーションコードを、スレッドプールに基づく従来の ExecutorService から、リクエスト毎の仮想スレッド ExecutorService に移行することになります。スレッドプールは、他のリソースプールと同様に、高価なリソースを共有するためのものですが、仮想スレッドは高価ではないので、プールする必要はありません。

開発者は、限られたリソースへの同時アクセスを制限するために、スレッドプールを使用することがあります。例えば、あるサービスが20以上の同時リクエストを処理できない場合、サイズ20のプールに投入されたタスクを経由してサービスへのすべてのアクセスを実行することで、それを確実にできます。プラットフォームスレッドのコストが高いため、スレッドプールが一般的になり、このイディオムも一般的になりました。しかし、開発者は並行処理を制限するために仮想スレッドをプールする誘惑に駆られるべきではありません。限られたリソースへのアクセスを保護するために、セマフォのようなその目的のために特別に設計された構造を使用する必要があります。これはスレッドプールよりも効果的で便利です。また、スレッドローカルデータがあるタスクから別のタスクに誤って漏れる危険性がないため、より安全です。

仮想スレッドの監視

明確なコードを書くことが全てではありません。実行中のプログラムの状態を明確に示すことは、トラブルシューティング、メンテナンス、最適化にも不可欠であり、JDKは長い間、スレッドのデバッグ、プロファイル、監視のためのメカニズムを提供してきました。このようなツールは、おそらくは、その膨大な量に多少の配慮をした上で仮想スレッドに対しても同じことを行うべきです。おそらく、仮想スレッドは結局のところ、java.lang.Threadのインスタンスなのです。

Javaデバッガは、仮想スレッドのステップ実行、コールスタックの表示、スタックフレーム内の変数の検査が可能です。JDK Flight Recorder (JFR) は、JDK の低オーバーヘッドのプロファイリングとモニタリングのメカニズムで、アプリケーションコードからのイベント(オブジェクトの割り当てや I/O 操作など)を正しい仮想スレッドに関連付けられます。これらのツールは、非同期スタイルで書かれたアプリケーションに対してこれらのことを行えません。このスタイルでは、タスクはスレッドに関連しないため、デバッガはタスクの状態を表示したり操作したりできず、プロファイラもタスクがI/O待ちをしている時間を知れないのです。

スレッドダンプは、リクエスト毎のスレッドで書かれたアプリケーションのトラブルシューティ ングによく使われるツールのひとつです。残念ながら、JDKの従来のスレッドダンプは、jstackやjcmdで得られる、スレッドのフラットなリストを提示するものである。これは、数十または数百のプラットフォームスレッドには適していますが、数千または数百万の仮想スレッドには不向きです。したがって、従来のスレッドダンプを拡張して仮想スレッドを含めるのではなく、jcmdに新しい種類のスレッドダンプを導入して、仮想スレッドをプラットフォームスレッドと一緒に、すべて意味のある方法でグループ化して表示することにします。プログラムが構造化された並行処理を使用する場合、スレッド間のより豊かな関係を表示できます。

多くのスレッドを可視化し、分析することは、ツールの恩恵を受けるので、jcmdは、プレーンテキストに加えて、JSONフォーマットで新しいスレッドダンプを出力できます。

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

新しいスレッドダンプ形式は、ネットワークI/O操作でブロックされた仮想スレッドと、上に示した新しいリクエスト毎のExecutorServiceで作成された仮想スレッドを列挙します。オブジェクトアドレス、ロック、JNI統計、ヒープ統計など、従来のスレッドダンプに表示される情報は含まれません。さらに、非常に多くのスレッドをリストアップする必要があるため、新しいスレッドダンプを生成しても、アプリケーションを一時停止することはありません。

このようなスレッドダンプの例を、上記の2番目の例と同様のアプリケーションから取得し、JSONビューアでレンダリングしたものを示します。

仮想スレッドはJDKに実装され、特定のOSスレッドと関連付けられていないため、OSからは不可視であり、OSはその存在に気付きません。OSレベルの監視は、JDKプロセスが使用するOSスレッドの数が仮想スレッドの数より少ないことを観察します。

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