Javaの仮想スレッド(Virtual Threads)とは、JVMが管理するとても軽いスレッドで、I/O待ちの多い処理を「1タスク1スレッド」の素直なコードのまま大量に同時実行できるしくみです。 Java 21(JEP 444)で正式機能になりました。OSのスレッドを増やさずに、何万もの待ち時間を重ねて処理できるのが最大の特長です。

この記事では、Java 21の単体ファイル(java ファイル名.java で実行)で、プラットフォームスレッドと仮想スレッドの処理時間・OSスレッド数を1万〜10万タスクで実測しました。あわせて「CPU処理では速くならない」ことも実測で確かめ、synchronized によるpinning(ピン留め)を実際のスタックトレースとJFRで可視化しています。さらに、Java 24以降で何が変わったか、Spring Bootでの有効化、使いどころの判断表までを1本にまとめました。

Java 仮想スレッドの早見表

項目 プラットフォームスレッド 仮想スレッド
正体 OSスレッドの薄いラッパー(1対1) JVMが管理。少数のOSスレッド(キャリア)の上で動く
作り方 new Thread() / Thread.ofPlatform() Thread.ofVirtual() / Thread.startVirtualThread()
おすすめの実行方法 スレッドプール(newFixedThreadPool など) Executors.newVirtualThreadPerTaskExecutor()(プールしない)
同時に持てる数の目安 数千程度(メモリ・OS設定しだい) 数十万〜数百万
得意な処理 CPU計算、長時間動く処理 I/O待ちが多い処理(DB・HTTP・ファイル)
今回の実測(I/O待ち10万タスク) 固定プール200本:約50秒 約0.8〜1.2秒、OSスレッドのピーク9本
今回の実測(CPU処理2,000タスク) 約3.6〜4.4秒 約3.2〜4.1秒(差は測定のばらつきの範囲)
注意点 スレッド数の上限 Java 21〜23はsynchronized内のブロックでpinning(Java 24で解消)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java 仮想スレッド入門|10万タスク実測と使いどころ判断表」

仮想スレッドのしくみ

キャリアスレッドとマウント/アンマウント

仮想スレッドは、実際にはキャリアスレッドと呼ばれるプラットフォームスレッドの上で動きます。ポイントは、I/O待ちなどでブロックした瞬間に、仮想スレッドがキャリアから外れる(アンマウント)ことです。空いたキャリアはすぐ別の仮想スレッドを実行し、待ちが終わった仮想スレッドはまた空いているキャリアに載ります(マウント)。

flowchart LR
    subgraph JVM
        V1[仮想スレッド1<br>DB応答待ち] -.アンマウント.-> H[(ヒープに退避)]
        V2[仮想スレッド2<br>実行中] --> C1[キャリア1]
        V3[仮想スレッド3<br>実行中] --> C2[キャリア2]
        V4[仮想スレッド4<br>待ち解除] -.次に空いたキャリアへ.-> C1
    end
    C1 --> OS1[OSスレッド]
    C2 --> OS2[OSスレッド]

キャリアは既定ではForkJoinPoolで、本数はCPUコア数と同じです。今回の検証環境は2コアなので、キャリアは2本でした。

「速いスレッド」ではなく「たくさん待てるスレッド」

JEP 444は、仮想スレッドを「速いスレッドではない。速度ではなくスケール(高いスループット)を提供する」と説明しています。1つのタスクの処理時間は短くなりません。同時にさばけるタスク数が増えることで、全体の処理量が伸びるしくみです。この違いは、後半の実測ではっきり見えます。

並行処理の変遷と、仮想スレッドが解決したこと

Javaの並行処理は、次のように進化してきました。

段階 書き方 得意なこと 課題
① スレッドを直接作る new Thread(r).start() わかりやすい 生成コストが大きく、数に上限がある
② スレッドプール Executors.newFixedThreadPool(n) スレッドを使い回せる I/O待ちの間もスレッドを占有。プールの大きさが同時実行数の上限になる
③ CompletableFuture(非同期) supplyAsync().thenCompose() 待ち中にスレッドを返せる コードがコールバック中心になり、読み書き・デバッグが難しくなる
④ 仮想スレッド newVirtualThreadPerTaskExecutor() ①の書きやすさと③の効率を両立 pinning、ThreadLocalの使い方に注意

仮想スレッドの価値は、「同期的で素直なコード」のまま、非同期と同じくらい多くの待ちをさばける点にあります。

基本の書き方

// 1本だけ起動する
Thread t = Thread.ofVirtual().name("worker-1").start(() -> System.out.println("hello"));
t.join();

// ショートカット
Thread.startVirtualThread(() -> System.out.println("hello"));

// 実務で最もよく使う形:1タスクにつき1本の仮想スレッド
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> callApi());   // ブロッキングなコードをそのまま書ける
    }
} // close() で全タスクの完了を待つ(Java 19以降、ExecutorService は AutoCloseable)

// 今のスレッドが仮想スレッドかを確認
System.out.println(Thread.currentThread().isVirtual());

仮想スレッドはプールせず、タスクごとに作って使い捨てます。 Oracleの公式ガイドも「スレッドプールを使わない」ことを推奨しています。同時アクセス数を絞りたいときは、プールの大きさではなくSemaphoreで制御します(後述)。

実測1:I/O待ち1万〜10万タスク

検証コード(Java 21 単体ファイル)

1タスクを「100ミリ秒のI/O待ち」と見立て、Thread.sleep で模擬しました。3つの方式で全タスクが終わるまでの時間と、プラットフォームスレッド数のピークを測ります。

import java.lang.management.ManagementFactory;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;

public class IoBench {
    // I/O待ちを sleep で模擬する1タスク
    static void ioTask() {
        try { Thread.sleep(Duration.ofMillis(100)); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    static void run(String label, ExecutorService executor, int tasks) {
        var mx = ManagementFactory.getThreadMXBean();
        mx.resetPeakThreadCount();
        long start = System.nanoTime();
        try (executor) {
            IntStream.range(0, tasks).forEach(i -> executor.submit(IoBench::ioTask));
        } // close() が全タスクの完了を待つ
        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.printf("%-28s tasks=%,7d time=%,6d ms  peakOsThreads=%,6d%n",
                label, tasks, ms, mx.getPeakThreadCount());
    }

    public static void main(String[] args) {
        int tasks = Integer.parseInt(args[0]);
        switch (args[1]) {
            case "pool200" -> run("platform fixed pool(200)", Executors.newFixedThreadPool(200), tasks);
            case "perTask" -> run("platform thread-per-task",
                    Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory()), tasks);
            case "virtual" -> run("virtual thread-per-task", Executors.newVirtualThreadPerTaskExecutor(), tasks);
        }
    }
}

ThreadMXBean のスレッド数には仮想スレッドは含まれず、プラットフォームスレッドだけが数えられます。つまりpeakOsThreadsは「OSスレッドを何本使ったか」の目安になります。

実行結果(OpenJDK 21.0.10、2コアのLinuxコンテナ)

$ java IoBench.java 10000 pool200   (以下、方式とタスク数を変えて1回ずつ実行)
platform fixed pool(200)     tasks= 10,000 time= 5,047 ms  peakOsThreads=   206
platform thread-per-task     tasks= 10,000 time= 2,107 ms  peakOsThreads= 1,220
virtual thread-per-task      tasks= 10,000 time=   261 ms  peakOsThreads=     9
platform fixed pool(200)     tasks=100,000 time=50,173 ms  peakOsThreads=   206
platform thread-per-task     tasks=100,000 time=18,425 ms  peakOsThreads= 1,396
virtual thread-per-task      tasks=100,000 time= 1,048 ms  peakOsThreads=     9

同じ条件で再実行すると、仮想スレッド10万タスクは 1,248ms / 840ms、プラットフォームのタスクごと生成(1万)は 1,901ms / 2,075ms でした。

方式 1万タスク 10万タスク OSスレッドのピーク
固定プール200本 約5.0秒 約50.2秒 206本
プラットフォームをタスクごとに生成 約1.9〜2.1秒 約18.4秒 約1,200〜1,400本
仮想スレッドをタスクごとに生成 約0.26秒 約0.8〜1.2秒 9本

結果の読み方

  • 固定プール200本は、同時に待てるのが200件までなので「100ms × 10万 ÷ 200 = 50秒」と、計算どおりの時間がかかりました。プールの大きさがそのまま上限になります。
  • プラットフォームをタスクごとに生成すると同時実行数は増えますが、OSスレッドの生成コストが大きく、10万件で約18秒かかりました。
  • 仮想スレッドは、OSスレッドを9本しか使わずに10万件の待ちを重ね、約1秒で終わりました。理想値の0.1秒に対する残りは、主に10万個のタスクの生成と投入の時間です。

I/O待ちが中心の処理では、仮想スレッドは「同時に待てる数」を大きく広げ、全体の処理時間を短くします。

実測2:CPU処理では速くならない

次に、I/O待ちのないCPUだけを使う処理(6万未満の素数を数える)を2,000タスク実行しました。比較相手はコア数と同じ2本の固定プールです。1回目はJITコンパイルのウォームアップを兼ねるため、4ラウンド繰り返しました。

static long cpuTask() {
    long count = 0;
    for (int n = 2; n < 60_000; n++) {
        boolean prime = true;
        for (int d = 2; (long) d * d <= n; d++) {
            if (n % d == 0) { prime = false; break; }
        }
        if (prime) count++;
    }
    return count;
}
$ java CpuBench.java
availableProcessors=2
--- round 1
platform fixed pool(2)         tasks=2,000 time=3,847 ms
virtual thread-per-task        tasks=2,000 time=3,244 ms
--- round 2
platform fixed pool(2)         tasks=2,000 time=3,634 ms
virtual thread-per-task        tasks=2,000 time=3,994 ms
--- round 3
platform fixed pool(2)         tasks=2,000 time=3,882 ms
virtual thread-per-task        tasks=2,000 time=3,247 ms
--- round 4
platform fixed pool(2)         tasks=2,000 time=3,650 ms
virtual thread-per-task        tasks=2,000 time=3,279 ms

ラウンドごとに勝ち負けが入れ替わり、差は測定のばらつきの範囲でした。CPUを使い続ける処理では、キャリア(=CPUコア数)以上に並列にはならないためです。CPU処理の高速化には、コア数に合わせたプールや並列ストリームなど、従来の方法が引き続き適しています。

実測3:pinning(ピン留め)を可視化する

pinningとは

pinningとは、仮想スレッドがブロックしてもキャリアから外れられず、キャリアごと止まってしまう状態です。Java 21では、主に次の2つで起きます。

  • synchronized ブロック/メソッドの中でブロックしたとき
  • ネイティブメソッドや外部関数の呼び出し中にブロックしたとき

キャリアはコア数分しかないため、pinningが頻繁に起きると同時に待てる数がキャリアの本数まで下がります。

synchronized と ReentrantLock で比べる

タスクごとに別々のロックを持たせ(=ロックの取り合いは起きない)、ロックの中で100ms待つ処理を100タスク実行しました。

static final class SyncCounter {
    synchronized void work() { sleep100(); }      // synchronized の中でブロック
}
static final class LockCounter {
    private final ReentrantLock lock = new ReentrantLock();
    void work() { lock.lock(); try { sleep100(); } finally { lock.unlock(); } }
}
$ java PinningDemo.java 100 sync
synchronized + sleep   tasks=100 time=5,022 ms
$ java PinningDemo.java 100 lock
ReentrantLock + sleep  tasks=100 time=135 ms

Java 21では、synchronized 版はキャリア2本分しか同時に待てず「100ms × 100 ÷ 2 = 5秒」かかりました。 ReentrantLock 版はアンマウントできるため、約0.1秒で終わっています。

-Djdk.tracePinnedThreads=full で原因箇所を出す

Java 21〜23では、システムプロパティ jdk.tracePinnedThreads でpinningが起きた場所を出力できます。

$ java -Djdk.tracePinnedThreads=full PinningDemo.java 2 sync
VirtualThread[#19]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
    java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
    java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
    java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
    java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
    java.base/java.lang.Thread.sleep(Thread.java:590)
    PinningDemo.sleep100(PinningDemo.java:8)
    PinningDemo$SyncCounter.work(PinningDemo.java:14) <== monitors:1
    ...

reason:MONITOR と <== monitors:1 が、synchronized(モニター)を持ったままブロックした箇所を示しています。

JFRでも確認できる

本番に近い環境では、JDK Flight Recorder(JFR)の jdk.VirtualThreadPinned イベントが便利です(既定では20ms以上のpinningが記録されます)。

$ java -XX:StartFlightRecording=filename=pin.jfr,settings=default PinningDemo.java 4 sync
$ jfr print --events jdk.VirtualThreadPinned pin.jfr
jdk.VirtualThreadPinned {
  startTime = 00:49:00.350 (2026-09-29)
  duration = 99.4 ms
  eventThread = "" (javaThreadId = 23, virtual)
  stackTrace = [
    java.lang.VirtualThread.parkOnCarrierThread(boolean, long) line: 689
    java.lang.VirtualThread.parkNanos(long) line: 648
    java.lang.VirtualThread.sleepNanos(long) line: 807
    java.lang.Thread.sleep(Duration) line: 590
    PinningDemo.sleep100() line: 8
    ...
  ]
}

duration = 99.4 ms が、キャリアを止めていた時間です。

Java 24以降の変化(JEP 491)

Java 24で JEP 491「Synchronize Virtual Threads without Pinning」が取り込まれ、synchronized によるpinningはほぼ解消されました。 モニターの取得待ちや Object.wait() の間も、仮想スレッドはキャリアから外れられます。

項目 Java 21〜23 Java 24以降
synchronized 内でのブロック pinningする pinningしない
ネイティブメソッド・FFM API経由のブロック pinningする pinningする(残る)
-Djdk.tracePinnedThreads 使える 削除(指定しても効果なし)
JFR jdk.VirtualThreadPinned 使える 残るpinningの検出用として継続

そのため、対応は利用中のJavaのバージョンで変わります。

  • Java 21を使い続ける場合:頻繁にI/Oする synchronized を ReentrantLock に置き換えると、今回の実測のように同時実行数を保てます。ライブラリ内部の synchronized はJFRで見つけます。
  • Java 24以降(LTSならJava 25)に上げられる場合:synchronized を書き換えなくてもよくなり、移行の手間が大きく減ります。

※本記事の実測はJava 21で行っています。Java 24以降での挙動はJEP 491の記載にもとづきます。

Spring Bootで有効にする

Spring Boot 3.2以降は、プロパティ1行で仮想スレッドを使えます。

# application.properties
spring.threads.virtual.enabled=true

# @Scheduled などでJVMを動かし続けたい場合
spring.main.keep-alive=true

公式リファレンスのポイントは次のとおりです。

  • Java 21以上が必要で、Java 24以上が強く推奨されています(pinningの改善のため)。
  • 有効にすると、スレッドプールを設定するプロパティは効かなくなります。仮想スレッドはJVM全体で共有するキャリアの上で動くためです。
  • 仮想スレッドはデーモンスレッドです。すべてのスレッドがデーモンだとJVMは終了するため、スケジューラ中心のアプリでは spring.main.keep-alive=true を設定します。

実務での注意点

同時実行数は Semaphore で絞る

仮想スレッドはいくらでも作れるため、DBやAPIの相手側の上限(コネクション数、レート制限)を超えないように制御します。

private final Semaphore permits = new Semaphore(20);  // 同時20件まで

Result callLimited() throws InterruptedException {
    permits.acquire();
    try {
        return callExternalService();
    } finally {
        permits.release();
    }
}

DBのコネクションプールも同じ役割を果たします。仮想スレッドを1万本にしても、コネクションが20本なら同時にDBへ行けるのは20件です。ボトルネックがどこにあるかを先に確認しておくと、効果を正しく見積もれます。

ThreadLocal に重いオブジェクトをキャッシュしない

仮想スレッドは使い捨てなので、ThreadLocal でのキャッシュは再利用されず、スレッドの数だけメモリを使います。DateTimeFormatter のような不変で共有できるオブジェクトを使うのがおすすめです。

よくある失敗と対策

よくある失敗 起きること 対策
仮想スレッドを固定プールに入れる 同時実行数がプールの大きさで止まる newVirtualThreadPerTaskExecutor() を使う
CPU処理を仮想スレッドにする 速くならない(実測2) コア数ベースのプールや並列ストリームを使う
Java 21で synchronized 内のI/Oが多い キャリア数までしか同時に待てない(実測3) ReentrantLock に置き換える、またはJava 24以降へ
同時実行数を制御しない 接続先の上限を超える Semaphore やコネクションプールで絞る
ThreadLocalに重いキャッシュ メモリが増える 不変オブジェクトの共有に変える

使いどころ判断表

処理の特徴 仮想スレッド 理由
Webアプリのリクエスト処理(DB・API呼び出し中心) 向いている 待ち時間が大半を占める
多数の外部APIを並行して呼ぶバッチ 向いている 同時に待てる数が増える
ファイル・ネットワークの大量I/O 向いている ブロッキングI/Oでアンマウントされる
画像処理・集計などCPU中心の処理 効果は小さい コア数以上に並列化されない
同時実行数が数十程度で足りる処理 どちらでもよい 既存のプールで十分なことが多い
ネイティブ呼び出しの中で長く待つ処理 注意して使う Java 24以降もpinningが残る

判断の軸は「処理時間のうち、待ち時間が占める割合」です。待ちが多いほど、仮想スレッドの効果が大きくなります。

よくある質問

Q. 仮想スレッドにすると、1リクエストの応答時間は短くなりますか? A. 1件あたりの処理時間は基本的に変わりません。仮想スレッドが増やすのは同時に処理できる件数(スループット)です。混雑時の待ち行列が減ることで、結果として応答が安定するケースはあります。

Q. 既存のコードはどこまで書き換えが必要ですか? A. ExecutorService を作る部分を Executors.newVirtualThreadPerTaskExecutor() に替えるだけで動くことが多いです。Java 21〜23では、I/Oを含む synchronized を ReentrantLock に替えると効果を保てます。Spring Bootなら spring.threads.virtual.enabled=true から試せます。

Q. -Djdk.tracePinnedThreads が効きません。 A. Java 24以降では、JEP 491によりこのプロパティは削除されています。JFRの jdk.VirtualThreadPinned イベントで確認してください。

Q. CompletableFutureやリアクティブは不要になりますか? A. ブロッキングで書けるならコードは読みやすくなります。一方で、ストリーム処理やバックプレッシャー(流量制御)が必要な場面ではリアクティブにも強みがあります。チームの既存資産と要件に合わせて選ぶのがよいでしょう。

まとめ

  • 仮想スレッドは「たくさん待てるスレッド」。Java 21で正式機能になった
  • I/O待ち10万タスクは、OSスレッド9本・約1秒で完了(固定プール200本は約50秒)
  • CPU処理は速くならない。差は測定のばらつきの範囲だった
  • Java 21〜23は synchronized 内のブロックでpinning。ReentrantLock で約5秒→約0.1秒に
  • Java 24(JEP 491)以降は synchronized のpinningが解消。確認はJFRで
  • Spring Bootは spring.threads.virtual.enabled=true。同時実行数は Semaphore やコネクションプールで管理する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java 仮想スレッド入門|10万タスク実測と使いどころ判断表」

株式会社RJCでは、新しいJavaの機能を実際に動かして確かめ、その結果をプロジェクトの仲間と共有しています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?