1. 要点
すでにパッケージjava.util.concurrentのクラス、特にExecutorServiceには少し馴染みがあるはずです。これはいわば「タスクマネージャ」です。例えばsubmit()で仕事を渡すと、いつ・どのスレッドで実行するかを自動で判断してくれます。通常は内部で固定サイズのスレッドプールが動作し、リソースを節約しつつタスクごとに新しいスレッドを作らないようになっています。
しかし仮想スレッドの登場で状況は一変します。今や各タスクに専用スレッドを割り当てるという「ぜいたく」が可能で、しかもJVMが「食べ過ぎで破裂する」心配もありません。
新しい方法: Executors.newVirtualThreadPerTaskExecutor()
Java 21では、各タスクを個別の仮想スレッドで実行するExecutorServiceを作成する新しい方法が追加されました:
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
重要な違い:
- 従来のスレッドプール(Executors.newFixedThreadPool、Executors.newCachedThreadPool)は、OSスレッドのコストが高いため同時実行できるタスク数に制限がありました。
- 新しい仮想Executorはほぼ無制限です。タスクごとに軽量な仮想スレッドを割り当てます。
簡単な例
10個のタスクを仮想Executorに送ってみましょう:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 10; i++) {
int taskId = i; // ラムダで使うために変数をキャプチャ
executor.submit(() -> {
System.out.println("Task " + taskId + " is running in thread: " +
Thread.currentThread());
});
}
executor.shutdown();
}
}
何が起こるのか?
各タスクは自分専用の仮想スレッドで実行され、次のような出力が見られます:
Task 1 is running in thread: VirtualThread[#24]/runnable@ForkJoinPool-1-worker-1
...
2. 大規模な並行実行: 数千のタスクでも問題なし!
仮想スレッドの威力を体感するために、ExecutorServiceへ10ではなく、例えば100_000個のタスクを送ってみましょう。従来のプールなら、まるで象を冷蔵庫に押し込むようなもの。JVMのメモリがすぐ尽きるか、ひどく遅くなってしまいます。仮想スレッドなら話は別です!
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class VirtualExecutorMassiveDemo {
public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 1; i <= 100_000; i++) {
int taskId = i;
executor.submit(() -> {
// 例として、1 msだけスリープする
try {
Thread.sleep(1);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.out.println("Task " + taskId + " done."); // 行が多すぎるため表示しない!
});
}
executor.shutdown();
}
}
注意: 100_000行をコンソールに出力するのは悪手です。コンソールのほうが仮想スレッドより先に詰まります。出力しないか、先頭の数件だけにしましょう。
3. newVirtualThreadPerTaskExecutorの仕組み
要するに、このExecutorServiceは、送られた各タスクに対して新しい仮想スレッドを作成します。固定プールと異なり、ここにはタスクの待ち行列や同時スレッド数の厳しい制限がありません(JVMやハードウェアの限界はあります)。
アーキテクチャの観点:
- 仮想スレッドは、少数の実スレッド(キャリアスレッド、carrier threads)にマッピングされます。
- JVMが、どの仮想スレッドをいつ実行・一時停止・再開するかを判断します。
- スレッドがブロックされた場合(ファイル読み込みやネットワーク待機など)、JVMは仮想スレッドを「凍結」し、キャリアスレッドを他のタスクに解放できます。
4. 例: Futureで結果を扱う
タスクが結果を返す場合、ExecutorServiceはFutureを返します。通常のスレッドと同様に扱えます。
import java.util.concurrent.*;
public class VirtualExecutorWithResult {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> future = executor.submit(() -> {
Thread.sleep(500);
return "Hello from virtual thread!";
});
System.out.println("Result: " + future.get()); // 結果を待つ
executor.shutdown();
}
}
いつも通り: 値を返すタスクを送信し、get()で結果を待てます。例外処理も標準的なやり方で行えます。
5. Executorを正しく終了させる方法
ExecutorServiceの終了を忘れないことがとても重要です(仮想スレッドであっても「実スレッド」でなくても、プログラムが終了しない場合があります)。
shutdown() と awaitTermination
executor.shutdown(); // これ以上タスクは受け付けないと宣言
executor.awaitTermination(1, TimeUnit.MINUTES); // すべてのタスクの終了を待つ(最大1分)
なぜ重要か?
shutdown()を呼ばないと、仮想スレッドが生き続け、main()が終わってもプログラムが終了しないことがあります。これは初心者がよくやるミスです。
6. 役立つ注意点
比較: 仮想Executor vs 従来のスレッドプール
| 従来のプール(newFixedThreadPool) | 仮想Executor(newVirtualThreadPerTaskExecutor) | |
|---|---|---|
| スレッド数 | プールサイズに制限される | タスクごとに1つの仮想スレッド、ほぼ無制限 |
| キュー中のタスク | あり(すべてのスレッドが埋まっている場合) | 通常はなし。タスクはすぐにスレッドを得る |
| スレッドのコスト | 高い(スタック、OS資源) | 非常に低い(JVMのスケジューリングによる) |
| スケーラビリティ | 制限あり | ほぼ制限なし |
| 適する用途 | CPUバウンドのタスク、限定的な並行性 | I/Oバウンドのタスク、大量並行 |
Webサーバとの統合
最新のWebサーバ(Tomcat、Jetty、Undertowなど)は仮想スレッドのサポートを始めています。つまり、各HTTPリクエストを個別の仮想スレッドで処理でき、ユーザが殺到しても「詰まる」心配が減ります。
利点: 複雑なコールバックやCompletableFutureを使った非同期パターンを無理に設計しなくても、コードを単純に—従来どおりのブロッキングコードで—書けますが、アプリケーションはそれでもスケールします。
大規模テストと負荷シミュレーション
仮想スレッドは、数千の同時ユーザ・リクエスト・操作を「模擬」するテストに最適です。例えば、10_000個の並列リクエストをサーバへ送るテストで、各リクエストを自分の仮想スレッドで処理できます。
ファイルやネットワーク接続の並列処理
大量のファイルやネットワーク接続を扱うアプリケーションでは、各接続を個別の仮想スレッドで処理できます。プールの手動管理を気にする必要はありません。
7. 仮想Executorでよくある誤り
エラー1: shutdown()の呼び出しを忘れる。 Executorを閉じないとプログラムは終了しません—仮想スレッドが新しいタスクを待ち続けます。必要に応じてawaitTermination(...)を併用しましょう。
エラー2: 重い計算に仮想スレッドを使う。 仮想スレッドはCPUをフルに使うタスクを速くしません。CPUバウンドには固定プール(Executors.newFixedThreadPool)を使い、サイズを慎重に調整しましょう。
エラー3: タスク内の例外を無視する。 タスクが例外を投げてもメインスレッドには届きません。Future(get()メソッド)で処理するか、ラムダ内でtry/catchを使って処理してください。
エラー4: 古い構文やJDKのバージョンと混同する。 JDKのバージョン(Java 21+)が正しいこと、IDEが仮想スレッドをサポートする設定であることを確認しましょう。使うべきメソッドはExecutors.newVirtualThreadPerTaskExecutor()です。
エラー5: コンテキスト伝搬にThreadLocalへ依存する。 仮想スレッドは頻繁に作成・破棄されます。ThreadLocalは期待どおりに振る舞わないことがあります。コンテキストの伝搬にはScopedValue(Scoped Values。詳しくは次の講義)を使いましょう。
GO TO FULL VERSION