LongAdder 与 AtomicLong 对比

候选人小沈在面试快手 P6 时,面试官问道:

"在极高并发场景下,LongAdder 为什么会比 AtomicLong 性能好?"

小沈说:"因为 LongAdder 用了分段..."面试官追问:"具体怎么分段?为什么分段能减少竞争?Cell 数组是怎么工作的?"

小沈支支吾吾答不上来。面试官继续追问:"LongAdder 有什么缺点?sum() 为什么不保证精确?"

小沈彻底答不上来了...

一、核心问题:LongAdder 为什么更快 🔴

1.1 问题拆解

第一层:问题背景(为什么需要?)
  "在高并发计数场景下,AtomicLong 有什么性能问题?"
  考察点:CAS 竞争、缓存行失效、自旋开销

第二层:分段设计(怎么解决?)
  "LongAdder 的分段设计是什么?Cell 数组的作用?"
  考察点:分段热点、空间换时间思想

第三层:伪共享问题(有什么坑?)
  "Cell 数组中的伪共享问题是什么?怎么解决?"
  考察点:缓存行、false sharing、@Contended 注解

第四层:代价与权衡(有什么缺点?)
  "LongAdder 的 sum() 为什么不精确?什么场景不适合用 LongAdder?"
  考察点:一致性 vs 性能权衡

1.2 ❌ 错误示范

候选人原话 A:"LongAdder 比 AtomicLong 快,因为它内部用了多个 AtomicLong。"

问题诊断:这是错误的理解。LongAdder 不是用多个 AtomicLong,而是用了一个 Cell 数组,每个 Cell 存储一个 long 值。Cell 内部的值不是原子的(Cell 本身不需要 volatile,通过数组引用保证可见性)。

候选人原话 B:"所有并发计数场景都应该用 LongAdder。"

问题诊断:LongAdder 牺牲了强一致性(sum() 不是精确快照),只适合统计场景(如 UV 计数、限流计数器)。对于需要精确值的场景(如库存扣减),不能用 LongAdder。

1.3 标准回答

P5 级别:问题与解决方案

AtomicLong 的性能瓶颈

在极高并发场景下(如每秒百万次 incrementAndGet()),所有线程竞争同一个 value 字段:

  1. 同一时刻只有一个线程能 CAS 成功
  2. 其他失败的线程必须自旋重试
  3. 失败的 CAS 广播 Invalidate 消息,使其他 CPU 缓存行失效
  4. 结果:大量 CPU 周期浪费在自旋和缓存一致性上

LongAdder 的核心思想

"一个变量扛不住,就用多个变量"——将单一的 value 分成多个段(Cell),每个线程累加到不同的 Cell。竞争被分散到多个缓存行,最终求和时将所有 Cell 合并。

// LongAdder 的核心结构(JDK 8)
public class LongAdder extends Striped64 {
    transient volatile Cell[] cells;  // Cell 数组
    transient volatile long base;     // 无竞争时使用

    public void add(long x) {
        Cell[] cs;
        long b, v;
        int m;
        Cell c;

        if ((cs = cells) != null || !casBase(b = base, b + x)) {
            // cells 已初始化或有竞争 → 进入 CAS 循环
            boolean uncontended = true;
            if (cs == null || (m = cs.length - 1) < 0 ||
                (c = cs[probe & m]) == null ||
                !(uncontended = c.cas(v = c.value, v + x))) {
                // 重试(可能扩容或重哈希)
                longAccumulate(x, null, uncontended);
            }
        }
    }
}

Cell 的结构

@sun.misc.Contended  // 解决伪共享
static final class Cell {
    volatile long value;
    Cell(long x) { value = x; }

    final boolean cas(long cmp, long val) {
        return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
    }
}

每个 Cell 对象存储一个 long 值,线程根据其 ThreadLocalRandom 的 probe 值(哈希值)选择 Cell。

P6 级别:分段竞争与伪共享

Cell 数组的寻址

// Striped64 中使用的分段策略
int h = ThreadLocalRandom.getProbe();  // 获取当前线程的哈希值
int m = cells.length - 1;               // 位掩码(数组长度是 2 的幂)
int index = h & m;                       // 取模:线程 i 使用 Cell[i]

// 如果 Cells 数组长度为 16,则每个 Cell 平均被 N/16 个线程使用
// 竞争从 N:1 降低到 N/16:1

伪共享(False Sharing)问题

CPU 缓存以缓存行为单位(通常 64 字节)。如果两个变量的地址在同一缓存行,一个变量的修改会导致另一个变量所在缓存行失效:

graph TD
    A[Cell[0]: value] -->|同一缓存行| B[Cell[1]: value]
    C[Thread 1 修改 Cell[0]] -->|缓存行失效| B

@Contended 注解的解决方案

@sun.misc.Contended 使得每个 Cell 独占一个缓存行(通过填充避免与其他数据共享缓存行)。填充大小为 128 字节(@Contended("tlr"))或 128 字节(@Contended("pad"))。

@sun.misc.Contended
static final class Cell {
    volatile long value;
    // JDK 会在 Cell 前后各填充约 120 字节
    // 使得每个 Cell 独占一个缓存行(64 字节对齐 + 填充)
}

Cell 数组的扩容

当竞争激烈时(CAS 多次失败),Striped64 会扩容 Cell 数组(翻倍),重新分配 Cell,并调整线程到新的 Cell。扩容是保守的(避免过度扩容),通常在 1~2 次失败后扩容。

P7 级别:一致性代价与选型

LongAdder.sum() 的不精确性

public long sum() {
    Cell[] cs = cells;
    long sum = base;  // 先加上 base
    if (cs != null) {
        for (Cell c : cs) {
            if (c != null) {
                sum += c.value;  // 再加上每个 Cell 的值
            }
        }
    }
    return sum;
}

sum() 不是原子快照。在 sum() 执行期间:

  • 其他线程可能正在修改 Cell(c.value 不是 volatile)
  • Cell 数组本身可能被扩容或重哈希
  • 返回值是近似值,不是精确的某个时刻的快照

LongAdder 的正确使用场景

场景推荐原因
UV 统计(去重计数)LongAdder近似值可接受,高并发性能好
限流计数器LongAdder限流允许微小误差
QPS 统计LongAdder时间窗口内的近似计数
库存扣减AtomicLong必须精确,不允许误差
账户余额AtomicLong/其他必须精确
分布式 ID 生成器LongAdder不适用(原子操作不止加法)

为什么不直接用 AtomicLong + 锁?

因为 LongAdder 追求的是高吞吐量,牺牲了精确性。在读远多于写的场景(读 : 写 = 1000 : 1),LongAdder 的写分段策略使得写操作几乎无锁(CAS 冲突率极低),吞吐量比 AtomicLong 高出 5~10 倍。

【面试官心理】 这道题我能问到 P7 级别,是因为它涉及了并发性能优化、缓存行伪共享、空间换时间思想等多个维度。能说出 @Contended 注解细节的候选人说明他看过 JDK 源码。能正确选择 LongAdder vs AtomicLong 的候选人说明他有生产经验的权衡思考。

1.4 追问升级

追问 1:Striped64 的 design 思想是什么?

Striped64 的核心思想是分段锁 + 无锁设计

  • base:无竞争时使用 CAS 更新,无锁开销
  • cells[]:竞争激烈时,每个线程使用独立的 Cell(类似分段锁),减少竞争
  • sum():最终合并所有 Cell 的值

这个设计在一致性上做了妥协(最终结果需要合并),换取了极高的写吞吐量。

追问 2:LongAdder 可以用于分布式场景吗?

不能。LongAdder 的累加只存在于单个 JVM 进程中,不涉及跨进程同步。如果需要跨 JVM 的计数,使用 Redis INCR 或其他分布式计数器。

二、Striped64 的实现细节 🟡

2.1 Cell 的填充策略

@Contended 填充的内存布局:

[Padding: ~120 bytes][Cell.value: 8 bytes][Padding: ~120 bytes]

总计约 128 字节,确保每个 Cell 独占一个缓存行(64 字节)。

为什么不用手动 padding?

JDK 8 之前,确实有人手动在字段间插入 long p1, p2, p3, p4, p5, p6, p7; 填充。但手动填充容易出错(如果 JDK 内部类结构改变),且不够优雅。@Contended 是 JDK 8 提供的官方解决方案。

2.2 LongAccumulator vs LongAdder

LongAccumulatorLongAdder 的泛化版本,支持任意二元操作(而不只是加法):

// LongAdder 是 LongAccumulator 的特例(操作是 x + y)
LongAdder adder = new LongAdder();
// 等价于
LongAccumulator acc = new LongAccumulator(Long::sum, 0L);

// 自定义操作:求最大值
LongAccumulator maxAcc = new LongAccumulator(Math::max, Long.MIN_VALUE);

三、生产避坑

3.1 在需要精确值的场景用 LongAdder

场景:用 LongAdder 做库存扣减,高并发下出现超卖。

根因adder.sum() 是近似值,实际累加值可能比 sum() 返回值大。在高并发扣减场景下,sum() 返回 100 实际可能是 80,导致继续扣减时超卖。

正确做法:库存扣减用 AtomicLong 或分布式锁。

3.2 LongAdder.sum() 的原子性问题

sum() 执行期间调用 reset()(JDK 9+ 提供的 reset() 方法),可能出现数据丢失:

LongAdder adder = new LongAdder();
adder.add(100);
// 在线程 A 调用 sum() 的同时,线程 B 在 reset()
// 可能导致计数丢失

解决:JDK 9+ 提供 sumThenReset() 原子地求和并重置,但仍然不是完美快照(在高并发下可能有微小误差)。

💡

面试加分点:能说出"JDK 8 的 ConcurrentHashMap 也使用了类似 Striped64 的分段思想——每个桶的锁是独立的,减少了全局竞争",说明他理解了分段设计的通用性。

⚠️

面试陷阱:被问到"LongAdder 的 sum() 返回 0,但实际 add() 过多次,这是可能的吗",答"不可能"是错的。在极短时间内(小于纳秒级),如果所有 add() 操作都在 Cell 数组初始化前发生(竞争不够激烈),且 sum() 在所有 add() 完成前执行,确实可能看到近似值。这是因为 Cell 数组的初始化是懒加载的。