LongAdder vs AtomicLong:为什么分槽比单槽快?
一句话结论(30s)
LongAdder 的本质是「分槽」——用一个 Cell 数组把 N 个线程对单一 value 的 CAS 竞争,分散成每个 Cell 上 N/M 个线程的竞争,从而把 CAS 失败率从 (N-1)/N 降到 (N/M-1)/(N/M)。它的关键设计是两步:Cell 数组分槽降竞争 + @Contended 填充 128 字节消除伪共享,因为相邻 Cell 若落在同一个 64 字节 Cache Line 上会互相触发 MESI 失效、让分槽名存实亡。核心权衡是用 sum() 的非原子一致性(汇总时可能有线程正在改 Cell)换高并发写入性能,所以需要精确快照时仍要用 AtomicLong。
核心原理(2min)
主流程:竞争不激烈时线程直接 CAS base,竞争激烈时才按线程哈希路由到不同 Cell、各 CAS 自己的 Cell.value,读的时候 sum() 汇总 base + 所有 Cell.value。这里不妨先问自己一句:LongAdder 为什么比 AtomicLong 快?答案就藏在这前半句里——它把「N 个线程抢一个槽」拆成「N/M 个线程各抢一个槽」,单槽的 CAS 竞争骤降。关键机制是伪共享的消除:相邻 Cell 若共享同一 Cache Line,线程 A 写 Cell[0] 会让线程 B 所在 CPU 的 Cell[1] 所在 Cache Line 失效、被迫重新读内存,@Contended 在 Cell 前后各填 128 字节保证绝不共享 Cache Line,代价是每个 Cell 实际占约 256 字节。选择标准:线程 <4 用 AtomicLong(简单直接),>8 用 LongAdder(分槽降竞争),需要精确值用 AtomicLong、高并发统计用 LongAdder。再看一眼这条标准,反问一下:为什么「需要精确值」就得退回 AtomicLong?因为 LongAdder 的 sum() 汇总时可能正有线程在改 Cell,读到的只是近似快照而非原子精确值。
底层深入(5-10min)
AtomicLong 的瓶颈
AtomicLong counter = new AtomicLong();
// 100 个线程同时 counter.incrementAndGet()
// → 全部 CAS 争同一个 value 字段
100 个线程的 CAS 只有一个成功,其他 99 个自旋重试。N 线程争 1 槽 → CAS 失败率 (N-1)/N → CPU 空转。 停下来想一想:这 99 个失败的线程此刻在干嘛?它们在自旋——反复读 value、比较、写回、失败,把 CPU 烧在「抢槽」而不是「干活」上。这就是单槽 AtomicLong 高并发下的天花板。
LongAdder 的分槽设计
两个核心字段定义在父类 Striped64 中(LongAdder 直接继承使用),JDK 源码逐字如下:
// java.util.concurrent.atomic.Striped64
transient volatile Cell[] cells; // Cell 数组,懒初始化,size 恒为 2 的幂
transient volatile long base; // 无竞争时的基础值,直接 CAS 累加
分槽累加的核心是 add() 方法:
// java.util.concurrent.atomic.LongAdder
public void add(long x) {
Cell[] cs; long b, v; int m; Cell c;
if ((cs = cells) != null || !casBase(b = base, b + x)) {
int index = getProbe();
boolean uncontended = true;
if (cs == null || (m = cs.length - 1) < 0 ||
(c = cs[index & m]) == null ||
!(uncontended = c.cas(v = c.value, v + x)))
longAccumulate(x, null, uncontended, index);
}
}
分析:add() 第一步是 (cs = cells) != null || !casBase(b = base, b + x)——当 cells 尚未初始化(无竞争阶段),或对 base 的 CAS 成功时,直接走单槽快路径返回。只有当 cells 已存在或 base 的 CAS 失败(出现竞争)才进入第二段:用线程探针 getProbe() 算出 index & m 哈希到某个 Cell,再对该 Cell.value 做 CAS;若槽位为空或 CAS 再次失败,才落到 longAccumulate(x, null, uncontended, index) 处理初始化、扩容、重哈希等慢路径。N 个线程被哈希分散到 M 个 Cell → 每个 Cell 上只有 N/M 个线程竞争 → 单槽 CAS 失败率大幅下降,这就是「分槽」带来吞吐提升的本质。
sum():非原子快照的汇总
// java.util.concurrent.atomic.LongAdder
public long sum() {
Cell[] cs = cells;
long sum = base;
if (cs != null) {
for (Cell c : cs)
if (c != null)
sum += c.value;
}
return sum;
}
分析:sum() 只是 base 加上 cells 数组里每个非空 Cell.value 的普通累加,全程没有任何锁或 CAS。这也意味着它不是原子的快照——遍历过程中其他线程可能正在写某个 Cell.value,那些更新可能被算进去、也可能被漏掉。这正是 LongAdder 的取舍:用 sum() 的最终一致性换取写入端的高并发性能,需要精确值时必须退回 AtomicLong。读到这里不妨反问:为什么 LongAdder 敢牺牲 sum() 的精确性?因为它的定位是「高并发统计计数」——多算少算一次对计数来说无伤大雅,但省掉加锁/汇总的成本,能换来写入吞吐的量级提升。
@Contended:防伪共享
相邻 Cell 对象可能落在同一个 CPU Cache Line(64 字节)。线程 A 写 Cell[0].value → 引起线程 B 所在 CPU 的 Cell[1] 所在 Cache Line 失效(MESI 协议的 Invalidate)→ 线程 B 被迫重新从内存读取——尽管线程 B 的 Cell[1] 和线程 A 的 Cell[0] 是不同变量。想想看:分槽设计好不容易把竞争拆开了,为什么还会互相拖累?因为 CPU 缓存一致性以 Cache Line 为最小单位——两个「不相干」的变量一旦同属一条 64 字节 Cache Line,一个被写就会让另一个所在的行整体失效,分槽就名存实亡了。这正是 @Contended 存在的理由。
// java.util.concurrent.atomic.Striped64
@jdk.internal.vm.annotation.Contended static final class Cell {
volatile long value;
Cell(long x) { value = x; }
final boolean cas(long cmp, long val) {
return VALUE.weakCompareAndSetRelease(this, cmp, val);
}
final void reset() {
VALUE.setVolatile(this, 0L);
}
final void reset(long identity) {
VALUE.setVolatile(this, identity);
}
final long getAndSet(long val) {
return (long)VALUE.getAndSet(this, val);
}
// VarHandle mechanics
private static final VarHandle VALUE = MhUtil.findVarHandle(
MethodHandles.lookup(), "value", long.class);
}
分析:Cell 本质是「加了 @Contended 填充的 AtomicLong」——只持有一个 volatile long value 字段,cas() 通过 VarHandle 的 weakCompareAndSetRelease 做弱 CAS。@Contended 注解(jdk.internal.vm.annotation.Contended)让 JVM 在该对象前后各填充 128 字节,保证数组中相邻的 Cell 对象绝不共享同一 64 字节 Cache Line。
@Contended 在 Cell 前后各填充 128 字节(2 个 Cache Line),保证相邻 Cell 决不共享同一 Cache Line。代价:一个 Cell 实际占用 ~256 字节(实际数据 8B + 填充 ~248B),但消除了最隐蔽的并发性能杀手——伪共享。
选择指南
| AtomicLong | LongAdder | |
|---|---|---|
| 线程数少(<4) | ✅ 简单直接 | 额外 Cell 数组开销 |
| 线程数多(>8) | CAS 失败率飙升 | ✅ Cell 分槽 |
| 需要精确值 | ✅ get() 是原子的 | sum() 非原子 |
| 场景 | 低竞争、需精确快照 | 高并发统计/计数 |
总结
LongAdder 的优化三步:① Cell 数组分槽 → 降低 CAS 竞争 ② @Contended 消除伪共享 → 保证分槽真的”独立” ③ sum() 接受非一致性 → 用”最终一致”换”写入高性能”。
章末提问
-
LongAdder 为什么比 AtomicLong 快? 结论先行:分槽降竞争。因为把 N 个线程抢一个 value 的 CAS 竞争,拆成 M 个 Cell 各自 N/M 个线程竞争,单槽 CAS 失败率从 (N-1)/N 降到 (N/M-1)/(N/M)。
-
@Contended 解决什么问题? 结论先行:消除伪共享。因为相邻 Cell 落在同一条 64 字节 Cache Line 上会互相触发 MESI 失效、被迫重读内存,让分槽名存实亡。
-
sum() 为什么不是精确值? 结论先行:它是非原子快照。因为遍历汇总时可能正有线程在写某个 Cell.value,那些更新可能被算进去也可能被漏掉。
-
什么时候该用 AtomicLong 而不是 LongAdder? 结论先行:需要精确快照或线程极少时。因为 AtomicLong.get() 是原子的,而 LongAdder 的 sum() 只有最终一致性。
-
一个 Cell 为什么占约 256 字节? 结论先行:@Contended 前后各填 128 字节。因为只有拉开到不共享任何 64 字节 Cache Line,相邻 Cell 才真正独立。