第一次被面试官问“你讲讲CAS”的时候,我脑子里只有三个字母:Compare And Swap。但往下追问“底层指令是什么”、“ABA怎么解决”、“自旋锁为什么不能重入”,就有点冒汗了。后来自己做并发组件,排查线上AtomicLong抢热点导致的CPU飙升,才对CAS有了真正的体感。这篇内容就是把“CAS”从概念、硬件指令、工程应用到面试话术整个链路拆一遍,既给准备面试的朋友一条清晰的主线,也给实际写并发代码的人一些能落地的经验。无论你是刚接触并发编程,还是已经会用AtomicInteger但没看源码,都应该能从这里拿到点东西。
1. 先从一条CPU指令说起:CAS的原子语义与硬件支撑
1.1 什么是CAS:一次比较,一次交换
CAS的操作很简洁:它接收三个参数,内存地址V、期望值E、新值N。当V当前存放的值恰好等于E时,就把它更新为N,返回成功;如果V当前值不等于E,就什么都不做,返回失败。整个过程是原子的,中间不会插入其他线程的操作。
这句话为什么关键?因为“比较”和“交换”是两个动作。如果不做成原子,就可能出现一个线程刚刚比较完,另一个线程已经修改了值,等第一个线程再交换时,它写进去的新值基于的是一份过期数据。这其实就是并发编程里经典的“读-改-写”竞态。
我习惯用一个生活例子解释:你想在会议室白板上登记“当前值班人”。你看到白板上写的是“小明”,你计划改成“小红”。如果“看一下”和“改一下”之间别人把白板改成了“小刚”,你再改成“小红”,就把别人的登记覆盖了。CAS想解决的就是这个覆盖问题,它把“看”和“改”合并成一个不可打断的动作,只有白板还是“小明”时才允许你改成“小红”。
强调一点:CAS是“无锁编程的核心”,不等于“无锁编程就是CAS”。真正无锁数据结构通常会用CAS作为基础原语,但还需要考虑内存序、失败重试、对象回收等多个维度。但无论如何,CAS是所有lock-free算法绕不开的基石。
1.2 硬件与内存屏障:CMPXCHG、LDREX/STREX、可见性
很多同学学了CAS,却不知道它在硬件上长什么样。实际上,现代CPU基本都提供了对应的原子指令。
x86平台提供的是CMPXCHG指令,如果要保证多核CPU之间的原子性,还需要配合LOCK前缀,也就是LOCK CMPXCHG指令。加了LOCK前缀后,处理器会在执行期间锁住总线或缓存行,让其他核心无法同时操作该内存区域,从而保证原子性。
ARM平台的实现不太一样,它用的是LDREX/STREX这对指令。LDREX负责读取地址并打上独占标记,STREX负责在独占标记仍然有效时写入;如果标记失效,写入失败。这种实现方式叫“加载链接/条件存储”(Load-Linked / Store-Conditional),RISC-V也用类似的LR/SC机制。所以你在不同指令集上看到CAS的实现细节会不同,但对外呈现的都是一种原子化的“比较并交换”。
真正容易被忽视的是内存屏障的问题。CAS本身是一个读+写操作,它保证原子性,但如果不处理乱序和可见性,多核情况下依然可能出问题。x86因为内存模型相对强,很多时候一个LOCK前缀已经把屏障也带上了;ARM是弱内存模型,LDREX/STREX之外往往还要加屏障指令(如DMB)。高级语言层面对此做了抽象,所以Java里的compareAndSet看起来不涉及屏障,其实是封装好的。
在Java内存模型里,AtomicInteger的value字段是volatile修饰的,这就保证了读到的值始终是主存里的最新值;Unsafe的compareAndSwap方法在HotSpot里会被编译成具备较强内存屏障语义的原子操作。所以平时使用JUC原子类时,基本不需要手写屏障逻辑。但如果你自己用Unsafe做一些自定义操作,就一定要理解可见性和指令重排,否则容易写出偶尔才报错的问题。
1.3 Java和C++里怎么暴露这个能力
Java层面,我们最常用的是AtomicInteger、AtomicReference、AtomicLong等原子类。它们内部封装了CAS,用户可以调用compareAndSet、incrementAndGet等方法。这些类在JDK很多并发容器里出现,比如ConcurrentLinkedQueue、ConcurrentHashMap,都是建立在CAS之上的。
C++里对应的能力是std::atomic 的compare_exchange_weak和compare_exchange_strong。两者的区别是,weak版本可能产生伪失败,也就是即使值匹配也可能返回false,所以通常要放在循环里重试;strong版本不会伪失败。伪失败来源于部分硬件在极端时序下会让条件存储失败,即使值没变。这个细节在Java里体现得不多,但在C++面试或实际开发时经常被问到。
还要提一下内存序(memory order)。C++的compare_exchange可以传内存序参数,比如memory_order_relaxed、memory_order_acq_rel、memory_order_seq_cst。如果选错,代码可能编译出来也是对的,但在弱内存模型上会偶发怪异结果。Java因为JVM已经帮你规定了比较强的语义,普通程序员基本不感知;可如果你想深入底层,还是要理解这套知识。
2. 为什么要用CAS:锁的任务从悲观到乐观
2.1 锁的开销:挂起/恢复不便宜
传统加锁的思路是悲观的:它假设临界区一定会被多个线程争夺,所以干脆把没抢到锁的线程挂起,等锁释放了再唤醒。问题在于,线程的挂起和恢复不是免费的,需要操作系统进行线程调度,涉及用户态和内核态的切换。如果临界区本身只有几十纳秒,但挂起唤醒却要几微秒,锁的代价就会被无限放大。
加锁还会带来死锁、优先级反转、线程饥饿等问题。死锁大家都很熟,四个必要条件里只要破坏一个就能避免,但工程里依然防不胜防。优先级反转则是因为持锁的线程优先级低,结果高优先级线程被一个低优先级线程阻塞住。
这些痛点催生了乐观并发策略:默认冲突是少数,先把操作试一下,冲突了再重试。CAS就是这个策略的最小实现。用一个词形容,CAS是“干了再说,不行就再来一次”。
2.2 一次CAS自旋更新:无锁累加器的写法
我们看一个最简单的场景:多线程对一个计数器执行自增。用synchronized大概长这样:
class Counter { private int count = 0; public synchronized void increment() { count++; } }用AtomicInteger可以写成:
class CasCounter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } }incrementAndGet的内部逻辑,拆开看就是CAS自旋:不断读取当前值,计算下一个值,再尝试CAS。如果CAS失败,说明有其他线程抢先修改了count,于是拿到最新值重新试,直到成功。伪代码如下:
public final int incrementAndGet() { while (true) { int current = count.get(); int next = current + 1; if (count.compareAndSet(current, next)) { return next; } } }这个循环看起来简单,但它体现了一个重要思想:失败不阻塞,而是重试。多线程并发量低时,基本第一次CAS就能成功;并发量中等时,少数线程会自旋几次;并发量极高且都抢同一个变量时,大量线程会白白空转,这也是后面要说的性能瓶颈。
如果你用C++,写法类似:
std::atomic<int> count{0}; int expected = count.load(); while (!count.compare_exchange_weak(expected, expected + 1)) { // expected已经被更新为最新值,继续尝试 }注意compare_exchange_weak失败时,会把expected参数更新为当前实际值,所以循环里不用重新load。
2.3 无锁不是免费午餐:优势、代价与适用边界
基于CAS的无锁方案,最直接的优势是避免了线程阻塞和上下文切换,也不会出现死锁。在延迟敏感的系统里,无锁结构往往能提供更稳定的响应时间,不会因为某个线程被挂起而出现“卡顿尖刺”。
但它有三个不可忽略的代价:第一,竞争激烈时CPU空转,因为失败线程不会让出CPU,而是继续循环;第二,存在ABA问题;第三,CAS只能保证一个变量的原子操作,复合状态需要额外设计。这三个代价我后面会单独展开。
在实际业务里,CAS适合“读多写少、冲突概率低”的场景,比如计数器、状态位、缓存标记、唯一序列号。如果一段逻辑特别长,或者临界区里有IO、网络请求、线程睡眠,那 CAS 自旋就会变成灾难——线程在循环里空转,CPU被白白耗掉,还不如用锁挂起线程来得高效。
3. 从理论到源码:CAS在并发工具里做了什么
3.1 原子类内部流程:AtomicInteger到Unsafe再到硬件
很多人用AtomicInteger,却不知道它为什么线程安全。看源码会发现,AtomicInteger内部维护了一个volatile int value,而CAS最终会走到sun.misc.Unsafe(或JDK9以后的VarHandle)的compareAndSwapInt方法。
我这里基于常见的JDK实现解释:compareAndSet方法接收expect和update,调用Unsafe.compareAndSwapInt(this, valueOffset, expect, update)。valueOffset是value字段在对象内存布局中的地址偏移量,可以理解为告诉Unsafe“我要操作这个对象的哪个属性”。Unsafe拿到地址后,在JNI层调用C++的原子比较交换函数,最终对应到CPU指令。
JDK9以后,官方推荐用VarHandle替代Unsafe,因为VarHandle是公开且有规范支持的API,能更优雅地访问指定字段并进行原子操作。不过底层语义依然和Unsafe一致。这也解释了为什么AtomicInteger里的value必须是volatile:CAS保证“比较并交换”的原子性,volatile保证“读到的值是最新的”和“写入后立即可见”。两者缺一不可。
我们甚至可以自己实现一个极简的AtomicInteger,借助AtomicInteger或者VarHandle来模拟,但实际开发没必要重复造轮子。理解这层调用链,面试时能答出“AtomInteger最终落到CPU的CAS指令”,就已经超过大多数只会背API的人。
3.2 手写一把可用的自旋锁
既然CAS能原子更新一个变量,那就可以用它来实现一个简单的自旋锁。思路是使用AtomicReference 保存当前持有锁的线程,初始为null。lock操作不断尝试CAS,把null改成当前线程;成功则获得锁,失败就继续自旋。unlock操作把当前线程改成null。
public class SpinLock { private final AtomicReference<Thread> owner = new AtomicReference<>(); public void lock() { Thread current = Thread.currentThread(); while (!owner.compareAndSet(null, current)) { // 自旋等待,不做任何操作 } } public void unlock() { Thread current = Thread.currentThread(); owner.compareAndSet(current, null); } }这段代码能跑,但问题不少。第一,不可重入。线程A获得锁后再去调lock(),owner已经不是null,compareAndSet会失败,于是自己和自己死锁。解决思路是记录持有线程,如果owner已经是当前线程,就重入计数加1,这基本就是轻量级锁的做法。第二,不公平。后来的线程和等着的线程一起抢锁,抢到全靠运气,可能有人饿死。第三,临界区如果太长,CPU资源会被空转耗光。
所以自旋锁的适用场景非常窄:锁内代码极其短、没有阻塞、没有IO,且多核环境。比如操作系统内核互斥锁的快速路径,或者JVM偏向锁的撤销过程,会短暂自旋;业务代码里的自旋锁如果锁内执行逻辑超过几百条指令,就需要重新评估。
3.3 无锁数据结构:队列与Map里的CAS技巧
CAS在无锁数据结构里用得非常多。以ConcurrentLinkedQueue的入队为例,它维护了head和tail两个AtomicReference类型的节点。入队时不会简单地把tail的next设为新节点,而是先CAS更新tail节点自身的next,再CAS移动tail指针。中间任何一个CAS失败,都会根据当前节点状态重新调整,再重试。
这样做的好处是,多个线程可以同时入队,它们之间不会互相阻塞,只是通过CAS竞争更新节点。要是没有CAS,用一把锁锁整个队列,并发性能会差一个量级。
ConcurrentHashMap在JDK8里也大量使用CAS。比如初始化table时,通过CAS更新sizeCtl字段,避免多个线程同时初始化;插入元素时,如果对应桶位为空,就用CAS直接放入bin的头节点;put之后对CounterCell的累加,也用CAS保证计数准确。很多人以为ConcurrentHashMap只是把HashEntry数组的每个桶加了锁,实际上JDK8更多是用CAS加局部锁的组合,只有发生哈希冲突,链表转树或扩容时才动用synchronized锁住bin头节点。
把这些工具用起来不难,但要理解它们为什么高效,关键就是看到CAS在关键路径上降低了锁竞争。
4. 被问到烂的三个坑:ABA、自旋、单变量
4.1 ABA问题:为什么值没变不等于状态没变
假设线程1读取变量值为A。线程2把值改成B,然后又改成A。线程1再次CAS时发现当前值还是A,于是CAS成功。问题是,在这个时间窗口里,状态其实已经变过一圈,线程1却以为“一切都没有变化”,这就叫ABA问题。
ABA问题在纯数字计数器上一般不致命,因为最终结果可能没影响。但在无锁数据结构里会很危险,典型场景是链表头节点回收再复用:线程1准备CAS头节点,但节点被弹出、被释放、又被新的内存分配器复用,CAS比较引用还是同一个地址,实际对象语义已经完全不同,最后导致链表结构被破坏。
解决ABA的通用方法是给变量加版本号。只要版本号发生变化,即使值一样,CAS也会失败。Java的AtomicStampedReference就是干这个的,它的compareAndSet同时比较引用和stamp:
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(0, 0); int[] stampHolder = new int[1]; Integer current = ref.get(stampHolder); boolean success = ref.compareAndSet(current, 1, stampHolder[0], stampHolder[0] + 1);stamp的意思就是版本戳。拿借书举例子,不是只看“台灯还在桌上”,还要确认“期间有没有人借走过又还回来”。只看台灯在不在,能防住普通并发,但防不住中间被偷走的可能。
另外,AtomicMarkableReference只记录“是否被改动过”,不如stamped version灵活,但在有些场景够用。
4.2 自旋开销:退让策略与LongAdder思路
CAS自旋最怕的是竞争激烈。一两个线程争一个变量还好,几十个线程全部围着一个AtomicLong转,失败率急剧上升。每个线程都在占用CPU时间片,但绝大多数CAS操作都失败,相当于一群人抢一把椅子,谁都没坐下来,却把整个房间挤得水泄不通。
碰到这种情况,最简单的退让策略是让线程主动让出CPU。比如自旋一定次数后调用Thread.yield(),或者用LockSupport.parkNanos睡眠一小段时间,再继续CAS。很多并发源码里会有限次自旋,比如某些队列在入队失败时会自旋几次,然后阻塞挂起,避免无限空转。
更根治的思路是减少单点竞争。LongAdder就是典型,它内部维护了一个Cell数组,不同线程通过哈希落到不同的Cell上累加,最后再求和。这样CAS竞争被分散到多个槽位上,总吞吐量大幅提升。这给我的启发是:无锁冲突率太高时,不一定硬撑单变量CAS,可以考虑“分段”思路。
还有一点经验:单核CPU上自旋锁基本没有价值,因为一个线程自旋占用CPU,其他线程根本无法并行执行。多核CPU且临界区极短时,自旋才有收益。这也是有些自旋锁会统计上次是否等待过,超过阈值再加锁的原因。
4.3 只保一个变量:复合状态的两种组织方式
CAS的原子粒度是单个内存地址。业务对象通常不止一个字段,比如“余额”和“积分”需要同时更新,这就不能简单地对两个变量分别CAS了,因为两次CAS之间可能被其他线程插入,破坏整体一致性。
第一种方式是“状态对象替换”。把需要共同变更的字段封装进一个不可变对象,整体用AtomicReference引用。更新时创建新对象,CAS替换引用。比如:
class AccountState { final long balance; final int points; AccountState(long balance, int points) { this.balance = balance; this.points = points; } } AtomicReference<AccountState> ref = new AtomicReference<>(new AccountState(1000, 0)); void addReward(long delta, int bonus) { while (true) { AccountState current = ref.get(); AccountState next = new AccountState(current.balance + delta, current.points + bonus); if (ref.compareAndSet(current, next)) { break; } } }这种写法的优点是简单、线程安全;缺点是在高并发下会频繁创建新对象,GC压力明显。如果状态字段多且更新频繁,需要仔细权衡。
第二种方式是“打包单变量”。如果只有两个int字段,可以把它们塞进一个long,高位存余额,低位存积分。CAS操作这个long,保证两个字段同时更新。这种方法的效率更高,但编码复杂,且需要留足位数防止溢出。
如果业务复杂度实在太高,与其强行无锁,不如用一个短临界区的锁。CAS再香,也不该硬撑在所有场景。
5. 面试问答与实战排查:把CAS讲出亮点
5.1 高频连环问与回答框架
我整理了一下面试里关于CAS的常见问题,基本会沿着下面这条线展开:
- CAS是什么?一句话解释:比较并交换,一个原子操作,成功则交换,失败则提示。
- CAS底层怎么实现?答到CPU指令级别:x86的LOCK CMPXCHG,ARM的LDREX/STREX,并补充内存屏障和缓存一致性。
- CAS和synchronized有什么区别?从阻塞/非阻塞、乐观/悲观、适用场景、风险点几个维度说。
- AtomicInteger为什么是线程安全的?因为volatile保证可见性,CAS保证更新原子性。
- 什么是ABA?怎么解决?用AtomicStampedReference加版本号。
- 手写自旋锁?能写出可重入或不可重入版本,并主动分析问题。
- 无锁队列了解吗?谈ConcurrentLinkedQueue的入队CAS流程,甚至可以画一下节点状态。
回答的时候别只背概念,我建议按“定义 -> 原理 -> 工程应用 -> 局限性 -> 解决手段”的顺序组织。比如面试官问CAS,你可以说:CAS是一种乐观并发原语,底层由CPU指令支撑;在JDK里是Unsafe/VarHandle封装的compareAndSet;优点是避免线程阻塞,缺点是ABA和自旋消耗,所以常常加版本号,也可以配合分段或退让。这一套下来,信息量足够,也自然引出下一步讨论。
5.2 一次AtomicLong高并发事故的排查
某团队上线了一个热点统计功能,核心就是一个AtomicLong作为全局计数器,每次请求都incrementAndGet。刚开始并发量不高,一切正常。后来业务做推广,流量翻了十几倍,监控发现CPU使用率异常高,线程很多处于Runnable状态,但业务吞吐量反而下降。
初步排查时先看了jstack,发现大量业务线程都堆在同一个代码行:count.incrementAndGet()。这就很可疑。再用perf top看热点,几乎都是原子操作指令。进一步分析指标发现,CAS失败率已经超过80%,也就是说绝大多数自旋都在空转。
问题本质是单一CAS变量成为全局热点。修复方案是把单点计数拆成多个分片,每个线程根据线程ID哈希到不同分片累加,最终汇总时再求和。JDK里的LongAdder就是这个思路,直接替换后CPU使用率明显下降,语速也稳定了。
这次经历说明一件事:面试时你回答“CAS在高竞争下性能不一定好”,不能只是背书。要能解释为什么——所有失败线程都在忙等,且同一个缓存行/内存地址竞争会放大问题。能说出这种实操排查思路,面试官会认为你真的踩过坑。
5.3 实战避坑:这些场景少用CAS
用CAS前,先问自己几个问题。
第一,临界区会长吗?如果锁内逻辑超过一个函数调用或者包含循环、IO、睡眠,就不要用CAS。它没法“等待”,只会“重试”,CPU消耗肉眼可见。
第二,是不是一个变量能表达完整状态?如果对象里有多个字段需要逻辑一致更新,CAS要付出额外设计成本。此时用ReentrantLock可能更清晰。
第三,你能接受线程饥饿吗?纯CAS循环是机会均等的随机竞争,没有排队机制。某些线程可能连续失败很久,虽然概率低,但确实存在。如果有严格公平要求,需要阻塞队列或锁。
第四,平台和内存模型是否支持高效CAS?普通业务代码不用太关心,但如果做底层中间件,要确认目标架构有没有对应的原子指令,也要明确使用的内存序。
另外补充一点并发编程里的朴素质朴认识:AtomicInteger并不是“一定比synchronized快”。我见过一些测试报告,线程数少时两者差不多;线程数量上升,且临界区极小时,AtomicInteger有优势;但如果大量线程同抢,AtomicInteger反而可能因为自旋拖垮CPU。性能优化永远要量数据,不能只看名词高大上。
再有一个经验:读无锁数据结构的源码时,把“失败重试”当成主路径来读,而不是把“一次成功”当成全部。比如ConcurrentLinkedQueue入队,成功往往是巧合,失败后怎么修正才是核心逻辑。理解了失败路径,才算真的理解无锁编程。
我个人在实际面试候选人时,不太愿意听到那种“CAS就是比较并交换,ABA加版本号”的标准答案。我希望你讲清楚为什么要用CAS、CAS在CPU上做了什么、边界在哪里。如果你能把LongAdder的分段策略和AtomicStampedReference的设计动机都串起来,那这道题基本就是加分项。准备的时候,可以自己手写一个自旋锁,再写一个AtomicStampedReference的版本号场景,跑一跑,看看失败重试的次数。这种“动手+理解”的方式,比单纯背十遍概念有用得多。