仮想スレッドサポートリファレンス
このガイドでは、QuarkusアプリケーションでJava 21+の仮想スレッドを使用する方法について説明します。
仮想スレッドとは?
用語解説
- OSスレッド
-
オペレーティング・システムによって管理される「スレッドのような」データ構造。
- プラットフォームスレッド
-
Java 19までは、 Thread クラスのインスタンスはすべて、OSスレッドのラッパーであるプラットフォーム・スレッドでした。 プラットフォーム・スレッドを作成するとOSスレッドが作成され、プラットフォーム・スレッドをブロックするとOSスレッドがブロックされます。
- 仮想スレッド
-
軽量で、JVMが管理するスレッド。 Thread クラスを拡張していますが、1つの特定のOSスレッドに縛られることはありません。したがって、仮想スレッドのスケジューリングはJVMの責任です。
- キャリアスレッド
-
仮想スレッドを実行するために使用されるプラットフォームスレッドは、 キャリアスレッド と呼ばれます。 これは Thread や
VirtualThreadとは異なるクラスではなく、機能的な呼称です。
仮想スレッドとプラットフォームスレッドの違い
ここではその概要を説明します。詳細は JEP425 を参照してください。
仮想スレッドは、Java 19から利用可能な機能で(Java 21は、仮想スレッドを含む最初のLTSバージョン)、 I/Oバウンドのワークロードに対して、プラットフォームスレッドの安価な代替を提供することを目的としています。
これまで、プラットフォーム・スレッドがJVMの同時実行ユニットでした。 スレッドは、OS構造体のラッパーです。 Javaプラットフォーム・スレッドを作成すると、オペレーティング・システムに「スレッドのような」構造が作成されます。
一方、仮想スレッドはJVMによって管理されます。実行するには、(その仮想スレッドのキャリアとして機能する)プラットフォーム・スレッドにマウントする必要があります。 そのため、以下のような特徴があります:
- 軽量
-
仮想スレッドは、プラットフォーム・スレッドよりもメモリ上の占有領域が小さくなります。 したがって、メモリを消費することなく、プラットフォーム・スレッドよりも多くの仮想スレッドを同時に使用することが可能になります。 デフォルトでは、プラットフォーム・スレッドは約1MBのスタックで作成されますが、仮想スレッドのスタックは "pay-as-you-go "です。 Loomプロジェクト(JVMに仮想スレッドサポートを追加したプロジェクト)のリード開発者が行った プレゼンテーション で、これらの数字や仮想スレッドの他の動機を見つけることができます。
- 作成が安価
-
Javaでプラットフォーム・スレッドを作成するには時間がかかります。 現在では、スレッドを一度作成してから再利用するプーリングのようなテクニックが、スレッドの起動にかかる時間のロスを最小限に抑えるために強く推奨されています(同様に、メモリ消費量を低く抑えるためにスレッドの最大数を制限することも推奨されています)。 仮想スレッドは必要なときに作成する使い捨てのものであり、 スレッドをプールしたり別のタスクに再利用したりすることは推奨されません。
- 安価なブロック
-
ブロッキングI/Oを実行するとき、Javaプラットフォームのスレッドによってラップされた基礎となるOSスレッドは待ち行列に入れられ、新しいスレッドコンテキストをCPUコアにロードするためにコンテキストスイッチが発生します。 この操作には時間がかかります。JVMは仮想スレッドを管理するため、OSスレッドがブロッキング操作を実行しても、そのスレッドがブロックされることはありません。 それらの状態はヒープに格納され、別の仮想スレッドが同じJavaプラットフォーム(キャリア)スレッド上で実行されます。
継続ダンス
前述のように、JVM は仮想スレッドをスケジュールします。これらの仮想スレッドはキャリアースレッドにマウントされます。このスケジューリングには少し魔法のような仕組みがあります。仮想スレッドがブロッキング I/O を使用しようとすると、JVM はこの呼び出しをノンブロッキングの呼び出しに 変換 し、仮想スレッドをアンマウントして、別の仮想スレッドをキャリアースレッドにマウントします。I/O が完了すると、待機していた 仮想スレッドが再び実行可能な状態になり、再びキャリアースレッドにマウントされて処理を続行します。ユーザーからは、この一連の動作はすべて見えません。同期コードは非同期で実行されます。
仮想スレッドを同じキャリアスレッドに再マウントすることはできません。
仮想スレッドはI/Oバウンドワークロードにのみに有効
私たちは、プラットフォームスレッドよりも多くの仮想スレッドを作成できることを理解しました。このため、長時間の計算 (CPU バウンドワークロード) をするために仮想スレッドを使用したいと考えるかもしれません。 しかし、これは無意味であり、逆効果です。 CPU バウンド処理は、I/O の完了を待つ間にスレッドをすばやく切り替えるのではなく、 スレッドを CPU コアにアタッチして何らかの計算を行うものです。 このようなケースでは、数十個の CPU コアに対して数千個のスレッドを持つことは非常に無意味で、仮想スレッドが CPU バウンドワークロードのパフォーマンスを向上させることはありません。 それどころか、仮想スレッド上で CPU バウンドワークロードを実行すると、仮想スレッドがマウントされているキャリアースレッドをその仮想スレッドが独占してしまいます。 その結果、他の仮想スレッドが実行される機会が減るか、新しいキャリアースレッドが次々に作成されることになり、メモリー使用量が増大してしまいます。
@RunOnVirtualThread を使用した仮想スレッド上でのコード実行
Quarkus では、仮想スレッドのサポートは @RunOnVirtualThread アノテーションを使用して実装されています。 このセクションでは、その論理的根拠と使い方について簡単に説明します。 このアノテーションをサポートするエクステンションには、以下のような専用のガイドも用意されています。
なぜすべてを仮想スレッドで実行しないのか?
前述したように、すべてが仮想スレッド上で安全に実行できるわけではありません。
独占 のリスクはメモリ使用量の多さにつながります。
また、仮想スレッドをキャリアスレッドからアンマウントできない状況もあります。
これは pinning と呼ばれます。
最後に、一部のライブラリは ThreadLocal を使用してオブジェクトを保存し再利用します。
これらのライブラリで仮想スレッドを使用すると、意図的にプールされたオブジェクトが(使い捨てで一般に短命な)仮想スレッドごとにインスタンス化されるため、大量の割り当てが発生します。
現在のところ、仮想スレッドを自由に使うことはできません。 このような自由放任のアプローチをとると、すぐにメモリやリソースの枯渇という問題が発生します。 そのためQuarkusでは、前述の問題がなくなるまで(Javaエコシステムが成熟するまで)、明示的なモデルを使用します。 リアクティブ エクステンションが仮想スレッドをサポートし、 古典的な スレッドをほとんどサポートしない理由もここにあります。 仮想スレッドでディスパッチするタイミングを知る必要があります。
これらの問題は、Quarkusの制限やバグではなく、仮想スレッドフレンドリーに進化する必要があるJavaエコシステムの現状によるものであることを理解することが重要です。
| 内部設計と選択の詳細については、 Considerations for integrating virtual threads in Java framework: a Quarkus example in resource-constrained environment を参照してください。 |
独占の場合
独占については、 仮想スレッドはI/Oバウンドのワークロードにのみ有効 のセクションで説明しました。 長い計算を実行する場合、仮想スレッドが終了するまで、JVMがアンマウントして別の仮想スレッドに切り替えることを許可しません。 実際、現在のスケジューラはタスクのプリエンプトをサポートしていません。
この独占は、他の仮想スレッドを実行するために新しいキャリアスレッドを作成することにつながります。 キャリア・スレッドを作成すると、プラットフォーム・スレッドを作成することになります。 そのため、この作成にはメモリ・コストがかかります。
コンテナのような制約のある環境で実行するとします。 その場合、メモリ使用量の多さがメモリ不足の問題やコンテナの終了につながる可能性があるため、独占はすぐに懸念事項となります。 メモリ使用量は、スケジューリングと仮想スレッドに固有のコストがかかるため、通常のワーカースレッドよりも高くなる可能性があります。
拘束の場合
「安価なブロッキング」の約束は常に成り立つわけではありません。仮想スレッドは特定の状況でそのキャリアーを ピン することがあります。 この場合、プラットフォームスレッドはブロックされ、通常のブロッキングシナリオと同様に動作します。
JEP 425 によると、これは2つの状況で発生する場合があります:
-
仮想スレッドが
synchronizedブロックまたはメソッドの内部でブロッキング操作を実行する場合 -
ネイティブメソッドや外部関数内でブロッキング操作を実行した場合
コード内でこのような状況を回避するのは比較的簡単ですが、使用する依存関係をすべて検証することは困難です。 通常、仮想スレッドを試している際に、postgresql-JDBC driver の 42.6.0 より古いバージョンでは、頻繁にピニングが発生することがわかりました。 ほとんどの JDBC ドライバーは、引き続きキャリアースレッドをピン留めします。 さらに悪いことに、多くのライブラリーではコードの変更が必要になります。
詳細は、When Quarkus meets Virtual Threads を参照してください。
| このピンニングのケースに関する情報は、PostgreSQL JDBC ドライバー 42.5.4 以前に適用されます。 PostgreSQL JDBC ドライバー 42.6.0 以降では、事実上すべての同期されたメソッドが再入可能ロックに置き換えられています。 詳細は、PostgreSQL JDBC ドライバー 42.6.0 の Notable Changes を参照してください。 |
プーリングの場合
一部のライブラリーは、オブジェクトプーリングメカニズムとして ThreadLocal を使用しています。
Jackson および Netty などの非常に人気の高いライブラリーは、アプリケーションが限られた数のスレッドを使用し、それらのスレッドが (スレッドプールを使用して) リサイクルされて、複数の (無関係だが連続した) タスクを実行することを前提としています。
このパターンには、次のような複数の利点があります。
-
割り当ての利点: 重いオブジェクトはスレッドごとに 1 回だけ割り当てられますが、これらのスレッドの数は制限されることが意図されているため、メモリーがあまり使用されません。
-
スレッドの安全性: スレッドローカルに格納されたオブジェクトにアクセスできるのは 1 つのスレッドのみであり、同時アクセスを防止します。
ただし、このパターンは仮想スレッドを使用する場合には逆効果になります。
仮想スレッドはプールされず、一般的に短命です。
そのため、少数のスレッドを使用するのではなく、大量のスレッドを扱うことになります。
それぞれのスレッドに対して、 ThreadLocal に格納されるオブジェクトが作成されます (多くの場合、サイズが大きく、コストもかかります)。しかし、仮想スレッドはプールされず (実行が完了した後に別のタスクを実行することもないため)、これらのオブジェクトは再利用されません。
この問題により、メモリー使用量が増加します。
残念ながら、この問題を解決するには、ライブラリー自体に高度なコード変更が必要になります。
RESTEasy Reactiveで@RunOnVirtualThreadを使用する。
このセクションでは、 @RunOnVirtualThread アノテーションの簡単な使用例を示します。 また、Quarkusが提供するさまざまな開発モデルと実行モデルについても説明します。
@RunOnVirtualThread アノテーションは、Quarkusに対して、現在のスレッドではなく 新しい 仮想スレッドでアノテーションされたメソッドを呼び出すように指示します。
Quarkusは仮想スレッドの作成とオフロードを処理します。
仮想スレッドは使い捨てのエンティティであるため、 @RunOnVirtualThread の基本的な考え方は、エンドポイントハンドラの実行をイベントループやワーカースレッド(RESTEasy Reactiveの場合)で実行する代わりに、新しい仮想スレッド上でオフロードすることです。
そのためには、エンドポイントに @RunOnVirtualThread アノテーションを追加すれば十分です。 アプリケーションの 実行 に使用される Java 仮想マシンが仮想スレッドのサポートを提供している場合 (Java 21 以降のバージョン)、エンドポイントの実行は仮想スレッドにオフロードされます。 これにより、仮想スレッドがマウントされているプラットフォーム・スレッドをブロックすることなく、ブロッキング処理を実行できるようになります。
In the case of RESTEasy Reactive, this annotation can only be used on endpoints annotated with @Blocking or considered blocking because of their signature. You can visit Execution model, blocking, non-blocking for more information.
RESTEasy Reactiveで仮想スレッド入門
ビルドファイルに以下の依存関係を追加します:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy-reactive</artifactId>
</dependency>
implementation("io.quarkus:quarkus-resteasy-reactive")
次に、Java 21 以降を使用していることも確認する必要があります。これは、pom.xml ファイルで次のように強制できます。
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
3つの開発・実行モデル
以下の例は、3つのエンドポイントの違いを示しています。これらのエンドポイントはすべて、データベース上の 財産 を照会し、それをクライアントに返します。
-
1つ目は伝統的なブロッキングスタイルを採用しており、そのシグネチャからブロッキングとみなされます。
-
2つ目はMutinyを使用し、そのシグネチャによりノンブロッキングとみなされます。
-
3番目はMutinyを使用しますが、同期的な方法で使用し、"リアクティブ型"を返さないので、ブロッキングとみなされ、 @RunOnVirtualThread アノテーションが使用できます。
package org.acme.rest;
import org.acme.fortune.model.Fortune;
import org.acme.fortune.repository.FortuneRepository;
import io.smallrye.common.annotation.RunOnVirtualThread;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import java.util.List;
import java.util.Random;
@Path("")
public class FortuneResource {
@Inject FortuneRepository repository;
@GET
@Path("/blocking")
public Fortune blocking() {
// Runs on a worker (platform) thread
var list = repository.findAllBlocking();
return pickOne(list);
}
@GET
@Path("/reactive")
public Uni<Fortune> reactive() {
// Runs on the event loop
return repository.findAllAsync()
.map(this::pickOne);
}
@GET
@Path("/virtual")
@RunOnVirtualThread
public Fortune virtualThread() {
// Runs on a virtual thread
var list = repository.findAllAsyncAndAwait();
return pickOne(list);
}
}
以下の表は各選択肢の概要です:
| モデル | シグネチャの例 | 長所 | 短所 |
|---|---|---|---|
同期コードでワーカースレッド上 |
|
シンプルなコード |
ワーカースレッドを使用 (同時実行に制限) |
リアクティブコードでイベントループ上 |
|
高い同時実行性と低いリソース使用量 |
より複雑なコード |
同期コードで仮想スレッド上 |
|
シンプルなコード |
ピン止め、独占、効率の悪いオブジェクト・プーリングのリスク |
この3つのモデルすべてを1つのアプリケーションで使用できます。
仮想スレッドフレンドリーなクライアントの使用
すべてを仮想スレッドで実行しない理由 セクションで述べたように、Java エコシステムは仮想スレッドに完全には対応していません。 したがって、特に I/O を実行するライブラリーを使用する場合は注意が必要です。
幸いなことに、Quarkus は仮想スレッドですぐに使用できる大規模なエコシステムを提供しています。 Quarkus で使用されるリアクティブプログラミングライブラリーである Mutiny と Vert.x Mutiny バインディングは、キャリアースレッドをピン留めしないブロッキングコード (したがって、恐れる必要はなく、学習曲線もありません) を記述する機能を提供します。
上記の結果として、以下のようになります。
-
Quarkus extensions providing blocking APIs on top of reactive APIs can be used in virtual threads. This includes the reactive rest client, the redis client, the mailer…
-
Uniを返す API は、uni.await().atMost(…)を使用して直接使用できます。これは、キャリアースレッドをブロックせずに仮想スレッドをブロックし、簡単な (非ブロッキング) タイムアウトサポートによってアプリケーションの耐障害性も向上させます。 -
Mutiny バインディングを使用する Vert.x クライアント を使用する場合は、キャリアースレッドをピニングせずに結果を取得するまでブロックする
andAwait()メソッドを使用します。これには、すべてのリアクティブ SQL ドライバーが含まれます。
テストでピン止めされたスレッドを検出
仮想スレッドを使用するアプリケーションでテストを実行する場合は、以下の設定を使用することをお勧めします。 テストが失敗することはありませんが、コードがキャリアのスレッドをピン留めした場合は、少なくともスタートトレースをダンプします:
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
<argLine>-Djdk.tracePinnedThreads</argLine>
</configuration>
</plugin>
仮想スレッドを使用したアプリケーションの実行
java -jar target/quarkus-app/quarkus-run.jar
Java 21以前では、仮想スレッドはまだ実験的な機能であったため、 --enable-preview フラグを付けてアプリケーションを起動する必要があります。
|
仮想スレッドを使用したアプリケーションのコンテナのビルド
アプリケーションをJVMモードで実行する場合(つまりネイティブにコンパイルしない場合、ネイティブについては 専用のセクション を参照してください)、 コンテナ化ガイド に従ってコンテナをビルドすることができます。
このセクションでは、JIB を使用してコンテナをビルドします。 他の方法については、 コンテナ化ガイド を参照してください。
@RunOnVirtualThread を使用する Quarkus アプリケーションをコンテナ化するには、 application.properties に以下のプロパティを追加します:
quarkus.container-image.build=true
quarkus.container-image.group=<your-group-name>
quarkus.container-image.name=<you-container-name>
quarkus.jib.base-jvm-image=registry.access.redhat.com/ubi8/openjdk-21-runtime (1)
quarkus.jib.platforms=linux/amd64,linux/arm64 (2)
| 1 | 仮想スレッドをサポートするベースイメージを使用するようにしてください。ここでは、Java 21 を提供するイメージを使用します。設定しない場合は、Quarkus によって Java 21+ を提供するイメージが自動的に選択されます。 |
| 2 | ターゲットアーキテクチャーを選択します。複数選択して multi-archs イメージを構築できます。 |
次に、通常と同じようにコンテナをビルドします。 例えば、Mavenを使用している場合、次を実行します:
mvn package
仮想スレッドを使用したQuarkusアプリケーションのネイティブ実行可能ファイルへのコンパイル
ローカルにインストールしたGraalVMの使用
@RunOnVirtualThread を活用した Quarkus アプリケーションをネイティブ実行可能ファイルにコンパイルするには、仮想スレッドをサポートする GraalVM/Mandrel native-image を使用する必要があり、少なくとも Java 21 を提供する必要があります。
ネイティブコンパイルガイド に示されているように、ネイティブ実行可能ファイルをビルドします。 たとえば、Maven の場合は、以下を実行します。
mvn package -Dnative
コンテナ内ビルドの使用
コンテナ内ビルドでは、コンテナ内で動作する native-image コンパイラを使用して Linux 64 実行可能ファイルをビルドできます。
これは、 native-image をマシンにインストールする手間を省き、必要な GraalVM バージョンを設定することもできます。
コンテナ内ビルドを使用するには、DockerまたはPodmanがマシンにインストールされている必要があることに注意してください。
そして、 application.properties ファイルに追加します:
# In-container build to get a linux 64 executable
quarkus.native.container-build=true (1)
| 1 | コンテナ内ビルドを有効にします。 |
|
ARM/64からAMD/64へ
Mac M1またはM2(ARM64 CPUを使用)を使用している場合、コンテナ内ビルドを使用して取得するネイティブ実行可能ファイルはLinux実行可能ファイルですが、ホスト(ARM64)アーキテクチャを使用していることに注意する必要があります。 以下のプロパティでDockerを使用する際に、エミュレーションを使用して強制的にアーキテクチャを変更することができます:
コンパイル時間がかなり長くなる(10分以上)ことに注意してください。 |
仮想スレッドを使用したネイティブ・アプリケーションのコンテナ化
ネイティブ実行可能ファイルにコンパイルされた仮想スレッドを使用してQuarkusアプリケーションを実行するコンテナをビルドするには、 Linux/AMD64実行ファイル(ARMマシンを対象としている場合はARM64)を用意する必要があります。
application.properties に ネイティブ・コンパイルのセクション で説明した設定が含まれているようにしてください。
次に、通常と同じようにコンテナをビルドします。 例えば、Mavenを使用している場合、次を実行します:
mvn package -Dnative
ネイティブコンテナイメージをビルドしたいときに、すでに既存のネイティブイメージがある場合は、 -Dquarkus.native.reuse-existing=true を設定すれば、ネイティブイメージのビルドは再実行されません。
|
仮想スレッドで複製されたコンテキストの使用
@RunOnVirtualThread でアノテーションされたメソッドは、複製された元のコンテキストを継承します (詳細は 複製されたコンテキストのリファレンスガイド を参照ください)。
そのため、フィルタやインターセプタによって複製されたコンテキストに書き込まれたデータ (およびリクエストスコープが複製されたコンテキストに格納されているため、リクエストスコープ) は、メソッドの実行中に (フィルタやインターセプタが仮想スレッド上で実行されていなくても) 利用可能です。
ただし、スレッドローカルは伝播されません。
仮想スレッド名
仮想スレッドは、デフォルトではスレッド名なしで作成されます。これは、デバッグやロギング目的で実行を識別するためには実用的ではありません。
Quarkusの管理対象仮想スレッドには名前が付けられ、 quarkus-virtual-thread- というプレフィックスが付けられます。
このプレフィックスはカスタマイズすることも、空の値を設定して命名を完全に無効にすることもできます:
quarkus.virtual-threads.name-prefix=
仮想スレッドエグゼキュータの注入
仮想スレッド上でタスクを実行するために、Quarkusは内部 ThreadPerTaskExecutor を管理します。
まれにこのエクゼキューターに直接アクセスする必要がある場合は、 @VirtualThreads CDI修飾子を使用して注入できます:
| Virtual Thread ExecutorService の注入は実験的なもので、将来のバージョンで変更される可能性があります。 |
package org.acme;
import org.acme.fortune.repository.FortuneRepository;
import java.util.concurrent.ExecutorService;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import io.quarkus.logging.Log;
import io.quarkus.runtime.StartupEvent;
import io.quarkus.virtual.threads.VirtualThreads;
public class MyApplication {
@Inject
FortuneRepository repository;
@Inject
@VirtualThreads
ExecutorService vThreads;
void onEvent(@Observes StartupEvent event) {
vThreads.execute(this::findAll);
}
@Transactional
void findAll() {
Log.info(repository.findAllBlocking());
}
}
仮想スレッドアプリケーションのテスト
上述したように、仮想スレッドにはいくつかの制限があり、 アプリケーションのパフォーマンスやメモリ使用量に大きな影響を与える可能性があります。 junit5-virtual-threads エクステンションは、テストの実行中にピン留めされたキャリアスレッドを検出する方法を提供します。 このため、最も顕著な制限のひとつを取り除いたり、 問題に気づいたりすることができます。
この検出を有効にするには
-
1)
junit5-virtual-threadsの依存関係をプロジェクトに追加します:
<dependency>
<groupId>io.quarkus.junit5</groupId>
<artifactId>junit5-virtual-threads</artifactId>
<scope>test</scope>
</dependency>
-
2) テストケースに、
io.quarkus.test.junit5.virtual.VirtualThreadUnitとio.quarkus.test.junit.virtual.ShouldNotPinのアノテーションを追加します:
@QuarkusTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@VirtualThreadUnit // Use the extension
@ShouldNotPin // Detect pinned carrier thread
class TodoResourceTest {
// ...
}
テストを実行すると(Java 21+を使用することを忘れないでください)、Quarkusはピンされたキャリアスレッドを検出します。 この場合、テストは失敗します。
@ShouldNotPin はメソッドに直接使うこともできます。
junit5-virtual-threadsは 、ピニングが避けられない場合のために @ShouldPin アノテーションも提供しています。
次のスニペットは @ShouldPin アノテーションの使い方を示しています。
@VirtualThreadUnit // Use the extension
public class LoomUnitExampleTest {
CodeUnderTest codeUnderTest = new CodeUnderTest();
@Test
@ShouldNotPin
public void testThatShouldNotPin() {
// ...
}
@Test
@ShouldPin(atMost = 1)
public void testThatShouldPinAtMostOnce() {
codeUnderTest.pin();
}
}