AtomicLong 与 LongAdder:并发计数器选型
本文从 LongAdder 的设计动机出发,结合 Striped64 源码与多平台 JMH 实测数据,分析两者在不同核数、不同竞争强度下的真实性能差异,并给出选型决策原则。
相关笔记:Java 并发编程 · Java 业务开发陷阱 · Java 虚拟机
目录
| 章节 | 说明 |
|---|---|
| 设计背景 | AtomicLong 的瓶颈与 LongAdder 的诞生 |
| 核心原理 | Striped64、Cell 数组、add() 路径 |
| 源码分析 | add() 的四条执行路径 |
| 性能实测 | macOS 12核 vs Linux 2核 JMH 数据 |
| 反直觉结论分析 | 为什么 2 核机器上 AtomicLong 更快 |
| 选型决策 | 一张表做决定 |
设计背景
AtomicLong 基于 CAS(LOCK CMPXCHG 或 ARM64 LDADD)实现原子递增。在单线程或低竞争场景下表现极佳,但在高并发多核竞争下存在结构性瓶颈:
8 个线程同时对同一 AtomicLong 做 CAS:
- 一次只有 1 个线程能成功
- 其余 7 个线程全部失败,自旋重试
- 越多核心同时竞争,失败率越高,CPU 越浪费
LongAdder(JDK 8,Doug Lea 设计)的核心思路:用空间换竞争。
与其让所有线程争一个变量,不如让每个线程写自己的"格子",读的时候再把所有格子加起来。
核心原理
LongAdder 继承自 Striped64,内部维护两个字段:
// Striped64 的核心字段
volatile long base; // 无竞争时的累加器(与 AtomicLong 等价)
volatile Cell[] cells; // 有竞争后动态扩展的 Cell 数组
// Cell:每个 Cell 是一个对齐到 Cache Line 的独立 long
@sun.misc.Contended // 防止伪共享(每个 Cell 独占 64 字节缓存行)
static final class Cell {
volatile long value;
boolean cas(long cmp, long val) {
return VALUE.weakCompareAndSetRelease(this, cmp, val);
}
}
@Contended 确保每个 Cell 独占一条 Cache Line,避免不同线程写不同 Cell 时因共享缓存行而产生伪共享(false sharing)。
整体逻辑:
flowchart TD
A["longAdder.increment()"] --> B{cells 已初始化?}
B -->|否| C{CAS base 成功?}
C -->|是| Z["返回(快路径,≈ AtomicLong)"]
C -->|否| D["longAccumulate()<br/>初始化 cells 或扩容"]
B -->|是| E{CAS 对应 Cell 成功?}
E -->|是| Z
E -->|否| F["longAccumulate()<br/>rehash 换格子或扩容"]
style Z fill:#cfc,stroke:#060
style D fill:#fcc,stroke:#c00
style F fill:#ff9,stroke:#960
关键设计:Cell 数组大小上限 = NCPU(物理 CPU 核数)。2 核机器上 cells.length 最大为 2。
源码分析
// java.util.concurrent.atomic.LongAdder#add(JDK 21)
public void add(long x) {
Cell[] cs; long b, v; int m; Cell c;
if ((cs = cells) != null // 路径 1:cells 已存在,跳过 base CAS
|| !casBase(b = base, b + x)) { // 路径 2:CAS base 失败(竞争发生)
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 // cells 为空
|| (c = cs[getProbe() & m]) == null // 对应槽位为 null
|| !(uncontended = c.cas(v = c.value, v + x))) // Cell CAS 失败
longAccumulate(x, null, uncontended); // 路径 3/4:初始化或扩容
}
}
四条路径的代价对比:
| 路径 | 触发条件 | 指令数 | 相对代价 |
|---|---|---|---|
| base CAS 成功(无竞争) | cells=null 且 CAS base 成功 | ~3 | 与 AtomicLong 相当 |
| Cell CAS 成功 | cells 已初始化,对应 Cell CAS 成功 | ~8 | 稍高于 AtomicLong |
| longAccumulate(初始化) | 首次竞争时初始化 cells | 高 | 一次性开销 |
| longAccumulate(rehash/扩容) | Cell 竞争时换槽位或扩容 | 高 | 低频触发 |
注意:一旦竞争发生(cells != null),即便后续竞争消失,每次 add() 都走路径二(先检查 cells),比 AtomicLong 多 ~5 条指令的固定开销。
性能实测
测试设备:macOS Apple M3 Pro(12核 ARM64)、Linux 腾讯云 CVM(2核 Intel x86_64)
JMH 配置:@GroupThreads(8),Warmup 3×1s,Measurement 5×1s,Fork 1,附 -prof gc
| macOS(12核) | Linux(2核) | |
|---|---|---|
| AtomicLong | 39.658 ops/us | 152.975 ops/us |
| LongAdder | 1577.083 ops/us | 137.806 ops/us |
| LongAdder/AtomicLong | +3879%(快 39.7 倍) | −10%(慢 1.1 倍) |
| GC 分配 | 0 B/op(两者相同) | 0 B/op(两者相同) |
反直觉结论分析
为什么 macOS 12 核上 LongAdder 快 39.7 倍?
12 个物理核心可以同时运行 8 个线程,每个线程写自己的 Cell,物理上真正并行,CAS 失败率趋近 0。
macOS 上 8 线程的理想状态:
Thread-0 → Cell[0] ← 几乎不竞争
Thread-1 → Cell[1] ← 几乎不竞争
...(每线程独占一条 Cache Line)
为什么 Linux 2 核上 AtomicLong 反而更快?
关键认知:2 核机器上,"高并发"是假象。
Linux 2核 + 8线程的实际状态:
任意时刻只有 2 个线程在物理 CPU 上运行
OS 调度器把 8 个线程轮转到 2 个核上
在物理只有 2 个并发线程的情况下:
-
Cell 数组的分散优势消失:
cells.length ≤ NCPU = 2,最多 2 个槽位,8 个线程轮流竞争同样 2 个 Cell,实际 CAS 冲突率与 AtomicLong 持平 -
固定指令开销变成负担:每次
add()需要检查cells != null、计算getProbe() & m、做数组寻址,约 8 条指令。而AtomicLong.incrementAndGet()在 x86 上就是一条LOCK XADD。在没有竞争优势可以摊薄的情况下,多出的 ~5 条指令直接体现在吞吐量差距上 -
量化验证:
| Linux(2核,8线程) | |
|---|---|
| AtomicLong 每线程每 op | ~52 ns(1条 LOCK XADD + 调度开销) |
| LongAdder 每线程每 op | ~58 ns(8条指令 + 1条 CAS + 调度开销) |
| 差距来源 | 纯指令数差异,与竞争无关 |
选型决策
| 维度 | AtomicLong | LongAdder |
|---|---|---|
| 低竞争 / 单线程 | ✅ 推荐(简单直接) | ⚠️ 可用,开销略高 |
| 高并发 + 核数 ≥ 8 | ⚠️ CAS 竞争激烈 | ✅ 首选(Cell 分散,吞吐高数十倍) |
| 高并发 + 核数 ≤ 4 | ✅ 通常更优 | ⚠️ Cell 优势不显著,指令开销拖累性能 |
| 需要精确原子快照 | ✅(get() 是精确值) | ❌(sum() 非原子快照) |
| 需要 CAS 条件更新 | ✅(compareAndSet()) | ❌(不支持) |
| 统计计数器(QPS、访问量) | ⚠️ 可用 | ✅ 首选(不需要精确快照) |
一句话决策原则:
- 服务器 8 核以上 + 多线程计数 →
LongAdder- 需要读精确值 / 核数少 / 低并发 →
AtomicLong
参考资料
评论 (0)