仮想スレッド
仮想スレッドは、高スループットの同時アプリケーションの書込み、メンテナンスおよびデバッグ作業を削減する軽量スレッドです。
仮想スレッドの背景情報は、JEP 444を参照してください。
スレッドは、スケジュールできる処理の最小単位です。これは、他の同様のユニットと同時に、多くの場合は独立して実行されます。これは java.lang.Threadのインスタンスです。スレッドには、プラットフォーム・スレッドと仮想スレッドの2種類があります。
プラットフォーム・スレッドとは
プラットフォーム・スレッドは、オペレーティング・システム(OS)スレッドを囲むthinラッパーとして実装されます。プラットフォーム・スレッドは、基礎となるOSスレッド上でJavaコードを実行します。また、プラットフォーム・スレッドはプラットフォーム・スレッドの存続期間中ずっとOSスレッドを保持します。このため、使用可能なプラットフォーム・スレッドの数はOSスレッドの数に制限されます。
通常、プラットフォーム・スレッドには、オペレーティング・システムによって保守される大きなスレッド・スタックおよびその他のリソースがあります。これらは、すべてのタイプのタスクの実行に適していますが、リソースに制限がある場合があります。
仮想スレッドとは
プラットフォーム・スレッドと同様に、仮想スレッドもjava.lang.Threadのインスタンスです。ただし、仮想スレッドは特定のOSスレッドに関連付けられません。それにもかかわらず仮想スレッドはOSスレッド上でコードを実行します。ただし、仮想スレッドで実行されているコードがブロッキングI/O操作をコールすると、Javaランタイムは、再開できるまで仮想スレッドを一時停止します。一時停止された仮想スレッドに関連付けられていたOSスレッドは解放され、他の仮想スレッドの操作を実行できます。
仮想スレッドは、仮想メモリーと同様の方法で実装されます。大容量のメモリーをシミュレートするため、オペレーティング・システムは大きな仮想アドレス空間を限られた量のRAMにマップします。同様に、多数のスレッドをシミュレートするため、Javaランタイムは多数の仮想スレッドを少数のOSスレッドにマップします。
プラットフォーム・スレッドとは異なり、通常、仮想スレッドのコール・スタックは浅く、1つのHTTPクライアント・コールまたは1つのJDBC問合せとして実行されます。仮想スレッドでもスレッド・ローカル変数と継承可能なスレッド・ローカル変数がサポートされますが、1つのJVMが数百万個の仮想スレッドをサポートする場合があるため、このような変数の使用は慎重に検討してください。
仮想スレッドが適しているのは、ほとんどの時間をブロックされて(多くの場合、I/O操作の完了を待機して)費やすタスクの実行です。ただし、長時間実行されるCPU集約型の操作は対象ではありません。
仮想スレッドを使用する理由
仮想スレッドは、高スループットの同時アプリケーションで使用します。特に、時間のほとんどを待機に費やす大量の同時タスクで構成されるアプリケーションです。サーバー・アプリケーションは高スループット・アプリケーションの一例です。通常、ブロッキングI/O操作(リソースのフェッチなど)を実行する多くのクライアント・リクエストを処理するためです。
仮想スレッドは高速スレッドではありません。つまりプラットフォーム・スレッドよりも速くコードが実行されることはありません。速度(低レイテンシ)ではなく、スケール(高スループット)を提供するために存在します。
仮想スレッドの作成と実行
Thread APIおよびThread.Builder APIは、プラットフォーム・スレッドと仮想スレッドの両方を作成する方法を提供します。java.util.concurrent.Executorsクラスは、各タスクに対して新しい仮想スレッドを開始するExecutorServiceを作成するメソッドも定義します。
ThreadクラスおよびThread.Builderインタフェースを使用した仮想スレッドの作成
Thread.ofVirtual()メソッドをコールして、仮想スレッドを作成するためのThread.Builderのインスタンスを作成します。
次の例では、メッセージを出力する仮想スレッドを作成して開始します。これはjoinメソッドをコールして、仮想スレッドが終了するまで待機します。(これにより、出力れたメッセージをメイン・スレッドが終了する前に確認できます。)
Thread thread = Thread.ofVirtual().start(() -> System.out.println("Hello"));
thread.join();Thread.Builderインタフェースを使用すると、一般的なThreadプロパティ(スレッドの名前など)のスレッドを作成できます。Thread.Builder.OfPlatformサブインタフェースによってプラットフォーム・スレッドが作成され、Thread.Builder.OfVirtualによって仮想スレッドが作成されます。
次の例では、Thread.Builderインタフェースを使用してMyThreadという名前の仮想スレッドを作成します:
Thread.Builder builder = Thread.ofVirtual().name("MyThread");
Runnable task = () -> {
System.out.println("Running thread");
};
Thread t = builder.start(task);
System.out.println("Thread t name: " + t.getName());
t.join();次の例では、Thread.Builderを使用して2つの仮想スレッドを作成して開始します:
Thread.Builder builder = Thread.ofVirtual().name("worker-", 0);
Runnable task = () -> {
System.out.println("Thread ID: " + Thread.currentThread().threadId());
};
// name "worker-0"
Thread t1 = builder.start(task);
t1.join();
System.out.println(t1.getName() + " terminated");
// name "worker-1"
Thread t2 = builder.start(task);
t2.join();
System.out.println(t2.getName() + " terminated");この例では、次のような出力が表示されます:
Thread ID: 21
worker-0 terminated
Thread ID: 24
worker-1 terminatedExecutors.newVirtualThreadPerTaskExecutor()メソッドを使用した仮想スレッドの作成と実行
エグゼキュータを使用すると、アプリケーションの他の部分からスレッド管理と作成を分離できます。
次の例では、Executors.newVirtualThreadPerTaskExecutor()メソッドを使用してExecutorServiceを作成します。ExecutorService.submit(Runnable)がコールされるたびに、新しい仮想スレッドが作成され、タスクを実行するために開始されます。このメソッドは、Futureのインスタンスを戻します。メソッドFuture.get()は、スレッドのタスクが完了するまで待機します。したがって、この例では、仮想スレッドのタスクが完了したときにメッセージが出力されます。
try (ExecutorService myExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?> future = myExecutor.submit(() -> System.out.println("Running thread"));
future.get();
System.out.println("Task completed");
// ...マルチスレッド・クライアント・サーバーの例
次の例は、2つのクラスで構成されています。EchoServerは、ポートでリスニングし、接続ごとに新しい仮想スレッドを開始するサーバー・プログラムです。EchoClientは、サーバーに接続し、コマンドラインに入力されたメッセージを送信するクライアント・プログラムです。
EchoClientはソケットを作成し、EchoServerへの接続を取得します。これは標準入力ストリームでユーザーからの入力を読み取り、そのテキストをソケットに書き込むことでEchoServerに転送します。EchoServerは、ソケットを介して、その入力をEchoClientにエコーします。EchoClientは、サーバーから戻されたデータを読み取って表示します。EchoServerは、仮想スレッド(クライアント接続ごとに1つのスレッド)を介して複数のクライアントに同時にサービスを提供できます。
public class EchoServer {
public static void main(String[] args) throws IOException {
if (args.length != 1) {
System.err.println("Usage: java EchoServer <port>");
System.exit(1);
}
int portNumber = Integer.parseInt(args[0]);
try (
ServerSocket serverSocket =
new ServerSocket(Integer.parseInt(args[0]));
) {
while (true) {
Socket clientSocket = serverSocket.accept();
// Accept incoming connections
// Start a service thread
Thread.ofVirtual().start(() -> {
try (
PrintWriter out =
new PrintWriter(clientSocket.getOutputStream(), true);
BufferedReader in = new BufferedReader(
new InputStreamReader(clientSocket.getInputStream()));
) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println(inputLine);
out.println(inputLine);
}
} catch (IOException e) {
e.printStackTrace();
}
});
}
} catch (IOException e) {
System.out.println("Exception caught when trying to listen on port "
+ portNumber + " or listening for a connection");
System.out.println(e.getMessage());
}
}
}public class EchoClient {
public static void main(String[] args) throws IOException {
if (args.length != 2) {
System.err.println(
"Usage: java EchoClient <hostname> <port>");
System.exit(1);
}
String hostName = args[0];
int portNumber = Integer.parseInt(args[1]);
try (
Socket echoSocket = new Socket(hostName, portNumber);
PrintWriter out =
new PrintWriter(echoSocket.getOutputStream(), true);
BufferedReader in =
new BufferedReader(
new InputStreamReader(echoSocket.getInputStream()));
) {
BufferedReader stdIn =
new BufferedReader(
new InputStreamReader(System.in));
String userInput;
while ((userInput = stdIn.readLine()) != null) {
out.println(userInput);
System.out.println("echo: " + in.readLine());
if (userInput.equals("bye")) break;
}
} catch (UnknownHostException e) {
System.err.println("Don't know about host " + hostName);
System.exit(1);
} catch (IOException e) {
System.err.println("Couldn't get I/O for the connection to " +
hostName);
System.exit(1);
}
}
}仮想スレッドのスケジュールおよび固定された仮想スレッド
プラットフォーム・スレッドがいつ実行されるかは、オペレーティング・システムによってスケジュールされます。ただし、仮想スレッドがいつ実行されるかはJavaランタイムによってスケジュールされます。Javaランタイムが仮想スレッドをスケジュールするとき、仮想スレッドをプラットフォーム・スレッドに割り当てます(すなわちマウントします)。その後、オペレーティング・システムがそのプラットフォーム・スレッドを通常どおりスケジュールします。このプラットフォーム・スレッドはキャリアと呼ばれます。いくつかのコードを実行した後で、仮想スレッドをそのキャリアからマウント解除することができます。通常これが発生するのは、仮想スレッドがブロッキングI/O操作を実行するときです。仮想スレッドがキャリアからマウント解除されると、キャリアは解放されます。これは、Javaランタイム・スケジューラが別の仮想スレッドをそのキャリアにマウントできることを意味します。
仮想スレッドがキャリアに固定されている場合、ブロッキング操作中にマウント解除することはできません。nativeメソッドまたは外部関数を実行すると、仮想スレッドが固定されます(「外部関数およびメモリーAPI」を参照)。固定してアプリケーションが正しくなくなることはありませんが、スケーラビリティが低下する可能性があります。
仮想スレッドのデバッグ
JDKには、アプリケーション内の仮想スレッドの監視に役立つツールと機能があります。
HotSpot VMスレッド・ダンプの作成
HotSpot VMスレッド・ダンプには、すべてのプラットフォーム・スレッドとマウントされた仮想スレッドが、スタック・トレースとともに含まれます。
HotSpot VMスレッド・ダンプを作成するには、コマンドjcmd <pid> Thread.printを実行します。次のコマンドの出力例は、マウントされたスレッド(#32)とそのキャリアスレッド(#33)を示しています:
$ jcmd 97310 Thread.print
"ForkJoinPool-1-worker-1" #33 [33539] daemon prio=5 os_prio=31 cpu=33651.61ms
elapsed=33.65s tid=0x000000013289f000 [0x000000016e225000]
Carrying virtual thread #32
at jdk.internal.vm.Continuation.run(java.base@25-internal/Continuation.java:251)
at java.lang.VirtualThread.runContinuation(java.base@25-internal/VirtualThread.java:297)
at java.lang.VirtualThread$$Lambda/0x0000000801007618.run(java.base@25-internal/Unknown Source)
at java.util.concurrent.ForkJoinTask$RunnableExecuteAction.compute(java.base@25-internal/ForkJoinTask.java:1750)
at java.util.concurrent.ForkJoinTask$RunnableExecuteAction.compute(java.base@25-internal/ForkJoinTask.java:1742)
at java.util.concurrent.ForkJoinTask$InterruptibleTask.exec(java.base@25-internal/ForkJoinTask.java:1659)
at java.util.concurrent.ForkJoinTask.doExec(java.base@25-internal/ForkJoinTask.java:511)
at java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(java.base@25-internal/ForkJoinPool.java:1450)
at java.util.concurrent.ForkJoinPool.runWorker(java.base@25-internal/ForkJoinPool.java:2019)
at java.util.concurrent.ForkJoinWorkerThread.run(java.base@25-internal/ForkJoinWorkerThread.java:187)
Mounted virtual thread #32
at Test.deadOrAlive(Test.java:40)
at Test.lambda$main$0(Test.java:29)
at Test$$Lambda/0x0000000801042a08.run(Unknown Source)
at java.lang.Thread.runWith(java.base@25-internal/Thread.java:1460)
at java.lang.VirtualThread.run(java.base@25-internal/VirtualThread.java:460)
at java.lang.VirtualThread$VThreadContinuation$1.run(java.base@25-internal/VirtualThread.java:252)
at jdk.internal.vm.Continuation.enter0(java.base@25-internal/Continuation.java:325)
at jdk.internal.vm.Continuation.enter(java.base@25-internal/Continuation.java:316)HotSpot VMスレッド・ダンプを使用して、ループしている仮想スレッドなどを識別できます。詳細は、『Java Platform, Standard Editionトラブルシューティング・ガイド』のスレッド・ダンプおよびプロセスのハングおよびループのトラブルシューティングに関する項を参照してください。
jcmd Thread.dump_to_fileを使用したスレッド・ダンプの作成
コマンドjcmd Thread.dump_to_fileは、仮想スレッドを含むすべてのスレッドの単純なスレッド・ダンプを作成します。これは、ハングしたシステムの確認に非常に役立ちます。
このスレッド・ダンプを使用してすべてのスレッドについて全体像を把握することはできますが、スレッドの一貫したスナップショットではありません。ダンプの取得時、VMは一時停止されません(つまり、HotSpot VMスレッド・ダンプのようなstop-the-world型のスレッド・ダンプではありません)。かわりに、スレッドごとにタイムスタンプが含まれます。また、ロック情報も含まれますが、デッドロックの検出は実行されません。
プレーン・テキストまたはJSON形式でスレッド・ダンプを作成するには、次のコマンドのいずれかを実行します:
jcmd <pid> Thread.dump_to_file -format=text <file>
jcmd <pid> Thread.dump_to_file -format=json <file>JSON形式は、ツールによって読取りおよび解析をするためのものです。
このスレッド・ダンプには、プラットフォーム・スレッドと仮想スレッドの両方を含むすべてのスレッドがリストされます。また、タイムスタンプ、スレッドの状態およびjava.util.concurrentロック情報も含まれます。ただし、オブジェクト・アドレス、JNI統計、ヒープ統計、および従来のスレッド・ダンプに表示されるその他の情報は含まれません。
仮想スレッド・デバッグ用のその他のjcmdコマンド
コマンドjcmd Thread.vthread_pollersを使用すると、ソケット/ネットワークI/Oでブロックされている仮想スレッドの数を把握できます。
コマンドjcmd Thread.vthread_schedulerは、jdk.management.VirtualThreadSchedulerMXBeanインタフェースが提供できるものと同様の情報を提供します。「Java Management Extensionsを使用した仮想スレッド・スケジューラの監視および管理」を参照してください。スケジューラを使用して、仮想スレッド・アクティビティの概要を取得できます。各タスクは、ブロック後に開始または続行する仮想スレッドに対応しています。これは、スターベーションやハングに関する問題の診断に役立ちます。
コマンドjcmd GC.heap_dumpは、Javaヒープのヒープ・プロファイラ(HPROF)形式のダンプを生成します。HPROFダンプには、ヒープ内のすべての仮想スレッドが含まれます。
仮想スレッドのJDK Flight Recorderイベント
JDK Flight Recorder (JFR)は、仮想スレッドに関連するイベントを記録します。これらのイベントの1つはjdk.VirtualThreadPinnedで、パフォーマンスの問題またはイベントのハングを診断するために使用できます。
jdk.VirtualThreadPinnedイベントは、しきい値期間より長く仮想スレッドが固定された(そしてそのキャリア・スレッドが解放されなかった)ことを示します。このイベントは、20ミリ秒のしきい値でデフォルトで有効になっています。このイベントは、仮想スレッドをブロックしている操作とその理由を示します。
ノート:
Javaアプリケーションの起動時にJFR記録を開始するには、次のコマンドを実行します:java -XX:StartFlightRecording:dumponexit=true Applicationオプションdumponexit=trueは、JVMの停止時に、Javaアプリケーションが起動されたディレクトリのファイルに記録が書き込まれることを指定します。ファイル名は、プロセスID、記録IDおよび現在のタイムスタンプを含むシステム生成名です。記録のファイル名を指定する場合は、次のコマンドを実行します:
java -XX:StartFlightRecording:dumponexit=true,filename=recording.jfr Application記録内のjdk.VirtualThreadPinnedという名前のすべてのイベントを表示するには、次のコマンドを実行します:
jfr print --events jdk.VirtualThreadPinned recording.jfr次に、jdk.VirtualThreadPinnedイベントの例を示します:
-
仮想スレッドのパーキングで固定: 仮想スレッドをパーキングすると、その基盤となるキャリア・スレッドが解放され、他の作業を行うことができます。仮想スレッドをパーキング解除すると、そのスレッドが再開されるようスケジューリングされます。
jdk.VirtualThreadPinned { startTime = 14:25:32.741 (2025-05-11) duration = 12.4 ms blockingOperation = "LockSupport.park" pinnedReason = "Native or VM frame on stack" carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 44) eventThread = "" (javaThreadId = 58, virtual) stackTrace = [ java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 826 java.lang.VirtualThread.parkNanos(long) line: 794 java.lang.System$1.parkVirtualThread(long) line: 2288 java.util.concurrent.locks.LockSupport.parkNanos(long) line: 406 JfrEvents.lambda$testParkWhenPinned$1(AtomicBoolean, AtomicBoolean, boolean) line: 117 ] } -
競合するモニターへの進入でのブロックで固定: 競合するモニターへの進入は、スレッドが同期ブロックに入ってモニターまたはブロックを取得しようとしたが、モニターが別のスレッドによってすでに保持されているため、待機する必要がある場合に発生します。仮想スレッドが競合するモニターを取得しようとすると、固定されることがあります。
jdk.VirtualThreadPinned { startTime = 14:25:32.706 (2025-05-11) duration = 4.75 ms blockingOperation = "Contended monitor enter" pinnedReason = "Native or VM frame on stack" carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 44) eventThread = "" (javaThreadId = 57, virtual) stackTrace = [ JfrEvents.lambda$testBlockWhenPinned$1(AtomicBoolean, Object) line: 155 jdk.test.lib.thread.VThreadPinner$TaskRunner.run() line: 74 jdk.test.lib.thread.VThreadPinner.callback() line: 90 jdk.test.lib.thread.VThreadPinner.runPinned(VThreadRunner$ThrowingRunnable) line: 105 JfrEvents.lambda$testBlockWhenPinned$0(AtomicBoolean, Object) line: 153 ] } -
Object.waitで固定:
jdk.VirtualThreadPinned { startTime = 14:25:32.811 (2025-05-11) duration = 12.4 ms blockingOperation = "Object.wait" pinnedReason = "Native or VM frame on stack" carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 44) eventThread = "" (javaThreadId = 61, virtual) stackTrace = [ java.lang.Object.wait0(long) java.lang.Object.wait(long) line: 382 JfrEvents.lambda$testObjectWaitWhenPinned$1(AtomicBoolean, Object, boolean) line: 194 jdk.test.lib.thread.VThreadPinner$TaskRunner.run() line: 74 jdk.test.lib.thread.VThreadPinner.callback() line: 90 ] } -
クラス・イニシャライザで待機中:
jdk.VirtualThreadPinned { startTime = 14:25:33.923 (2025-05-11) duration = 3.02 s blockingOperation = "Object.wait" pinnedReason = "Waited for initialization of JfrEvents$3TestClass by another thread" carrierThread = "ForkJoinPool-1-worker-4" (javaThreadId = 68) eventThread = "" (javaThreadId = 172, virtual) stackTrace = [ JfrEvents.lambda$testWaitingForClassInitializer$1(AtomicBoolean) line: 329 java.lang.VirtualThread.run(Runnable) line: 456 jdk.internal.vm.Continuation.enterSpecial(Continuation, boolean, boolean) ] }
一部の既存のJFRイベントは、jdk.SocketReadやjdk.ThreadSleepなどのプラットフォーム・スレッドに対して記録されるのと同様に、仮想スレッドに対して記録されます。
仮想スレッドに関連するその他のJFRイベント
jdk.VirtualThreadStartおよびjdk.VirtualThreadEndは、仮想スレッドが開始したときと終了したときを示します。これらのイベントはデフォルトでは無効です。jdk.VirtualThreadPinnedは、しきい値期間より長く仮想スレッドが固定された(そしてそのキャリア・スレッドが解放されなかった)ことを示します。このイベントは、20ミリ秒のしきい値でデフォルトで有効になっています。jdk.VirtualThreadSubmitFailedは、おそらくリソースの問題が原因で、仮想スレッドの開始またはパーキング解除が失敗したことを示します。仮想スレッドをパーキングすると、基礎となるキャリア・スレッドが他の作業をできるように解放されます。仮想スレッドをパーキング解除すると、仮想スレッドの続行がスケジュールされます。このイベントはデフォルトで有効です。
JDK Mission ControlまたはカスタムJFR構成を使用して、イベントjdk.VirtualThreadStartおよびjdk.VirtualThreadEndを有効にします。『Java Platform, Standard Edition Flight Recorder APIプログラマーズ・ガイド』のFlight Recorder構成に関する項を参照してください。
これらのイベントを出力するには、次のコマンドを実行します(recording.jfrは記録するためのファイル名です):
jfr print --events jdk.VirtualThreadStart,jdk.VirtualThreadEnd,jdk.VirtualThreadPinned,jdk.VirtualThreadSubmitFailed recording.jfr
Java Management Extensionsを使用した仮想スレッド・スケジューラの監視および管理
jdk.management.VirtualThreadSchedulerMXBeanインタフェースを使用すると、Java Management Extensions (JMX)に基づくツールを使用して、仮想スレッド・スケジューラを監視および管理できます。このインタフェースは、スケジューラが仮想スレッドの実行に使用できるスレッド数を決定する、スケジューラのターゲット並列度のモニタリングおよび動的変更をサポートします。また、スケジューラによって使用されるスレッドと、スケジューラにキューイングされている仮想スレッドの数を監視することもできます。
仮想スレッド: 採用ガイド
仮想スレッドは、OSではなくJavaランタイムによって実装されるJavaスレッドです。仮想スレッドと従来のスレッド(プラットフォーム・スレッドと呼ぶようになりました)の主な違いは、非常に多く(何百万も)のアクティブな仮想スレッドを簡単に作成して同じJavaプロセスで実行できることです。仮想スレッドを強化するものは大きな数です。サーバーがより多くのリクエストを同時に処理できるようにすることで、リクエストごとのスレッド・スタイルで記述されたサーバー・アプリケーションをより効率的に実行できるため、スループットの向上とハードウェアの浪費の軽減につながります。
仮想スレッドはjava.lang.Threadの実装であり、Java SE 1.0以降にjava.lang.Threadを指定した同じルールに準拠しているため、開発者はそれらを使用するために新しい概念を学習する必要はありません。ただし、非常に多くのプラットフォーム・スレッドを生成できないこと(Javaで長年使用可能なスレッドの唯一の実装)は、高コストに対処するために設計され育成されたプラクティスです。これらのプラクティスは、仮想スレッドに適用した場合は逆効果であるため、忘れる必要があります。さらに、コストの大幅な違いはスレッドについての新しい考え方をもたらしますが、最初は異質に思えるかもしれません。
このガイドの目的は、仮想スレッドについて網羅したり、すべての重要な詳細をカバーすることではありません。仮想スレッドの使用を開始するユーザーがそれらを最大限に活用できるようにするためのガイドラインの入門セットを提供することが目的です。
リクエストごとのスレッド・スタイルのブロックI/O APIを使用した単純な同期コードの記述
仮想スレッドは、リクエストごとのスレッド・スタイルで記述されたサーバーの(レイテンシではなく)スループットを大幅に改善できます。このスタイルでは、サーバーはスレッドをその期間全体にわたって各受信リクエストの処理専用にします。単一のリクエストを処理するときに、複数のスレッドを使用して複数のタスクを同時に実行する必要があるため、少なくとも1つのスレッドを専用にします。
プラットフォーム・スレッドをブロックすると、意味のある作業を実行していない間もスレッド(比較的少ないリソース)が占有されるため、コストが高くなります。仮想スレッドは豊富にあるため、ブロックは低コストであり推奨されます。そのため、簡単な同期スタイルでコードを記述し、ブロックI/O APIを使用する必要があります。
たとえば、非ブロッキングの非同期スタイルで記述された次のコードは、仮想スレッドの恩恵をあまり受けません。
CompletableFuture.supplyAsync(info::getUrl, pool)
.thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofString()))
.thenApply(info::findImage)
.thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofByteArray()))
.thenApply(info::setImageData)
.thenAccept(this::process)
.exceptionally(t -> { t.printStackTrace(); return null; });一方、同期スタイルで記述され、単純なブロックIOを使用する次のコードは、非常に恩恵を受けます。
try {
String page = getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString());
String imageUrl = info.findImage(page);
byte[] data = getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray());
info.setImageData(data);
process(info);
} catch (Exception ex) {
t.printStackTrace();
}また、このようなコードの方が、デバッガでデバッグしたり、プロファイラでプロファイルを作成したり、スレッド・ダンプで監視したりするのが簡単です。仮想スレッドを監視するには、jcmdコマンドを使用してスレッド・ダンプを作成します。
jcmd <pid> Thread.dump_to_file -format=json <file>このスタイルで記述されるスタックが多いほど、パフォーマンスとオブザーバビリティの両方に仮想スレッドがより適しています。タスクごとにスレッドを専用にしない他のスタイルで記述されたプログラムまたはフレームワークは、仮想スレッドから大きな恩恵を受けることを期待できません。同期的なブロック・コードを非同期フレームワークと混在させないでください。
すべての同時タスクを仮想スレッドとして表現し、仮想スレッドをプールしない
仮想スレッドについて内在化するのが最も難しいのは、プラットフォーム・スレッドと動作は同じでも、同じプログラム概念を表さないことです。
プラットフォーム・スレッドは希少であるため、貴重なリソースです。貴重なリソースは管理する必要があり、プラットフォーム・スレッドを管理する最も一般的な方法はスレッド・プールを使用することです。次に答える必要がある質問は、プール内のスレッド数です。
ただし、仮想スレッドは豊富にあるため、それぞれが、共有のプールされたリソースではなくタスクを表す必要があります。管理対象リソース・スレッドから、アプリケーション・ドメイン・オブジェクトに変わります。一連のユーザー名をメモリーに格納するために使用する文字列の数に関する質問の答えが明らかであるのと同様に、必要な仮想スレッドの数に関する質問の答えも明らかです。仮想スレッドの数は、常にアプリケーション内の同時タスクの数と等しくなります。
n個のプラットフォーム・スレッドをn個の仮想スレッドに変換しても、メリットはほとんどありません。むしろそれは変換する必要があるタスクです。
すべてのアプリケーション・タスクをスレッドとして表現するために、次の例のような共有スレッド・プール・エグゼキュータは使用しないでください。
Future<ResultA> f1 = sharedThreadPoolExecutor.submit(task1);
Future<ResultB> f2 = sharedThreadPoolExecutor.submit(task2);
// ... use futuresかわりに、次の例のような仮想スレッド・エグゼキュータを使用します。
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<ResultA> f1 = executor.submit(task1);
Future<ResultB> f2 = executor.submit(task2);
// ... use futures
}コードでは引き続きExecutorServiceを使用しますが、Executors.newVirtualThreadPerTaskExecutor()から返されたコードはスレッド・プールを使用しません。かわりに、発行したタスクごとに新しい仮想スレッドが作成されます。
さらに、ExecutorService自体は軽量で、単純なオブジェクトの場合と同様に新しいものを作成できます。これにより、新しく追加されたExecutorService.close()メソッドおよびtry-with-resources構成に依存できます。tryブロックの最後に暗黙的に呼び出されるcloseメソッドは、ExecutorServiceに送信されたすべてのタスク(つまり、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);
}
}短期間の小さな同時タスクでも、前述のように新しい仮想スレッドを作成する必要があります。
さらに、ファンアウト・パターンおよびその他の一般的な並行処理パターンを記述し、オブザーバビリティを向上させるには、構造化並行性を使用します。
原則として、アプリケーションに10,000個以上の仮想スレッドがない場合、仮想スレッドのメリットはほとんどありません。負荷が軽すぎてスループットを向上させることができないか、または仮想スレッドに十分に多くのタスクが示されません。
セマフォを使用した並行処理の制限
特定の操作の並行処理を制限する必要がある場合があります。たとえば、外部サービスによっては、10を超える同時リクエストを処理できない場合があります。プラットフォーム・スレッドは、通常はプールで管理される貴重なリソースであるため、スレッド・プールは非常に広く存在するようになり、次の例のように、並行処理を制限するこの目的で使用されるようになりました。
ExecutorService es = Executors.newFixedThreadPool(10);
...
Result foo() {
try {
var fut = es.submit(() -> callLimitedService());
return f.get();
} catch (...) { ... }
}この例では、限定サービスに対する同時リクエストが最大10個あることを確認します。
ただし、並行処理の制限は、スレッド・プールの操作の副次的な効果にすぎません。プールは希少なリソースを共有するように設計されており、仮想スレッドは希少ではないため、プールすべきではありません。
仮想スレッドを使用する場合、一部のサービスへのアクセスの並行処理を制限するには、その目的専用に設計された構成(Semaphoreクラス)を使用する必要があります。このクラスの例を次に示します。
Semaphore sem = new Semaphore(10);
...
Result foo() {
sem.acquire();
try {
return callLimitedService();
} finally {
sem.release();
}
}fooを呼び出すために発生するスレッドは、一度に10個しか進行できないように抑制(ブロック)され、他のスレッドは、その責務から解放されます。
セマフォを使用して一部の仮想スレッドを単純にブロックすることは、タスクを固定スレッド・プールに送信することと大きく異なるように見えますが、そうではありません。スレッド・プールにタスクを送信すると、後で実行するためにキューに入れられますが、セマフォ内部(またはその問題に対する他のブロッキング同期構成)では、プールされたスレッドによって実行されるのを待機しているタスクのキューをミラー化する、ブロックされているスレッドのキューが作成されます。仮想スレッドはタスクであるため、結果の構造は同じです。
プラットフォーム・スレッドのプールはキューから取り出したタスクを処理するワーカーと考えることができ、仮想スレッドのプールは続行するまでブロックされるタスク自体と考えることができますが、コンピュータ内の基礎となる表現は事実上同じです。キューに入れられたタスクとブロックされたスレッドが同等であることを認識すると、仮想スレッドを最大限に活用できます。
データベース接続プール自体がセマフォとして機能します。10接続に制限された接続プールは、接続の取得を試行する11番目のスレッドをブロックします。接続プールの上にセマフォを追加する必要はありません。
スレッド・ローカル変数で高コストの再利用可能なオブジェクトをキャッシュしない
仮想スレッドは、プラットフォーム・スレッドと同様にスレッド・ローカル変数をサポートします。詳細は、「スレッド・ローカル変数」を参照してください。通常、スレッド・ローカル変数は、現在実行中のコードに現在のトランザクションやユーザーIDなどのコンテキスト固有の情報を関連付けるために使用されます。このスレッド・ローカル変数の使用は、仮想スレッドでは完全に妥当です。ただし、より安全で効率のよいスコープ値の使用を検討してください。詳細は、「スコープ値」を参照してください。
仮想スレッドとは基本的に相容れないスレッド・ローカル変数のもう1つの用途があります。それは、再利用可能なオブジェクトのキャッシュです。通常、これらのオブジェクトは作成にコストがかかり(また大量のメモリーを消費し)、変更可能ではなく、スレッドセーフでもありません。これらは、インスタンス化される回数とメモリー内のインスタンス数を減らすためにスレッド・ローカル変数にキャッシュされますが、異なる時刻にスレッドで実行される複数のタスクによって再利用されます。
たとえば、SimpleDateFormatのインスタンスは作成にコストがかかり、スレッドセーフではありません。出現したパターンは、このようなインスタンスを次の例のようにThreadLocalにキャッシュすることです。
static final ThreadLocal<SimpleDateFormat> cachedFormatter =
ThreadLocal.withInitial(SimpleDateFormat::new);
void foo() {
...
cachedFormatter.get().format(...);
...
}この種類のキャッシュが役立つのは、プラットフォーム・スレッドがプールされる場合のように、スレッド(したがって、スレッドにローカルにキャッシュされた高コストのオブジェクト)が複数のタスクによって共有および再利用される場合のみです。多くのタスクでは、スレッド・プールでの実行時にfooを呼び出すことができますが、プールには少数のスレッドしか含まれていないため、オブジェクトは数回(プール・スレッド・キャッシュごとに1回)のみインスタンス化され、キャッシュされて再利用されます。
ただし、仮想スレッドはプールされることがなく、関連のないタスクによって再利用されることはありません。すべてのタスクには独自の仮想スレッドがあるため、異なるタスクからfooを呼び出すたびに、新しいSimpleDateFormatのインスタンス化がトリガーされます。さらに、多数の仮想スレッドが同時に実行されている可能性があるため、高コストのオブジェクトはかなりのメモリーを消費する可能性があります。これらの結果は、スレッド・ローカルのキャッシュが実現しようとしているものとは正反対です。
提供する一般的な代替案は1つではありませんが、SimpleDateFormatの場合はDateTimeFormatterに置き換える必要があります。DateTimeFormatterは不変であるため、単一のインスタンスをすべてのスレッドで共有できます。
static final DateTimeFormatter formatter = DateTimeFormatter….;
void foo() {
...
formatter.format(...);
...
}スレッド・ローカル変数を使用した高コストの共有オブジェクトのキャッシュは、非常に少数のプールされたスレッドによって使用されるという暗黙的な仮定の下で、非同期フレームワークによってバックグラウンドで実行される場合があります。これは、仮想スレッドと非同期フレームワークを混在させることがよい考えではない理由の1つです。メソッドを呼び出すと、キャッシュおよび共有されることを意図していたコストの高いオブジェクトがスレッド・ローカル変数内でインスタンス化される可能性があります。
