CompletableFutureのget()メソッドに潜む性能問題について

Dubboのソースコードを調査していた際、興味深い実装箇所を見つけました。

org.apache.dubbo.rpc.protocol.AbstractInvoker#waitForResultIfSync

このメソッド内では、CompletableFutureのget(long, TimeUnit)が呼び出されており、タイムアウト値としてInteger.MAX_VALUEが指定されています。一見すると、単純なget()メソッドを使用するのと同等に見えます。

ではなぜ、無駄に複雑なget(long, TimeUnit)を採用しているのでしょうか。メソッドのコメントに明確な理由が記載されていました。

「CompletableFuture.get()ではなくCompletableFuture.get(long, TimeUnit)を呼び出す必要があります。get()メソッドは深刻な性能低下を引き起こすことが証明されているためです」

Dubboのようなミドルウェアフレームワークは、多様なJDKバージョン上で動作する可能性があります。特定のJDKバージョンにおいて、この最適化は性能向上に大きく寄与します。

性能問題の正体

この問題を調査するため、OpenJDKのバグトラッキングシステムを確認しました。JDK-8227019として登録されている「CompletableFutureの性能改善」というバグレポートが見つかりました。

問題の核心は、CompletableFuture.waitingGetメソッド内のループ処理にあります。

JDK 1.8.0_202以前のバージョンでは、以下のようなコードが存在しました:

while (result == null) {
    if (spins < 0) {
        spins = (Runtime.getRuntime().availableProcessors() > 1) ?
                1 << 8 : 0;
    }
    // ... 自旋等待ロジック
}

このコードは、マルチプロセッサ環境での「短い自旋待機」を実現するためのものです。しかし、Runtime.getRuntime().availableProcessors()がループ内で繰り返し呼び出される問題がありました。

availableProcessorsの呼び出しコスト

JDK-8227006のバグレポートによると、Linux環境においてRuntime.availableProcessors()の実行時間が約100倍に増加する問題が報告されています。

JDK 1.8b191より前のバージョンでは、このメソッドは毎秒400万回以上呼び出し可能でした。しかし、JDK 1.8b191以降では毎秒約4万回程度にまで性能が低下しました。

この性能劣化の原因は、Dockerコンテナ検出とリソース設定の改善に伴うOSContainer::is_containerized()メソッドの呼び出しコストにあります。

以下のベンチマークコードで検証可能です:

public class ProcessorBenchmark {
    public static void main(String[] args) {
        AtomicBoolean shouldStop = new AtomicBoolean(false);
        AtomicInteger counter = new AtomicInteger(0);

        Thread worker = new Thread(() -> {
            while (!shouldStop.get()) {
                Runtime.getRuntime().availableProcessors();
                counter.incrementAndGet();
            }
        });
        worker.start();

        int previousCount = 0;
        for (int i = 0; i < 10; i++) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                break;
            }
            int currentCount = counter.get();
            System.out.printf("Calls per second: %d%n", currentCount - previousCount);
            previousCount = currentCount;
        }
        shouldStop.set(true);
    }
}

修正内容

この問題に対する修正は、availableProcessors()の戻り値をstatic finalフィールドにキャッシュすることでした:

private static final int SPINS = (Runtime.getRuntime().availableProcessors() > 1) ?
                                  1 << 8 : 0;

この修正により、メソッド呼び出しが1回のみとなり、ループ内での高コストな呼び出しが回避されます。

なお、JDK 9以降では、この自旋待機ロジック自体が完全に削除されました。Doug Lea氏の判断により、短い自旋待機のメリットが限定的であると判断されたためです。

timedGetメソッドとの違い

タイムアウト付きのget(long, TimeUnit)メソッドがこの問題の影響を受けない理由は、内部でtimedGetメソッドが呼び出されるためです。

timedGetメソッドのコメントには以下のように記載されています:

「ここでは意図的に自旋待機を行わない。nanoTime()の呼び出しが既に自旋待機に類似しているため」

このメソッド内ではRuntime.availableProcessors()が呼び出されないため、該当の性能問題が発生しません。

実践への応用

アプリケーションコードにおいても、特に高スループットが要求される環境でJDK 8u191以降を使用する場合、以下の点に留意すべきです:

  1. CompletableFuture.get()の代わりに、適切なタイムアウト値を指定したget(long, TimeUnit)を使用する
  2. Runtime.availableProcessors()をホットパスで頻繁に呼び出さないよう、必要に応じてキャッシュを検討する

Dubboの実装は、この微妙な性能特性を考慮した堅牢な設計と言えます。

タグ: Java CompletableFuture Dubbo JDK Performance

5月23日 06:47 投稿