1. 実践: 仮想スレッドをどう作るか
そろそろ理論から実践へ進みましょう。従来のやり方でスレッドを作る方法は既にご存じですね。
Thread t = new Thread(() -> System.out.println("Hello from thread!"));
t.start();
もう少し短く書くと:
new Thread(() -> System.out.println("Hi!")).start();
Java 21 からは新しい方法が追加されました:
Thread.startVirtualThread(() -> System.out.println("Hello from virtual thread!"));
あるいは、より明示的に:
Thread t = Thread.ofVirtual().start(() -> System.out.println("Hello from virtual thread!"));
何が違うのか?
- Thread.ofVirtual().start(...) は 仮想スレッド(Virtual Thread)を作成します。これは OS ではなく JVM によって管理されます。
- Thread.ofPlatform().start(...)(または new Thread(...))は、従来のプラットフォームスレッドです。
なぜ重要か
仮想スレッドは OutOfMemoryError を心配せずに数万単位で作成できます。たとえ 100 万件のリクエストを捌こうとしても、Java は「問題ない、どんどん来い!」と言ってくれるでしょう。
2. 仮想スレッド作成の構文
基本例:
public class VirtualThreadDemo {
public static void main(String[] args) {
Thread thread = Thread.ofVirtual().start(() -> {
System.out.println("仮想スレッドからこんにちは! スレッド: " + Thread.currentThread());
});
// スレッドの終了を待つ(main が先に終了しないように)
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
何が起きているか
- Thread.ofVirtual().start(...) で仮想スレッドを作成しています。
- スレッドの中ではメッセージを出力するだけの簡単な処理です。
- 最後に thread.join() を呼び、メインスレッドが仮想スレッドの終了を待つようにします(そうしないと、出力が終わる前にプログラムが終了してしまうことがあります)。
注意:
仮想スレッドは見た目も挙動もほぼ通常のスレッドですが、その内部では JVM のマジックが働いています。
3. 大量生成: Loom の力を実践で
では、従来のスレッドでは危険(あるいは不可能)だったことを試してみましょう。各自が自分の番号を出力する 10_000 個の仮想スレッドを作成します。
public class VirtualThreadMassive {
public static void main(String[] args) throws InterruptedException {
int N = 10_000;
Thread[] threads = new Thread[N];
for (int i = 0; i < N; i++) {
int threadNum = i;
threads[i] = Thread.ofVirtual().start(() -> {
System.out.println("仮想スレッド #" + threadNum + " が動作中!");
});
}
// すべてのスレッドの終了を待つ
for (Thread t : threads) {
t.join();
}
System.out.println("すべての仮想スレッドが完了しました!");
}
}
- 通常のスレッド(new Thread(...))で同じことをすると、高確率で OutOfMemoryError で落ちます。
- 仮想スレッドならこれが通常運用です。JVM は数千〜数万のスレッドを難なくさばきます。
ちなみに、10_000 が多いと感じるなら、100_000 やさらには 1_000_000 に挑戦しても構いません。処理が簡単、または I/O 待ち中心であれば、現代のマシンなら JVM は十分対応できます。
4. Runnable とラムダ式: 仮想スレッドにコードを渡す方法
仮想スレッドは通常のスレッドと同様に、インターフェイス Runnable でタスクを受け取ります。つまり、ラムダ式、メソッド参照、そして Runnable を実装した任意のオブジェクトを渡せます。
ラムダの例:
Thread.ofVirtual().start(() -> System.out.println("仮想スレッドでラムダ!"));
メソッド参照の例:
public class TaskRunner {
public static void main(String[] args) {
Thread.ofVirtual().start(TaskRunner::doWork);
}
static void doWork() {
System.out.println("仮想スレッドで作業中: " + Thread.currentThread());
}
}
無名クラスの例:
Thread.ofVirtual().start(new Runnable() {
@Override
public void run() {
System.out.println("仮想スレッドの無名クラス!");
}
});
まとめ:
通常のスレッドで動いていたものは、仮想スレッドでもそのまま動きます。違うのは「軽くて速い」ことです。
5. ExecutorService との比較: 古いやり方と新しいやり方
従来の ExecutorService
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
int taskNum = i;
executor.submit(() -> {
System.out.println("タスク #" + taskNum + " を実行中");
});
}
executor.shutdown();
問題点:
タスクが多くスレッドが少なければ、タスクはキューで待たされます。逆にスレッドを増やしすぎると、リソース不足でプログラムが苦しくなります。
新しいやり方: 仮想スレッドベースの Executor
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 100_000; i++) {
int taskNum = i;
executor.submit(() -> {
System.out.println("仮想タスク #" + taskNum);
});
}
executor.shutdown();
何が起きるか?
- 各タスクごとに専用の仮想スレッドが作られます.
- JVM がスケジューリングを担い、システムに過負荷をかけません。
- プールサイズを厳密に制限する必要はありません — 仮想スレッドは「ほぼ無料」です。
仮想スレッドの Executor を使うべきとき
- 大量のタスクがあり、プールサイズを気にせず投げたいとき。
- タスク同士が独立しており並列実行できるとき。
- ExecutorService を使う既存のアーキテクチャ(例: Web サーバ、タスク実行基盤など)に統合したいとき。
アドバイス:
迷ったら、まずは仮想スレッドの Executor から始めましょう。最も汎用的で現代的なやり方です。
6. 実践的な指針: どちらをいつ使うか
Thread.ofVirtual().start() を直接使うべきとき
- 単発のユニークなタスク(テスト、デモ、簡単な実験など)のために個別スレッドを作りたいとき。
- スレッド数が少なく、自分で手動管理したいとき。
Executors.newVirtualThreadPerTaskExecutor() を使うべきとき
- 大量のタスクを起動したいとき(多数のリクエスト、ファイル、ネットワーク接続の処理など)。
- タスクが独立しており相互調整を必要としないとき。
- 既存のアーキテクチャで ExecutorService を既に使っている場合(例: Web サーバ、タスク処理系など)。
アドバイス:
もし判断に迷うなら、仮想スレッドの Executor から始めるのが最も無難で汎用的です。
7. 仮想スレッドでの例外処理
仮想スレッドは、try-catch の観点では通常のスレッドと同じです。Runnable の内部で例外が発生しても、JVM 全体が「壊れる」ことはなく、そのスレッドだけがエラーで終了します。
例:
Thread t = Thread.ofVirtual().start(() -> {
throw new RuntimeException("何かがうまくいきませんでした!");
});
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("メインスレッドは処理を続行しました。");
ExecutorService の場合:
submit でタスクを送る場合、結果は Future で取得でき、get() 呼び出し時に例外がスローされます。
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<?> f = executor.submit(() -> {
throw new RuntimeException("仮想タスクのエラー");
});
try {
f.get();
} catch (ExecutionException e) {
System.out.println("仮想スレッドからのエラーを捕捉: " + e.getCause());
}
executor.shutdown();
8. 仮想スレッド作成時のよくある誤り
エラー No.1: 仮想スレッドとプラットフォームスレッドを混同する。 new Thread(...) や Thread.ofPlatform() で作ったものは 仮想スレッドではありません。仮想スレッドになるのは Thread.ofVirtual().start(...) や Executors の対応メソッドだけです。
エラー No.2: 重い計算の高速化を期待する。 仮想スレッドは CPU バウンドな処理を速くしません。もし 100 万のスレッドそれぞれで円周率を何百万桁まで計算するなら、JVM は「加速」できず、単にスレッド切り替えが増えるだけです。
エラー No.3: 各スレッドにリソース(例: データベース)を持たせる。 100 万の仮想スレッドを作っても、それぞれが別個のデータベース接続を必要とするなら、データベースが耐えられません。仮想スレッドは、主な時間が待機(I/O)で占められるタスクに向いています。
エラー No.4: 重要なのにスレッドの終了を待たない。 メインスレッドが仮想スレッドより先に終了してしまうと、結果が出る前にプログラムが終わる可能性があります。join() や、ExecutorService の shutdown() と awaitTermination() を使いましょう。
エラー No.5: 仮想スレッドと互換性のない古いライブラリを使う。 一部のサードパーティライブラリは OS レベルでスレッドをブロックしたり、ネイティブ同期を使うため、仮想スレッドの効率を下げます。必ず互換性を確認しましょう。
GO TO FULL VERSION