本記事はこちらのブログを参考にしています。
翻訳にはアリババクラウドのModelStudio(Qwen)を使用しております。
仮想スレッドの紹介と実装方法
作者: Yanxun
仮想スレッドとは
仮想スレッドは、JDKが実行時に実装するJavaスレッドであり、オペレーティングシステムによって生成されるものではなくなりました。仮想スレッドと従来のスレッド(プラットフォームスレッドとも呼ばれます)の主な違いは、同じJavaプロセス内で非常に多くのアクティブな仮想スレッドを簡単に実行できる点にあります。最大数は数百万まで達します。大量の仮想スレッドを処理する能力により、彼らには強力な機能が与えられます。サーバーが要求ごとにスレッドを使用したモデルでより多くのリクエストを並列処理できるようになり、高スループットとハードウェアの無駄を減らすことができるようになります。最近、個人のプロジェクトでJDK21を使って開発を行い、仮想スレッドの原理と実装について調べました。私の技術的な知識が限られているため、このテーマに関する意見交換や討論を歓迎します。
1. 背景
仮想スレッドは、高スループットの並行アプリケーションの作成、保守、観測の負荷を軽減するために導入されました。アプリケーションのインターフェースにとって、応答時間が一定であれば、その時のスループットはアプリケーションが同時に処理できるリクエスト数(つまり同時リクエスト数)に比例しています。例えば、インターフェースが50ミリ秒で応答し、アプリケーションが10個のリクエストを同時に処理できる場合、スループットは1秒あたり200リクエスト(1秒/50ミリ秒×10)となります。アプリケーションの並行処理能力が100に増加すれば、スループットは1秒あたり2,000リクエストに達します。明らかに、並行処理可能なスレッド数を増やすことで、アプリケーションのスループットは大幅に向上します。しかし、Javaのプラットフォームスレッドは高価な資源であり、デフォルトではそれぞれ1MBのスタックメモリを消費し、JVM内で実行できるプラットフォームスレッドの数が制限されます。さらに、オペレーティングシステムもサポートできる最大スレッド数に上限があるため、カーネルスレッドの数を無限に増やすことはできません。次の図はシステムがサポートするスレッドの最大数を示しています。
ほとんどのJVM実装において、Javaスレッドは1対1でオペレーティングシステムスレッドと対応しています(下の図参照)。要求ごとにスレッドを作成する(TomcatやJettyなどのサーバーでよく見られる)1スレッド・1リクエストモデルを使用すると、すぐにOSスレッドの上限に達するでしょう。
リクエストがI/O操作が重い場合、ほとんどのスレッドはI/O操作の完了を待つためにブロックされ、スレッドリソースが枯渇しながらCPU使用率が低い状態となります。したがって、プラットフォームスレッドをユーザーのリクエストに専念させると、高並行ユーザーのアプリケーションではスレッドプールが迅速に満杯になり、後続のリクエストがブロックされる可能性が高いです。ハードウェアをフルに活用したい開発者は、1スレッド・1リクエストモデルを捨ててリアクティブプログラミングに移行しています。これは、リクエスト処理コードが開始から終了まで単一スレッドで実行されるのではなく、I/O操作を待つ間スレッドをプールに戻し、他のリクエストを処理するために再利用することを意味します。この細かいスレッド共有では、計算を行っている間だけコードがスレッドに残り、I/O待ちにはスレッドを占有しないため、多くの並行操作を実現できます。しかし、このアプローチはOSスレッドの不足によるスループットの制限を解消する一方で、プログラムの理解やデバッグコストが大幅に増加します。待機而不待I/O操作完了の別のI/Oメソッドセットを使用し、完了時にコールバックに通知します。開発者はリクエスト処理ロジックを小さなステージに分解し、それらを連続的なパイプラインに組み合わせる必要があります。リアクティブプログラミングでは、リクエストの各ステージが異なるスレッド上で実行され、各スレッドは異なるリクエストのステージを交互に実行します。これは複雑で、リアクティブチャンネルの作成、デバッグ、実行フローの理解が困難であり、例外発生時のトラブルシューティングは言うまでもありません。
仮想スレッドの導入はこれらの問題に対処します。Javaは実行時にJavaスレッド(または仮想スレッド)を実装し、JavaスレッドとOSスレッドとの1対1対応を破ります。OSが大規模な仮想アドレス空間を限定的な量のRAMにマップして豊富なメモリの錯覚を提供するように、Javaは実行時に大規模な仮想スレッドを少数のOSスレッドにマッピングすることで豊富なスレッドの錯覚を提供することができます。プラットフォームスレッド(java.lang.Thread)は、従来の方法で実装されたインスタンスであり、OSスレッドの薄いラッパーとして作用し、システムスレッドと1対1対応します。一方、仮想スレッドは特定のOSスレッドに束縛されていないインスタンスです。1リクエスト・1スレッドのアプリケーションコードはリクエストの全期間を通じて仮想スレッド上で実行できますが、仮想スレッドはCPU上で計算を行うときにだけOSスレッドを使用します。仮想スレッドは非同期スタイルと同じスケーラビリティを提供しますが、その実装は透過的であり、追加の理解や開発労力は必要ありません。仮想スレッド上で実行されているコードがブロッキングI/O操作を呼び出すと、Javaはそれを実行時に自動的に中断し、後で再開できるまで待ちます。関連するOSスレッドはその後、他の仮想スレッドのアクションを実行するために自由になります。仮想スレッドの実装は仮想メモリと似ています。大容量メモリを模擬するために、OSは大規模な仮想アドレス空間を限定的な量のRAMにマップします。同様に、大量のスレッドを模擬するために、Javaは実行時に大規模な仮想スレッドを少数のOSスレッドにマッピングします。
実装
定義
仮想スレッドはプラットフォームスレッドと同様にjava.lang.Threadのインスタンスですが、特定のOSスレッドにバインドされません。仮想スレッドは依然としてOSスレッド上でコードを実行しますが、差異は、仮想スレッド上で実行されているコードがブロッキングI/O操作を呼び出すと、Javaがそれを実行中に中断し、後で再開できるまで待ち合わせることです。これにより、関連するOSスレッドは他の仮想スレッドのためのアクションを実
原理java
final class VirtualThread extends BaseVirtualThread {
private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler();
private final Executor scheduler;
private final Continuation cont;
private final Runnable runContinuation;
private volatile Thread carrierThread;
VirtualThread(Executor scheduler, String name, int characteristics, Runnable task) {
super(name, characteristics, /*bound*/ false);
Objects.requireNonNull(task);
// スケジューラが指定されていない場合は選択する
if (scheduler == null) {
Thread parent = Thread.currentThread();
if (parent instanceof VirtualThread vparent) {
scheduler = vparent.scheduler;
} else {
scheduler = DEFAULT_SCHEDULER;
}
}
this.scheduler = scheduler;
this.cont = new VThreadContinuation(this, task);
this.runContinuation = this::runContinuation;
}
private static ForkJoinPool createDefaultScheduler() {
ForkJoinWorkerThreadFactory factory = pool -> {
PrivilegedAction<ForkJoinWorkerThread> pa = () -> new CarrierThread(pool);
return AccessController.doPrivileged(pa);
};
PrivilegedAction<ForkJoinPool> pa = () -> {
int parallelism, maxPoolSize, minRunnable;
String parallelismValue = System.getProperty(jdk.virtualThreadScheduler.parallelism);
String maxPoolSizeValue = System.getProperty(jdk.virtualThreadScheduler.maxPoolSize);
String minRunnableValue = System.getProperty(jdk.virtualThreadScheduler.minRunnable);
if (parallelismValue != null) {
parallelism = Integer.parseInt(parallelismValue);
} else {
parallelism = Runtime.getRuntime().availableProcessors();
}
if (maxPoolSizeValue != null) {
maxPoolSize = Integer.parseInt(maxPoolSizeValue);
parallelism = Math.min(parallelism, maxPoolSize);
} else {
maxPoolSize = Math.max(parallelism, 256);
}
if (minRunnableValue != null) {
minRunnable = Integer.parseInt(minRunnableValue);
} else {
minRunnable = Math.max(parallelism / 2, 1);
}
Thread.UncaughtExceptionHandler handler = (t, e) -> { };
boolean asyncMode = true; // FIFO
return new ForkJoinPool(parallelism, factory, handler, asyncMode,
0, maxPoolSize, minRunnable, pool -> true, 30, SECONDS);
};
return AccessController.doPrivileged(pa);
}
private void runContinuation() {
// キャリアはプラットフォームスレッドである必要がある
if (Thread.currentThread().isVirtual()) {
throw new WrongThreadException();
}
// ステートをRUNNINGに設定
int initialState = state();
if (initialState == STARTED || initialState == UNPARKED || initialState == YIELDED) {
// 新規起動またはパーキング/ブロッキング/Thread.yieldの後に再開する場合
if (!compareAndSetState(initialState, RUNNING)) {
return;
}
// パーキング後に再開するときに駐車許可証を消費する
if (initialState == UNPARKED) {
setParkPermit(false);
}
} else {
// 実行可能な状態ではないので終了
return;
}
mount();
try {
cont.run();
} finally {
unmount();
if (cont.isDone()) {
afterDone();
} else {
afterYield();
}
}
}
}
仮想スレッドに関わる主要なオブジェクトは次のとおりです:
- Continuation: 実際のユーザー・タスクのラッパー。仮想スレッドはタスクをContinuationインスタンスで包みます。タスクがブロックする必要がある場合、スレッドはContinuationインスタンスのyield操作を呼び出します。
-
Scheduler: タスクをプラットフォームスレッドプールに提出して実行します。仮想スレッドはデフォルトのスケジューラ「DEFAULT_SCHEDULER」を維持しており、これはForkJoinPoolのインスタンスです。最大スレッド数はデフォルトでシステムコア数であり、256に上限が設けられています。
jdk.virtualThreadScheduler.maxPoolSize
を使用して構成可能です。 - carrier: カリヤースレッド(Threadオブジェクト)は、仮想スレッドのタスクを実行する責任を持つプラットフォームスレッドを指します。
- runContinuation: タスクが実行されるか再開される前に、仮想スレッドが現在のスレッドにロードするために使用するRunnableオブジェクトです。タスクが完了すると、runContinuationはアンロードされます。
仮想スレッドの特定のワークフローに関するソースコードの詳細な解析は、今後のディスカッションで扱うかもしれません。
3. 利用方法
Threadクラスで仮想スレッドを作成するjava
// name(String prefix, Integer start) p0: prefix p1: 初期カウンター値
Thread.Builder.OfVirtual virtualThreadBuilder = Thread.ofVirtual().name("worker-", 0);
Thread worker0 = virtualThreadBuilder.start(this::doSomethings);
worker0.join();
System.out.print("worker-0 完了");
Thread worker1 = virtualThreadBuilder.start(this::doSomethings);
worker1.join();
System.out.print("worker-1 完了");
Thread.ofVirtual()
メソッドを呼び出すことで、仮想スレッドを作成するために使用されるThread.Builderインスタンスが生成されます。
Executorで仮想スレッドを作成するjava
try (ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor()) {
Future> submit = executorService.submit(this::doSomethings);
submit.get();
System.out.print("実行完了");
}
仮想スレッドは安価であり、豊富なので、プールされるべきではありません。代わりに、各アプリケーション・タスクに対して新しい仮想スレッドを作成する必要があります。newVirtualThreadPerTaskExecutor
を使用すると、スレッドの再利用を目的とした典型的なスレッドプールではなく、制限なしのスレッド数を持つスレッドプールを作成し、各提出されたタスクに新しい仮想スレッドを作成します。
仮想スレッドでサーバーを実装するjava
public class Server {
public static void main(String[] args) {
Set<String> platformSet = new HashSet<>();
new Thread(() -> {
try {
Thread.sleep(10000);
System.out.println(platformSet.size());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
try (ServerSocket serverSocket = new ServerSocket(9999)) {
Thread.Builder.OfVirtual clientThreadBuilder = Thread.ofVirtual().name("client", 1);
while (true) {
Socket clientSocket = serverSocket.accept();
clientThreadBuilder.start(() -> {
String platformName = Thread.currentThread().toString().split("@")[1];
platformSet.add(platformName);
try (
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
) {
String inputLine;
while ((inputLine = in.readLine()) != null) {
System.out.println(inputLine + " (from: " + Thread.currentThread() + ")");
out.println(inputLine);
}
} catch (IOException e) {
System.err.println(e.getMessage());
}
});
}
} catch (IOException e) {
System.err.println("ポート9999でのリスニング中に例外が発生しました");
System.err.printf(e.getMessage());
}
}
}
クライアント接続を待ち受けます。各受信接続に対して仮想スレッドを作成し、仮想スレッドが実行されている間に基盤となるプラットフォームスレッドの名前をセットに追加します。さらに、別のスレッドが10秒間スリープした後、セットのサイズを印刷することで、これらの仮想スレッドによって実際に使用されているプラットフォームスレッドの数がわかります。
Clientクラスjava
public class Client {
public static void main(String[] args) throws InterruptedException {
Thread.Builder.OfVirtual builder = Thread.ofVirtual().name("client", 1);
for (int i = 0; i < 100000; i++) {
builder.start(() -> {
try (
Socket serverSocket = new Socket("localhost", 9999);
BufferedReader in = new BufferedReader(new InputStreamReader(serverSocket.getInputStream()));
PrintWriter out = new PrintWriter(serverSocket.getOutputStream(), true);
) {
out.println("hello");
String inputLine;
while ((inputLine = in.readLine()) != null) {
System
仮想スレッドがキャリアに固定されると、ブロックしても接続されたままである。仮想スレッドは、以下のシナリオで固定される:
- 仮想スレッドによって実行されるメソッドまたはブロックが
synchronized
キーワードでマークされている場合。 - 仮想スレッドが外部関数を実行している場合。固定はアプリケーションの機能に支障をきたすことはないが、スケーラビリティに影響を与える可能性がある。以下のようにして、頻繁に実行されるブロックやメソッドを修正し、潜在的に長いアイドルな I/O 操作を保護することで、頻繁で長期的な固定を避けることができる。java
java.util.concurrent.locks.ReentrantLock.synchronized - 注意事項
仮想スレッドは java.lang.Thread
の実装であり、Java SE 1.0 以来 java.lang.Thread
に対して指定された同じ規則に従うため、開発者はそれらを使用するために新しい概念を学ぶ必要はありません。しかし、長年にわたって Java で利用可能な唯一のスレッド実装であるプラットフォームスレッドを非常に多く生成できないことが原因で、その高コストに対処するための慣行が生まれました。これらの慣行は仮想スレッドに適用すると逆効果となることがあるため、捨て去るべきです。
簡単な同期コードを書く、ブロッキング API を使用する
仮想スレッドは、要求ごとにスレッドを割り当てるスタイルで書かれたサーバーのスループット(遅延ではない)を大幅に改善することができます。このスタイルでは、サーバーは各受信リクエストに対応するスレッドを専有し、そのリクエストの全期間を担当します。プラットフォームスレッドをブロックすることは高価であり、多くの有用な作業を行わずシステムスレッド(比較的希少な資源)を占有するからです。過去には、非同期かつ非ブロッキングアプローチを使って特定の機能を実装していたかもしれませんが、仮想スレッドが多数生成できるため、ブロックのコストが低いため、シンプルで同期的なスタイルでコードを書くべきです。例えば、次のような非同期かつ非ブロッキングスタイルで書かれたコードは仮想スレッドから大きな恩恵を受けません。java
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; });
同期的なスタイルで簡単なブロッキング I/O を使ったコードは非常にメリットを得ます。java
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) {
ex.printStackTrace();
}
このようなコードはデバッグやプロファイル、スレッドダンプでの可視性も向上し、このようなスタイルで書かれたスタックが増えるほど、仮想スレッドのパフォーマンスと観察性が向上します。各タスクにスレッドを割り当てないプログラムやフレームワークは、仮想スレッドからあまり恩恵を受けないかもしれません。同期的でブロッキングのコードと非同期フレームワークを混ぜないようにしましょう。
仮想スレッドを共有しない
仮想スレッドはプラットフォームスレッドと同じ振る舞いを示すものの、同じプログラミング概念を表すべきではありません。プラットフォームスレッドは希少であり、貴重なリソースであるため、管理が必要です。プラットフォームスレッドを管理する最も一般的な方法はスレッドプールを通じて行われますが、プールのサイズをどのように設定するべきかという問題が生じます。一方、仮想スレッドは豊富であり、それぞれのスレッドが共有されプールされる資源ではなく、タスクを表すべきです。スレッドは管理対象のリソースからアプリケーションドメインオブジェクトに移行します。必要な仮想スレッドの数についての疑問は明らかになり、例えばメモリ上でユーザー名セットを保存するために必要な文字列の数を決定するのと同様になります:仮想スレッドの数は常にアプリケーション中の並行タスクの数と等しいです。各アプリケーションタスクをスレッドとして表すために、次の例のように共有スレッドプールエグゼキュータを使わないでください。java
Future f1 = sharedThreadPoolExecutor.submit(task1);
Future f2 = sharedThreadPoolExecutor.submit(task2);
// ... futures の使用
代わりに、次のアプローチを使うこと:java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future f1 = executor.submit(task1);
Future f2 = executor.submit(task2);
// ... futures の使用
}
コードは依然として ExecutorService
を使用していますが、Executors.newVirtualThreadPerTaskExecutor()
によって返されるインスタンスは仮想スレッドを再利用しません。代わりに、各提出されたタスクに対して新しい仮想スレッドを作成します。java
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);
}
}
さらに、ExecutorService
自体が軽量であり、単純なオブジェクトのように扱って新しいものを作成することができます。同じインスタンスを維持し、繰り返し使用する必要はありません。必要なときに新しいものを作成してください。例えば、小さく短命な並行タスクであっても、上の方法で新しい仮想スレッドを作成することをお勧めします。一般的な基準として、アプリケーションで 10,000 以上の仮想スレッドが決してない場合、仮想スレッドから恩恵を受けられない可能性が高いです。負荷が軽すぎてスループットが向上する必要がないか、仮想スレッドに十分なタスクを提示していないからです。
セマフォで並行性を制限する
ある操作の並行性を制限することが必要な場合があります。例えば、一部の外部サービスが 10 個を超える同時リクエストを処理できない場合があります。プラットフォームスレッドを使用している場合、スレッドプールのサイズを設定することで並行性を制限できます。仮想スレッドを使用している場合、特定のサービスへのアクセスの並行性を制限したい場合は、その目的に特化した Semaphore
クラスを使用する必要があります。次の例はこのクラスの使用法を示しています。java
Semaphore sem = new Semaphore(10);
// ......
Executors.newVirtualThreadPerTaskExecutor().submit(() -> {
try {
// タスクを実行する前に、セマフォを 1 減算して、もう 1 つの並行スレッドが実行中であることを示し、残っている並行実行数を減らす。
// セマフォ(パーミット)が 0 の場合、別のスレッドが完了し、パーミットを解放するまでブロックする。
sem.acquire();
doSomething();
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// タスクが完了したら、セマフォを 1 減算する。
sem.release();
}
});
高価な再利用可能なオブジェクトをスレッドローカル変数にキャッシュしない
仮想スレッドはプラットフォームスレッドと同様にスレッドローカル変数をサポートしています。通常、スレッドロー
パフォーマンステスト
JDK: OpenJDK 21.0.4
物理マシン: Win11 & i5-14600KF (14コア、20スレッド)
プラットフォームスレッドと仮想スレッドの簡単な比較例java
public class PerformanceTest {
private static final int REQUEST_NUM = 10000;
public static void main(String[] args) {
long vir = 0, p1 = 0, p2 = 0, p3 = 0, p4 = 0;
for (int i = 0; i < 3; i++) {
vir += testVirtualThread();
p1 += testPlatformThread(200);
p2 += testPlatformThread(500);
p3 += testPlatformThread(800);
p4 += testPlatformThread(1000);
System.out.println("--------------");
}
System.out.println("Virtual thread average duration: " + vir / 3 + "ms");
System.out.println("Platform thread [200] average duration: " + p1 / 3 + "ms");
System.out.println("Platform thread [500] average duration: " + p2 / 3 + "ms");
System.out.println("Platform thread [800] average duration: " + p3 / 3 + "ms");
System.out.println("Platform thread [1000] average duration: " + p4 / 3 + "ms");
}
private static long testVirtualThread() {
long startTime = System.currentTimeMillis();
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < REQUEST_NUM; i++) {
executorService.submit(PerformanceTest::handleRequest);
}
executorService.close();
long useTime = System.currentTimeMillis() - startTime;
System.out.println("Virtual thread duration: " + useTime + "ms");
return useTime;
}
private static long testPlatformThread(int poolSize) {
long startTime = System.currentTimeMillis();
ExecutorService executorService = Executors.newFixedThreadPool(poolSize);
for (int i = 0; i < REQUEST_NUM; i++) {
executorService.submit(PerformanceTest::handleRequest);
}
executorService.close();
long useTime = System.currentTimeMillis() - startTime;
System.out.printf("Platform thread [%d] duration:%dms%n", poolSize, useTime);
return useTime;
}
private static void handleRequest() {
try {
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
テスト結果:
- 仮想スレッド継続時間: 654ms
- プラットフォームスレッド [200] 継続時間: 15,551ms
- プラットフォームスレッド [500] 継続時間: 6,241ms
- プラットフォームスレッド [800] 継続時間: 4,069ms
- プラットフォームスレッド [1000] 継続時間: 3,137ms
- 仮想スレッド継続時間: 331ms
- プラットフォームスレッド [200] 継続時間: 15,544ms
- プラットフォームスレッド [500] 継続時間: 6,227ms
- プラットフォームスレッド [800] 継続時間: 4,047ms
- プラットフォームスレッド [1000] 継続時間: 3,126ms
- 仮想スレッド継続時間: 326ms
- プラットフォームスレッド [200] 継続時間: 15,552ms
- プラットフォームスレッド [500] 継続時間: 6,228ms
- プラットフォームスレッド [800] 継続時間: 4,054ms
- プラットフォームスレッド [1000] 継続時間: 3,151ms
- 仮想スレッド平均継続時間: 437ms
- プラットフォームスレッド [200] 平均継続時間: 15,549ms
- プラットフォームスレッド [500] 平均継続時間: 6,232ms
- プラットフォームスレッド [800] 平均継続時間: 4,056ms
- プラットフォームスレッド [1000] 平均継続時間: 3,138ms
仮想スレッドは無制限に作成できる一方、プラットフォームスレッドはスレッドプールのサイズに制約されるため、10,000件のリクエストを同時に処理することはできない。その後のリクエストは、前のリクエストが完了しスレッドを解放するまで待たなければならないため、仮想スレッドを使用する場合と比較して、大幅に長い継続時間が発生する。シンプルなWebサービステスト
- springboot-web バージョン (Tomcat/10.1.19): 3.2.3 / springboot-webflux バージョン (Netty): 3.2.3
簡単なテストプログラムを書くことで、Thread.sleep
を使って300msのブロッキングをシミュレートし、JMeterを使って3,000人のユーザーからの同時リクエストをシミュレートする。
Web版プログラム:java
@RestController
public class TestController {
@GetMapping("get")
public String get() {
try {
// System.out.println(Thread.currentThread());
Thread.sleep(300);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
return "ok";
}
}
スレッド数と仮想スレッドの有効化は、application.yaml
設定ファイルで制御する:yaml
server:
tomcat:
threads:
max: 200
spring:
threads:
virtual:
enabled: false # 仮想スレッドの有効化を指定
WebFlux版プログラム:java
@Configuration
public class TestWebClient {
@Bean
public RouterFunction<ServerResponse> routes() {
return route(
GET("/get"),
request -> ok()
.contentType(MediaType.APPLICATION_JSON)
.body(fromPublisher(Mono.just("ok").delayElement(Duration.ofMillis(300)), String.class))
);
}
}
使用されたプラットフォームスレッド数 | スループット (req/s) | 平均応答時間 (ms) | 90% | 95% | 99% |
---|---|---|---|---|---|
仮想スレッド | 20 | 5217 | 316 | 311 | 344 |
プラットフォームスレッド | 200 | 624.5 | 2660 | 4407 | 4782 |
プラットフォームスレッド | 512 | 1564.1 | 984 | 1683 | 1693 |
プラットフォームスレッド | 800 | 2340 | 661 | 1067 | 1070 |
WebFlux | 5281.3 | 310 | 374 | 321 | 325 |
仮想スレッドとWebFluxのリアクティブプログラミングを使用したスループットは、通常のスレッドプールを使用するよりも遥かに高いことが確認できます。また、仮想スレッドのスループットはWebFluxに劣らないことがわかります。仮想スレッドは複雑なリアクティブプログラミングを必要とせず、単純に仮想スレッドの使用を構成することで高いスループットを達成することができます。
- 結論
要約すると、Java仮想スレッドの導入は現代の並行