CompletableFutureがJava 8で登場したとき、その設計者はデフォルトの非同期操作を**ForkJoinPool.commonPool()**にバインドすることでゼロ構成の並列性を最適化しました。このシングルトンエグゼキュータは、Runtime.getRuntime().availableProcessors() - 1にサイズを設定しますが、これはCPU集約型で短命のタスク向けに調整された計算です。
低下は、開発者が供給Async()やthenApplyAsync()を通じてブロッキングI/O作業(HTTPリクエストなど)を実行する際に現れます。カスタムExecutorを指定しない場合、共通プールがJVM全体で共有されるため、その限られたスレッドをブロックすると、系統的な飢餓が生じます。すべてのスレッドがネットワークソケットで待機している場合、CPU集約型タスク(Streamの並列パイプラインを含む)は実行できず、アプリケーションのスループットが実質的に凍結されます。
解決策は明示的なエグゼキュータの分離を必要とします。プロダクションコードは、カスタムエグゼキュータ引数を受け入れるオーバーロードを通じて、専用のExecutorService(理想的にはI/O用の仮想スレッドまたはキャッシュされたスレッドプールに基づく)を提供しなければなりません。このアーキテクチャの境界により、ブロッキング待機は隔離された名前空間からリソースを消費し、共通プールを計算作業のために妨げないようになります。
// 危険: 暗黙的にForkJoinPool.commonPool()を使用 CompletableFuture<String> risky = CompletableFuture.supplyAsync(() -> { // 共通プールスレッドをブロック! return httpClient.send(request, BodyHandlers.ofString()).body(); }); // 安全: ブロッキングI/O用の孤立したエグゼキュータ try (ExecutorService ioExecutor = Executors.newVirtualThreadPerTaskExecutor()) { CompletableFuture<String> safe = CompletableFuture.supplyAsync( () -> httpClient.send(request, BodyHandlers.ofString()).body(), ioExecutor ); }
マーケットデータを非同期に外部REST APIからの信用評価を取得して拡張する高頻度取引分析プラットフォームを考えてみてください。元の実装では、デフォルトの共通プールに依存してCompletableFuture.supplyAsync(() -> fetchRating(ticker))が数千のティッカーでチェーンされていました。市場の変動中に、HTTPタイムアウトで全ての15の共通スレッドがブロックされるため、レイテンシが劇的に急増し、アプリケーション全体の並列データパイプラインが凍結し、取引が逃されました。
考慮された解決策: 共通プールの並列性を拡張
開発者は最初に、ブロッキング待機を考慮して-Djava.util.concurrent.ForkJoinPool.common.parallelism=200を設定することを提案しました。利点は、コードの変更なしに即座に救済されることでした。しかし、このアプローチは正当な計算作業のためのCPUキャッシュを激しく撹乱し、過剰なアイドルスレッドを維持するためのメモリを無駄にしました。このため、CPUとI/Oリソースプロファイルを単一のプール内で混合しているため、根本的に持続不可能です。
考慮された解決策: get()による同期ブロッキング
別の選択肢は、各Futureの作成後すぐに.get()を呼び出し、操作を同期的にすることでした。これにより、共通プールの飢餓問題が解消されましたが、非同期の利点はすべて無効化されました。コードは逐次実行に劣化し、サーバーリソースの利用が低下し、ピークロード時にエンドツーエンドの処理時間が桁違いに増加しました。
考慮された解決策: I/O専用のエラスティックエグゼキュータ
採用された戦略は、プロセッサカウントとは独立してサイズ設定された仮想スレッド(または事前Loom Javaバージョンのキャッシュスレッドプール)を使用した別のExecutorServiceを導入しました。各非同期ステージはthenApplyAsync(transform, ioExecutor)を通じて明示的にこのエグゼキュータを参照しました。利点は、I/Oレイテンシを計算スループットから完全に隔離し、詳細な可観測性を提供します。唯一の欠点はエグゼキュータのライフサイクルとシャットダウンフックを管理するための控えめなボイラープレートです。
選択された解決策と結果
チームは、Java 21のExecutors.newVirtualThreadPerTaskExecutor()を使用して専用のエグゼキュータアプローチを実装しました。これにより、ブロッキングHTTPレイテンシがCPU集約型分析から直ちに切り離されました。システムのスループットはストレステスト中に1秒あたり50,000リクエストで安定しましたが、共通プールのバリアントは1,000を下回りました。レイテンシのパーセンタイルも95%低下し、エグゼキュータの分離の重要性を示した。
なぜForkJoinPoolのサイズがavailableProcessors() - 1にデフォルト設定されているのか、それとも物理コア数と一致していないのか?
この減算により、ガーベジコレクターとシステムスレッドに対して1つの物理コアが専用に確保され、GCの一時停止が計算タスクと競合するのを防ぎます。候補者は、より多くのスレッドが常にパフォーマンスを向上させると誤解することが多いですが、この特定の計算はCPUキャッシュの常駐を最適化し、コンテキストスイッチを最小化します。このカウントを超えると、CPU集約型作業のスループットがキャッシュの撹乱やスケジューラの競合により実際に低下します。
カスタムForkJoinPoolの内部でCompletableFutureを作成すると、なぜそのカスタムプールを使用せず共通プールを使用するのか?
CompletableFutureはオブジェクト構築時にデフォルトエグゼキュータの参照を共通プールシングルトンとして明示的にハードコーディングしており、現在のスレッドの実行コンテキストを検査しません。これにより、非同期の変換は、明示的にエグゼキュータ引数を渡さない限り、常に共通プールに戻ります。開発者はスレッドのローカリティが保持されていると思い込み、見えないクロスプールの競合やキャッシュラインのバウンシングが並列パフォーマンスを破壊します。
なぜCompletableFuture内のブロッキング操作がJava 21の仮想スレッドを使用しているときでもキャリアスレッドを予期せずピン止めするのか?
仮想スレッドで実行する際、ブロッキング操作は通常、仮想スレッドをそのキャリアからアンマウントします。しかし、ブロッキングコードにsynchronizedブロックまたはネイティブメソッド(JNI)が含まれている場合、それは基盤となるプラットフォームキャリアスレッドを仮想スレッドにピン止めします。もしForkJoinPoolがこれらのキャリアを供給し、すべてがピン止めされると、プールはLoom以前の時代と同じように飢餓に陥ります。候補者は、ピン止めを防ぎ、の不適切なキャリア枯渇を防ぐためにsynchronizedキーワードをReentrantLockで置き換える必要があることを見落としています。