尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

高并发计数性能对比:AtomicLong与LongAdder分段CAS原理与实战

高并发计数性能对比:AtomicLong与LongAdder分段CAS原理与实战 先说结论在高并发计数的场景里把核心计数器从AtomicLong换成LongAdder性能差距真的可以达到一个数量级以上。我在一台 24 核的机器上复现过这个对比16 个线程同时对同一个计数器做自增AtomicLong.longAdder()跑了将近 320 毫秒换成LongAdder.add(1)之后只有大约 12 毫秒差了差不多 26 倍。这个数据第一次看到的时候我也觉得有点反直觉毕竟两行代码长得几乎一样凭什么底层改个数据结构就快这么多后来把并发包源码翻了一遍又用火焰图确认了瓶颈所在才真正搞明白分段 CAS这套设计是什么意思。这篇内容适合所有用 Java 做高并发服务、写过或看过AtomicLong计数器的开发者。不管你是刚接触并发编程的新手还是已经在生产环境摸爬滚打多年的老手我都会从一次真实的压测问题讲起拆解AtomicLong在高并发下慢在哪、LongAdder的秘密武器是什么、什么时候它反而不好用最后带大家手写一个简化版的分段计数器把底层原理彻底吃透。1. 一次压测引发的疑问同样的自增为什么差了一个数量级1.1 计数逻辑五行代码却是全服务最大的瓶颈去年我维护过一个埋点统计服务高峰期每秒要处理几十万次点击上报上游会不停地对某个业务维度的计数器做自增操作。当时的核心逻辑非常朴素一个ConcurrentHashMapString, AtomicLong每次请求进来就counter.computeIfAbsent(key, k - new AtomicLong()).incrementAndGet()。这套代码从功能上讲完全没问题线上也没出过故障但压测的时候我发现一个问题当并发线程数超过 8 个之后CPU 使用率直线上升而且GC并没有异常火焰图里一眼就能看到一个热点就是sun.misc.Unsafe.getAndAddLong这一行下面挂着密密麻麻的AtomicLong.incrementAndGet调用。这就是典型的共享变量竞争。高并发下所有线程都在抢同一个long变量的写入权CAS 循环反复失败重试CPU 大量消耗在缓存一致性协议和自旋重试上。更麻烦的是这种问题不是加个锁能解决的synchronized只会更慢因为它让所有线程串行化而 CAS 虽然比锁轻量但竞争激烈时本质上也是在排队只不过排队方式是自旋。我当时的第一个想法是换一个思路不要再死磕AtomicLong。因为埋点统计这个场景有一个特点我们根本不关心任何一个瞬间的精确数值只关心最终这个计数器累计到多少、每个时间窗口内的增量是多少。这就意味着计数器内部的状态不一定要全局一致可以把一个变量拆成多个变量让不同线程更新不同部分最后用的时候再汇总。1.2 AtomicLong 的瓶颈根源CAS 循环与缓存行战场先看看AtomicLong.incrementAndGet()底层做了什么。AtomicLong内部持有一个volatile long valueincrementAndGet()实际调用的是Unsafe.getAndAddLong执行逻辑相当于下面这段代码public final long getAndAddLong(Object o, long offset, long delta) { long v; do { v getLongVolatile(o, offset); // volatile 读 } while (!compareAndSet(o, offset, v, v delta)); // CAS 写 return v; }这是一个标准的 CAS 自旋循环先读取当前值然后尝试把vdelta写回去如果期间有其他线程先写了CAS 就会失败于是重新读取、再次尝试。单线程下这个循环一次就成功了但 16 个线程同时更新同一个变量时每次只有一个线程能成功其他 15 个线程全部白忙活一轮。更可怕的是硬件层面的竞争。一个long变量在内存里位于某个缓存行Cache Line上而现代 CPU 的缓存一致性协议比如 MESI要求当一个核修改了某个缓存行上的数据其他核心持有的同一缓存行副本必须失效。于是高并发下这个变量所在的缓存行会在各个 CPU 核心之间来回颠簸每一次成功写入都会导致其他核心的缓存行失效迫使它们重新从主内存加载。这就是缓存行乒乓Cache Line Bouncing。打个比方这就像一间办公室里只有一台打印机所有人都要打印但打印机只有一个出纸口大家排队时还经常有人插队导致前面的人白排。无论 CPU 多快、CAS 多轻量存储层次上的共享瓶颈是物理性的改代码改不出性能来只能改变数据的组织方式。2. 打破单点瓶颈LongAdder 的分段计数设计2.1 从一把锁到一组 Cell思路转变的关键LongAdder是 JDK 8 引入的类设计者就是并发大师 Doug Lea。它解决高并发计数问题的核心思路简单到令人发指既然一个变量被抢来抢去那就不抢了我把一个计数器拆成一组计数器每个线程只更新其中某一个最后汇总的时候把这些值加起来。具体结构上LongAdder内部维护了一个volatile long base和一个Cell[] cells数组。Cell是一个内部类核心就是一个volatile long value和对应的 CAS 方法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); } }当并发量低的时候LongAdder直接用base做 CAS和AtomicLong没什么区别一旦某个线程在base上 CAS 失败说明竞争激烈了它会尝试用一个probe值散列到cells数组的某个槽位在那个Cell上做 CAS。这样一来不同线程大概率会落到不同的槽位上每个Cell上的竞争压力就被摊薄了。从全局一个变量变成一个数组 一个兜底变量这种设计思想在分布式系统里叫分片Sharding在数据库里叫分库分表在并发编程里就是分段 CAS。核心都是同一个道理把单一热点拆成多个热点让每个热点上的压力在企业可接受的范围内。2.2 线程如何选坑hash 分散与扩容机制看到这里你肯定会问线程是怎么决定自己更新哪个Cell的总不能让每个线程随机挑一个挑到同一个就白搭了。答案是ThreadLocalRandom的探针机制。每个线程内部有一个threadLocalRandomProbe字段它就是一个哈希探针值。LongAdder通过ThreadLocalRandom.getProbe()拿到这个探针然后probe (cells.length - 1)算出落在数组的哪个下标这里要求cells.length必须是 2 的幂所以数组初始长度是 2扩容时翻倍。当线程落到某个Cell上并且 CAS 失败时说明这个槽位竞争也非常激烈LongAdder不会死磕它会调用ThreadLocalRandom.advanceProbe()改一下当前线程的探针值让线程跳到另一个槽位试试。如果整个数组的所有槽位都明显扛不住就会触发扩容数组从 2 变成 4、再变 8最大不超过 CPU 的核心数。为什么上限是 CPU 核心数因为cells数组本质上就是为了让不同 CPU 核心上的线程尽量更新不同的内存地址超过核数之后容量再多也没有意义每个线程抢同一个槽位的概率也低不到哪去。每次求和时LongAdder.sum()做的事情是遍历cells数组把所有Cell的值和base加起来public long sum() { Cell[] cs cells; long sum base; if (cs ! null) { for (Cell c : cs) if (c ! null) sum c.value; } return sum; }注意这里没有加锁也没有做一致性快照。也就是说求和过程中可能有线程正在更新某个Cell你拿到的值可能不是绝对精确的。但对计数统计类场景来说这种弱一致性完全可接受因为我们要的是大概准确的最终值不是某个瞬间数据库一样的强一致值。3. Striped64 底层细节热点迁移、伪共享与内存布局3.1 热点迁移自适应扩容背后到底发生了什么LongAdder继承自Striped64真正的扩容和槽位管理逻辑都在Striped64.longAccumulate()里。我之前读源码的时候花了很久才理清热点迁移这四个字到底意味着什么这里直接把我梳理后的逻辑讲出来。整个算法的骨架大致是这样初始状态cells为 null所有线程都在base上做 CAS维持最轻量路径。一旦某个线程在base上 CAS 失败进入longAccumulate()尝试初始化cells或竞争某个cellBusy锁来操作数组。如果cells已经存在根据probe找到槽位如果槽位上的Cell还是 null先创建它如果已经存在直接对它 CAS。如果在这个槽位上 CAS 失败说明槽位也有竞争了此时先尝试扩容数组cells.length NCPU且没人正在扩容时而不是死循环重试。同时用advanceProbe更新探针让线程换一个槽位。如果数组长度已经达到 CPU 核数所有槽位都试过还是失败那也认了在这个竞争最重的槽位上继续 CAS。这个设计非常有意思的地方在于它没有像ConcurrentHashMap那样对加锁操作做特别精细的队列控制而是用一种摊薄 避让的策略竞争激烈的位置会被自动发现然后通过扩容和探针重哈希把压力分散出去。从宏观上看就变成了热点从base迁移到了某个Cell再从单个Cell迁移到整个数组。3.2 伪共享与 Contended一条缓存行的隐形战争分段计数的第二个大坑是伪共享False Sharing。如果cells数组里相邻的两个Cell对象恰好落在同一条 64 字节的 CPU 缓存行上那么线程 A 更新槽位 0、线程 B 更新槽位 1 时两个核心仍然会互相使对方的缓存行失效性能就直接被打回原形。Doug Lea 的解决方案就藏在Cell类的sun.misc.Contended注解上。这个注解会告诉 JVM给被注解的类或字段补位padding让它的内存地址至少跨越两条缓存行。换句话说每个Cell独占一条或多条缓存行相邻Cell之间不会互相干扰。这个细节如果不了解即使写出和LongAdder一模一样的逻辑性能也会差出好几倍。在 JDK 8 里Contended默认只在 JVM 内部生效如果你是自己在代码里打这个注解需要加上-XX:-RestrictContended参数才能让注解对用户类生效。JDK 15 之后注解路径换成了jdk.internal.vm.annotation.Contended依然属于内部 API。我自己手写实验类时通常不去依赖这个注解而是用一个long p1, p2, ...手动补位效果差不多只是代码丑一点。内存布局这件事还带出另一个现实问题LongAdder比AtomicLong更吃内存。一个AtomicLong实例可能只占 24 字节左右而一个LongAdder实例在扩容到 16 个槽位时光Cell对象就是 16 个每个为了避免伪共享还带 padding轻松能占几百字节。这里不需要精确计算只需要知道一个结论如果你系统里有几百万个LongAdder对象内存开销会非常可观不适合做细粒度对象属性更适合做全局级或维度级计数器的统计容器。4. LongAdder 不是银弹适用边界与实战选型4.1 三类常见错误用法与纠正LongAdder火了之后很多人开始无脑替换结果踩了一堆坑。下面这三类是我在实际项目里看到过最多的问题。第一类是拿LongAdder做唯一序列生成器。有些人觉得AtomicLong在高并发下生成 ID 慢换成LongAdder就可以了但LongAdder.sum()只能拿到累计值它不能让你原子地拿到当前值 1这个结果为后续业务使用。如果你需要的是类似数据库自增 ID 这样的语义必须保证每次取值是唯一的、连续的或至少是单调的AtomicLong的incrementAndGet()才是正确选择因为它在修改的瞬间就返回了确定性结果。第二类是频繁调用sum()来做业务判断。比如if (counter.sum() threshold) doSomething()这个判断在高并发下可能永远不准确因为sum()跑的过程中其他线程还在疯狂add。如果你要的是精确到某一个瞬间的计数LongAdder给不了AtomicLong可以。所以在做限流、状态机、开关判断这些对一致性有要求的场景时千万不要用LongAdder。第三类是误以为LongAdder在低并发或者短临界区下也更快。事实不是这样。在单线程或两三个线程的情况下因为每次add还要计算probe、判断cells状态、可能扩容LongAdder的常数开销比AtomicLong高一点性能通常是略低或者打平的。我压测过单线程 1 亿次自增AtomicLong大概 28msLongAdder要 40ms 左右。只有在竞争激烈的时候LongAdder的收益才体现出来。替换之前应该先确认你的并发度真的够高。4.2 和 AtomicLong、LongAccumulator 等工具的选型对比维度AtomicLongLongAdderLongAccumulator核心机制单变量 CASbase Cell 数组分段 CAS类似 LongAdder可自定义运算强一致性支持每次操作结果确定不保证sum 可能非精确快照不保证高并发写性能竞争高时退化严重分段摊薄竞争力强分段摊薄竞争力强首次/低并发开销低略高略高适用场景ID 生成、状态标志、阈值判断统计计数、埋点、QPS 指标自定义规约统计如求最大值、累乘内存占用小较大数组 补位较大LongAccumulator是LongAdder的泛化版本。LongAdder底层是Striped64.longAccumulateLongAccumulator允许你传入一个二元运算函数比如Math::max然后用完全相同的分段机制去求并发环境下的最大值。如果统计场景不是简单求和而是计平均、求最大版本号之类的可以考虑它。在这个表格之外我还想提一点选型心法先看语义再看性能。计数器的首要问题不是哪个快而是我需要它在哪个时刻保证什么样的正确性。如果业务要求下一次读到的值一定包含上一次写入那完全不考虑LongAdder。如果业务只是最终统计个量级、监控个趋势那LongAdder就是最优解。5. 手写一个简化版 LongAdder搞懂并发原语组合5.1 核心骨架与关键字段源码看再多遍不如自己动手写一个。下面这段代码是一个简化版的LongAdder保留了核心的分段 CAS、扩容和求和逻辑但没有做内存补位和完整的扩容边界处理。它的目标不是替代官方实现而是帮助理解背后的并发原语组合方式。public class SimpleLongAdder { private static final int NCPU Runtime.getRuntime().availableProcessors(); static final class Cell { volatile long value; Cell(long x) { value x; } } private volatile Cell[] cells; private volatile long base; private volatile int cellsBusy; // 扩容时当自旋锁用 // 省略 Unsafe 相关代码用 AtomicLongFieldUpdater 更安全 private static final AtomicLongFieldUpdaterSimpleLongAdder BASE_UPDATER AtomicLongFieldUpdater.newUpdater(SimpleLongAdder.class, base); private static final AtomicIntegerFieldUpdaterSimpleLongAdder BUSY_UPDATER AtomicIntegerFieldUpdater.newUpdater(SimpleLongAdder.class, cellsBusy); public void add(long x) { Cell[] cs cells; // 没有 cells 时直接在 base 上 CAS if (cs null) { if (BASE_UPDATER.compareAndSet(this, base, base x)) { return; } } boolean uncontended true; if (cs ! null) { int index ThreadLocalRandom.current().nextInt(cs.length); Cell c cs[index]; if (c ! null) { long v c.value; uncontended BASE_UPDATER.compareAndSet(this, base, base x); // 真实实现是对 c.value 做 CAS这里简化示意 } } longAccumulate(x, uncontended); } }这段代码只是骨架真实实现里对Cell.value的 CAS 是通过Unsafe.compareAndSwapLong(this, valueOffset, ...)完成的。BASE_UPDATER和BUSY_UPDATER是AtomicLongFieldUpdater和AtomicIntegerFieldUpdater它们可以更新volatile字段而不需要额外创建AtomicLong对象这也是一种降低内存开销的小技巧。5.2 用 ThreadLocalRandom 做线程槽位分配线程和槽位的对应关系是这段代码的灵魂。真实LongAdder里用的不是ThreadLocalRandom.current().nextInt()而是直接操作当前线程的threadLocalRandomProbe字段因为这样可以避免每次调用nextInt()带来的随机种子更新开销。不过我写简化版时直接用ThreadLocalRandom.current().nextInt(cs.length)更直白private void longAccumulate(long x, boolean wasUncontended) { if (cells null) { if (cellsBusy 0 BUSY_UPDATER.compareAndSet(this, 0, 1)) { // 初始化这里直接创建长度为 2 的数组 } } }竞争最激烈的时候比如一个槽位上 CAS 连续失败两次真实实现会调用ThreadLocalRandom.advanceProbe()来给当前线程换一个新的probe让这个线程不要死磕同一个槽位。简化版略过这部分但你要知道探针重哈希不是用来保证随机分布的而是用来在特定线程冲突时快速找到空闲槽位的。实际执行过程中longAccumulate还会处理一种情况如果cells数组存在但某个槽位还是 null说明之前没有线程用过这里那就在这个槽位上 new 一个Cell出来。数组扩容也是在这个方法里完成的扩容条件包括cells.length NCPU且cellsBusy 0然后通过BUSY_UPDATER.compareAndSet(0, 1)抢锁抢到之后才能操作数组防止两个线程同时扩容出问题。5.3 和真实实现的差距还有哪些细节简化版能跑通但距离真实LongAdder还有几个关键差距我踩坑之后才意识到这些细节有多重要。第一没有Contended补位。不补位的分段计数在压力上来之后会因为伪共享损失大量性能甚至可能比AtomicLong还慢。真实实现的Cell被标注了sun.misc.Contended一个Cell独占缓存行这个必须保留。第二没有probe机制。我用ThreadLocalRandom.current().nextInt()每次生成新随机数开销比真实实现高不少。真实实现里probe是线程本地字段处理槽位冲突时只是改一下字段值不需要重新生成随机数。第三没有base兜底与cells切换的完整迁移逻辑。真实实现里如果cells为空直接在base上 CAScells存在时只有probe定位到的Cell为 null 或 CAS 失败才继续进入longAccumulate。这里面各种分支组合非常多只看我自己写的简化版是体会不到的建议对照 JDK 源码一点点读。写完简化版之后我再回头去看Striped64的源码瞬间就明白了为什么代码要写得这么复杂。它本质上是在用一个非常轻量的自旋锁cellsBusy来保护数组的初始化和扩容用 CAS 来保护每个计数单元用probe来做线程和槽位的绑定三样东西组合在一起才撑起了高并发场景下的性能。6. 我的实战体会与最终建议最后说点压测之外的东西。那次把埋点服务的AtomicLong换成LongAdder之后我其实还顺手排查了其他几个热点但收益最大的就是计数器这块。改动非常简单counter.incrementAndGet()变成了counter.add(1)读取的时候从counter.get()变成counter.sum()对调用方来说几乎零成本但高峰期的 CPU 直接降了一个档位。这让我对手头这个类的信任度大幅提升但同时也逼着我养成了一个习惯每次引入一个新的并发原语之前先问自己这个类的语义和我的业务语义是否对得上。如果你现在正准备在生产环境替换计数器我的建议是分三步走。第一步确认你的读取时机。如果只是定期上报、定时汇总、监控面板轮询LongAdder稳的。如果是每次请求都要拿当前值来做条件判断继续保持AtomicLong。第二步压测时注意线程数和竞争方式。LongAdder的优势集中体现在多核高竞争阶段单核或两线程场景下优势很小甚至为负别被网上那些26 倍之类的标题冲昏头。我的实测数据是 16 线程自增场景大约差 26 倍但换到 4 线程可能只差两三倍换到 2 线程甚至反超。第三步注意内存和序列化。大量实例化LongAdder对象之前先估算内存另外它的序列化会把所有分段拍平成base因为分段状态只在当前 JVM 内存里有效跨进程汇总计数时必须自己在业务层处理。我自己现在做并发统计时默认选择LongAdder作为全局计数的主力但凡是需要下一次读取必须包含上一次写入结果的地方一律回到AtomicLong或者干脆用synchronized。这个选择标准至今没出过错。
返回列表