做 Java 并发编程优化时,我经常被问到同一个问题:都已经有 synchronized 了,而且近几年 JVM 对内置锁做了那么多优化,为什么还要花精力去研究 CAS 和 Atomic 原子操作类?每次听到这个问题,我都觉得它问到了点子上。因为不理解锁的痛点,就理解不了 CAS 的价值;不理解 CAS 的价值,后面所有关于 Atomic、LongAdder、并发容器的知识都只是在背 API 而已。
这篇文章直接以"CAS 与 Atomic 原子操作类"为核心,从锁在哪里慢讲起,带你看完 CAS 的底层原理、Atomic 家族的分类与用法、ABA 问题的完整解决方案,再聊聊 LongAdder 的高并发优化思路,最后把我实战中踩过的坑一条条列出来。适合正在学 Java 并发、准备面试、或者要在真实项目里做并发改造的开发者。如果你能跟着把代码跑一遍,收获会更大。
1. 从锁的痛点说起:CAS 要解决的到底是什么问题
1.1 锁的三重代价:阻塞、粒度与调试成本
很多人觉得 synchronized 用着挺舒服,那是因为还没在高并发场景下被它折磨过。锁的第一个代价是线程阻塞与唤醒。当一个线程拿不到锁时,它会被挂起,等锁释放后再被唤醒。这个挂起-唤醒过程涉及操作系统层面的线程调度和上下文切换,是要消耗 CPU 时钟周期的。一个临界区里如果只是做一个计数器加一的操作,而锁的获取与释放本身的成本比这个操作还高,那就很不划算。想象一下过独木桥,明明每个人跨一步就能过去,偏偏要等前面的人完全走到对岸才能上桥,后面的队伍越排越长,整个系统的吞吐量就被一个点卡死了。
第二个代价是临界区粒度被人为放大。synchronized 保护的是整个代码块,一旦进入,里面的所有步骤都串行执行。可很多时候真正需要互斥的只是"读取旧值、计算新值、写回"这三个动作,其他逻辑根本不涉及共享数据。把不相关的业务逻辑也锁起来,等于把并发执行的机会全部浪费掉了。
第三个代价是调试成本。多把锁嵌套使用时,死锁问题可能几周才出现一次,一旦出现,线程转储文件里看到的都是 BLOCKED 状态,很难立刻定位到是哪两个锁形成了循环等待。CAS 之所以被设计出来,就是为了从根上绕开这三重代价。它不阻塞线程,不对整个代码块加锁,也不存在锁的竞争与死锁问题,它只关心一个变量的原子更新。
1.2 CAS 的核心思想:一条 CPU 指令搞定"比较-交换"
CAS 是 Compare And Swap 的缩写,翻译过来就是"比较并交换"。它做的事情可以拆成三步:先把内存中的当前值 V 读出来,然后把预期值 E 和目标值 N 准备好,最后一步是核心——只有当 V 仍然等于 E 时,才把 N 写回内存;如果 V 已经被其他线程改过了,就什么都不做,并返回失败。
关键就在这里:那个"判断是否相等,相等就写入"的过程,不是用 Java 代码写的多条指令,而是 CPU 层面的一条原子指令完成的,比如 x86 架构上的 CMPXCHG。既然指令级不可分割,就不会出现"判断完了,还没来得及写,就被别的线程打断"的问题。这是 CAS 在并发安全上最大的底气。
用生活常识来类比,CAS 就像银行柜台的叫号。你到了柜台,先确认当前叫到的号码是不是自己的,如果是,直接办理业务;如果不是,说明你排的号已经过期了,得重新取号。这个"确认号码 + 办理业务"的动作是在柜员那里一气呵成的,中间不允许下一个客户插进来。对应到程序里,那个"确认"和"办理"就是 CPU 的 CAS 指令,天然具备原子性。
1.3 CAS 的优势与适用边界:什么时候该用它,什么时候别碰
CAS 的优势非常直观:无锁、无阻塞、无死锁。线程在 CAS 失败后不会像锁那样被挂起,而是立刻拿最新值再试一次,这种"失败重试"的操作叫自旋。自旋对 CPU 的消耗完全取决于竞争程度,低竞争场景下几乎无感,高竞争场景下需要额外警惕。
但 CAS 也不是银弹。它只适合保护"单一变量"的简单状态更新,比如计数器、累加器、布尔开关。一旦你的业务逻辑需要保证多个变量的数据一致性,或者需要一套完整的互斥流程,CAS 的表达能力就不够了。这时候强行用 CAS 去实现,代码复杂度会成倍上升,还不如老老实实加锁。我的经验是:能用 CAS 解决的问题,代码通常很短;需要绕很多圈才能用 CAS 解决的问题,直接锁住反而更稳。
2. Java 里的 CAS 落地:Unsafe 与 Atomic 原子操作类全景
2.1 sun.misc.Unsafe:藏在 JDK 底层的 CAS 入口
Java 连 CPU 指令的活都要自己干,靠的是一个特殊类:sun.misc.Unsafe。这个类里定义了 compareAndSwapInt、compareAndSwapLong、compareAndSwapObject 等 native 方法,它们直接调用操作系统和 CPU 的原子指令。以 compareAndSwapInt 为例,签名是:
public final native boolean compareAndSwapInt(Object obj, long offset, int expect, int update);里面的几个参数可以这样理解:obj 是要操作的对象,offset 是被修改字段在对象内存中的偏移量,expect 是预期值,update 是目标值。注意,它能直接对"某个对象的某个字段"做 CAS,不需要先把字段读出来再更新。这一点是整个 Atomic 系列能够高效工作的基础。
不过,业务代码几乎不会直接碰 Unsafe。原因很现实:Unsafe 的 API 不稳定、不归官方支持、并且直接操纵内存地址很容易写出难以排查的问题。它更像是 JUC 的"幕后发动机",普通的编程入口始终是 java.util.concurrent.atomic 包。我的建议是:学习时要理解 Unsafe 的存在,编码时千万别直接依赖它。
2.2 Atomic 原子操作类家族的四类成员
JDK 的 atomic 包经过多年演进,工具已经相当齐全。我习惯把它们分成四类,方便记忆:
第一类是基本类型原子类,包括 AtomicInteger、AtomicLong、AtomicBoolean。分别解决 int、long、boolean 三种基本类型的原子更新需求。这是最常用的一组,日常写计数器、累加器、开关标志都靠它们。
第二类是引用类型原子类,包括 AtomicReference、AtomicStampedReference、AtomicMarkableReference。AtomicReference 可以让你对任意对象引用做 CAS 更新,相当于把 CAS 从基本数据类型推广到对象级别。Stamped 和 Markable 两个版本则是专门为 ABA 问题设计的,一个用 int 版本号,一个用 boolean 标记位,后面会展开讲。
第三类是数组原子类,有 AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。这几个类保证的是数组里面"单个元素"的原子性,不是整个数组的原子性。也就是说,元素的 get、set、compareAndSet 是线程安全的,但如果你要对数组做批量操作或者动态扩容,不能指望这些类帮你搞定,还得自己做并发控制。
第四类是字段更新器,包括 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater。它们的价值在于:不用创建新的 Atomic 对象,直接让一个普通对象的 volatile 字段具备原子更新能力。好处是省内存、更贴合已有对象的字段;代价是要求字段必须是 volatile,并且不能用 static 修饰,这个坑后面会专门讲。
2.3 核心 API 对照表与使用要点
AtomicInteger 是家族里最典型的代表,它暴露的关键方法并不多,但每个方法背后的语义要搞清。我整理了一张表:
| 方法名 | 功能 | 返回值 |
|---|---|---|
| get() | 获取当前值 | 当前值 |
| set(int) | 直接设置新值(volatile 写) | 无 |
| getAndSet(int) | 设置新值并返回旧值 | 旧值 |
| compareAndSet(int, int) | 当前值与预期值一致时改成目标值 | 是否成功 |
| getAndIncrement() | 自增 1,返回自增前的值 | 旧值 |
| incrementAndGet() | 自增 1,返回自增后的值 | 新值 |
| getAndAdd(int) | 加上一个增量,返回加之前的值 | 旧值 |
| addAndGet(int) | 加上一个增量,返回加之后的值 | 新值 |
最容易踩的坑是 getAndIncrement 和 incrementAndGet 的返回值语义。前者返回旧值,后者返回新值。我在项目里就犯过这种错:统计请求量时用了 getAndIncrement,然后直接把返回的旧值当成本次请求的计数去上报,结果第一条统计记录直接是 0。这种 bug 不报错,但数据就是错的,排查起来非常隐蔽。
2.4 选型参考:锁、volatile、原子类怎么组合
有时候我们需要的不是非此即彼,而是把几种工具组合起来用。volatile 解决的是可见性和有序性问题,保证一个变量被某个线程修改后,其他线程能立刻看到最新值;但 volatile 不保证复合操作的原子性,比如 i++ 这种"读-改-写"三步操作,volatile 扛不住。CAS 补上的正是这个短板:让读、比较、写这三步在指令层面原子化。
synchronized 和锁工具则解决的是更大范围的互斥问题:当多个变量需要作为一个整体被修改时,或者业务逻辑本身需要串行执行时,CAS 和 volatile 都不够用。实践中最常见的组合是:用 volatile 做状态标记,用 Atomic 类做计数与累加,用锁保护真正复杂的复合流程。没有哪个工具是万能的,组合使用才是常态。
3. 实战:AtomicInteger 从计数器到自旋锁的完整落地
3.1 第一个并发计数器:200 线程自增的经典验证
学 AtomicInteger,最好的入门实验就是写一个并发计数器。下面这段代码我建议你亲手跑一下:
import java.util.concurrent.atomic.AtomicInteger; public class CounterDemo { private static final AtomicInteger count = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { Thread[] threads = new Thread[200]; for (int i = 0; i < threads.length; i++) { threads[i] = new Thread(() -> { for (int j = 0; j < 10000; j++) { count.incrementAndGet(); } }); } for (Thread t : threads) { t.start(); } for (Thread t : threads) { t.join(); } System.out.println("最终 count = " + count.get()); } }这个程序开 200 个线程,每个线程自增 1 万次,理论上最终结果是 200 万。用 AtomicInteger,跑出来一定是 2000000,因为 incrementAndGet 是 CAS 自旋实现的原子操作,不会出现丢失更新。如果换成普通 int 变量,结果大概率会小于 200 万,少多少取决于线程调度。这个实验每次跑都能看到差异,非常适合用来直观感受"原子操作"到底意味着什么。
3.2 源码拆解:自旋循环是怎么跑起来的
要真正理解 AtomicInteger,不能只看 API 文档,要看一眼源码。JDK 8 的 incrementAndGet 实现是这样的:
public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; }而 Unsafe 里的 getAndAddInt 是核心:
public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { var5 = this.getIntVolatile(var1, var2); } while (!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }我拆一下这个 do-while 循环。var1 是 AtomicInteger 对象本身,var2 是 value 字段在对象内的内存偏移量,var5 每次从内存里用 volatile 读取出当前值。接下来调用 compareAndSwapInt:如果当前值仍然等于 var5,就把 var5 + var4(也就是加 1 后的值)写回去,循环结束,返回旧值;如果失败了,说明有其他线程抢先一步改了值,循环再读一次最新值,重新尝试。
这个过程就是自旋。它不阻塞线程,所以没有上下文切换开销。但也要注意:如果竞争特别激烈,CAS 会反复失败,循环可能长时间不退出,CPU 就在那里空转。当并发升级到一定程度,这种空转的消耗反而会成为新的瓶颈。这也是后面要讨论 LongAdder 的动机所在。
3.3 实战场景:接口 QPS 统计改造
AtomicLong 在真实项目里的经典用法之一是做 QPS 统计。我之前维护过一个搜索服务,需要统计每个接口的请求量。最初的实现是 synchronized 加锁,每次请求进来先拿锁再更新计数器,高峰期锁竞争直接把统计逻辑变成了热点。后来改成 AtomicLong:
private final AtomicLong requestCount = new AtomicLong(0); public void recordRequest() { requestCount.incrementAndGet(); } public void report() { long total = requestCount.get(); // 上报总数,或者计算 QPS }这个改动很小,但效果立竿见影:统计模块不再是瓶颈,接口响应时间明显下降。这里的本质是,计数器更新只涉及一个 long 变量,完全属于 CAS 的射程范围,根本不需要锁住整个统计流程。
3.4 基于 CAS 手写自旋锁:原理演示与上线警告
CAS 还有一个经典应用——实现轻量级自旋锁。下面这个实现虽然简单,但足以说明原理:
import java.util.concurrent.atomic.AtomicBoolean; public class SpinLock { private final AtomicBoolean lockFlag = new AtomicBoolean(false); public void lock() { while (!lockFlag.compareAndSet(false, true)) { // 自旋等待 } } public void unlock() { lockFlag.set(false); } }lock() 的思路很直接:CAS 把 false 改成 true 成功,说明抢到锁;失败就继续转圈尝试。低竞争场景下,这把锁的效率比 synchronized 高不少,因为抢锁线程不会阻塞,省掉了上下文切换。但它的缺陷也同样明显:没有可重入性,同一个线程如果递归调用 lock(),自己会把自己锁死;没有超时机制,极端情况下线程会无限空转;没有公平性,后来者可能一直抢不到锁。
这里必须说一句:生产环境不要自己写这种自旋锁,直接用 JUC 里的锁工具要稳妥得多。自旋锁的代码价值是用来学习 CAS 思想、理解无锁编程的代价,而不是真的上线使用。真要实现可控的自旋,可以看看 JUC 里 LockSupport.parkNanos 的用法,那套机制考虑得比手写版本周全得多。
4. ABA 问题:CAS 最隐蔽的坑与版本号解决方案
4.1 ABA 是什么:数字回来了,但状态已经变过
CAS 的判定原则是"当前值等于预期值就更新"。这句话隐含了一个漏洞:变量从 A 变成 B,再从 B 变回 A,当前线程看到的值还是 A,它会认为变量从未被修改过,于是 CAS 成功。可实际上,变量中间已经"变过心"了,这个被忽略掉的变化就是 ABA 问题。
举一个非常典型的场景。账户余额是 100 元,线程 T1 打算执行"余额是 100 时改成 200"这个 CAS。T1 把 100 作为预期值记下来之后,在它真正执行 CAS 之前,线程 T2 把余额改成了 90,紧接着又改回 100。等 T1 真正执行时,余额确实还是 100,CAS 判断相等,执行成功。从结果看,最终余额确实是 200,似乎没有影响。但如果这个"中间变化"本身携带着业务含义,比如一次消费、一笔退款、一个状态切换,那么 T1 就等于无视了这次业务事件,数据的一致性就被破坏了。
4.2 哪些场景必须防 ABA:数值型与状态型判断
在纯数值累积的场景里,ABA 通常无害。比如一个计数器从 5 变成 8 又变回 5,最终值没有变,不会影响统计结果。但是,当并发逻辑依赖"对象是否被修改过""状态是否发生过迁移"这类信息时,ABA 就必须解决。
我遇到过两类典型场景。一类是内存中的对象池,线程 A 从池中取出对象 X 做处理,线程 B 并发修改了 X 再改回去,线程 A 处理完把 X 放回池中,但中间 X 的内容已经被改过了,放回去的对象已经不再是原来的对象。另一类是链表或栈的无锁实现,例如 CAS 修改栈顶元素时,如果栈顶经历了 A→B→A 的变化,线程 A 会误以为栈顶没变,导致把新的栈顶节点覆盖掉,破坏整个数据结构。这两类场景的共同点是:我们关心的不仅是当前值,更是"历史路径"。
4.3 AtomicStampedReference 实战:资金操作防重
解决 ABA 的标准思路是引入版本号。每次修改值时,不仅更新值本身,还让版本号递增。CAS 时不仅比较值,还要比较版本号,版本号不一致就说明中间发生过变化,CAS 直接失败。JDK 为此提供了 AtomicStampedReference,下面是一个简化的防重示例:
import java.util.concurrent.atomic.AtomicStampedReference; public class Account { // 初始余额 100,版本号从 0 开始 private final AtomicStampedReference<Integer> balance = new AtomicStampedReference<>(100, 0); public boolean transferIfBalanceEquals(int expect, int update) { int[] stampHolder = new int[1]; Integer current = balance.get(stampHolder); if (current != expect) { return false; } return balance.compareAndSet(current, update, stampHolder[0], stampHolder[0] + 1); } }credit这里有几个必须解释清楚的细节。第一,AtomicStampedReference 的 compareAndSet 方法签名是四个参数:期望引用、新引用、期望版本号、新版本号。只有引用值和版本号都匹配,CAS 才会成功。第二,balance.get(stampHolder) 这一行很关键,它把"当前引用"和"当前版本号"一次性取出来,版本号通过 stampHolder 数组带出来。为什么要用数组而不是直接返回一个对象?因为 Java 方法只能返回一个值,这里需要同时拿到两个值,用数组做"输出参数"是标准解法。
第三,不要分开调用 getReference() 和 getStamp()。两步分开读取之间存在时间窗口,可能读到"引用是新值、版本号是旧值"这种不一致的组合,反而引发新的并发问题。官方提供 get(int[]) 这种一次性读取方式,就是用来规避这个坑的。
4.4 容易混淆的概念:ABA 与 ABB
顺带说一个容易混淆的概念。ABA 是 A 变成 B 再变回 A,问题核心在于"状态回退造成的识别失误"。而 ABB 是指 A 变成 B 之后继续变成 C,前后值不一样,CAS 通过"当前值是否等于预期值"就能判断出来,自然失败重试,不存在隐患。只有成对出现的往返变化,也就是 A→B→A,才是真正需要提防的 ABA。理解了这一点,在排查并发问题时你就能准确判断,遇到的到底是真正的 ABA 还是普通的 CAS 竞争失败。
5. 性能进阶:LongAdder 与 AtomicLong 的取舍
5.1 AtomicLong 在高并发下的瓶颈:全局热点
AtomicLong 在低竞争场景下性能很好,但线程数一多,问题就出现了。所有线程都在抢同一个底层内存单元的 CAS 更新权,这会造成一个全局热点。CAS 失败时线程要重新读取内存值再自旋,竞争越激烈,失败率越高,自旋次数越多,CPU 空转越严重。同时,频繁的共享数据修改会让缓存一致性协议不断同步缓存行,进一步放大性能损耗。
有一个数字我印象很深:在做 64 线程并发累加测试时,AtomicLong 的耗时比 8 线程时高出好几倍,而 LongAdder 的耗时增长则平缓得多。这个对比说明,AtomicLong 的性能瓶颈不是操作本身,而是高竞争带来的自旋放大效应。
5.2 LongAdder 的解决思路:分段计数 + 最终归并
LongAdder 的设计思路非常巧妙:既然多个线程抢一个变量会打架,那就让它们各抢各的。LongAdder 内部维护了一个 base 字段和一个 Cell 数组。在没有竞争或者低竞争时,线程直接操作 base,这时候 LongAdder 的行为和一个普通原子变量几乎一样,性能与 AtomicLong 相当。一旦检测到 base 上的 CAS 失败,说明竞争已经发生,LongAdder 就会创建 Cell 数组,把计数分散到各个槽位上。
这个思想你可以理解成银行柜台的服务模式。平时只有一个柜台,所有客户排队办业务;人一多,银行就开放多个窗口,客户分流到不同窗口办理,最后下班清点总账时再把所有窗口的数据汇总。LongAdder 的 sum() 方法就是这个清点动作,它把 base 和所有 Cell 的值累加起来返回总和。
这里有一个细节值得注意:LongAdder 内部的 Cell 数组元素用 @Contended 注解做了缓存行填充,目的是避免伪共享。关于伪共享,后面一节会专门讲,你可以先记住结论:多个线程修改恰好落在同一缓存行中、但彼此独立的不同变量时,性能会被严重拖累,填充正是为了隔离这种干扰。
5.3 强一致 vs 高吞吐:两种原子类怎么选
LongAdder 适合"高并发写、最终一致性读"的场景。所谓最终一致性,是因为 sum() 方法在遍历求和时没有一个全局锁,如果求和过程中还有线程在写,sum() 返回的结果可能不是某一瞬间的精确快照,而是一个近似值。对监控统计、QPS 上报、热点指标累计来说,这个近似值完全够用,因为关心的本来就是一段时间内的总量。
反过来,如果业务需要每次读取都拿到强一致的精确值,比如全局唯一 ID 生成、版本号控制、CAS 依赖的判断值,那就必须用 AtomicLong。它的 get() 能保证强一致语义。我的选型建议是:写多读少、能容忍短暂统计偏差的场景,优先 LongAdder;需要强一致、频繁读取的场景,用 AtomicLong。
6. 踩坑集锦与排查实录:原子类实战中的血泪教训
6.1 别忘了:原子类不能保护"多变量复合操作"
这是我在实际项目里栽过的最大的一个跟头。当时写一个领取优惠券的接口,流程是:先判断用户是否已领过,再发放优惠券并更新计数。直觉上觉得用了 AtomicInteger 做计数,应该就并发安全了,结果高并发下出现了同一个用户领到多张券的问题。原因很简单:判断是否已领过和更新计数是两个独立的原子操作,但放在一起就不是一个原子操作了。线程 A 判断"没领过",线程 B 同时判断"没领过",两个线程都通过检查,然后各自执行发券和计数,计数最终可能加到了 2。
这就是经典的"先检查后执行"问题。CAS 能保证单步原子性,保护不了多步业务逻辑。要修复这个 bug,要么把"判断 + 发券 + 计数"放进同一个锁保护的范围,要么使用 AtomicReference 把一个不可变的快照对象(比如"用户ID + 领券状态"的组合)作为 CAS 的对象,让检查和更新变成一次 CAS。如果你的业务逻辑太复杂,第三种选择是直接上数据库的唯一约束,从存储层兜底。这个案例让我彻底明白了一个道理:原子类不是万能的锁替代品,它的边界在于"单变量状态"。
6.2 伪共享带来的隐藏性能杀手
伪共享这个概念,在我第一次看到 LongAdder 的源码时才算真正搞懂。CPU 的缓存是按缓存行加载的,一个缓存行通常有 64 字节。即使两个变量在逻辑上毫无关系,只要它们被分配到了同一个缓存行中,其中一个被修改,整个缓存行都会被标记为失效,另一个变量所属的线程也得跟着重新从内存加载。两个线程明明在改不同的数据,却因为共用缓存行而互相拖累,这就是伪共享。
在无锁并发场景下,伪共享会把 CAS 的性能优势吞噬掉大半。解决思路是在变量前后填充无意义字节,让关键变量独占一个缓存行。JDK 8 引入了 @sun.misc.Contended 注解,JVM 在布局对象时可以自动添加填充,但该注解需要配合特定 JVM 参数使用,并且属于内部实现,高风险区域。普通业务开发不需要手动处理伪共享,但排查高并发性能问题时,如果发现某个原子类的表现异常,不妨往这个方向想一想。
6.3 字段更新器逃不开的两个低级坑
AtomicIntegerFieldUpdater 用起来很方便,但它有两个极易踩中的低级坑。第一个坑是字段必须声明为 volatile,否则初始化更新器时直接跑出 IllegalArgumentException,错误信息会提示缺少 volatile 修饰。这个问题在代码 review 阶段很难发现,因为编译不会报错,只有运行到 newUpdater 那一行才炸。第二个坑是字段不能是 static 的。我最初写公共工具类时,把计数变量设成了 static volatile,想着既能共享又保证可见性,结果初始化更新器时直接爆异常。
还有一个隐蔽的坑:newUpdater 传入的字段名是字符串,没有编译期校验。字段一旦改名或者拼写错误,运行时才抛出 NoSuchFieldException。所以实践中我建议把字段名提取成常量,并且每次做完字段重命名之后,全局搜一遍所有用到 newUpdater 的地方。这类问题虽然不复杂,但生产环境出一次就会让人措手不及。
6.4 简单压测比较:64 线程下 AtomicLong 与 LongAdder
与其背结论,不如自己动手验证一次。下面是一个极简的对比测试框架,不需要引入 JMH 也能看出趋势:
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.LongAdder; public class CompareDemo { public static void main(String[] args) throws InterruptedException { int threadCount = 64; int iterations = 1_000_000; // 测试 AtomicLong AtomicLong atomicLong = new AtomicLong(0); Thread[] threads1 = new Thread[threadCount]; long start1 = System.currentTimeMillis(); for (int i = 0; i < threadCount; i++) { threads1[i] = new Thread(() -> { for (int j = 0; j < iterations; j++) { atomicLong.incrementAndGet(); } }); } for (Thread t : threads1) t.start(); for (Thread t : threads1) t.join(); long cost1 = System.currentTimeMillis() - start1; // 测试 LongAdder LongAdder longAdder = new LongAdder(); Thread[] threads2 = new Thread[threadCount]; long start2 = System.currentTimeMillis(); for (int i = 0; i < threadCount; i++) { threads2[i] = new Thread(() -> { for (int j = 0; j < iterations; j++) { longAdder.increment(); } }); } for (Thread t : threads2) t.start(); for (Thread t : threads2) t.join(); long cost2 = System.currentTimeMillis() - start2; System.out.println("AtomicLong 耗时: " + cost1 + " ms"); System.out.println("LongAdder 耗时: " + cost2 + " ms"); } }我本地的数据大致是 AtomicLong 耗时接近 LongAdder 的三倍。不同机器结果会不一样,但趋势是稳定的:线程数越多、自增次数越大,LongAdder 的优势越明显。做这个测试的时候有两点要注意:一是先跑几轮预热 JIT,直接用冷启动数据对比会有噪音;二是观察维度别只看总耗时,最好能顺带看一眼 CPU 占用,高竞争下 AtomicLong 的自旋会推高 CPU 使用率。我在实测时一度以为是机器问题,后来才发现是自旋把 CPU 打满了。
最后再分享一个小体会:Java 并发编程的核心不是记住某个 API,而是理解每个工具面对的是什么问题。CAS 用一条原子指令换掉了整个锁机制,Atomic 家族把 CAS 包装成了开箱即用的原子类,LongAdder 又在高竞争场景下填补了 AtomicLong 的短板。你在选型的时候,只要想清楚自己的业务是"单变量简单更新"还是"多变量复合操作"、是"强一致读"还是"高并发写"、是"低竞争"还是"激烈竞争",答案就自然浮现出来了。我自己最早学并发时死记硬背了一堆原子类的用法,后来在真实项目里排查性能问题、踩过 ABA 的坑、对比过 LongAdder 与 AtomicLong 的实测数据,才真正理解这些设计的价值。这篇内容就是沿着同样的路径整理出来的,希望它能帮你少走几步弯路。