LongAdder 与 AtomicLong 对比
候选人小沈在面试快手 P6 时,面试官问道:
"在极高并发场景下,LongAdder 为什么会比 AtomicLong 性能好?"
小沈说:"因为 LongAdder 用了分段..."面试官追问:"具体怎么分段?为什么分段能减少竞争?Cell 数组是怎么工作的?"
小沈支支吾吾答不上来。面试官继续追问:"LongAdder 有什么缺点?sum() 为什么不保证精确?"
小沈彻底答不上来了...
一、核心问题:LongAdder 为什么更快 🔴
1.1 问题拆解
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字段:
- 同一时刻只有一个线程能 CAS 成功
- 其他失败的线程必须自旋重试
- 失败的 CAS 广播 Invalidate 消息,使其他 CPU 缓存行失效
- 结果:大量 CPU 周期浪费在自旋和缓存一致性上
LongAdder 的核心思想:
"一个变量扛不住,就用多个变量"——将单一的 value 分成多个段(Cell),每个线程累加到不同的 Cell。竞争被分散到多个缓存行,最终求和时将所有 Cell 合并。
Cell 的结构:
每个 Cell 对象存储一个 long 值,线程根据其
ThreadLocalRandom的 probe 值(哈希值)选择 Cell。
P6 级别:分段竞争与伪共享
Cell 数组的寻址:
伪共享(False Sharing)问题:
CPU 缓存以缓存行为单位(通常 64 字节)。如果两个变量的地址在同一缓存行,一个变量的修改会导致另一个变量所在缓存行失效:
@Contended 注解的解决方案:
@sun.misc.Contended使得每个 Cell 独占一个缓存行(通过填充避免与其他数据共享缓存行)。填充大小为 128 字节(@Contended("tlr"))或 128 字节(@Contended("pad"))。Cell 数组的扩容:
当竞争激烈时(CAS 多次失败),Striped64 会扩容 Cell 数组(翻倍),重新分配 Cell,并调整线程到新的 Cell。扩容是保守的(避免过度扩容),通常在 1~2 次失败后扩容。
P7 级别:一致性代价与选型
LongAdder.sum() 的不精确性:
sum()不是原子快照。在sum()执行期间:
- 其他线程可能正在修改 Cell(
c.value不是 volatile)- Cell 数组本身可能被扩容或重哈希
- 返回值是近似值,不是精确的某个时刻的快照
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填充的内存布局:总计约 128 字节,确保每个 Cell 独占一个缓存行(64 字节)。
为什么不用手动 padding?
JDK 8 之前,确实有人手动在字段间插入
long p1, p2, p3, p4, p5, p6, p7;填充。但手动填充容易出错(如果 JDK 内部类结构改变),且不够优雅。@Contended是 JDK 8 提供的官方解决方案。
2.2 LongAccumulator vs LongAdder
LongAccumulator是LongAdder的泛化版本,支持任意二元操作(而不只是加法):
三、生产避坑
3.1 在需要精确值的场景用 LongAdder
场景:用 LongAdder 做库存扣减,高并发下出现超卖。
根因:adder.sum() 是近似值,实际累加值可能比 sum() 返回值大。在高并发扣减场景下,sum() 返回 100 实际可能是 80,导致继续扣减时超卖。
正确做法:库存扣减用 AtomicLong 或分布式锁。
3.2 LongAdder.sum() 的原子性问题
在 sum() 执行期间调用 reset()(JDK 9+ 提供的 reset() 方法),可能出现数据丢失:
解决:JDK 9+ 提供 sumThenReset() 原子地求和并重置,但仍然不是完美快照(在高并发下可能有微小误差)。
面试加分点:能说出"JDK 8 的 ConcurrentHashMap 也使用了类似 Striped64 的分段思想——每个桶的锁是独立的,减少了全局竞争",说明他理解了分段设计的通用性。
面试陷阱:被问到"LongAdder 的 sum() 返回 0,但实际 add() 过多次,这是可能的吗",答"不可能"是错的。在极短时间内(小于纳秒级),如果所有 add() 操作都在 Cell 数组初始化前发生(竞争不够激烈),且 sum() 在所有 add() 完成前执行,确实可能看到近似值。这是因为 Cell 数组的初始化是懒加载的。