
没有主标题直接从二级标题开始。1. 为什么高并发下的计数会“打架”1.1 一个 i 背后藏着的三步操作做后端开发的朋友应该都对“高并发计数”这个需求不陌生。无论是统计在线人数、记录接口调用次数、生成递增序列号还是做各种限流和监控本质上都是同一个模型多个线程同时对同一个共享变量做“读-改-写”操作。而这个模型里最经典的坑就是那个看起来人畜无害的i。很多人第一步接触并发时都会想单线程里i不就是一行代码吗还能出错但严格来说i在JVM字节码层面根本不是一条指令。它拆开大概是三步先读取i的当前值然后把这个值加1最后把结果写回i。如果在多线程环境下线程A读到了i5还没来得及写回6线程B也读到了5那A和B各自写回6最终i只增加了1真实的两次请求“丢失”了一次计数。这就是典型的丢失更新问题。我在很多项目里见过这种场景。最典型的是用HashMap或者普通int变量来做缓存命中统计初始看起来没事压测一旦把并发喊上去统计数字就永远对不上账。排查成本还特别高因为它是偶发的不是必现的。线程调度稍微错开一点结果就不同log里根本看不出规律。所以搞高并发计数第一件事就是把“非原子操作”这个认知刻在脑子里。1.2 从加锁到无锁三种方案的演进既然i不安全那最早期的做法自然是加锁。原先的写法通常是给整个increase()方法加synchronized或者用ReentrantLock包住临界区。锁的方案逻辑上绝对正确问题在于性能——锁的获取、竞争、释放都涉及操作系统层面的上下文切换和线程阻塞唤醒在高竞争场景下锁非但没帮上忙反而变成了瓶颈。用最直白的话说本来加个数字只要几纳秒抢一次锁可能要花几微秒幅度差了两三个数量级。后来引入了原子类也就是java.util.concurrent.atomic包下面那一堆类。它们背后的思想完全不同不需要阻塞线程而是通过CPU底层的CAS指令乐观地尝试更新。没有竞争就能直接成功有竞争就“自旋”重试。这等于把“排队进厕所”改成了“自己看着办有空位就进没空位就再试一次”吞吐量和延迟表现都好很多。到了更高并发阶段还演化出了LongAdder这样的“分段计数”方案。它把原来一个共享热点变量拆成多个Cell单元各个线程分散到不同单元里加最后求和时再合并。这个思路跟ConcurrentHashMap的分段锁是一脉相承的核心都是降低热点竞争。1.3 什么时候该用原子类原子类不是万能的但有非常明确的适用边界。它适合的是单变量、操作简单、逻辑独立、且对数据一致性要求不是“强事务性”的场景。比如计数器、状态开关、序列号生成器、统计指标等。反过来如果你要做的是一组变量必须同时更新或者某个操作需要先检查再决定还伴随业务判断那就不是原子类能搞定的必须上synchronized、ReentrantLock甚至分布式锁。我自己的选型习惯是能无锁就无锁但要判断操作组合的复杂度。单飞的操作交给原子类多个步骤组合的原子性交给锁。这不是偷懒而是让每一个工具待在它该待的位置。2. 原子类的心脏CAS 原理硬核拆解2.1 CAS 的三个操作数与硬件级支持CAS全称 Compare And Swap翻译成中文就是“比较并交换”。它是无锁并发最底层的基石。CAS操作接收三个参数内存地址V、预期值A、要写入的新值B。它的执行逻辑是只有当V当前位置存储的数和A相等时才把V更新为B否则什么都不做。这看起来不就是“先比一比再决定要不要写”吗但关键在于“比较”和“交换”这两个动作在硬件层面被设计成了一个不可分割的原子操作。CPU提供了对应的指令x86平台上是CMPXCHGARM平台是LDREX/STREX一族的实现。也就是说原子性这件事在CAS这里已经不依赖Java的锁机制而是直接把宝压在CPU指令上既不用阻塞线程也不需要上下文切换。用一个生活化的比方来解释你去抢最后一个停车位系统显示“空余1个”你点“占用”但就在你点击的瞬间另一个人也看到了“空余1个”并抢先点了占用。这时候CAS会怎么处理它会发现你预期的“空余1个”已经变成了“空余0个”于是你的操作失败只能重新加载最新状态再试一次。整个过程你没有被阻塞不需要拿号排队只是“失败重试”而已。2.2 从 Java 到 CPU一条 compareAndSet 的完整链路很多刚开始看源码的人看到AtomicInteger.compareAndSet()内部的unsafe.compareAndSwapInt会一头雾水。这个Unsafe类虽然名字叫“不安全”但它在JDK内部大量使用。它提供了一些绕过Java内存管理、直接操作内存地址的底层能力。compareAndSwapInt接收对象引用、字段偏移量、预期值、更新值四个参数然后把这个调用映射到JVM的本地方法最终执行到CPU的CAS指令。整条链路是这样的Java代码调用AtomicInteger.compareAndSet(expect, update)进入AtomicInteger内部调用Unsafe.compareAndSwapIntJVM通过JNIJava Native InterfaceJava本地接口转发到C实现的底层方法最后落到lock cmpxchg指令。x86平台上的cmpxchg指令不是天然完全原子的所以前缀还会加上一个lock来锁住总线或缓存行确保多核CPU之间的协同不会被干扰。这条链路清楚之后很多问题自然就通了。比如为什么说CAS是无锁的——它确实没加Java层面的锁但底层其实还是需要硬件级别的 “lock” 来保证原子性只不过这个开销远比操作系统锁小得多。2.3 自旋与重试无锁并发的基本动作CAS操作单次要么成功要么失败但调用方通常不会“失败就放弃”而是会循环重试这个循环过程叫自旋Spin。自旋锁的名字也源自于此——线程在循环里“转圈”等待而不是被挂起。写一个最简单的自旋实现底层逻辑就是getAndIncrement的核心public static int spinIncrement(AtomicInteger atomic, int times) { int result; do { // 先拿到当前值 result atomic.get(); // 计算新值再尝试CAS写入 } while (!atomic.compareAndSet(result, result 1)); return result; }do-while会一直执行到某个线程CAS成功才退出。如果竞争不激烈第一次尝试往往就成功了开销极小。这也是为什么在低并发场景下原子类比锁更快的原因——它没有线程切换成本只做一次CPU比较和写操作。但自旋也有代价。如果竞争非常激烈大量线程同时失败、同时重试CPU就被白白消耗在空转上核数越多这种“空转风暴”越明显。所以“无锁”并不等于“零成本”理解这一层才能在适当地点选择锁还是CAS。3. 常用 JUC 原子类逐个击破3.1 四大基础原子类速查表java.util.concurrent.atomic包里的类可以按用途大概归成这么几类后面实战部分我会逐个配场景讲。原子类类型核心用途典型应用场景AtomicInteger整型对int做原子更新计数器、序号生成、限流计数AtomicLong长整型对long做原子更新大数值计数、全局ID段、时间戳AtomicBoolean布尔型对boolean做原子更新状态开关、幂等标记AtomicReferenceV引用类型对对象引用做原子更新无锁栈/队列、配置热更新AtomicIntegerArray整型数组对数组元素做原子更新分片统计、钉扎数据AtomicLongArray长整型数组对数组元素做原子更新分桶队列指标AtomicStampedReferenceV引用版本号带版本号的引用原子更新解决ABA问题、无锁数据结构LongAdder长整型高并发累加热点统计消息量统计、访问量累计读少写多LongAccumulator长整型自定义累加规则自定义聚合计算每个类都有各自的get/set/compareAndSet/getAndAdd/addAndGet这一类方法接口风格高度一致。掌握了AtomicInteger其余基本都是举一反三。3.2 AtomicInteger 核心 API 使用要点日常开发里AtomicInteger是出现频率最高的一个。它的核心方法不多但对这些方法背后的语义差异要有感知。addAndGet(int delta)是“加 delta 并返回新值”getAndAdd(int delta)是“返回旧值再增加 delta”。这里在并发场景下有个容易忽略的点这两个方法返回的“值”只能代表方法执行那一刻的瞬时快照不等同于业务上的最终结果。如果 A、B 两个线程同时addAndGet(1)可能A返回1B返回2这个顺序本身是确定的但如果后续业务要依赖这两个返回值做某种“谁先谁后”的判断那就需要额外设计。compareAndSet(int expect, int update)是手动控制更新逻辑的关键。比如我们要实现一个“不超过某个上限的计数器”public static boolean incrementWithLimit(AtomicInteger counter, int limit) { while (true) { int current counter.get(); if (current limit) { return false; // 已达到上限放弃 } int next current 1; if (counter.compareAndSet(current, next)) { return true; // CAS成功本次更新有效 } // 如果CAS失败说明值被其他线程改了循环再试 } }这个手动CAS的写法就是在解决“先检查后操作”的原子性问题。如果用addAndGet再事后判断是否越界在并发下一定会出现瞬间超限的问题。lazySet(int newValue)是一个比较冷门但高效的方法。它只保证最终的可见性不保证顺序性相比set少了内存屏障的开销。在高吞吐、低即时性要求场景比如统计上用到的重置可以考虑它但常规业务不建议轻易用。3.3 AtomicReference 与 ABA 克星 AtomicStampedReferenceAtomicReference是把CAS从基本类型扩展到了对象引用。它可以用来做无锁的链表、栈或者实现配置的热更新。但引用类型的CAS有一个著名的问题就是ABA问题。什么叫ABA简单说线程A读取到变量值是X然后它在做其他事情的过程中变量被线程B改成了Y又被线程C改回了X。等线程A要执行CAS的时候它发现变量还是X于是认为“没有被改动过”更新就成功了。可实际上值被改过一轮了这在某些场景下会导致隐患。怎么解决加版本号。AtomicStampedReference内部持有一个引用对象和一个整型版本号。每次修改时只要版本号变CAS就会失败AtomicStampedReferenceString ref new AtomicStampedReference(init, 0); // 更新时同时检查引用和版本号 boolean ok ref.compareAndSet(init, updated, 0, 1);这样即使引用值看起来没变只要版本号对不上CAS依然会失败。在我做无锁队列和无锁栈的时候这个类就是“保命”的关键。常规业务计数器很少用到它但一旦你写底层中间件或者自己封装无锁数据结构就绕不过去了。3.4 LongAdder高竞争场景的终极形态如果只是看AtomicLong在极低竞争下的性能它几乎无可挑剔。可当线程数一多同一个内存地址成为全JVM的热点所有CAS都往这一个坑里挤性能就会快速下降。LongAdder的思路是把这个热点拆开。LongAdder内部维护一个基准值base和一个Cell[]数组。每个Cell是一小块缓存行对齐的空间。线程做累加时先通过ThreadLocalRandom算出当前线程的哈希定位到其中一个Cell上进行CAS累加。这样不同线程落在不同Cell上把原来单点的竞争分散成了多个点的分散竞争。只有需要sum()的时候才把base和所有Cell的值加到一起但sum()本身是不加锁的属于“最终一致性”的快照。所以LongAdder特别适合“写多读少”的场景。比如统计平台每秒消息积压量、累计PV、请求次数的累计指标这类场景根本不在乎中间某个瞬间的准确值只在乎最终结果。反过来如果你需要用计数器生成全局唯一ID那就必须用AtomicLong或者AtomicInteger因为每一步操作都需要拿到一个精确且全局递增的值LongAdder给不了这种强一致性。4. 实战复盘用原子类解决四个真实计数场景4.1 场景一在线人数统计告别 synchronized很多项目在初期喜欢用synchronized方法或AtomicInteger来统计在线人数。如果并发低什么都好说可一旦秒杀或者活动上线同时登录/登出的用户一多锁竞争就会让原本简单的增减操作变得昂贵。用AtomicInteger重构在线人数统计其实特别简单public class OnlineUserCounter { private final AtomicInteger count new AtomicInteger(0); public void onUserLogin() { count.incrementAndGet(); } public void onUserLogout() { count.decrementAndGet(); } public int currentOnline() { return count.get(); } }看起来平平无奇但它去掉了一个潜在的性能地雷。这里提个细节如果是“先判断是否已登录再决定是否 1”这种复合逻辑单纯incrementAndGet是不够的必须做CAS自旋或者配合业务状态字段用锁。我自己写游客登录统计时就踩过这个坑——没做判断直接 同个用户从两个设备进来在线数瞬间翻了倍。4.2 场景二本地唯一序列号分配订单号、流水号这类需求在分布式架构下通常由发号器统一生成但很多时候单机场景也可以用本地序列号过渡。用AtomicLong做本地发号器是最经典也最可靠的方式之一。public class SeqGenerator { private final AtomicLong seq new AtomicLong(0L); public long next() { return seq.incrementAndGet(); } }再进阶一点如果发号需要带业务前缀、机房编号和时间戳一般会组装成一个字符串public String nextOrderNo() { long l seq.incrementAndGet(); return String.format(%s%04d%010d, NO, bizCode, l); }这里有个注意点AtomicLong.incrementAndGet()在达到Long.MAX_VALUE时会溢出为负数。真实业务里几乎不会触到这个上限但为了防止异常数据可以在达到某阈值之后做告警或者设计重置逻辑。别在这块省代码线上看到负数序列号的时候排查成本远大于提前防御的成本。4.3 场景三接口 QPS 实时监控QPS每秒请求数统计是运维排障和容量评估的基础数据。实现思路不复杂维护一个当前秒的计数器新来一个请求就加1同时通过定时任务或者懒刷新方式每到新秒就重置计数并上报。用AtomicLong实现一个多线程安全的QPS计数器比用HashMapLong, Long存每秒的累计值要轻量得多。public class QpsMeter { private final AtomicLong qps new AtomicLong(0); private volatile long lastSecond System.currentTimeMillis() / 1000; public void hit() { long currentSecond System.currentTimeMillis() / 1000; if (currentSecond ! lastSecond) { // 新的一秒清掉旧计数 synchronized (this) { if (currentSecond ! lastSecond) { qps.set(0); lastSecond currentSecond; } } } qps.incrementAndGet(); } public long currentQps() { return qps.get(); } }这里用了一个synchronized快路径拦截是因为“跨秒重置”这个动作是典型的复合操作先判断当前秒和上一秒是否相等再决定要不要清零。直接用原子类做也是CAS自旋的变体但重置动作本身就涉及共享状态lastSecond的更新用一小段同步代码反而更稳妥。这个设计思想值得记住原子类和锁不是互斥的把锁的粒度放到最小大多数路径用无锁只在极少数跨秒时连接处才用锁是性能与正确性的均衡。4.4 场景四库存扣减为什么不要直接上原子类原子类在计数类场景里大放异彩但有一个非常容易踩坑的领域库存扣减。很多人觉得库存也是数字扣库存就是decrementAndGet嘛多简单。但库存和“计数”最大的区别在于库存扣减涉及“事务边界”——通常还得同时记录订单、锁定用户、记录扣减流水。如果只更新一个库存数字数据库行锁和事务的一致性就被破坏了。举个典型的并发秒杀例子库存剩1件A、B两个用户同时提交订单用AtomicInteger扣减库存能保证只有一个用户扣到但从业务上看这个扣减动作发生在事务提交之前还是之后如果先扣库存后下单订单创建失败库存已经飞了如果先下单后扣库存又可能出现超卖。所以原子类做不了这件事的全局一致性必须配合数据库事务和锁策略。那原子类在库存场景就没用了吗也不是。它可以用来做“预占式限流”用一个AtomicInteger初始化成总库存每次购买前先decrementAndGet如果返回负数就拒绝。这个方案叫“预扣减”能快速拦掉一批并发请求是保护数据库的有效前置措施。但真正的扣减操作最终还是要回到数据库事务里去完成。5. 性能对决atomic 与锁的实测对比与选型建议5.1 JMH 压测结果与对比表我在本地用JMHJava Microbenchmark Harness官方微基准测试工具对不同方案的计数器做过一组粗测。机器是8核16G的普通开发机JDK版本17。测试内容都是循环执行一百万次累加操作对比synchronized、ReentrantLock、AtomicLong、LongAdder四类实现。实现方式低竞争2线程中竞争8线程高竞争32线程synchronized较快性能明显下降吞吐很差线程阻塞增多ReentrantLock略慢于synchronized与synchronized接近同样受限于阻塞开销AtomicLong最快吞吐下降但依然可用自旋导致CPU占用飙升LongAdder略慢于AtomicLong开始超越AtomicLong优势非常明显吞吐领先数倍这个表格只是趋势参考具体数字跟机器和JVM参数有关但方向是稳定的。低竞争环境下AtomicLong就是最优解简单且确定性高一旦线程数超过CPU核心数的两三倍竞争激烈程度上来LongAdder能在吞吐上甩开AtomicLong一个档次。那个“最快”的结论是不是意味着我们一律用AtomicLong就对了不是。选型还要看读写的比例。LongAdder的sum()需要遍历所有Cell如果每次都要拿精确值判断比如“当前计数必须精确等于5才放行”那LongAdder反而慢。它适合“最后汇总”而不是“实时精确读取”。5.2 伪共享False Sharing对性能的隐性杀伤聊性能就绕不开伪共享。CPU缓存是以缓存行Cache Line为粒度加载的x86架构下通常一个缓存行是64字节。如果两个共享变量落在同一个缓存行里且分别被两个不同核心的线程频繁修改这两个核心就会不断同步缓存行导致性能剧烈下降。LongAdder的高明之处在于它的Cell对象用sun.misc.Contended注解做了填充扩展保证每个Cell独立占满一个缓存行。这就是“以空间换时间”的一个经典工程实现。如果你自己要设计一个“高频写”的多变量结构可以通过填充字段来避免伪共享public class PaddedCounter { public volatile long value 0; // 填充到接近64字节避免与其他变量共享缓存行 public long p1, p2, p3, p4, p5, p6, p7; }这种写法在日常业务代码里用不上但如果你的中间件被压测时性能上不去排查时一定别忘了看一下是不是伪共享在捣乱。用perf工具或者JFR热方法分析能看到端倪。5.3 选型决策流程经过大量实战我总结出一个简单好用的选型流程如果只是统计一个全局递增或递减的数字且并发不高用AtomicInteger/AtomicLong简单可靠语义清晰。如果统计场景是“写多读少”且读取频率远低于累加频率用LongAdder。如果统计需要实时精确值且并发又很高不要执着于无锁考虑分段计数器配合定期汇总。如果还需要条件更新和对象级别的引用变更用AtomicReference或AtomicStampedReference。如果操作涉及多个变量的协同一致性放弃原子类直接用锁。这套流程还不至于套用到所有场景但覆盖了绝大多数业务使用方向。6. 常见坑点与面试高频题实战解读6.1 ABA 问题彻底搞懂前面提过ABA问题的概念这里再展开一次因为它是最经典的原子类“隐藏陷阱”也是面试几乎必问的考点。用代码复现一个最简单的ABA场景AtomicInteger ai new AtomicInteger(1); Thread t1 new Thread(() - { // 等待t2完成修改 while (ai.get() ! 1) { Thread.yield(); } // 此刻值已经是1了CAS成功 boolean ok ai.compareAndSet(1, 2); System.out.println(CAS result: ok); }); Thread t2 new Thread(() - { ai.compareAndSet(1, 5); // 1 - 5 ai.compareAndSet(5, 1); // 5 - 1又变回1了 });这种“改了一圈又改回来”的过程在计数器场景下通常是没问题的因为计数本身只看最终值。但在无锁数据结构里比如无锁栈节点A被弹出后又被压入如果栈顶引用还指向这个对象CAS就可能把已经被回收的对象误认为“还是原来的栈顶”导致DAG倒错甚至内存破坏。解决方案就是加版本号AtomicStampedReference。每次更新引用时同时更新版本号任何修改都会导致版本号变化CAS之前如果发现版本号和预期不匹配直接判定失败。我在实现无锁栈的时候就是靠这个类避免旧引用复活的问题。6.2 自旋带来的 CPU 飙升与缓解原子类自旋在表面上看不到线程阻塞但这是把双刃剑。高竞争下每个失败的线程都会立刻进入下一轮CAS这些“空转指令”虽然耗费极少但积少成多会把CPU打满。我在一个流量监控组件里遇到过这个现象统计接口QPS用AtomicLong高峰时段CPU直接从20%飙到95%。排查时先看线程状态没发现阻塞再看火焰图发现热点集中在Unsafe.compareAndSwapLong。原因就是所有统计线程都在抢着更新同一个AtomicLong。当时把计数数据结构换成了LongAdderCPU立刻降回40%以内。这就是拆分散热点的价值。如果无法换结构还可以在自旋循环里加入Thread.yield()或者LockSupport.parkNanos(1)短暂退让让竞争线程有机会完成更新而不是大家一起死磕。条件允许时设置一个自旋次数上限超过上限就转换策略也是业界比较成熟的“自适应自旋”思路。6.3 原子类不能解决“复合操作”的原子性这是我认为最容易被忽略的边界。原子类只保证了“单次方法调用”的原子性但并不保证“多方法组合”的原子性。比如我们有个需求扣减余额之前必须确认余额大于0。用AtomicLong实现时如果写两个独立调用——先get()检查再compareAndSet()扣减——那在检查之后和扣减之前别的线程可能已经把余额改成负数了。这不是原子类的错而是我们错误地把“两步操作”当成了“一个操作”。解决方式要么是把检查和扣减合并到一个CAS自旋循环里要么就直接用锁包住整个判断和更新过程。这里我强烈建议当一个操作需要读取多个状态或依赖判断结果时优先考虑锁。原子类的价值在于单点更新它不是万能的事务替代品。6.4 避坑自查清单最后整理一份自查清单都是我在实际项目里遇到或复盘过的问题现象可能原因排查思路统计数字明显偏小使用了i或非原子更新检查字节码或代码确认是否用了原子类高并发下CPU飙升大量线程CAS自旋竞争火焰图定位热点考虑LongAdder或退让策略CAS明明成功了值却不符合预期逻辑上使用了两步操作检查是否涉及多个变量的组合更新改用锁无锁数据结构偶发数据错乱ABA问题使用AtomicStampedReference加版本号压测成绩提升不上去伪共享检查热点变量是否在同一缓存行做填充计数结果毫秒级不准确LongAdder.sum()不保证强一致确认当前场景是否需要精确值选择合适类关于压力测试多说一句压测一定要测到线程数超过核心数的场景不能只在2线程的“舒适区”里验证。并发性能这东西症状在极端参数下才会显形。我个人的经验是原子类本质上是一种“非常贴心但边界分明”的工具。它把并发中最常见的单变量计数问题压缩到极致几乎不让开发者感知到任何线程同步的存在但它也有自己的软肋——复合操作、强一致读取、跨变量约束这些场景还是回归锁和事务。理解了边界再去看AtomicInteger、LongAdder这些类就不再是死记API了。做高并发计数本质上是在“性能”和“一致性”之间反复权衡。无锁不是炫技锁也不是原罪用最小代价解决当前问题才是真正值得追求的目标。