没有主标题,直接从二级标题开始。
1. 为什么高并发下的计数会“打架”
1.1 一个 i++ 背后藏着的三步操作
做后端开发的朋友应该都对“高并发计数”这个需求不陌生。无论是统计在线人数、记录接口调用次数、生成递增序列号,还是做各种限流和监控,本质上都是同一个模型:多个线程同时对同一个共享变量做“读-改-写”操作。而这个模型里最经典的坑,就是那个看起来人畜无害的i++。
很多人第一步接触并发时都会想:单线程里i++不就是一行代码吗?还能出错?但严格来说,i++在JVM字节码层面根本不是一条指令。它拆开大概是三步:先读取i的当前值,然后把这个值加1,最后把结果写回i。如果在多线程环境下,线程A读到了i=5,还没来得及写回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平台上是CMPXCHG,ARM平台是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.compareAndSwapInt,JVM通过JNI(Java Native Interface,Java本地接口)转发到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做原子更新 | 状态开关、幂等标记 |
AtomicReference<V> | 引用类型 | 对对象引用做原子更新 | 无锁栈/队列、配置热更新 |
AtomicIntegerArray | 整型数组 | 对数组元素做原子更新 | 分片统计、钉扎数据 |
AtomicLongArray | 长整型数组 | 对数组元素做原子更新 | 分桶队列指标 |
AtomicStampedReference<V> | 引用+版本号 | 带版本号的引用原子更新 | 解决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返回1,B返回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 克星 AtomicStampedReference
AtomicReference是把CAS从基本类型扩展到了对象引用。它可以用来做无锁的链表、栈,或者实现配置的热更新。但引用类型的CAS有一个著名的问题,就是ABA问题。
什么叫ABA?简单说:线程A读取到变量值是X,然后它在做其他事情的过程中,变量被线程B改成了Y,又被线程C改回了X。等线程A要执行CAS的时候,它发现变量还是X,于是认为“没有被改动过”,更新就成功了。可实际上值被改过一轮了,这在某些场景下会导致隐患。
怎么解决?加版本号。AtomicStampedReference内部持有一个引用对象和一个整型版本号。每次修改时,只要版本号变,CAS就会失败:
AtomicStampedReference<String> 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计数器,比用HashMap<Long, 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 压测结果与对比表
我在本地用JMH(Java 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。
当时把计数数据结构换成了LongAdder,CPU立刻降回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了。做高并发计数,本质上是在“性能”和“一致性”之间反复权衡。无锁不是炫技,锁也不是原罪,用最小代价解决当前问题,才是真正值得追求的目标。