原子変数クラスにおける競合の課題
Java の concurrent パッケージ提供する AtomicLong や AtomicInteger は、ロックフリーでスレッドセーフな操作を保証しますが、高競合状態下では性能が低下する傾向があります。これは、内部で CAS (Compare-And-Swap) 命令をループさせて更新を試みる仕組みに起因します。
複数のスレッドが同時に同一のメモリ位置を更新しようとする場合、CAS 操作の失敗率が上昇し、再試行が頻繁に発生します。さらに、マルチコア環境では、あるコアが値を更新すると、他のコアが持つキャッシュラインが無効化され、メインメモリからの再読み込み(キャッシュコヒーレンシの維持)が必要になります。このキャッシュラインの無効化と同期コストが、スループット低下の主要な要因となります。
以下のコードは、複数のスレッドから AtomicLong に対してインクリメント操作を行うベンチマークの例です。
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class AtomicContentionTest {
public static void main(String[] args) throws Exception {
AtomicLong counter = new AtomicLong(0L);
int threadCount = 32;
int tasksPerThread = 500;
CountDownLatch latch = new CountDownLatch(threadCount);
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
for (int j = 0; j < tasksPerThread; j++) {
counter.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}
latch.await();
executor.shutdown();
System.out.println("Total Count: " + counter.get());
}
}
この実装では、32 スレッドがそれぞれ 500 回ずつカウントを増加させます。結果は正確に 16,000 となりますが、競合が激しい環境では CAS の失敗によるバスケットウィービング(busy-waiting)やキャッシュメモリのフラッシュ処理により、処理時間が延長する可能性があります。
LongAdder によるアーキテクチャの改善
JDK 8 から導入された LongAdder は、このような高競合下のカウンター用途において、AtomicLong よりも高いスループットを発揮するように設計されています。その核心は、単一の共有変数へのアクセスを分散させる「セグメント化」のアイデアにあります。
LongAdder の内部では、base という変数と、Cell[] という配列が用意されています。競合が低い場合は base 値直接更新を試みますが、競合が検知されると、スレッドごとに異なる Cell オブジェクトに対して加算を行います。各 Cell は独立したメモリ領域を持つため、キャッシュラインの競合を回避できます。
最終的な合計値が必要な場合には、base とすべての Cell の値を合算します。この合算処理はロックフリーで行われるため、厳密な瞬間的な値ではなく、概算値となる可能性がありますが、統計用途としては十分です。
同じ処理を LongAdder で実装した場合は以下のようになります。
import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;
public class StripedCounterTest {
public static void main(String[] args) throws Exception {
LongAdder adder = new LongAdder();
int threadCount = 32;
int tasksPerThread = 500;
CountDownLatch latch = new CountDownLatch(threadCount);
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
for (int j = 0; j < tasksPerThread; j++) {
adder.increment();
}
} finally {
latch.countDown();
}
});
}
latch.await();
executor.shutdown();
System.out.println("Total Sum: " + adder.sum());
}
}
この実装では、increment() メソッドが呼び出されるたびに、内部ハッシュ値に基づいて適切な Cell が選択され、更新が行われます。これにより、複数のスレッドが同時に異なるメモリ位置を書き込むことが可能になり、キャッシュコヒーレンシのプロトコルによるオーバーヘッドが大幅に削減されます。
適用場景の選定基準
AtomicLong と LongAdder の使い分けは、必要な操作の種類と競合の程度によって決定されます。競合が低い環境であれば、両者の性能差は顕著ではありません。AtomicLong は単一の値に対する原子性を保証し、compareAndSet や getAndAccumulate といった複雑な CAS 操作をサポートしています。
一方、LongAdder は加算・減算といった単純な累積操作に特化しており、内部状態を直接参照したり、特定の値との CAS 比較を行ったりすることはできません。メモリ使用量については、LongAdder が複数の Cell を保持するため、AtomicLong よりも大きくなります。
したがって、純粋なカウンターや統計値の収集など、高い書き込み競合が予想される場面では LongAdder の採用が推奨されます。逆に、値の状態に基づいて条件付きで更新を行うような複雑な同期制御が必要な場合は、機能豊富な AtomicLong を選択すべきです。