歴史: Java 8 より前は、並行した蓄積が AtomicLong に依存しており、単一のメモリ位置がスレッドコンテンションの下でスケーラビリティのボトルネックとなり、CPU コア間のキャッシュラインの無効化が過剰になっていました。LongAdder は、java.util.concurrent.atomic パッケージの一部としてこの問題に対処するために導入され、Striped64 アルゴリズムからインスパイアされた手法を利用して、複数のパッドセルに書き込み操作を動的に分散しています。
問題: 多数のスレッドが同時に共有の AtomicLong で CAS 操作を試みると、各失敗がキャッシュ整合性ブロードキャストを引き起こし、メモリトラフィックを直列化し、コア数の増加に伴ってスループットを指数関数的に低下させます。この現象はキャッシュラインのバウンスとして知られ、他のタスクでさえも線形スケーラビリティを妨げます。
解決策: LongAdder は、最初に単一の base フィールドを使用して CAS による更新を試みます。コンテンションを検出した場合、具体的にはスレッドが確率的なプロービングシーケンスの後にベースロックを取得できない場合(通常は Striped64 で実装された衝突カウンタおよびスレッドローカルハッシュによって)に、@Contended にアノテーションされた Cell オブジェクトの配列を遅延的に割り当てます。その後、各スレッドは異なるセルにハッシュし、孤立したキャッシュライン上で競合のない加算を実行し、sum() メソッドは一貫したスナップショットが必要なときのみこれらの値を遅延的に集約します。
ある高頻度取引プラットフォームは、64 コアの展開での注文スループットを検証するためのグローバルカウンターを必要としており、最初は AtomicLong を使用して実装されていました。市場の変動が激しいとき、システムは非線形のレイテンシ低下を示し、99 パーセンタイルの応答時間が 10 倍に増加し、プロファイリングの結果、CPU サイクルの 40% がカウンターの単一メモリアドレスを争うキャッシュ整合性プロトコルに浪費されていることがわかりました。
エンジニアリングチームは、3 つのアーキテクチャソリューションを検討しました。まず、各スレッドが独立した AtomicLong を ConcurrentHashMap に保持し、定期的にバックグラウンドレポーターによって集約される手動スレッドローカルカウンターマップを評価しました。この方法は競合を排除しましたが、スレッドごとに大きなメモリオーバーヘッドが発生し、スレッドプールのリサイズ時に複雑なライフサイクル管理が必要になり、長期間稼働するエグゼキュータにおいてメモリリークのリスクがありました。次に、スレッドプールが ID を再利用する際に不均一な分配を引き起こし、トラフィックの増加に伴う配列のリサイズを手動で処理する必要があったため、キャッシュトラフィックを削減するカスタムシャーディング戦略を使用した 64 の AtomicLong インスタンスの配列をプロトタイプしました。このアプローチは保守の負担を増大させました。第三に、LongAdder への移行を評価しました。この方法は、誤共有を防ぐための自動 @Contended パディングを備えた動的ストライピングを提供しましたが、読取り操作は正確な原子値ではなく、弱い整合性の近似値を返すというトレードオフがありました。
チームは最終的に LongAdder を選択しました。なぜなら、ビジネス要件は監視ダッシュボードのために少し古い読み取り値を許容した一方で、書き込みが重い検証パスは最大のスループットを必要としたからです。自動セル拡張ヒューリスティックは、トラフィックが少ない期間中にオブジェクトを軽量に保ち(単一のベースフィールド)、高いコンテンションが発生した際にはパッドされたセルに透明にスケールすることができました。デプロイ後のレイテンシは安定し、スループットはキャッシュ無効化トラフィックが単一のホットスポットに集中するのではなく、異なるメモリ領域に分散することで、64 コアまで線形にスケーリングしました。
質問: LongAdder.sum() をタイトなループで頻繁にポーリングすると、ストライピングのパフォーマンス利点がどのように失われる可能性があり、このメソッドはどのような整合性の保証を提供するのか?
回答: sum() メソッドは、合計を計算するために base フィールドと配列内のすべてのアクティブ Cell を走査する必要があり、すべての参加コア間でキャッシュ整合性同期を引き起こすメモリフェンスが必要です。その結果、連続的な読み取りが重いワークロードは、ストライプ化された書き込みを実質的に直列化し、LongAdder が回避するために設計された競合を再導入します。さらに、sum() は、同時更新に対する原子性の保証なしに、呼び出しの瞬間に正確な値を返すため、結果は一時的な状態を表している可能性があり、いくつかのスレッドの増分が可視である一方、他のスレッドのものはそうでないかもしれません。
質問: LongAdder の内部の Cell クラス内での @Contended 注釈は、どのように誤共有を防ぎ、このパディング動作を制御するJVMフラグは何か?
回答: @Contended は、HotSpot コンパイラに対して、各 Cell 内の value フィールドの周りに 128 バイト(または -XX:ContendedPaddingWidth で指定された値)のパディングを挿入するよう指示し、隣接する配列要素がオブジェクトのレイアウト最適化に関係なく異なるキャッシュラインに配置されることを保証します。このパディングがなければ、連続したセルが 64 バイトのキャッシュラインを共有し、1 つのセルへの書き込みが他のコアの近隣のキャッシュコピーを無効にし、キャッシュバウンスを再導入することになります。候補者は、この注釈が JDK 内部クラスのために予約されていることを見落としがちであり、ユーザーコードの利用を許可するためには -XX:-RestrictContended を明示的に無効にする必要があります。
質問: どのような特定の状況下で LongAdder は AtomicLong よりもパフォーマンスが劣るか、また longValue() の実装がどのようにこの危険に影響するのか?
回答: LongAdder は、その Cell 配列およびハッシュ計算ロジックの割り当てオーバーヘッドが発生し、競合の少ないシングルスレッドの実行中は AtomicLong が優れています。さらに、longValue() は sum() に直接委任されているため、カウンターの値を継続的にチェックするコードパス—スピンロックやバックプレッシャーアルゴリズムなど—は、すべてのキャッシュラインを同期させるグローバル集計を強制し、ストライプ構造を競合が発生するシングルトンに変えてスケーラビリティを破壊します。