Java 並行処理における LongAdder の内部構造と性能優位性

高並下におけるカウント処理の課題

JDK 1.8 において導入された LongAdder クラスは、マルチスレッド環境下でのカウンター処理を最適化するために設計された原子操作クラスです。従来の AtomicLong は CAS(Compare-And-Swap)命令を用いて非阻塞な原子性を実現しており、ロックベースの同期機構と比較して高性能ですが、極端に競合が激しいシーンでは性能低下が課題となります。

この最適化手法は ConcurrentHashMap の要素数カウント機能にも採用されており、その内部仕組みを理解することは並行プログラミングの知識を深める上で重要です。

動作原理の概要

LongAdder の核心は、単一の変数へのアクセス競合を分散させることにあります。内部では主に以下の 2 つの要素で構成されています。

  • base フィールド:競合がない場合に使用される基本値
  • Cell[] 配列:競合が発生した場合に値を分散して保持する配列

スレッド競合が発生しない場合は、すべての加算操作が base フィールドに対して CAS 操作で行われます。しかし、競合が検知されると、システムは Cell 配列を初期化し、各スレッドがハッシュ値に基づいて特定の配列要素(Cell)に対して加算を行うように切り替わります。これにより、ロック競争を分散させ、スループットを向上させます。

実装検証コード

以下のコードは、複数スレッドによる同時加算処理において、LongAdder がどのように线程安全性を確保するかを検証する例です。

public class ConcurrencyTest {
    private static final LongAdder stats = new LongAdder();
    private static volatile int sharedCounter = 0;

    public static void main(String[] args) throws Exception {
        final int workerCount = 20;
        final int iterations = 50000;
        Thread[] workers = new Thread[workerCount];

        for (int i = 0; i < workerCount; i++) {
            workers[i] = new Thread(() -> {
                for (int j = 0; j < iterations; j++) {
                    stats.increment();
                    sharedCounter++;
                }
            });
            workers[i].start();
        }

        for (Thread worker : workers) {
            worker.join();
        }

        System.out.println("LongAdder Result: " + stats.sum());
        System.out.println("Volatile Counter: " + sharedCounter);
    }
}

実行結果では、LongAdder の合計値が期待通り正確に計算される一方、volatile 変数を用た単純なインクリメントでは値が欠落することが確認できます。これは LongAdder が内部で適切な同期制御を行っていることを示しています。

内部ロジックの詳細分析

加算処理の中心となる add メソッドは、まず Cell 配列の存在を確認し、存在しない場合は base フィールドへの CAS 操作を試みます。ここで競合により CAS が失敗した場合、または配列が既に存在する場合は、longAccumulate メソッドへ処理が委譲されます。

public void add(long delta) {
    Cell[] array; long baseVal, cellVal; int mask; Cell cell;
    // 配列が存在するか、base への CAS が失敗した場合
    if ((array = cells) != null || !casBase(baseVal = base, baseVal + delta)) {
        boolean uncontended = true;
        // 配列未初期化、または該当インデックスが空、または CAS 失敗の場合
        if (array == null || (mask = array.length - 1) < 0 ||
                (cell = array[getProbe() & mask]) == null ||
                !(uncontended = cell.cas(cellVal = cell.value, cellVal + delta))) {
            longAccumulate(delta, null, uncontended);
        }
    }
}

longAccumulate メソッド内では、スレッドローカルなハッシュ値を用いて配列インデックスを決定します。もし該当するセルが存在しない場合は、ロック(cellsBusy)を取得して新しい Cell オブジェクトを生成・登録します。競合が激しい場合は配列の容量を拡張し、さらに分散度を高める処理が行われます。

AtomicInteger との比較

AtomicIntegergetAndIncrement などは、内部で CAS と自旋(spin)を組み合わせています。競合が少ない環境では高速ですが、多数のスレッドが同時に更新を試みると、CAS 失敗による自旋処理が頻発し、CPU リソースを浪費します。

一方、LongAdder は値を複数のセルに分散させることで、特定の変数へのアクセス集中を避けます。これにより、高並下における自旋回数を大幅に削減し、全体のスループットを向上させます。ただし、複数のセルを保持するためのメモリ消費が増加するというトレードオフがあります。

設計思想の抽象化

この実装から読み取れる設計パターンは、以下の要点に集約されます。

  1. 競合の分散:単一リソースへの集中アクセスを避け、複数のスロットへ処理を分散させる(セグメンテーション鎖の思想)。
  2. 動的拡張:競合状況に応じてデータ構造の容量を動的に増加させ、リソース消費と性能のバランスを取る。
  3. 階層化戦略:低競合時は単一変数、中競合時は小配列、高競合時は大配列というように、状況に応じた最適な構造へ遷移する。
  4. 再ハッシュによる最適化:競合発生時にハッシュ値を変更し、スレッドが独占可能なスロットを早期に発見できるようにする。

これらの工夫により、LongAdder は高並発環境において AtomicLong を凌駕する性能を発揮します。例えば、100 スレッドで 100 万回の加算を行うベンチマークでは、競合状況によっては 10 倍以上の性能差が観測されることもあります。

タグ: Java concurrency LongAdder AtomicInteger ConcurrentHashMap

6月14日 18:48 投稿