承接的问题
8.1 的重试数据说明了一件事:AtomicInteger 的所有线程抢同一个地址,4 线程时平均每次成功自增要多付 1.03 次失败重试。失败重试只是症状,病根是单点。只要所有写都落到同一个缓存行,增加线程只会增加争抢。
LongAdder 的思路不是把 CAS 做得更快,而是让线程写不同的地址。它维护一个 base 和一个 Cell[],低竞争时写 base,有竞争就把线程散到不同的 Cell 上,读的时候再全部加起来。代价是 sum() 不再是一个精确快照。
本节回答四个问题:base 与 cells 如何协作;@Contended 到底让每个 Cell 付出多少空间;sum() 的弱一致在真实代码里会怎么咬人;什么时候 LongAdder 反而不如 AtomicLong。
结构:base 是快路径,cells 是溢洪道
先看 Striped64 的字段与 Cell。LongAdder 本身几乎没有状态,全部机制都在父类里:
// JDK 25 源码摘录,已删减
abstract class Striped64 extends Number {
@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);
}
private static final VarHandle VALUE = MhUtil.findVarHandle(
MethodHandles.lookup(), "value", long.class);
}
static final int NCPU = Runtime.getRuntime().availableProcessors();
transient volatile Cell[] cells;
transient volatile long base;
transient volatile int cellsBusy;
}
再看 LongAdder 的三个关键方法:
// JDK 25 源码摘录,已删减
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);
}
}
public long sum() {
Cell[] cs = cells;
long sum = base;
if (cs != null) {
for (Cell c : cs)
if (c != null)
sum += c.value;
}
return sum;
}
public void reset() {
Cell[] cs = cells;
base = 0L;
if (cs != null) {
for (Cell c : cs)
if (c != null)
c.reset();
}
}
add 的四层判断就是整个设计:
(cs = cells) != null:一旦分片表建起来,就不再走base,直接进分片路径。!casBase(b = base, b + x):表还没建时先试base。成功就返回,这是无竞争时的全部开销——一次 CAS。(c = cs[index & m]) == null:槽位是懒创建的,第一次落到这个槽才分配。!c.cas(v = c.value, v + x):槽位存在就 CAS 自己的Cell。只有这一步也失败,才进longAccumulate去扩容或换探针。
cs == null || (m = cs.length - 1) < 0 这个条件看起来奇怪,其实是防御 cells 刚被别的线程初始化成长度 2 之前的瞬态:表非空但长度为 0 的情况理论上不该出现,源码仍然兜住。
sum() 的顺序是 base 在前、cells 在后,逐个 volatile 读。每个 Cell 的 cas 用的是 weakCompareAndSetRelease——release 足够,因为这里不需要 acquire:写者只关心自己的更新别被重排到 CAS 之前,读者用 volatile 读拿到值即可。
为什么每个 Cell 要花 289 字节
Cell 上有 @jdk.internal.vm.annotation.Contended。本机 javap -v 确认注解真实存在于类文件:
RuntimeVisibleAnnotations:
0: #68()
jdk.internal.vm.annotation.Contended
这个注解告诉 JVM:把整个对象的前后各填充一段,避免它和别的对象共享缓存行。填充宽度由 -XX:ContendedPaddingWidth 控制,本机默认值:
int ContendedPaddingWidth = 128 {product} {default}
bool RestrictContended = true {product} {default}
128 字节,前后各一份。为什么单个 Cell 要这么奢侈?因为 Cell 总是成组出现在数组里。如果两个 Cell 落在同一个 64 字节缓存行,线程 A 改 Cell[0]、线程 B 改 Cell[1],硬件仍然要在两个核之间来回搬运这一行——这叫伪共享(false sharing),分片就白分了。源码注释写得很直白:Atomic 对象通常零散分布,填充是过度的;但 “Atomic objects residing in arrays will tend to be placed adjacent to each other”,不填充就会共享缓存行,并带来 “a huge negative performance impact”。
空间代价可以实测。本机用反射构造 Striped64$Cell 和 AtomicLong 各 10 万、30 万个,做两次堆占用差,得到边际大小(含数组槽位):
Cell 边际大小 = 289.1 B
AtomicLong 边际大小 = 32.5 B
约 8.9 倍。一个 8 槽的 LongAdder 光 Cell 就 2.3 KB 左右;如果你在 ConcurrentHashMap 里给每个键挂一个 LongAdder(源码注释推荐的频率统计写法),每个都长到 8 槽时,十万个键就是两百多 MB。这不是小数点后的差异,是选型时就要算的账。
顺带说一个测量口径:本机 jol-core 0.17 在没有 agent 的模式下把 Cell 估成 152 字节,明显低于实测的 289 字节,因为它只猜到字段前的一段间隙、没算尾部填充。这类工具的输出要拿分配量交叉验证,不能直接引用。
sum() 不是快照,reset() 不是清零
sum() 的 javadoc 原话是 “The returned value is NOT an atomic snapshot”。它不是快照,原因在实现里一目了然:先读 base,再逐个读 cells,这中间没有任何锁或版本校验。读 base 之后发生的更新,如果落在还没读到的 Cell 上,会被算进去;落在已经读过的 Cell 上,就永远丢了。
本机让 8 个线程各自增 500 万次,同时不停做"连续两次 sum()":
连续两次 sum() 采样对=730200 不相等=240460 后一次更小=0
最终 sum=40000000 期望=40000000
73 万对采样里有 24 万对不相等(32.9%)。对只增不减的计数器,后一次不会更小,所以这个偏差表现为"某一对读到了不同的历史时刻",而不是数据错乱。最终值仍然精确等于 4000 万——只要写停了,sum() 就是准的。
更危险的是把两个 LongAdder 相减。本机 8 线程同时 borrowed.increment() 和 returned.increment(),采样 borrowed.sum() - returned.sum():
采样=1581050 出现负的在借数=247827 最小值=-2700556
158 万次采样里有 24.8 万次算出负数,最小 -270 万。原因很简单:两次 sum() 不在同一时刻,读 borrowed 之后、读 returned 之前,别的线程可能完成了一批归还。两个弱一致的值相减,误差会叠加,甚至会翻符号。 这不是 bug,是把非快照 API 当快照用的后果。
reset() 的语义在 javadoc 里写得很克制:“only effective if there are no concurrent updates”。实现是先 base = 0,再逐个 c.reset(),中间没有任何原子性。我在 8 线程共 800 万次自增的同时调了 323 万次 sumThenReset(),累计采样加末尾值恰好等于 800 万,没有观测到丢失——但这只是窗口太小(纳秒级),不是保证。源码自己都说 “intrinsically racy”,别把它当"读取并清零"的原子操作。
结构性指标:把 CAS 失败次数数出来
吞吐量受机器负载影响太大,不能单独当证据。更稳定的结构性指标是"每成功自增一次,背后有几次 CAS 失败"。控制变量很简单:同一段手写 CAS 循环,只改写入目标——一个 AtomicLong,对比一个 8 槽的 AtomicLongArray(按线程 id 取模选槽),每线程 100 万次:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicLongArray;
public final class StripeFailProbe2 {
static final int OPS = 1_000_000;
static long failures(int threads, boolean striped) throws Exception {
AtomicLong single = new AtomicLong();
AtomicLongArray slots = new AtomicLongArray(8);
long[] fails = new long[threads];
CountDownLatch start = new CountDownLatch(1);
Thread[] ts = new Thread[threads];
for (int t = 0; t < threads; t++) {
final int id = t;
ts[t] = Thread.ofPlatform().start(() -> {
long f = 0;
try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
for (int i = 0; i < OPS; i++) {
long prev, next;
do {
prev = striped ? slots.get(id & 7) : single.get();
next = prev + 1;
if (striped ? slots.compareAndSet(id & 7, prev, next)
: single.compareAndSet(prev, next)) break;
f++;
} while (true);
}
fails[id] = f;
});
}
start.countDown();
for (Thread th : ts) th.join();
long total = 0;
for (long f : fails) total += f;
return total;
}
public static void main(String[] args) throws Exception {
for (int t : new int[]{1, 4, 8, 16}) {
long expected = (long) t * OPS;
System.out.printf("threads=%2d 单槽 %.3f 次/op 8 槽 %.3f 次/op%n",
t, failures(t, false) / (double) expected, failures(t, true) / (double) expected);
}
}
}
本机输出(load average 73.9,10 核):
threads= 1 单槽 0.000 次/op 8 槽 0.000 次/op
threads= 4 单槽 1.080 次/op 8 槽 0.000 次/op
threads= 8 单槽 2.081 次/op 8 槽 0.000 次/op
threads=16 单槽 2.398 次/op 8 槽 0.243 次/op
单槽的失败次数随线程数上升:4 线程时每成功一次平均失败 1.08 次,8 线程 2.08 次,16 线程 2.40 次。8 槽在 8 线程时是 0,因为每个线程独占一槽;16 线程时两个线程挤一槽,才出现 0.243 次。这就是 LongAdder 的全部理由:不是让 CAS 变快,是让失败变少。 失败意味着重读、重算、重试,每一次都是实打实的指令和缓存行往返。
这里用的是固定 8 槽、按 id & 7 取模的理想化版本。真实的 LongAdder 用线程探针哈希选槽,槽数随竞争从 2 动态翻倍到 NCPU,而且槽位是懒创建的——8.3 拆它的引擎。
什么时候 LongAdder 反而不如 AtomicLong
先看高竞争下的对照。本机每档取 3 次的最好值,ns/op 按总操作数计(load average 65.5,10 核,数字只作结构参考):
threads=1 AtomicLong 7.57 ns/op LongAdder 7.43 ns/op 加速 1.02x
threads=2 AtomicLong 18.97 ns/op LongAdder 3.88 ns/op 加速 4.90x
threads=4 AtomicLong 34.89 ns/op LongAdder 2.13 ns/op 加速 16.37x
threads=8 AtomicLong 53.58 ns/op LongAdder 1.50 ns/op 加速 35.83x
单线程两者几乎一样(7.57 对 7.43),因为 LongAdder 也只是一次 casBase;线程一多,AtomicLong 的耗时随线程数线性上升,LongAdder 反而下降——每个线程写自己的槽,总耗时接近单线程。8 线程差 36 倍。
但下面这些场景,LongAdder 是错的选择:
- 要读旧值做判断。
LongAdder没有getAndIncrement()返回旧值,没有compareAndSet,只有add/increment/sum。库存扣减、序列号、许可计数都不能用它。 - 要把计数当同步条件。
if (adder.sum() == 0) shutdown()这类判断在并发下没有意义,sum()随时在变,而且读的瞬间就可能已经过时。 - 频繁读。每次
sum()要遍历所有Cell,8 槽就是 9 次volatile读。写多读少才划算;读比写还频繁时,AtomicLong的一次读更便宜。 - 内存敏感。289 字节一个
Cell,槽位只增不减。一次瞬时高并发把表撑到 8 槽,之后即使只剩一个线程,这 8 个Cell也一直占着。源码注释明确说不回收:长生命周期实例的竞争会复发,短生命周期实例不值得回收。 - 需要精确的版本号。库存快照版本号必须单调且精确,
LongAdder的sum()不是快照,连"读到一个确定版本"都做不到。
把这几个边界收成一张表:
| 需求 | 合适的选择 | 原因 |
|---|---|---|
| 高并发累加、只读总和 | LongAdder | 分片写,写停止后 sum() 精确 |
| 要读旧值、要 CAS | AtomicLong | LongAdder 没有读改写 API |
| 非加法的聚合(最大值、最小值) | LongAccumulator | 同一套 Striped64,但要求函数满足结合律 |
| 精确的在借数 / 序列号 / 版本号 | AtomicLong | 必须是一次原子读写 |
| 十万个键各挂一个计数 | 先算内存 | 每槽约 289 B,槽位不回收 |
LongAccumulator 值得单独说一句。它和 LongAdder 共用 Striped64,区别是构造时传一个 LongBinaryOperator:LongAdder.add(x) 等价于 accumulator.accumulate(x) 配 (a, b) -> a + b。因为它允许任意结合律函数,sum() 变成了 get(),语义同样是"读的过程中可能有更新没算进去"。用它求最大值时不能传 Math::max 之外的带副作用函数,也不能依赖调用顺序。
还要注意 LongAdder 的 intValue()/longValue()/doubleValue() 全都转调 sum():它们不是"读一个字段",是遍历所有 Cell。把 LongAdder 直接传给只认 Number 的日志或监控 API,每次格式化都会触发一次全表扫描。
一句话:LongAdder 是累加器,不是计数器对象。它的 API 表面上像计数器,语义上只承诺"写停止后总和正确"。
在 libraryd-conc 里落地
借阅指标是典型的写多读少:每次借书、还书、拒绝都加一,报表每分钟读一次。这类计数用 LongAdder;但库存快照版本号要精确,用 AtomicLong。两类状态放在同一个类里,边界正好对照:
package com.plume.conc.core;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
/** 借阅事件的累加指标;版本号必须精确,所以单独用 AtomicLong。 */
public final class BorrowMeter {
private final LongAdder borrowed = new LongAdder();
private final LongAdder returned = new LongAdder();
private final LongAdder rejected = new LongAdder();
private final AtomicLong stockVersion = new AtomicLong();
public void onBorrow() { borrowed.increment(); }
public void onReturn() { returned.increment(); }
public void onReject() { rejected.increment(); }
public long borrowed() { return borrowed.sum(); }
public long returned() { return returned.sum(); }
public long rejected() { return rejected.sum(); }
/** 库存快照版本号:读者用它判断"是不是同一个版本",必须精确。 */
public long bumpStockVersion() { return stockVersion.incrementAndGet(); }
public long stockVersion() { return stockVersion.get(); }
}
注意我没有提供 outstanding() 方法。前面那组负数实验说明,borrowed.sum() - returned.sum() 在并发下会算出负数。如果业务确实要"当前在借数",正确做法是维护一个单独的 AtomicLong outstanding,借出时 incrementAndGet、归还时 decrementAndGet,让"借出减归还"在一次原子操作里完成;或者接受它是估算值,并在展示层夹到非负。把两个累加器相减,是最容易写、也最容易出负数的一种。
下面这段可以独立运行,复现"两个 sum 相减出负数":
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.LongAdder;
public final class OutstandingProbe {
public static void main(String[] args) throws Exception {
LongAdder borrowed = new LongAdder(), returned = new LongAdder();
int threads = 8, perThread = 3_000_000;
CountDownLatch start = new CountDownLatch(1);
Thread[] ts = new Thread[threads];
for (int t = 0; t < threads; t++) {
ts[t] = Thread.ofPlatform().start(() -> {
try { start.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
for (int i = 0; i < perThread; i++) { borrowed.increment(); returned.increment(); }
});
}
start.countDown();
long samples = 0, negative = 0, min = 0;
boolean alive = true;
while (alive) {
long outstanding = borrowed.sum() - returned.sum();
samples++;
if (outstanding < 0) { negative++; min = Math.min(min, outstanding); }
alive = false;
for (Thread th : ts) alive |= th.isAlive();
}
for (Thread th : ts) th.join();
System.out.println("采样=" + samples + " 出现负的在借数=" + negative + " 最小值=" + min);
}
}
什么时候不要用 LongAdder 做指标?需要"每个事件都能被准确归属到某个时间窗口"的时候。sumThenReset 不是原子的,清点和清零之间发生的更新可能既没进这一期、也没进下一期。要严格分期,就在窗口边界停写(切换到一个新的 LongAdder 实例),或者接受报表是近似值并在文档里写清楚。
常见错误与边界
第一,把 sum() 当 size() 做业务判断。第 5 章已看到 ConcurrentHashMap.size() 走 sumCount(),同样是 base 加计数单元的弱一致读。
第二,用两个 LongAdder 相减表达"差值"。前面实测 158 万次采样里 24.8 万次为负;要精确差值就用一个 AtomicLong 或 LongAccumulator。
第三,在流量中调 reset()。javadoc 明说只在没有并发更新时有效;sumThenReset() 也一样。
第四,以为槽位会缩回去。Cell[] 只扩不缩,一次高并发峰值会永久抬高这个实例的内存占用。
第五,以为 LongAdder 一定比 AtomicLong 快。单线程两者打平,读多写少时 LongAdder 的 sum() 反而更贵,内存还贵 9 倍。
第六,在 LongAccumulator 里传不满足结合律的函数。它和 LongAdder 共用 Striped64,更新顺序不确定;减法、字符串拼接这类不满足结合律的操作会得到不可复现的结果。
小结
LongAdder 用 base 加 cells 把"一个热点地址"拆成"N 个地址":无竞争时一次 casBase,有竞争时按线程探针散到各自的 Cell,扩容到 NCPU 为止。每个 Cell 用 @Contended 前后各填 128 字节,换来不伪共享,代价是约 289 字节一个、约为 AtomicLong 的 9 倍。sum() 顺序读 base 和所有 Cell,不是快照——本机 32.9% 的连续采样对不相等,两个累加器相减甚至会出现负数。reset() 只适合停写之后。
下一节打开 Striped64 的引擎:探针怎么生成、cellsBusy 为什么是一把自旋锁、longAccumulate 的三条分支如何决定"建槽、重试、扩容"。
阅读导航:上一节 8.1 AtomicInteger 家族的 CAS 路径 | 下一节 8.3 Striped64 与高并发计数设计
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。