在构建高并发系统时,计数器是最基础也最容易成为瓶颈的组件。当多个线程同时对同一个原子变量做递增操作时,传统方案往往因为激烈的 CAS 竞争而让吞吐量急剧下滑。LongAdder 通过引入 Cell 数组分段累加的机制,把写压力分散开来,从而显著提升高并发下的计数性能。

一、AtomicLong 的性能瓶颈在哪里
AtomicLong 内部维护了一个 volatile 修饰的 long 型变量 value,所有自增操作都通过 Unsafe 的 compareAndSwapLong 方法完成。在单线程或低并发环境中,这种实现简单且高效。但在高并发场景中,成百上千的线程会同时尝试修改同一个 value,只有一个线程能成功,其余线程只能循环重试。
这种重试在 CPU 层面表现为大量的缓存一致性流量。由于 value 所在的内存地址被所有核同时争抢,MESI 协议会频繁触发缓存行失效,导致总线风暴。下面的代码展示了 AtomicLong 的典型用法,它在低并发下没有问题,但在压测中很容易看到线程等待时间飙升。
import java.util.concurrent.atomic.AtomicLong;
public class CounterDemo {
private static final AtomicLong counter = new AtomicLong(0);
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100000; i++) {
counter.incrementAndGet();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("final count=" + counter.get());
}
}
上述代码在双线程各十万次递增时可能还能接受,但当线程数上升到几十甚至上百时,incrementAndGet 的耗时会出现数量级增长。瓶颈并不在 Java 代码本身,而在底层硬件对共享变量的访问冲突。
二、LongAdder 的 Cell 数组分段思想
LongAdder 的核心思路是:不要求所有线程修改同一个变量,而是让线程分散到多个称为 Cell 的存储单元上。每个 Cell 本质上也是一个填充过的 volatile long 变量,被包装在 @Contended 注解的类中以减少伪共享。LongAdder 自身除了一个 base 变量外,还持有一个 Cell 数组。
当线程进行 add 操作时,LongAdder 会先根据线程的探针哈希(threadLocalRandomProbe)计算出一个 Cell 下标。如果该下标位置的 Cell 已存在,就尝试对它做 CAS 累加;如果数组尚未初始化,或者 CAS 失败发生竞争,才会去扩容数组或回退到 base 上操作。这种机制让不同的线程大概率操作不同的内存地址,从而把热点打散。
import java.util.concurrent.atomic.LongAdder;
public class LongAdderDemo {
private static final LongAdder adder = new LongAdder();
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100000; i++) {
adder.increment();
}
};
Thread[] threads = new Thread[10];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(task);
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
// sum 会累加 base 与所有 Cell 的值
System.out.println("final sum=" + adder.sum());
}
}
从代码可以看出,使用方式几乎和 AtomicLong 一样简单,但内部已经完全改变。Cell 数组的长度总是 2 的幂,方便用位运算定位,同时在扩容时也会尽量保持线程映射的均衡。
三、Cell 的惰性初始化与扩容机制
LongAdder 并不会在一开始就创建 Cell 数组。刚实例化时,所有累加都落在 base 上,此时表现和 AtomicLong 类似。只有当发生第一次 CAS 竞争,例如两个线程同时更新 base 失败,LongAdder 才会初始化长度为 2 的 Cell 数组,并把竞争线程引导到不同 Cell。
如果某个 Cell 上的 CAS 依然失败,说明该分段也出现了热点,此时会触发扩容:数组长度翻倍,并将原有 Cell 重新分配到新位置。扩容过程借助总线锁或 CAS 控制并发,保证同一时刻只有一个线程能修改 cells 引用。下面的简化逻辑说明了扩容判断的关键点:
// 伪代码:说明 LongAdder 内部扩容思路
if (cells == null) {
// 惰性初始化
cells = new Cell[2];
} else if (cells.length <= 当前竞争阈值) {
// 发生竞争,扩容为两倍
Cell[] newCells = new Cell[cells.length * 2];
System.arraycopy(cells, 0, newCells, 0, cells.length);
cells = newCells;
}
值得注意的是,Cell 类使用了缓存行填充,避免在多核 CPU 上因伪共享导致相邻 Cell 互相 invalidate。JDK 8 之后通过 @sun.misc.Contended 注解实现,运行时需开启相应参数才能生效。这也解释了为什么 LongAdder 在空间上比 AtomicLong 占用更多内存,但换来了写性能的数量级提升。
四、sum 方法的最终一致性与使用注意
由于累加被分散到 base 和多个 Cell 中,LongAdder 的 sum 方法在调用瞬间会把它们全部加起来。这个过程没有全局锁,因此在并发仍在进行的场景下,sum 返回的是一个近似值,而非实时精确值。这就是所谓的最终一致性。
对于绝大多数监控、限流、统计场景,短暂的数值偏差完全可以接受。但如果业务要求绝对精确的实时计数,例如金融账户余额,就不能使用 LongAdder,而应继续使用 AtomicLong 或加锁方案。此外,调用 sum 之后如果不再写入,数值便会稳定,因此很多系统会在分钟级定时采集 adder.sumThenReset() 来获取区间增量并清零。
| 对比维度 | AtomicLong | LongAdder |
|---|---|---|
| 竞争模型 | 单变量 CAS | Cell 数组分段 CAS |
| 高并发写性能 | 随线程数增加陡降 | 接近线性扩展 |
| 读取实时性 | 精确 | 最终一致 |
| 内存占用 | 低 | 较高 |
上表总结了两者差异。可以看到,LongAdder 用空间和弱一致性换取了高并发下的吞吐能力,这正是分段累加机制的价值所在。
五、在实际项目中落地分段计数
在网关层统计每接口 QPS 时,通常会为每个接口名维护一个 LongAdder。请求进入时调用 increment,后台定时任务每秒拉取 sum 并重置,既避免了锁开销,也能支撑海量并发。如果担心 @Contended 未生效,可以显式在启动参数加入 -XX:-RestrictContended 并确认 JDK 版本支持。
当计数器数量极多且访问极分散时,也可以考虑将多个 LongAdder 按业务维度分片,进一步降低单个 adder 的竞争概率。总之,理解 Cell 数组如何把原子操作从集中走向分布,是写出高吞吐并发代码的重要一步。