ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JUC原子类实战:高并发下AtomicLong与LongAdder原理与性能对比

JUC原子类实战:高并发下AtomicLong与LongAdder原理与性能对比 做后端开发这几年只要一聊到高并发JUC原子类就是绕不开的名字。计数器这种看似人畜无害的操作一旦放到多线程环境下就原形毕露count不是原子操作volatile只能保证可见性却保证不了复合操作的原子性加锁又太重。尤其当接口的QPS冲到几千甚至几万时如何把计数做得又准又快直接决定系统稳不稳。这篇文章就从JUC原子类入手围绕高并发计数场景把AtomicLong、LongAdder等核心类从原理到实现再到性能对比和面试题完整拆解一遍。1. 高并发计数场景下的痛点分析为什么需要JUC原子类1.1 当多个线程同时执行 count 时到底发生了什么在面试中我经常问候选人一个问题两个线程同时对一个int变量执行count循环一万次最终结果一定等于两万吗大多数人都知道不等于但能准确说出原因的很少。count在字节码层面其实分三步读取count当前值、把值加1、写回count。如果线程A和线程B同时读到了count100各自加1后都写回101那这个“1”的增量就丢了。这不是极端情况而是线程调度交错时必然发生的竞态条件。再看Java内存模型每个线程有自己工作内存中的副本count不加任何修饰时线程A修改了值线程B未必能立刻看到这是可见性问题。加上volatile能解决可见性但解决不了“读-改-写”这个复合操作的原子性——两个线程还是可以同时读到同一个值然后各自加1写回。这时候JUC包里的原子类就派上了用场。它用CASCompare And Swap取代了传统的加锁思路在硬件层面完成“读-改-写”的原子判断。后面你会看到原子类并不是简单地把i包一下而是提供了一套全新的并发计数姿态。1.2 JUC原子类家族不只 AtomicLong 一个选择JUC的java.util.concurrent.atomic包下最常用的原子类大致可以分成四类标量原子类、数组原子类、引用原子类、累加器类。先上一张分类表类别典型类核心用途标量原子类AtomicInteger、AtomicLong、AtomicBoolean单值计数、标志位、自增序号数组原子类AtomicIntegerArray、AtomicLongArray数组元素的原子更新引用原子类AtomicReference、AtomicStampedReference对象的原子替换带版本号解决ABA累加器类LongAdder、LongAccumulator高并发写多读少的计数、统计除了这些还有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater这类工具类可以直接更新某个类里的volatile int或volatile long字段省去包装对象的开销。不过实际用的人不多原因很简单用FieldUpdater需要反射访问字段代码可读性差而且一旦字段名写错运行时才报错很坑。我自己的建议是常规业务代码直接用原子类对象就好别为了省那点内存牺牲可维护性。标量原子类是今天的主角。AtomicInteger、AtomicLong适合中低并发下的计数LongAdder则针对高并发写多读少专门优化底层把单一热点值拆成多个单元每个线程分散到不同单元累加最后再求和。简单说AtomicLong是“一个总线上所有人抢”LongAdder是“多车道分流最后汇总”。理解这点基本就抓住了计数的性能命门。2. AtomicLong与LongAdder核心解析从CAS到分段热点2.1 AtomicLong的另一面CAS自旋的原理与代价原子类的底层核心是Unsafe类的compareAndSwapLong方法也就是CAS。CAS操作包含三个参数内存地址、预期旧值、新值。执行时处理器会比较内存里的当前值和预期旧值是否一致一致才更新为新值否则就不更新。返回结果告诉调用方是否成功。AtomicLong的incrementAndGet()其实就是在一个循环里反复尝试CAS先读当前值然后算出加1的结果再尝试CAS如果失败说明有别的线程“抢先”改了值于是重新读取再尝试直到成功。这个自旋过程是无阻塞的但有一个代价线程竞争越激烈重试次数越多CPU开销越大。我举个类比AtomicLong就像一家只有一个收银台的超市顾客线程多的时候都挤在一起排队每个人结账都要反复确认自己手里的钱数还是最新状态。这种自旋浪费在高并发下很明显这也是为什么后来JDK 8引入了LongAdder。顺带一提JDK 9之后Unsafe被逐步替换为VarHandle但CAS的原理没有变平时使用原子类完全不用关心底层实现变化只需要知道它的语义。2.2 LongAdder用分段累加解决热点冲突LongAdder的核心思想是把一个热点值拆成多个格子每个格子存一个分段值。线程做加法时通过哈希分配到一个格子只在那个格子内CAS更新。这样一来不同线程大概率落到不同格子上冲突概率大大降低。求和时把Base值和所有格子的值加起来返回。代码用法非常简单LongAdder counter new LongAdder(); counter.increment(); counter.add(10); long sum counter.sum();LongAdder里维护了一个Cell[]数组初始是空并发量小的时候直接累加到base变量只有竞争激烈时才扩容。这和ConcurrentHashMap的扩容思路有异曲同工之处先轻量运行压力大了再上“重武器”。要注意sum()得到的是近似值。因为累加过程中其他线程可能还在更新某个Cell读完Cell数组再累加得到的是某一瞬间的“快照”不能保证强一致。如果业务要求严格的精确值比如记账就不能用LongAdder。但如果是统计QPS、监控埋点、非精确的次数统计LongAdder就是神器。2.3 到底是选AtomicLong还是LongAdder两个判断标准选型时抓住两个判断标准就够了。标准一并发写量级。如果并发线程数不多CAS冲突不严重AtomicLong完全够用。一旦并发线程数达到十几个以上且都在高频自增同一个计数器LongAdder的优势会非常明显。比如一个网关系统要统计十几万QPS的请求量用AtomicLong每个请求都要抢同一个热点性能损耗很大换成LongAdder分散到Cell数组里吞吐量能提升好几个量级。标准二是否需要精确返回值。比如getAndIncrement()这种需要获取自增前值的场景LongAdder无法直接提供虽然可以后续通过sum()弥补但在某些算法中这会影响正确性。AtomicLong则支持incrementAndGet()、getAndAdd()等完整API语义更强。还要注意内存占用。LongAdder内部有Cell数组每个Cell是一个对齐填充的64字节对象在高并发下可能占用不少内存。如果一个类里同时放好几个LongAdder内存成本得掂量一下。AtomicLong只是一个long值内存小得多。3. 三种计数方案性能对比实战synchronized、AtomicLong、LongAdder3.1 基准测试环境与测试思路为了让大家有个直观体感我这里提供一个可以自己跑的对比测试思路。测试目标很简单用三种方式分别完成一定次数的自增操作开启多个线程并发执行统计总耗时。注意这里的测试结果在不同机器、不同JDK版本下会有差异重点看趋势。测试代码用ExecutorServiceCountDownLatch实现模拟线程同时起跑结束后统一计时。每个线程循环递n次数。为了避免JIT对空循环的优化需要把计数结果累加到一个total变量中。下面的代码片段能帮你快速搭一个测试骨架int threadCount 8; int loopCount 100_000; ExecutorService pool Executors.newFixedThreadPool(threadCount); CountDownLatch ready new CountDownLatch(threadCount); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(threadCount); AtomicLong total new AtomicLong(); for (int i 0; i threadCount; i) { pool.submit(() - { ready.countDown(); start.await(); for (int j 0; j loopCount; j) { increment(); // 换成不同实现 } total.incrementAndGet(); done.countDown(); }); } ready.await(); long begin System.nanoTime(); start.countDown(); done.await(); long end System.nanoTime(); System.out.println(cost: (end - begin) / 1_000_000 ms); pool.shutdown();3.2 synchronized方案简单但最慢volatile long count 0; synchronized void increment() { count; }每次加锁都要走JVM的锁升级过程即使无竞争是偏向锁一旦竞争升级成重量级锁就会涉及线程阻塞和唤醒上下文切换成本很大。在高并发下synchronized的吞吐量明显低于原子类这是第一个被淘汰的方案。如果只是演示用synchronized代码最简单初学者一眼能懂。但它的问题也很明显线程越多锁竞争越激烈性能急剧下降。实测中16个线程并发做自增耗时往往会比LongAdder高出十几倍。3.3 AtomicLong方案无锁但自旋损耗不容忽视AtomicLong count new AtomicLong(0); count.incrementAndGet();AtomicLong在低并发时性能不错一旦并发线程变多CPU几乎都在空转CAS吞吐量开始下滑。在8个线程以同步大循环竞争同一个AtomicLong的情况下耗时可能要比LongAdder高出数倍。但胜在代码简单语义精确。需要说明的是AtomicLong的get()是无锁的自旋只发生在写操作。如果业务里读远多于写AtomicLong其实很合适因为它不需要做分段汇总随时拿到的值都是当前精确值。3.4 LongAdder方案高并发下的性能王者LongAdder count new LongAdder(); count.increment();LongAdder的分段设计让每个线程拥有相对独立的累加单元写并发远好于AtomicLong。测试中当线程数提高到16、32时LongAdder的优势愈发明显总耗时甚至能不到AtomicLong的三分之一。我的本地采样数据长这样不同机器差异很大仅示意趋势方案8线程耗时(ms)16线程耗时(ms)32线程耗时(ms)synchronized约450约1200约2500AtomicLong约120约350约900LongAdder约60约90约150注意这个表格不是标准benchmark只代表我一次运行的趋势采样。要严谨对比建议用JMH做多轮预热。3.5 测试方法的一个坑防止JIT优化自己写对比测试最容易踩的坑就是JIT优化。如果你的循环里只是执行count.increment()但循环结束后这个count完全没用上JIT可能会判断这是一个无副作用的死代码直接把循环优化掉导致耗时几乎为零结果完全失实。解决办法有两个一是循环结束后打印最终结果保证count确实被读取二是用JMH框架通过BenchmarkMode和Blackhole来消费结果。我建议有条件直接用JMH它自己会做预热、统计、防优化比自己写System.nanoTime()靠谱得多。4. 高频面试题与实战避坑指南ABA问题、自旋与可见性4.1 面试题AtomicLong和LongAdder有什么区别这几乎是JUC面试绕不开的一题。回答要点有三块底层机制不同AtomicLong基于CAS自旋LongAdder基于热点分段累加。性能侧重不同LongAdder写并发更高但sum()是弱一致性的AtomicLong读写语义强一致。API丰富度不同AtomicLong支持getAndIncrement等完整操作LongAdder没有等价操作。如果再被追问“LongAdder为什么快”一定要说出关键因为把竞争分散到多个Cell上减少了CAS失败重试。最好结合源码解释回答会更有说服力。面试官如果继续问“LongAdder什么时候不准”就答sum()期间其他线程可能仍在更新Cell所以返回的是近似值。4.2 ABA问题AtomicReference也是“有坑”的CAS有一个经典陷阱ABA。线程A读取到值为A线程B把它改成B再改回A线程A再次CAS时发现值仍是A就认为没人动过但事实上中间变了两次。在计数场景里ABA问题不一定致命但在无锁数据结构、对象引用替换时可能引发隐蔽错误。解决方式是用AtomicStampedReference或AtomicMarkableReference通过带版本戳的CAS确认版本号没变。看个例子AtomicStampedReferenceInteger ref new AtomicStampedReference(100, 0); int[] stamp new int[1]; Integer value ref.get(stamp); ref.compareAndSet(value, 101, stamp[0], stamp[0] 1);版本号每次修改都加1比较时值和版本号都必须一致。这样做就相当于给对象引用加了一个“修改次数记录”ABA问题自然被拦住了。4.3 实战踩坑可见性、lazySet与reset这里分享几个我真实踩过的坑都是常规文档里不写的。第一个坑是错误地缓存get()结果。AtomicLong的get()方法具有volatile读语义所以跨线程读没问题。但如果为了性能在循环里把get()缓存到局部变量然后依赖旧值做业务判断就可能拿到过期数据。解决办法是该读的时候读不要过早优化。第二个坑是lazySet方法。lazySet会以牺牲顺序保证为代价降低写屏障开销它不能保证其他线程立即看到新值。如果用在需要实时反馈的标志位上会引起很难排查的延迟问题。除非你确认业务不要求及时可见否则别用。第三个坑最隐蔽——LongAdder的reset()不是强一致的。reset()和sum()一样是弱一致的在多线程同时reset和increment时可能出现计数被清零后丢失一部分增量导致统计不准确。如果需要周期清零重统计建议重新new一个LongAdder而不是复用reset。我在写周期计数任务时就见过有人用单个LongAdder做每小时计数到点就reset结果由于reset的弱一致性偶尔会重复计数或漏数。后来改成每轮new一个新实例问题才彻底解决。5. 原子类在真实业务中的落地场景限流、订单号与监控统计5.1 接口调用计数与周期限流高并发接口最常见的需求就是限流。用AtomicLong可以实现简单的固定窗口限流每秒最多处理N个请求到达窗口末尾后重置计数。代码可以这样写private AtomicLong requestCount new AtomicLong(0); private long lastResetTime System.currentTimeMillis(); public boolean tryAcquire() { long now System.currentTimeMillis(); if (now - lastResetTime 1000) { synchronized (this) { if (now - lastResetTime 1000) { requestCount.set(0); lastResetTime now; } } } return requestCount.incrementAndGet() 100; }这里的synchronized只用于定时重置防止多个线程并发清零。incrementAndGet()是原子操作所以判断是否超过100次是准确的。这个方案缺点是窗口边界处会有突刺但胜在实现简单、无外部依赖很多内部系统都在用。如果要更平滑的限流可以使用LongAdder配合定时任务周期取计数然后整体替换新实例。注意这里不要用reset()理由在上一节已经说过了。5.2 订单号生成与唯一序列高并发下生成全局唯一ID一个常见方案是“时间戳自增序列”。用AtomicLong维护序列比如private AtomicLong seq new AtomicLong(0); public String nextOrderNo() { long s seq.incrementAndGet() % 1_000_000; return System.currentTimeMillis() String.format(%06d, s); }这个方案有一个明显限制在同一个毫秒内最多只能生成100万个单号超过就会重复。如果业务量小可以这样用如果QPS很高就要扩大模数或者改用雪花算法。无论哪种方案都要注意AtomicLong溢出问题超过Long.MAX_VALUE后incrementAndGet()会变成负数如果业务有影响需要额外判断这是一个很少有人会主动提的面试加分点。5.3 统计埋点与监控中的“重复计数”问题在做业务埋点时经常需要统计成功数、失败数、重试数。LongAdder很适合这些只增不改的场景因为不需要精确值只需要最终数量。但如果不加控制前端或上游重试可能造成重复计数。比如用户点击按钮后网络超时又点了一次同一个事件被记了两次。解决重复计数不只是原子类层面的事还需要在业务入口做幂等。这里可以结合AtomicBoolean做一个防抖标记private AtomicBoolean processing new AtomicBoolean(false); public boolean tryProcessEvent() { if (!processing.compareAndSet(false, true)) { return false; // 已有线程在处理防止重复 } try { counter.increment(); return true; } finally { processing.set(false); } }每次处理事件前用compareAndSet(false, true)抢占只有抢到才计数最后再重置。注意重置时机必须放在finally里否则异常时标志位永远卡在true后续事件全部被拦掉。监控采集场景也很适合LongAdder采集线程定时把sum()快照发送到监控系统采集间隔内的数据不要求完全一致LongAdder的弱一致性反而成了优势。最后再分享一个我实际工作中总结的小经验在没压测之前不要靠猜决定用哪种计数器。我自己就吃过亏想当然用了LongAdder结果发现业务场景是读多写少每次sum都要遍历Cell数组换成AtomicLong后反而更顺手。技术选型永远基于业务特征而不是听着谁快就用谁。计数这个看似简单的功能背后牵扯到原子性、可见性、自旋、分段等多个维度弄懂了JUC原子类高并发计数这块基本就稳了。
返回列表