Java 21 仮想スレッド:リアクティブの複雑さを排除したスケーラブルな並行処理

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

ついに進化したJavaの並行処理

長年、Java開発者はフラストレーションの溜まるトレードオフに直面してきました。重い負荷で処理が滞ってしまうシンプルなブロッキングコードを書くか、あるいはI/O処理のためだけに第二言語を学ぶような複雑なリアクティブプログラミングを採用するかです。Java 21は仮想スレッド(Virtual Threads)によってこの行き止まりを打破しました。Project Loomの一部であるこの機能により、従来のOSレベルのスレッドのような重いメモリ負担なしに、数百万の並行タスクへとスケールさせることが可能になります。

5分でできる移行

使い始めるのにパラダイムシフトは必要ありません。Runnableを書けるなら、すでに仮想スレッドの使い方は知っているも同然です。APIは慣れ親しんだままですが、その基盤となるエンジンが作り替えられています。

最初の仮想スレッドを生成する

Thread.ofVirtual() ビルダーは、最も直接的なエントリーポイントです。これは、バックグラウンドで重い処理をこなす軽量なスレッドを作成します:

Thread vThread = Thread.ofVirtual().start(() -> {
    System.out.println("実行中: " + Thread.currentThread());
});

// タスクが完了するまでブロックする
vThread.join();

タスクごとのエグゼキューター

本番環境では、スレッドを手動で管理することはおそらくないでしょう。代わりに、Java 21は、送信するすべてのタスクに対して新しい仮想スレッドを作成する専用の ExecutorService を提供しています:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
} // 自動的にクローズし、10万件すべてのタスクを待機する

標準的なノートPCで10万件のプラットフォームスレッドを実行しようとすると、おそらく OutOfMemoryError が発生するでしょう。プラットフォームスレッドはデフォルトで各スレッド約1MBのスタック領域を確保します。スタックだけで100GBのRAMが必要です. 一方、仮想スレッドはヒープ上の数百バイトから開始されます。そのため、このような負荷も難なく処理できます。

JVMはどのように実現しているのか

仮想スレッドと**プラットフォームスレッド**の関係を理解することが、これらを正しく使いこなす鍵となります。

標準的なJavaスレッドは、オペレーティングシステム(OS)スレッドの薄いラッパーです。これらは高価なリソースです。作成が遅く、メモリを大量に消費するため、スレッドプールを使用してスレッドを存続させ、再利用します。これは根本的な制限に対する回避策でした。

仮想スレッドは、管理をOSからJVMへと移します。ランタイムは、コードを実行するために「キャリア」プラットフォームスレッドのプールに仮想スレッドを「マウント」します。コード内でデータベースクエリやREST APIリクエストのようなブロッキングコールが発生すると、JVMは仮想スレッドを「アンマウント」してヒープに退避(パーク)させます。これにより、キャリアスレッドが解放され、即座に別のタスクを引き受けることができます。これは、コールバック地獄なしで実現する高効率なスケジューリングです。

パフォーマンスの向上は顕著です。外部APIを呼び出すSpring Bootマイクロサービスの最近の移行例では、200スレッドの固定プールを仮想エグゼキューターに置き換えました。その結果、メモリ使用量は65%減少し、CPU使用率を上げることなく、ピーク時のスループットを3倍維持できました。

高度なパターン:構造化された並行性

「無限の」スレッドが手に入ると、それらを見失う危険性が生じます。Java 21では、関連するタスクをグループ化するために(プレビュー版として)構造化された並行性(Structured Concurrency)が導入されました。親タスクがキャンセルされると、すべてのサブタスクが自動的にクリーンアップされます。

安全なタスクのフォーク

2つの独立したサービスからデータを取得することを考えてみましょう。最初のサービスが失敗した場合、2番目のサービスを待つ意味はありません:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> userTask = scope.fork(() -> fetchUser(id));
    Subtask<String> orderTask = scope.fork(() -> fetchOrders(id));

    scope.join();           // 結果を待機する
    scope.throwIfFailed();  // エラーが発生した場合は早期終了する

    return new Response(userTask.get(), orderTask.get());
}

このアプローチにより、非同期ロジックが逐次実行コードのように見えます。読みやすく、テストしやすく、スレッドリークが発生する可能性が大幅に低くなります。

本番環境への導入ノート

仮想スレッドは強力ですが、万能の解決策ではありません。本番環境の安定性を保つために、以下の一般的な落とし穴を避けてください。

1. スレッドをプールするのをやめる

スレッドをプールしたいという本能を抑えるのは難しいものです。しかし、仮想スレッドをプールすることは、実際にはアンチパターンです。仮想スレッドは、安価で使い捨てのオブジェクトとして設計されています。必要に応じて作成し、クリーンアップはガベージコレクタ(GC)に任せましょう。プールしてしまうと、単に不要なオーバーヘッドを追加するだけになります。

2. ピン留め(Pinning)問題

仮想スレッドがキャリアスレッドに「ピン留め」されてしまうことがあります。これは、synchronized ブロックを使用したり、ネイティブメソッド(JNI)を呼び出したりした場合に発生します。ピン留めされると、キャリアスレッドは他のタスクに切り替えることができなくなり、せっかくの高性能エンジンが再びボトルネックに変わってしまいます。

解決策: 頻繁に実行されるパス(ホットパス)では、synchronizedReentrantLock に置き換えてください。これにより、ロックを待機している間でもJVMがスレッドをアンマウントできるようになります。

3. 下流リソースを保護する

標準的なスレッドプールは、データベースや外部APIに対する自然なブレーキとして機能していました。仮想スレッドを使用すると、50接続しかサポートしていないデータベースに対して簡単に5,000件の並行クエリを送信できてしまいます。外部リソースへのアクセスを明示的に制限してください。

private static final Semaphore DB_LIMITER = new Semaphore(50);

public void queryDatabase() {
    try {
        DB_LIMITER.acquire();
        // ここでクエリを実行する
    } finally {
        DB_LIMITER.release();
    }
}

4. ThreadLocalの扱いに注意する

100万スレッドまでスケールするということは、潜在的に100万個の ThreadLocal コピーが作成されることを意味します。それぞれが大きなオブジェクトを保持している場合、ヒープはあっという間に消え去ります。現代のJavaアプリでは、**Scoped Values** を優先してください。これらはスレッド境界を越えてコンテキストを共有するための、よりメモリ効率の良い方法を提供します。

仮想スレッドは、ここ20年でJavaの並行処理モデルにおける最も重要な変化です。ブロッキングの高コストを排除することで、現代のクラウドの需要に合わせてスケールするクリーンなコードを書くことができます。まずは、I/Oバウンドなサービスを特定することから始めましょう。それらはこのアップグレードに最適な候補です。

Share: