はじめに

Javaにおける並行処理は、マルチスレッドプログラミングの基本です。しかし、従来のプラットフォームスレッドはOSスレッドと1対1で対応するため、大量のスレッドを生成するとリソース消費が激しく、スケーラビリティに限界がありました。

Virtual Threads(仮想スレッド) は、Java 21で正式導入された軽量スレッドです。数百万単位のスレッドを効率的に扱えるため、スレッド・パー・リクエスト方式のサーバーアプリケーションのスループットを劇的に向上できます。

この記事では、Virtual Threadsの基本概念から、プラットフォームスレッドとの違い、Thread.ofVirtual()ExecutorServiceでの使い方、ブロッキング操作の扱い、Structured Concurrency(構造化並行性)まで、実践的なコード例とともに解説します。

Virtual Threadsとは何か

Virtual Threadsは、JDKが提供する軽量スレッドの実装です。従来のプラットフォームスレッドと同じjava.lang.Threadのインスタンスでありながら、OSスレッドに直接紐付かないため、非常に多くのスレッドを効率的に生成・管理できます。

Virtual Threadsの導入経緯

Virtual Threadsは、Project Loomの成果として以下の経緯を経て正式導入されました。

バージョン ステータス JEP
Java 19 プレビュー機能(第1弾) JEP 425
Java 20 プレビュー機能(第2弾) JEP 436
Java 21 正式機能 JEP 444

Virtual Threadsが解決する課題

従来のサーバーアプリケーション開発には、以下のような課題がありました。

方式 課題
スレッド・パー・リクエスト プラットフォームスレッドはOSスレッドを占有するため、数千程度でリソース枯渇
スレッドプール プールサイズがボトルネックになり、同時処理数に上限
非同期プログラミング CompletableFutureやリアクティブフレームワークはコードが複雑化

Virtual Threadsは、シンプルな同期的コードのまま、高いスループットを実現します。

Virtual Threadsの動作原理

flowchart TB
    subgraph "プラットフォームスレッド"
        PT1["プラットフォーム<br/>スレッド 1"] --> OS1["OSスレッド 1"]
        PT2["プラットフォーム<br/>スレッド 2"] --> OS2["OSスレッド 2"]
        PT3["プラットフォーム<br/>スレッド 3"] --> OS3["OSスレッド 3"]
    end
    
    subgraph "Virtual Threads"
        VT1["仮想スレッド 1"]
        VT2["仮想スレッド 2"]
        VT3["仮想スレッド 3"]
        VT4["仮想スレッド 4"]
        VT5["仮想スレッド 5"]
        VT6["仮想スレッド 6"]
        
        C1["キャリア<br/>スレッド 1"]
        C2["キャリア<br/>スレッド 2"]
        
        VT1 -.-> C1
        VT2 -.-> C1
        VT3 -.-> C1
        VT4 -.-> C2
        VT5 -.-> C2
        VT6 -.-> C2
        
        C1 --> OSA["OSスレッド A"]
        C2 --> OSB["OSスレッド B"]
    end

プラットフォームスレッドが1対1でOSスレッドを占有するのに対し、Virtual Threadsは少数のキャリアスレッド(プラットフォームスレッド)上で多数の仮想スレッドがスケジューリングされます。

プラットフォームスレッドとVirtual Threadsの違い

プラットフォームスレッドとVirtual Threadsの主な違いを理解しておきましょう。

特性の比較

特性 プラットフォームスレッド Virtual Threads
OSスレッドとの関係 1対1で紐付く M:Nでマッピング
生成コスト 高い(約1MB/スレッド) 非常に低い(約数KB)
同時実行数 数千程度が限界 数百万も可能
プーリング 推奨される 非推奨(都度生成)
スケジューリング OSが行う JVMが行う
デーモンスレッド 設定可能 常にデーモン
優先度 設定可能 常にNORM_PRIORITY
スレッドグループ 所属する 仮想グループに所属

スループットの違い

以下の例で、10,000個のタスク(各1秒のスリープ)を実行した場合の違いを示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// プラットフォームスレッド(固定プール)の場合
// 200スレッドのプールでは、10,000タスク完了に約50秒
try (var executor = Executors.newFixedThreadPool(200)) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}

// Virtual Threadsの場合
// 10,000スレッドが並行動作し、約1秒で完了
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}

Virtual Threadsでは、ブロッキング操作中にキャリアスレッドを解放するため、少ないOSスレッドで多数のタスクを効率的に処理できます。

Virtual Threadsの作成方法

Virtual Threadsを作成する方法は複数あります。用途に応じて使い分けましょう。

Thread.startVirtualThreadによる作成

最もシンプルな方法は、Thread.startVirtualThread()を使用することです。

1
2
3
4
5
6
7
8
9
// 仮想スレッドを作成して即座に開始
Thread thread = Thread.startVirtualThread(() -> {
    System.out.println("Hello from Virtual Thread!");
    System.out.println("Thread name: " + Thread.currentThread().getName());
    System.out.println("Is virtual: " + Thread.currentThread().isVirtual());
});

// スレッドの終了を待機
thread.join();

実行結果の例:

Hello from Virtual Thread!
Thread name: 
Is virtual: true

Thread.ofVirtualによる作成

Thread.ofVirtual()を使用すると、スレッドに名前を付けたり、開始タイミングを制御したりできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Thread.Builderを使用してVirtual Threadを作成
Thread.Builder builder = Thread.ofVirtual().name("my-virtual-thread");

Runnable task = () -> {
    System.out.println("Running in: " + Thread.currentThread().getName());
};

// unstartedで未開始のスレッドを作成
Thread thread = builder.unstarted(task);
System.out.println("Thread created but not started");

// 明示的に開始
thread.start();
thread.join();

連番名のVirtual Threadsを作成

複数のVirtual Threadsに連番の名前を付ける場合は、name(String prefix, long start)を使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// "worker-0", "worker-1", ... という名前のスレッドを作成
Thread.Builder builder = Thread.ofVirtual().name("worker-", 0);

Runnable task = () -> {
    System.out.println("Thread ID: " + Thread.currentThread().threadId() +
                       ", Name: " + Thread.currentThread().getName());
};

// worker-0
Thread t1 = builder.start(task);
t1.join();

// worker-1
Thread t2 = builder.start(task);
t2.join();

// worker-2
Thread t3 = builder.start(task);
t3.join();

実行結果の例:

Thread ID: 21, Name: worker-0
Thread ID: 24, Name: worker-1
Thread ID: 27, Name: worker-2

ThreadFactoryによる作成

ThreadFactoryを使用すると、既存のAPIとの互換性を保ちながらVirtual Threadsを使用できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Virtual Threads用のThreadFactoryを作成
ThreadFactory factory = Thread.ofVirtual()
    .name("request-handler-", 0)
    .factory();

// ThreadFactoryからスレッドを生成
Thread t1 = factory.newThread(() -> {
    System.out.println("Handling request in: " + Thread.currentThread().getName());
});
t1.start();
t1.join();

ExecutorServiceでのVirtual Threads活用

実際のアプリケーションでは、ExecutorServiceを使用してVirtual Threadsを管理することが推奨されます。

newVirtualThreadPerTaskExecutorの使用

Executors.newVirtualThreadPerTaskExecutor()は、タスクごとに新しいVirtual Threadを作成するExecutorServiceを返します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 複数のタスクを投入
    Future<String> future1 = executor.submit(() -> {
        Thread.sleep(Duration.ofMillis(500));
        return "Result 1";
    });
    
    Future<String> future2 = executor.submit(() -> {
        Thread.sleep(Duration.ofMillis(300));
        return "Result 2";
    });
    
    Future<String> future3 = executor.submit(() -> {
        Thread.sleep(Duration.ofMillis(100));
        return "Result 3";
    });
    
    // 結果を取得
    System.out.println(future1.get());
    System.out.println(future2.get());
    System.out.println(future3.get());
}
// try-with-resourcesでclose()が呼ばれ、すべてのタスク完了を待機

ファンアウトパターンの実装

複数の外部サービスに並行してリクエストを送信し、結果を集約するファンアウトパターンの例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
void handle(Request request, Response response) {
    var url1 = URI.create("https://api.example.com/users").toURL();
    var url2 = URI.create("https://api.example.com/orders").toURL();
    
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var userFuture = executor.submit(() -> fetchURL(url1));
        var orderFuture = executor.submit(() -> fetchURL(url2));
        
        // 両方の結果を待機して結合
        String result = userFuture.get() + orderFuture.get();
        response.send(result);
    } catch (ExecutionException | InterruptedException e) {
        response.fail(e);
    }
}

String fetchURL(URL url) throws IOException {
    try (var in = url.openStream()) {
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

Virtual Threadsをプーリングしてはいけない理由

プラットフォームスレッドではスレッドプールが一般的ですが、Virtual Threadsではプーリングは非推奨です。

1
2
3
4
5
6
7
// 悪い例:Virtual Threadsをプールする
// 意味がないだけでなく、パフォーマンスに悪影響
ExecutorService badExecutor = Executors.newFixedThreadPool(100, 
    Thread.ofVirtual().factory());

// 良い例:タスクごとに新しいVirtual Threadを作成
ExecutorService goodExecutor = Executors.newVirtualThreadPerTaskExecutor();
プラットフォームスレッド Virtual Threads
プーリング推奨(高コストのため再利用) プーリング非推奨(低コストのため都度生成)
スレッド数で同時実行を制限 Semaphoreで同時実行を制限
ThreadLocalでリソースをキャッシュ ThreadLocalのキャッシュは非推奨

ブロッキング操作とスケーラビリティ

Virtual Threadsの最大の利点は、ブロッキング操作時にキャリアスレッドを解放し、他のVirtual Threadsが実行できることです。

ブロッキング操作の動作

sequenceDiagram
    participant VT1 as 仮想スレッド1
    participant VT2 as 仮想スレッド2
    participant CT as キャリアスレッド
    participant OS as OSスレッド
    
    VT1->>CT: マウント
    CT->>OS: 実行
    Note over VT1,OS: I/O操作開始
    VT1-->>CT: アンマウント(ブロック)
    VT2->>CT: マウント
    CT->>OS: 実行
    Note over VT2,OS: 処理続行
    Note over VT1: I/O完了
    VT2-->>CT: アンマウント
    VT1->>CT: 再マウント
    CT->>OS: 実行続行

Virtual Threadがブロッキング操作(I/O、Thread.sleep()BlockingQueue.take()など)を実行すると、JVMスケジューラーはそのVirtual Threadをキャリアスレッドからアンマウントし、別のVirtual Threadをマウントします。

スケーラビリティが向上するケース

Virtual Threadsは以下のケースで特に効果を発揮します。

ケース 効果
I/O待ちが多い ブロッキング中にキャリアを解放
同時タスク数が多い 数千以上のタスクを効率的に処理
スレッド・パー・リクエスト シンプルなコードで高スループット

逆に、以下のケースではVirtual Threadsの恩恵は限定的です。

ケース 理由
CPU集約型の処理 CPUコア数以上の並列化は効果なし
同時タスク数が少ない Virtual Threadsのオーバーヘッドが相対的に大きい
非同期コードが既に最適化済み 移行メリットが小さい

ピンニングに注意する

Virtual Threadsがキャリアスレッドからアンマウントできない状態を**ピンニング(Pinning)**と呼びます。以下の状況でピンニングが発生します。

  1. synchronizedブロックまたはメソッド内でのブロッキング操作
  2. ネイティブメソッドまたは外部関数の実行中
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// ピンニングが発生する例(非推奨)
synchronized (lock) {
    // この中でブロッキング操作を行うと、キャリアスレッドがブロックされる
    Thread.sleep(Duration.ofSeconds(1));  // ピンニング発生
}

// ピンニングを回避する例(推奨)
private final ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // ReentrantLockを使用すれば、ブロッキング操作でもアンマウント可能
    Thread.sleep(Duration.ofSeconds(1));  // ピンニング回避
} finally {
    lock.unlock();
}

ピンニングを検出するには、JVMオプション-Djdk.tracePinnedThreads=fullを使用するか、JDK Flight Recorder(JFR)のjdk.VirtualThreadPinnedイベントを確認します。

Semaphoreによる同時実行数の制限

Virtual Threadsではスレッドプールによる同時実行数制限は不適切です。代わりにSemaphoreを使用します。

Semaphoreの使用例

外部サービスへの同時接続数を10に制限する例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class RateLimitedService {
    // 同時実行数を10に制限
    private final Semaphore semaphore = new Semaphore(10);
    
    public String callExternalService(String request) throws InterruptedException {
        semaphore.acquire();  // 許可を取得(ブロック可能)
        try {
            return doCallExternalService(request);
        } finally {
            semaphore.release();  // 許可を解放
        }
    }
    
    private String doCallExternalService(String request) {
        // 実際の外部サービス呼び出し
        return "Response for: " + request;
    }
}

スレッドプールとSemaphoreの等価性

flowchart LR
    subgraph "スレッドプールによる制限"
        T1[タスク1] --> Q1[キュー]
        T2[タスク2] --> Q1
        T3[タスク3] --> Q1
        Q1 --> P1[プールスレッド1]
        Q1 --> P2[プールスレッド2]
    end
    
    subgraph "Semaphoreによる制限"
        VT1[仮想スレッド1] --> S[Semaphore]
        VT2[仮想スレッド2] --> S
        VT3[仮想スレッド3] --> S
        S --> R[リソース]
    end

スレッドプールではタスクがキューに入り、プールスレッドが順次処理します。Semaphoreでは、Virtual Threadsがセマフォの許可を待ってブロックします。Virtual Threads自体がタスクを表すため、両者は等価な構造になります。

Structured Concurrency(プレビュー機能)

Structured Concurrency(構造化並行性)は、並行タスクを構造化して管理するためのAPIです。Java 21以降でプレビュー機能として提供されています。

Structured Concurrencyの目的

従来のExecutorServiceを使用した並行処理には以下の問題がありました。

問題 詳細
スレッドリーク サブタスクの失敗時に他のサブタスクがキャンセルされない
キャンセル伝播の欠如 親タスクの中断がサブタスクに伝播しない
可観測性の低下 タスク間の親子関係がスレッドダンプに現れない

Structured Concurrencyは、これらの問題を解決し、タスクの親子関係を明確にします。

StructuredTaskScopeの基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// プレビュー機能を有効にする必要がある
// java --enable-preview --source 21 Main.java

import java.util.concurrent.StructuredTaskScope;

Response handle() throws ExecutionException, InterruptedException {
    // ShutdownOnFailureは、いずれかのサブタスクが失敗したら全体をシャットダウン
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // サブタスクをフォーク(新しいVirtual Threadで実行)
        Supplier<String> user = scope.fork(() -> findUser());
        Supplier<Integer> order = scope.fork(() -> fetchOrder());
        
        // すべてのサブタスクの完了を待機
        scope.join();
        
        // エラーがあれば例外をスロー
        scope.throwIfFailed();
        
        // 両方成功した場合、結果を取得
        return new Response(user.get(), order.get());
    }
}

ShutdownOnFailureとShutdownOnSuccess

StructuredTaskScopeには、2つの組み込みポリシーがあります。

ポリシー 動作
ShutdownOnFailure いずれかのサブタスクが失敗したらシャットダウン
ShutdownOnSuccess いずれかのサブタスクが成功したらシャットダウン
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ShutdownOnSuccess: 最初に成功したサブタスクの結果を使用
<T> T race(List<Callable<T>> tasks, Instant deadline) 
        throws InterruptedException, ExecutionException, TimeoutException {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess<T>()) {
        for (var task : tasks) {
            scope.fork(task);
        }
        // デッドラインまで待機
        return scope.joinUntil(deadline).result();
    }
}

Structured Concurrencyのメリット

flowchart TB
    subgraph "Structured Concurrency"
        H[handle] --> S[StructuredTaskScope]
        S --> F1[findUser]
        S --> F2[fetchOrder]
        
        F1 -.->|成功/失敗| S
        F2 -.->|成功/失敗| S
        S -.->|結果集約| H
    end
    
    subgraph "メリット"
        M1[エラー時の自動キャンセル]
        M2[親タスク中断の伝播]
        M3[スレッドダンプでの可視化]
        M4[リソースリークの防止]
    end

Structured Concurrencyを使用すると、以下のメリットがあります。

  • サブタスクの失敗時に他のサブタスクを自動キャンセル
  • 親タスクの中断がサブタスクに自動伝播
  • スレッドダンプでタスクの階層構造を可視化
  • try-with-resourcesでリソースリークを防止

従来のスレッドプールからの移行

既存のコードをVirtual Threadsに移行する際のガイドラインを紹介します。

移行パターン

既存コード 移行後
Executors.newFixedThreadPool(n) Executors.newVirtualThreadPerTaskExecutor()
Executors.newCachedThreadPool() Executors.newVirtualThreadPerTaskExecutor()
スレッドプールでの同時実行制限 Semaphoreでの同時実行制限
ThreadLocalでのリソースキャッシュ 共有リソースの明示的な管理
synchronizedでのI/O保護 ReentrantLockでの保護

移行例:HTTPクライアント

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 移行前:固定スレッドプール
public class OldHttpClient {
    private final ExecutorService executor = Executors.newFixedThreadPool(100);
    
    public List<String> fetchAll(List<URL> urls) throws Exception {
        List<Future<String>> futures = new ArrayList<>();
        for (URL url : urls) {
            futures.add(executor.submit(() -> fetch(url)));
        }
        
        List<String> results = new ArrayList<>();
        for (Future<String> future : futures) {
            results.add(future.get());
        }
        return results;
    }
}

// 移行後:Virtual Threads
public class NewHttpClient {
    public List<String> fetchAll(List<URL> urls) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = urls.stream()
                .map(url -> executor.submit(() -> fetch(url)))
                .toList();
            
            List<String> results = new ArrayList<>();
            for (Future<String> future : futures) {
                results.add(future.get());
            }
            return results;
        }
    }
}

移行時の注意点

Virtual Threadsへの移行時に注意すべき点をまとめます。

  1. ThreadLocalの見直し:Virtual Threadsは大量に生成されるため、ThreadLocalでのリソースキャッシュは逆効果になる可能性があります。

  2. synchronizedブロックの確認:長時間のブロッキング操作を含むsynchronizedブロックは、ReentrantLockへの置き換えを検討してください。

  3. スレッド数による制限の撤廃:スレッドプールサイズによる同時実行制限は、Semaphoreに置き換えてください。

  4. デバッグとモニタリングjcmdの新しいスレッドダンプ形式(JSON)やJFRイベントを活用してください。

1
2
# JSONフォーマットでスレッドダンプを出力
jcmd <pid> Thread.dump_to_file -format=json thread-dump.json

まとめ

Virtual Threadsは、Javaの並行処理プログラミングを大きく変革する機能です。

ポイント 内容
本質 OSスレッドを直接占有しない軽量スレッド
メリット 同期的なコードのままスケーラビリティを向上
適用場面 I/O待ちが多い、同時タスク数が多いサーバーアプリケーション
注意点 プーリングしない、ピンニングを避ける、ThreadLocalの見直し
将来 Structured Concurrencyとの組み合わせでさらに強力に

Virtual Threadsを活用することで、シンプルで保守しやすいコードを維持しながら、高スループットなアプリケーションを構築できます。スレッド・パー・リクエスト方式のサーバーアプリケーションを開発している場合は、ぜひ導入を検討してください。

参考リンク