1. スケーラビリティ
なぜ従来のスレッドはスケールしにくいのか?
各クラシックなスレッド (Thread) は OS のエンティティで、専用スタック (通常は 1–2 MB) と状態管理構造を持ちます。たとえば 10 000 本の通常スレッドを作成しようとすると、しばしば OutOfMemoryError が発生します。そのため従来型のサーバでは制限付きのスレッドプールが使われます。
仮想スレッド: スケーラビリティの魔法
仮想スレッド (Java 21+) は JVM が管理する「軽量」スレッドです。スタックはヒープに置かれ、動的に拡大・縮小できます。スレッドが I/O でブロックすると、JVM はそのスレッドを「凍結」し、他のタスクの実行を続けます。
JVM 内部では少数の「キャリアスレッド」(carrier threads) — OS のプラットフォームスレッド — のプールが動作しており、その上で仮想スレッドが順番に実行されます。これにより、100 000+ のタスクをメモリを恐れずに作成できます。どの仮想スレッドをいつ実行するかは JVM が自動的にスケジューリングします。
デモ: 100_000 の仮想スレッド vs 1_000 のプラットフォームスレッド
例: 通常スレッドを 1000 本作成
// 1000 本の通常スレッドを作成してみる
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
threads.add(t);
t.start();
}
System.out.println("作成したスレッド数: " + threads.size());
結果: 多くのシステムでは 1 000–2 000 本程度は作成できますが、さらに増やすとメモリ問題や顕著な遅延が発生し始めます。
例: 100 000 の仮想スレッドを作成
// 100_000 個の仮想スレッドを作成
List<Thread> vThreads = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
Thread.sleep(10_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
vThreads.add(t);
}
System.out.println("作成した仮想スレッド数: " + vThreads.size());
結果: プログラムは問題なく 100 000 個の仮想スレッドを作成でき、異常終了や大きな遅延は起きません。必要メモリも桁違いに小さくなります。
視覚的な比較
| スレッドの種類 | 作成可能な本数 (概算) | メモリ使用量 | 起動時間 |
|---|---|---|---|
| 通常 (Thread) | 1 000 – 10 000 | 高い | 時間がかかる |
| 仮想 | 100 000 – 1 000 000+ | 低い | 瞬時 |
事実: 仮想スレッドにより、複雑なプールなしで「タスクごとに 1 スレッド」というコードを書け、システム過負荷のリスクを抑えられます。
2. パフォーマンス: 仮想スレッドが効くところ
I/O に律速されるタスク (I/O-bound)
仮想スレッドはネットワークリクエスト、ファイル I/O、DB 作業に最適です。操作がブロックされると、仮想スレッドは「キャリア」を解放し、JVM は他のタスクを実行します。多数の同時待ちがある場合のスループットが向上します。
例: 10 000 件の同時 HTTP リクエストを模擬
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
HttpClient client = HttpClient.newHttpClient();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
Thread t = Thread.ofVirtual().start(() -> {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com"))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("応答: " + response.statusCode());
} catch (Exception e) {
System.out.println("エラー: " + e.getMessage());
}
});
threads.add(t);
}
// すべてのスレッドの終了を待つ
for (Thread t : threads) {
t.join();
}
結果: 10 000 件すべてのリクエストが並行に処理され、プログラムは落ちず、コードもシンプルなままです。
CPU-bound: 仮想スレッドでは計算は速くならない
タスクが CPU を強く使う場合、仮想スレッドは速度を上げません。コア数は固定だからです。この場合は、不要な競合を生まないようにコア数に等しい固定プールが適しています。
// 各タスクが大きな範囲の合計を計算する
Runnable cpuTask = () -> {
long sum = 0;
for (int i = 0; i < 100_000_000; i++) {
sum += i;
}
System.out.println("合計: " + sum);
};
// 計算を行う仮想スレッドを 1000 本起動
for (int i = 0; i < 1000; i++) {
Thread.ofVirtual().start(cpuTask);
}
結果: スレッドは CPU を奪い合いますが高速化はしません—これは Virtual Threads の得意分野ではありません。
3. 制約と注意点
同期と仮想スレッドの落とし穴
- ネイティブロックに注意。 synchronized の使用は仮想スレッドをキャリアに「貼り付け」てしまい、利点を損ねる可能性があります。ReentrantLock、Semaphore、および java.util.concurrent の他のプリミティブ (仮想スレッド向けに最適化) を優先しましょう.
- 古いライブラリ。 一部の JDBC ドライバやネイティブライブラリはまだ Virtual Threads に最適化されていません。ブロッキング操作は入念にテストしてください。
長寿命タスクには不向き
Virtual Threads は「短い」作業単位、すなわちリクエスト処理や 1 回の操作と完了といった用途に最適です。無限動作するミリオン規模のタスク (例: 終わらない計算) は効果がなく、そうした用途にはプラットフォームスレッドを使いましょう。
4. Best practices: 仮想スレッドを使うべき場所
- I/O-bound なタスク: ネットワーク呼び出し、ファイル、DB—スレッドがよく待つものすべて。
- Web サーバ: 各 HTTP リクエストを個別の仮想スレッドで処理。
- 統合テスト: 何千ものクライアントを素早く模擬。
- 非同期処理: いつもの「ブロッキング」コードを書けば、JVM が賢くスケジューリングします。
使用を避けるべきケース:
- 常に CPU を占有するタスク。
- 低レベルライブラリとの互換性が極めて重要な場合 (まだ適応していないものがあるため)。
内部的には実行器として Executors.newVirtualThreadPerTaskExecutor() を使うと便利です—固定プールなしで「タスクごとに 1 仮想スレッド」になります。
5. 監視と計測: 仮想スレッドを実運用で可視化する
JVisualVM と Flight Recorder
JVisualVM はアクティブなスレッド、その状態、メモリを可視化します。Java 21 以降では仮想スレッドが個別に表示されます。Java Flight Recorder (JFR) は実行の詳細な「ブラックボックス」ログを記録し、Virtual Threads に関する統計も含まれます—ボトルネックの特定に便利です。
コードからスレッド数を確認する方法
JVM 内のスレッド総数を確認する簡単な方法:
System.out.println("総スレッド数: " + Thread.activeCount());
そのうち仮想スレッドがいくつかを数える:
long vCount = Thread.getAllStackTraces().keySet().stream()
.filter(Thread::isVirtual)
.count();
System.out.println("仮想スレッド数: " + vCount);
6. 仮想スレッドでよくあるミス
エラー No.1: 重い計算に仮想スレッドを使う。 CPU-bound な処理を数百万の仮想スレッドで走らせても CPU は速くなりません。Virtual Threads は計算の「ターボ」ではありません。
エラー No.2: 古いパターンの盲目的な踏襲。 仮想スレッドの固定プールは作らないでください。Executors.newVirtualThreadPerTaskExecutor() を使い、JVM に自動スケールを任せましょう。
エラー No.3: 非対応ライブラリの使用。 Loom に未対応のネイティブロックやライブラリはハングや性能低下の原因になり得ます。事前に互換性を確認してください。
エラー No.4: 早すぎる最適化。 スレッド数が少なく通常のマルチスレッドで十分な場合、すべてを Virtual Threads に移すのを急ぐ必要はありません。大量の I/O と待ちがあるときに真価を発揮します。
エラー No.5: 監視を無視する。 ミリオン規模のタスクは簡単に作れますが、監視や例外処理を怠ると「美しいベンチマーク」止まりで堅牢なシステムにはなりません。JVisualVM と JFR を活用しましょう。
GO TO FULL VERSION