
先说一个让我印象很深的线上事故。那年大促业务侧反馈监控面板上的调用量数字不对比网关日志少了将近三分之一。排查了一下午链路追踪、日志收集、存储链路都翻了一遍最后把目光落在统计代码里那行再普通不过的count上。从那时起我才真正理解所谓高并发计数难的不是加法本身而是让每一次加一在混乱的并发世界里都稳稳落账。JUC 的原子类家族——AtomicInteger、AtomicLong、LongAdder、LongAccumulator就是专为这种场景设计的一套武器。这篇文章不讲空泛概念直接从一次事故出发把原子类的原理、选型、坑点逐个拆开。如果你正在写多线程统计、接口打点、流量计数这类代码或者准备应对高并发面试这篇应该能帮你节省不少时间。1. 一次计数事故count 是如何在并发下“吞数字”的1.1 读改写三步每步都可能被打断先把事故现场的代码结构还原一下。当时监控模块里统计接口调用量的方式很简单每个请求进来就执行一次count。这段代码在单线程下没有任何问题但一旦并发量上来它就会开始“吞数字”。为什么因为count在 JVM 层面不是一个原子操作。把这段代码编译后javap -c看到的核心指令大概长这样getfield # 读取 count 当前值 iconst_1 # 压入常量 1 iadd # 执行加法 putfield # 把新值写回 count也就是说一次看似简单的自增实际上要经历“读旧值、算新值、写回”三步。问题就出在这三步之间。线程 A 刚读完 count100还没来得及写回线程 B 也读到 100A 写回 101B 也写回 101结果就是明明发生了两次自增最终只加了 1。并发越高两个线程在同一时刻读到同一个旧值的概率越大丢的计数也就越多。我当时排查了很久是因为这种问题根本不会报错。没有异常、没有死锁日志里一切正常只有最终的数字对不上。这恰恰是并发计数最阴险的地方它不是“崩”出来的而是“吞”出来的。1.2 volatile 能保证可见性却保不住原子性排查过程中我做的第一个尝试是给count加上volatile修饰想着“多线程能看到最新值应该就行了吧”。结果数字还是不对。volatile在 Java 内存模型里解决的是两个问题可见性和有序性。一个线程修改了volatile变量其他线程能立刻看到最新值编译器、CPU 对相关指令的重排序也会被限制。但它管不了“读-改-写”这个复合过程的原子性。用大白话讲volatile相当于前台贴了一张公告“当前人数100”。两个人同时看到公告各自在本地把数字改成了 101再贴回去后贴的覆盖先贴的。公告栏的可见性完全没问题问题在于两个人改公告的时候没有互斥最终结果还是错的。所以volatile适合的是“一个线程写、多个线程读”的状态标记场景比如关闭标志位。它天生不适合做累加器。这个认知很关键因为我在不少项目里见过有人用volatile int做计数结果线上数据对不上排查半天才发现根因在这里。1.3 锁能兜底但一行加法扛不起一副重甲既然volatile不行那用synchronized或者ReentrantLock把自增包起来能不能解决能绝对能。synchronized保证互斥同一时刻只有一个线程能执行count计数一定准。问题是代价。锁的实现依赖阻塞和唤醒线程拿不到锁时会被挂起锁释放后又要重新被调度。这个过程中涉及操作系统层面的上下文切换耗时可能是微秒级甚至更高。再加上 JVM 内部的锁升级机制——偏向锁、轻量级锁、重量级锁的膨胀过程——你的临界区里只有一次加法却要让线程经历“抢锁失败、阻塞、被唤醒、再抢锁”的完整套餐。不是说锁不好。锁适合临界区里有复杂逻辑的场景比如数据库事务、多步状态流转。但计数器这种极小粒度的操作用锁属于高射炮打蚊子并发一高整体吞吐很容易被压下来。原子类走上历史舞台本质上就是看中了“无锁”这两个字。2. AtomicInteger 的底层CAS 自旋是怎么办到的2.1 CAS 三要素内存地址、期望值、新值原子类底层的核心机制是 CASCompare-And-Swap。它的逻辑用一句话说清楚如果主存里的值还是我看到的那个值我就把新值写进去如果不是说明被别人改了这次操作作废。CAS 有三个参数内存地址 V、期望值 E、新值 N。运行结果只有两种V 等于 E则把 V 改成 N否则什么都不做。这本质上是乐观锁的思路默认没人抢先把值算好提交的时候再核对。核对通过就成交核对失败就重来。CPU 层面x86 平台用cmpxchg指令配合lock前缀把“比较”和“写入”绑成一个不可分割的原子操作。硬件直接支持比软件层的锁轻太多。所以 CAS 严格来说不是“无锁”而是“没有阻塞等待”的锁代价从线程挂起转移到了循环重试上。举个例子自定义一个 CAS 流程逻辑会是这样AtomicInteger count new AtomicInteger(0); // 期望当前值是 0想把它改成 1 boolean success count.compareAndSet(0, 1); System.out.println(success); // true当前值确实是 0 success count.compareAndSet(0, 100); System.out.println(success); // false当前值已经是 1不是期望的 0这个例子看着简单但它是理解后面所有自旋逻辑的地基。2.2 getAndIncrement 源码阅读失败就再赌一次以最常用的AtomicInteger.getAndIncrement()为例深入到源码里看它内部调用了Unsafe类的方法public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1); }Unsafe里的实现逻辑大致是这样的自旋循环int current; do { current getIntVolatile(object, offset); } while (!compareAndSwapInt(object, offset, current, current 1)); return current;这里有两个关键细节。第一个是valueOffset。AtomicInteger把value声明为volatile通过Unsafe.objectFieldOffset拿到它在对象里的内存偏移量。之后所有 CAS 操作都直接基于这个偏移量访问内存绕过了常规的字段访问路径。这也是为什么原子类能把性能压到那么低。第二个是do-while循环。每次循环先读一次当前值然后提交 CAS。如果失败——说明有别的线程抢先改了值——就重新读一遍再用新值去赌下一轮。只有赌赢的那一次自增才真正生效。用当前值加一作为新值的战术本质上是“如果现在还是我看到的那个旧值我就把结果写进去否则我认输重来”。如果你需要在业务代码里自定义类似的无锁逻辑JDK9 之后更推荐用VarHandle。它提供了和Unsafe同一级别的 CAS 能力但 API 更安全、经过更多校验不会在未来版本里被清理掉。2.3 自旋的代价CAS 不阻塞但会空转CAS 相比锁最大的优势是不阻塞。CAS 失败的线程不会进入睡眠而是继续循环所以没有上下文切换响应速度很快。在竞争不激烈的情况下绝大多数 CAS 一次就成功性能非常漂亮。但天下没有免费的午餐。如果竞争变得很激烈CAS 失败率升高大量线程都在循环里反复重试CPU 会被这堆“空转”烧得很厉害。这还没完还有一个更隐蔽的成本缓存一致性协议。多个 CPU 核心频繁修改同一个内存地址时为了保证每个核心看到的值一致它们各自缓存行里的副本要互相发送失效通知。总线上的流量激增内存控制器的压力也跟着上来整体吞吐不升反降。所以在高竞争场景下“无锁”的自旋反而可能比锁更烧 CPU。这个账等到介绍LongAdder的时候就能看懂为什么它要那么设计。3. 高竞争下的演进LongAdder 的分段计数思路3.1 AtomicLong 的单点争用所有线程挤一座桥AtomicLong的原理和AtomicInteger一模一样唯一的区别是操作的数据宽度从 32 位变成了 64 位。它非常适合读多写少、或者竞争不剧烈的场景比如并发 ID 生成每次 CAS 基本一次成功效率很高。但如果你做的是秒级几十万次甚至更高的打点统计问题就来了所有线程都在争抢同一个内存地址。桥上的人太多自旋失败的线程把 CPU 烧得很厉害我甚至见过业务监控的计数逻辑把业务线程拖慢的案例。问题不在 CAS 本身而在于“全场只有一个计数点”。能不能把这个点拆成很多个让每个线程各写各的最后再合并这就是LongAdder的设计初衷。3.2 LongAdder 的 baseCell[] 结构拆解LongAdder是并发大师 Doug Lea 设计的思路可以概括成一句话把总计数器拆成多份让不同线程尽量落在不同份上最后把所有份加起来。它内部维护了两个核心成员一个base值和一个Cell[]数组。工作流程大致是初始阶段没有竞争直接 CAS 更新base行为和AtomicLong没区别。一旦发现base上争抢激烈就把Cell[]数组建出来。每个线程通过ThreadLocalRandom的探针值哈希到某个Cell只更新自己那个槽位。Cell内部的value用volatile修饰保证跨线程可见性。最后调用sum()把base和所有Cell的值累加返回。这个结构把“一个热点”变成了“一整片热点”。每个核心大概率只操作自己缓存行里的Cell跨核竞争大幅减少。数组扩容由cellsBusy这个自旋锁保护长度按 2 的幂往上翻上限由 CPU 核数决定不会无限膨胀。使用上极其简单LongAdder requestCount new LongAdder(); // 业务请求到达 requestCount.increment(); // 定时汇总 long total requestCount.sum();另外LongAdder的Cell用了缓存行填充技术每个Cell周围隔开足够空间避免两个Cell落在同一个 64 字节缓存行里互相拖累。这一点后面单独讲。3.3 LongAdder 的反直觉边界什么时候不该用它LongAdder不是万能的我实际踩过的坑有四个。第一它没有getAndIncrement。increment()方法返回void你拿不到自增前的旧值或者自增后的新值。如果你需要计数器的返回值来生成序号比如分布式发号器LongAdder做不到老老实实用AtomicLong。第二sum()是弱一致的。它在遍历Cell的时候其他线程可能还在往里面写所以读到的可能是某个瞬间的近似值。做监控报表没问题但如果计数结果直接决定资金或库存千万别用。第三低并发下反而稍慢。每次increment都要判断cells是否为空、计算探针单线程下比AtomicLong多几条指令。如果你确定并发只有两三个线程用LongAdder只是心理安慰。第四reset()不是安全的“清零”。调用reset时如果有线程正在add写入可能被清掉。周期计数场景要用切桶方案绝对不要靠定时 reset。3.4 LongAccumulator把“加一”泛化成“任意聚合”LongAdder本质上是累加操作而LongAccumulator是它的泛化版本允许你传入任意二元操作。构造参数是一个LongBinaryOperator和初始值每次调用accumulate时会用当前值和传入值做一次函数运算LongAccumulator maxLatency new LongAccumulator(Long::max, Long.MIN_VALUE); maxLatency.accumulate(42); maxLatency.accumulate(100); maxLatency.accumulate(77); long result maxLatency.get(); // 100同样的分段思想适合统计最大耗时、最大队列深度、按位异或这类非加法聚合。面试里提到它能体现你对 JUC 工具链的完整认知而不是只背了一个LongAdder的用法。4. 三个真实计数需求去重计数、周期计数、算力计数的落地写法4.1 重复计数从易拉罐传感器到接口重试去重先讲一个生产中很常见的场景自动易拉罐计数。流水线上一个易拉罐经过光电传感器因为机械晃动同一个罐子可能触发好几次信号。如果每次都加一产量统计就会虚高。处理思路是以时间窗口去重同一个罐子产生的信号间隔极短超过间隔才算新罐子。如果多个线程同时读取传感器信号把“判断间隔是否超阈值”和“更新时间戳”拆成两个动作会出现竞态。正确写法是用 CAS 把两步合并成一次原子提交AtomicLong lastSignal new AtomicLong(0); LongAdder canCount new LongAdder(); public void onSignal() { long now System.currentTimeMillis(); long prev lastSignal.get(); while (true) { if (prev ! 0 now - prev 50) { return; // 50ms 内重复触发忽略 } if (lastSignal.compareAndSet(prev, now)) { canCount.increment(); return; } prev lastSignal.get(); // 失败说明其他线程先更新了重读再判断 } }这个写法的精髓是先拿时间戳再用 CAS 把时间戳更新成当前时间。抢到更新权的人才允许计数同一瞬间并发到达的多个信号只有 CAS 成功的那一个会进入计数逻辑。这比“先 if 判断再加锁”要轻量得多也是无锁编程里很典型的“抢令牌”模式。接口重试去重也是同一套思路。比如 HTTP 重试导致同一笔订单被回调多次用ConcurrentHashMap.newKeySet()记录已处理的requestIdadd返回true才计数和执行业务逻辑SetString seen ConcurrentHashMap.newKeySet(); if (seen.add(requestId)) { totalOrderCount.increment(); processOrder(requestId); }注意这种 Set 会无限增长要按时间窗口轮换或者换用过期的缓存方案。4.2 周期计数窗口切桶时别直接 reset另一个高频需求是周期计数统计每分钟 QPS、每小时活跃用户数。很多人的第一反应是定时任务到点调用LongAdder.reset()然后把清零前的sum存进历史表。这个做法有竞态问题。reset和正在执行的add会打架如果清零瞬间有请求正在累加它可能加到新窗口的大锅里也可能被清零逻辑顺手带走丢了一笔数据。更可靠的做法是双桶切换。维护两个AtomicLongcurrent记录当前窗口previous记录上一个窗口。业务线程永远只更新current定时任务到点时用getAndSet把current的值取出来、立刻归零再赋给previousclass TumblingCounter { private final AtomicLong current new AtomicLong(); private final AtomicLong previous new AtomicLong(); public void increment() { current.incrementAndGet(); } public void rotate() { previous.set(current.getAndSet(0)); } public long lastWindowValue() { return previous.get(); } }关键就在getAndSet(0)它“取旧值”和“归零”是原子地一起完成的。既不会漏掉上一个窗口的数字也不会在取值和归零之间插入一次并发自增造成错乱。如果你需要的是滑动窗口比如统计最近 5 分钟可以考虑环形时间槽定长数组每个元素一个LongAdder按时间戳取模落到对应槽位查询时把窗口内所有槽位加起来。槽位可以被覆盖复用不需要清空操作。4.3 完全平方数计数多线程并行统计的累加策略还有个很有意思的算力场景给定一批整数统计其中完全平方数的个数。这种任务计算密集单线程太慢必然要拆多线程。难点在于每个线程怎么把自己发现的完全平方数汇总到总数上。这里我推荐LongAdder而不是AtomicLong。理由很简单每个工作线程大概率长时间固定在一个核心上各写各的Cell几乎不发生争抢如果用AtomicLong每个线程发现一个平方数都要去抢同一个内存地址数值判断本身已经很烧 CPU加锁反而更痛。用并行流写非常简洁LongAdder squareCount new LongAdder(); int[] values loadValues(); IntStream.range(0, values.length).parallel().forEach(i - { if (isPerfectSquare(values[i])) { squareCount.increment(); } }); long total squareCount.sum();如果对线程池隔离有要求更可控的做法是用ExecutorService自己切分任务区间避免并行流默认的ForkJoinPool.commonPool()被其他任务干扰。判断完全平方数有个经典细节Math.sqrt返回double对接近long上限的大数浮点误差可能让平方根差 1。稳妥写法是校验相邻三个值boolean isPerfectSquare(long x) { long r (long) Math.sqrt(x); return r * r x || (r 1) * (r 1) x || (r - 1) * (r - 1) x; }如果除了总数还希望记录“最大的完全平方数是谁”LongAccumulator(Long::max)比再写一个AtomicLong维护逻辑干净得多。这说明计数需求从来不只有“加一”一种形态聚合方式可以很丰富。5. 原子类项目的避坑清单伪共享、ABA 与选型校准5.1 伪共享明明各写各的为什么还互相拖累CPU 读取数据不是按单个变量而是按缓存行通常 64 字节。如果两个long恰好落在同一个缓存行里线程 A 修改其中一个、线程 B 修改另一个物理上两者不相干但缓存一致性协议会把整个缓存行标记为失效。结果两个核心不得不在内存和缓存之间来回同步吞吐量可能暴跌一个数量级。这就是伪共享。LongAdder的Cell数组里每个元素周围都塞了填充字节目的就是让不同线程的Cell尽量落在不同缓存行。你在设计自己的高并发数据结构时同样要避开这个坑。一个经典的填充手法是在字段旁边塞 7 个longclass PadCounter { volatile long value 0; long p1, p2, p3, p4, p5, p6, p7; // 凑够 64 字节 }JDK 用户代码也可以用jdk.internal.vm.annotation.Contended注解自动填充但要配合-XX:-RestrictContended参数放开限制。这个坑在低并发下完全看不出来线程数一上去性能曲线会突然垮掉而且排查时很难想到问题出在内存布局上。5.2 ABA 问题当计数器的值绕了一圈CAS 有一个经典缺陷它检查的是“值是否还是 E”而不是“值有没有被改过”。如果一个值从 A 被改成 B又改回 ACAS 看到还是 A就以为没人动过。这就是 ABA 问题。计数器场景里最容易踩到的是“周期归零”。比如一个AtomicLong计数从 100 被清零成 0又有别的线程把它加回 100此时一个正在自旋 CAS 的线程看到期望值 100 仍然成立提交成功但这期间状态已经绕了一大圈。处理办法有三种业务上能容忍就不处理纯累加的计数器值单调递增本来就没有 ABA 风险必须防就用AtomicStampedReference它在值之外额外带一个版本号CAS 同时比较值和版本AtomicStampedReferenceInteger ref new AtomicStampedReference(100, 0); int[] stampHolder new int[1]; Integer value ref.get(stampHolder); boolean success ref.compareAndSet(value, 101, stampHolder[0], stampHolder[0] 1);用双桶替代反复清零的计数器也能顺带绕开 ABA一举两得。5.3 选型决策表先回答三个问题再挑原子类项目里到底用哪个原子类我习惯先问自己三个问题每次更新后需要立刻拿到结果吗需要就选AtomicInteger/AtomicLong不需要可以考虑LongAdder。是写多读少还是读写均衡极高频写入、偶尔汇总用LongAdder需要频繁get当前值用AtomicLong。计数器会被归零或复用吗会注意轮换切桶和 ABA。汇总成一张决策表需求特征推荐理由自增后要旧值/新值AtomicInteger / AtomicLong自带 getAndIncrement 返回值超高频写入只做定时汇总LongAdder分段降低争用最大/最小/异或等聚合LongAccumulator支持自定义算子引用状态替换AtomicReference配合 CAS 做状态机状态可能反复横跳AtomicStampedReference版本号防 ABA单个开关状态AtomicBooleangetAndSet 语义清晰5.4 压测观点用 JMH 验证而不是背结论网上流传“LongAdder 比 AtomicLong 快”这句话严格说只在高竞争下成立。我建议每个团队都用自己的业务模型跑一轮压测再选型。JMH 是比较可靠的基准测试框架它处理了 JIT 预热、黑孔消费等问题。最简单的对比可以长这样Benchmark public void atomicLongInc(Blackhole bh) { bh.consume(atomicLong.incrementAndGet()); } Benchmark public void longAdderInc(Blackhole bh) { longAdder.increment(); bh.consume(longAdder.sum()); // 模拟写读混合 }线程数分别设 1、4、8、16、32 跑几轮观察不同竞争梯度下的表现。我的个人经验是单线程或双线程时AtomicLong往往更快线程数超过 CPU 核数之后LongAdder的优势才开始显现。而且要注意每次操作都调用sum()会毁掉LongAdder的分段优势因为sum要遍历所有Cell频率高了同样贵。最后说一个自己的小习惯写任何计数逻辑之前先把“谁写、谁读、多久读一次”这三件事想清楚。想明白它们原子类的选型基本不会跑偏。这个习惯帮我避免过不少“代码能跑但一上线就出问题”的尴尬你也可以试试。