被人问得最多的并发问题里,CAS一定排在前三名。不管是初级岗还是资深岗,面试官只要想探你的并发功底,大概率会从CAS切入,聊无锁编程、聊JUC底层、聊乐观锁,一环扣一环。“深入理解CAS:无锁编程的核心,面试必考点解析”这个标题不是我起的,是几乎所有准备面试的人都会搜到的一个方向。今天这篇就围绕它展开,我会把这几年看源码、写并发代码、当面试官问别人和被别人问的经验全揉在一起讲清楚。
这篇内容适合谁?一类是准备跳槽、正在刷面试题的开发者,另一类是已经在写业务并发代码、但每次用到AtomicInteger和LongAdder都只是“会用”的人。看完之后,你能做到三件事:第一,把CAS的原理用大白话讲给面试官听;第二,能说出CAS的坑和对应的工程解法;第三,再看到并发工具类时,一眼能认出它底层是不是CAS那套思路。
1. CAS是什么:一个变量被安全修改的最短路径
1.1 从一段线程不安全的代码说起
先看一个最常见的例子。一个计数器,多个线程往里加:
public class Counter { private int count = 0; public void increment() { count++; // 很多人都知道这行不是原子的 } }count++实际上拆成了三步:读取count的值,在CPU里加1,把结果写回内存。两个线程同时读到100,各自加1,再写回,最后内存里还是101,丢了一次更新。
解决思路很多,最笨的是给increment加synchronized,性能差一点,但正确。更优雅的是用 AtomicInteger:
AtomicInteger counter = new AtomicInteger(0); counter.incrementAndGet();同样是并发自增,这段代码没有加锁,却在任何时刻都不会丢更新。为什么?因为incrementAndGet底层不是“读-改-写”三段操作,它把“比较”和“交换”合并成了两个动作,核心就是CAS。
1.2 CAS的三个操作数:V、E、N
CAS的全称是 Compare-And-Swap,比较并交换。它需要三个操作数:
- V:内存中的目标地址,也就是我们要更新的那个变量
- E:期望值,判断变量是不是“没被别人改过”的依据
- N:新值,如果判定通过,就把这个值写进去
整个过程只有一句话语义:只有当内存地址V里的当前值等于期望值E时,才把V更新成N;如果V的当前值不等于E,说明在这段时间里有人动过它,那本次操作失败,什么都不做。
这里要注意,CAS返回的是布尔结果,但底层指令往往还会返回旧值。Java里的Unsafe类对外提供的compareAndSwapInt,返回的是boolean,表示是否更新成功。
用一个生活化的类比:你想把宿舍床位上挂的袋子换成自己带的,规则是“先看看我的袋子还在不在,在就换,不在就说明别人动了,我不换”。这个“看袋子还在不在”并不是先看一眼再换,而是CPU一个指令同时完成的“检查+置换”。关键点就在这里——它不是多个步骤的拼接,而是一个原子动作,不可被打断。
1.3 为什么说CAS是“无锁编程”的核心
有了CAS,就可以实现一组线程安全操作,而不使用锁。这里的“无锁”不是没有并发控制,而是没有阻塞和线程挂起。加锁的代价在于:竞争锁的线程会进入阻塞状态,操作系统要把这个线程挂起,之后锁释放再唤醒它。挂起和唤醒涉及内核态切换,成本是纳秒级的锁操作、微秒级的上下文切换,量级差异非常明显。
CAS则完全不同。它让每个线程在CPU寄存器层面直接尝试“比较并交换”,失败了立刻重试,不会把自己挂起。也就是说,线程全程停留在用户态、自旋等待,CPU不用切换执行上下文。
这个特性就引出了无锁编程:一个人对共享数据执行CAS操作,如果失败就循环重试,直到成功为止。所有并发控制都在一条指令内完成。
2. CAS为什么能保证原子性:硬件和JVM的配合
2.1 Java里怎么调用CAS:Unsafe的进去看
Java标准库没有暴露直接操作内存的API,得靠JDK内部一个叫Unsafe的类。这个类在sun.misc包下,名字就叫“不安全”,因为它的方法大多直接操作内存地址,用不好能把JVM干崩。
public final class Unsafe { public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x); }这里的o是要操作的那个对象,offset是目标字段在对象内存布局中的偏移量,expected就是CAS期望值,x是新值。JVM把对象和字段偏移量传下去,由native方法把它翻译成一条CPU指令。
AtomicInteger的自增就是这么实现的:
public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; }getAndAddInt内部就是个do-while循环,反复CAS直到成功。
Unsafe类普通代码是拿不到的。它的构造器是私有的,而且类加载时有安全校验。不过JDK9以后提供了更友好的VarHandle,作用类似,可以在普通代码里安全使用。
2.2 硬件层面:CPU的一条指令就把事办了
CAS的原子性最终靠的是处理器提供的原子指令,x86架构上对应的是CMPXCHG指令,编译后就是一行汇编:
lock cmpxchg [reg], reglock前缀是关键,它锁住总线或者锁住缓存行,在指令执行期间,其他处理器无法访问这段内存。现代处理器缓存一致性协议会做更多优化,用锁缓存代替锁总线,性能更好。
也就是说,程序员眼中的“比较判断 + 条件更新 + 失败回退”,在硬件里只是一条带锁前缀的指令。它在执行过程中不会响应中断,也不会被其他CPU核心的写操作打断。
这里延伸一个高频面试点:为什么synchronized重,CAS轻?因为synchronized的加锁解锁不只是硬件指令,还涉及对象头里的mark word、锁升级、线程阻塞唤醒;而CAS本质上就是一条硬件指令,没有阻塞的概念。它们不是同一层级的方案,CAS是底层构件,锁是在它之上更重的策略。
2.3 JIT和内存模型:CAS和volatile是什么关系
CAS操作涉及的内存地址通常要声明为volatile。AtomicInteger内部的value字段就有一个volatile修饰符:
private volatile int value;原因是CAS要比较的值,必须是当前线程能看到的最新值。如果value没有volatile保证可见性,一个线程修改后,另一个线程的“比较期望值”可能还是旧值,CAS肯定会失败,逻辑不会错,但性能会崩。
volatile的语义保证了两点:写操作会立刻刷新到主内存,读操作会从主内存读取。两者配合起来,CAS检查的“当前值E”才是所有线程眼里一致的值。JIT编译时也会针对这种模式做优化,把高频自旋代码编译成比较高效的本地指令。
所以面试时不要只背“CAS是CPU指令”,还要加一句:在Java里,CAS一般配合volatile使用,这样读的就是最新值,写的就是同步值。
3. CAS在JDK和业务中的经典落地
3.1 Atomic系列:CAS最直白的使用
java.util.concurrent.atomic包整个就是CAS的教学现场。AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference,各自封装了对int、long、boolean、对象引用的CAS操作。
用AtomicLong举个例子。高并发下累加一个ID,或者统计某系统的总请求数,直接:
AtomicLong totalRequests = new AtomicLong(0); public void onRequest() { totalRequests.incrementAndGet(); }如果需要先读取再根据旧值做判断,还有updateAndGet方法,传一个函数进去,JVM会循环执行直到CAS成功:
atomicInteger.updateAndGet(x -> Math.max(x, 100));这个函数的写法天然规避了一个经典错误:先get再用compareAndSet,两步之间数据可能已经变了。updateAndGet把“读-计算-写回”封装成了一个带有重试语义的整体。
3.2 AQS背后:不经意间就用到CAS
AQS(AbstractQueuedSynchronizer)是JUC里ReentrantLock、Semaphore、CountDownLatch的地基。面试必问AQS,很多人背了“CLH队列、state变量”,却没意识到AQS的关键操作就是CAS。
AQS里有一个state字段,用来表示同步状态,比如锁的重入次数。加锁的核心动作,是尝试把state从0改成1,代码里就是compareAndSetState(0, 1)。多个线程同时抢锁,只有CAS成功的那个线程能拿到锁,其他线程进入队列等待。
protected final boolean compareAndSetState(int expect, int update) { return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }这个CAS就是锁的入口。一旦CAS失败,线程才需要付出较重代价:进入等待队列、阻塞。所以ReentrantLock本身是“先用CAS快速试一次,试不上再走锁机制”,这种设计叫快速路径。
3.3 乐观锁:业务系统里的CAS思维
CAS思想不光活在JDK里。数据库的乐观锁完全就是CAS这套逻辑:
update stock_table set stock = #{newStock}, version = version + 1 where id = #{id} and version = #{oldVersion};影响行数为0,说明version被别的事务改了,更新失败,业务层重试。这个version比对,就是“期望值E”的判断;where条件里的version = oldVersion,是CAS里的比较动作;set里的新库存和version+1,是交换动作。
我曾经在一个秒杀项目里给某电商平台做库存扣减,最初用悲观锁select for update,压测时吞吐上不去。后来改成乐观锁update语句,把库存判断直接塞进SQL条件里,吞吐翻了几倍。这里面的判断就是CAS思路——系统不对资源加锁,而是在最后写入前检查“是否已被修改”。
当然乐观锁不是银弹,下面详细说坑。
4. CAS的三大坑和工程解法
4.1 ABA问题:改来改去等于没改,但中间变了
CAS只检查“当前值是不是期望值”,它无法感知这个值中间是否被改过几次。最经典的例子:
线程A读到一个栈的栈顶元素是Node(1),准备用CAS把栈顶替换成Node(2)。线程B先一步把栈顶改成Node(3),紧接着又改成Node(1)。在线程A做CAS的那一刻,它发现栈顶还是Node(1),判断没变,更新成功。但实际上栈的结构已经变了,Node(1)下面的next节点都和A开始时不一样了。
用钱来做类比更直观:钱包余额是100元,A想扣10元。CAS期望值是100。B先转了10元进来变成110,又消费10元变回100。A的CAS依然成功,看似没扣错,但中间发生的那笔收支完全没被感知。
解决方案是加版本号。不仅比较值,还比较版本。Java里对应AtomicStampedReference,它内部维护一个int类型的戳,每次更新值的同时,戳也要对:
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0); boolean success = ref.compareAndSet(100, 90, stamp, stamp + 1);这里的stamp就是版本号,值变一次、戳变一次。哪怕金钱回到100,戳已经变了,CAS就会失败。
面试追问点:AtomicMarkableReference也能解决ABA,它只记录boolean,适合关注“是否被动过”而不是“被动了多少次”的场景。能说出这两个类的区别,说明你是真懂ABA。
4.2 自旋开销:失败到成功的路可能很长
CAS失败后继续重试,这个“继续”是消耗CPU的。如果竞争很激烈,大量线程同时自旋在一个共享变量上,CPU利用率可能飙升,性能反而比锁还差。
我用过一次真实教训。某系统做热点账户扣款,同一个账户被很多请求并发操作,每个请求都用AtomicLong做余额自减,结果压测时CPU密集区几乎全在自旋。自旋本身不阻塞线程,但它让线程占满时间片,其他等待线程也用不上CPU。
那里的解法不是去掉CAS,而是先分流。把同一个账户的更新请求按ID取模,分到多个子账户上,减小对同一个变量的竞争。或者改用LongAdder。
LongAdder的思路很妙:它内部维护一组Cell数组,每个线程更新时先通过哈希落到自己的Cell上,最后求和再汇总。相当于把CAS竞争的“单热点”打散成了“多分片”,对CPU总开销来说,自旋的次数大幅下降。如果你的场景是“读多写少”的统计计数器,LongAdder比AtomicLong合适得多。
这里放一张简单的对比表,方便记:
| 工具 | 适用场景 | 冲突处理方式 | 性能特点 |
|---|---|---|---|
| synchronized | 竞争少或需要等待语义 | 阻塞挂起 + 唤醒 | 冲突少时开销低,冲突多时线程切换成本高 |
| AtomicLong + CAS | 单变量更新,冲突少 | 自旋重试 | 冲突少性能极高,冲突多时CPU空转明显 |
| LongAdder | 频繁累加,写多读少 | 分片Cell + CAS | 写冲突大幅降低,读时求和略慢 |
| ReentrantLock | 临界区较长,可中断可超时 | CAS快速路径 + 等待队列 | 冲突多时比锁自旋好,避免CPU空转 |
4.3 只能搞定一个变量:多字段同改怎么办
CAS的V只能是一个内存地址。如果需要同时更新两个变量,并且要求两个变量要么都变、要么都不变,CAS就不好直接处理了。
比较实的解法是:把这几个字段封装成一个对象,用AtomicReference对对象引用做CAS。比如一个用户对象既改积分又改等级,每次都构造一个新的User对象,然后用compareAndSet替换:
class User { final int points; final int level; User(int points, int level) { this.points = points; this.level = level; } } AtomicReference<User> userRef = new AtomicReference<>(new User(0, 1)); void upgradePoints(int delta) { while (true) { User current = userRef.get(); User next = new User(current.points + delta, current.level); if (userRef.compareAndSet(current, next)) { break; } } }这叫不可变对象加CAS替换,很多并发容器的节点更新都是这么玩的。代价是每次更新都要new一个新对象,垃圾回收压力会大一点,但换来的是无锁原子性。
如果字段特别多,且更新频率极高,new对象的开销不可忽视,就得考虑另一个方案:把修改动作包到一个临界区里,用锁来做。LMAX Disruptor的高性能队列里也用了大量CAS加不可变对象的设计,可见这是一套经过验证的组合。
5. 面试实战:CAS高分回答与必问场
5.1 面试官想听的分析框架
很多候选人一上来就背定义:“CAS是compare and swap,有V、E、N,如果V等于E就设成N。”能背下来问题不大,但面试官立刻会感觉到你只是背了八股,没有自己的理解。
我建议的答题框架是这样的:
第一层,讲它是什么:一条CPU原子指令,通过比较内存值和期望值来决定是否更新,Java通过Unsafe调用。
第二层,讲它解决的问题:让单个变量的更新变得线程安全,不需要加锁,无阻塞、无上下文切换,并发性能更好。
第三层,讲它的边界:只能保证一个变量的原子性;因为硬件指令本身不维护历史,会出现ABA问题;竞争激烈时自旋抢占CPU。
第四层,讲工程应对:ABA用AtomicStampedReference加版本;自旋用LongAdder、分片或者减少热点变量;一个对象多个字段用AtomicReference封装不可变对象。
这套顺序从底层到应用层,再到坑和方案,面试官很难打断你,因为你想说的正是他要问的。
5.2 高频追问与参考答案
我把面试中围绕CAS的典型追问整理成一个速查表,每个问题的核心答案控制在两句话以内,再补一句可延展的点:
| 追问 | 核心答案 | 进阶点 |
|---|---|---|
| CAS真的不会阻塞吗? | 不会,自旋是在用户态反复尝试,不进入内核态,不挂起线程 | 如果长时间CAS不成功,会导致CPU占用上升,极端场景下类似忙等 |
| ABA是什么? | 值从A变成B又变回A,CAS认为没变,但中间状态被忽略了 | AtomicStampedReference和AtomicMarkableReference是两种版本策略 |
| CAS和volatile有什么关系? | CAS需要读取最新值,volatile保证可见性;Atomic类内部字段都是volatile | Java内存模型里volatile的写会触发后续读的可见性 |
| 为什么synchronized没被CAS替代? | CAS只适合单变量短操作;临界区多步操作、复杂状态转换仍然需要锁 | synchronized还有锁升级机制,JVM下性能并不差 |
| 数据库乐观锁是不是CAS? | 思路一样,版本号比较替代了CAS的期望值比较 | 需要关注更新失败后的业务重试机制 |
5.3 手把手写一个CAS自旋计数器
面试中如果让你实现一个线程安全的计数器,要求不许用锁、不许用Atomic类,那就手动模拟一个CAS循环。思路就是拿到旧值,算出新值,然后CAS。失败就重新读。
完整代码可以这样写:
public class SpinCounter { private volatile int count = 0; // 模拟CAS操作,实际环境下由Unsafe或VarHandle提供 private boolean compareAndSet(int expected, int newValue) { synchronized (this) { if (count == expected) { count = newValue; return true; } return false; } } public int increment() { while (true) { int current = count; int next = current + 1; if (compareAndSet(current, next)) { return next; } } } public int get() { return count; } }这段代码里的synchronized只是为了模拟“原子比较+更新”这个动作,真实场景中换成Unsafe的compareAndSwapInt或者VarHandle.compareAndSet即可。逻辑是一样的:先读旧值,尝试更新,失败重来。
很多同学面试时会忽略一点:compareAndSet里的期望值必须是循环内读到的current,不能是这次循环之前读的。写代码时代码块会产生自己实现的问题有两点:一是期望值必须同步更新,二是失败后要立刻重读,不能spin太久。
5.4 业务场景回答:账户扣款怎么用CAS思路
有一个被反复问到的场景题:某支付系统里,用户余额是一个整数,多个线程可能同时扣款,怎么保证不超扣?
如果数据在数据库里,答案乐观锁:update语句带version条件,影响行数为0则重试。
如果数据在进程内存里,比如网关限流的令牌桶计数,答案AtomicLong配合CAS:
AtomicLong balance = new AtomicLong(100); public boolean deduct(int amount) { while (true) { long current = balance.get(); if (current < amount) { return false; } long next = current - amount; if (balance.compareAndSet(current, next)) { return true; } } }注意里层的判断current < amount,是在CAS之外做的,可能会有竞态:两个线程同时读到100,都发现能扣10,都扣了,结果余额变成90还是80取决于谁先成功。为什么代码是对的?因为失败的那个线程会进入下一次循环重新读余额,最终只有一个线程能把余额从100变成90,另一个会重试再扣10或者因余额不足返回false。这个细节是面试加分点,很多人在第一版代码就漏了。
5.5 用一句话说服面试官:CAS与无锁的取舍
面试快结束时,面试官可能会来一句总结式提问:“你觉得CAS是万能的吗?”
要果断说不。CAS在低竞争场景下极其高效,因为它没有线程挂起、唤醒、上下文切换。但在高竞争场景下,它会退化为CPU自旋,性能反而不如锁。无锁之所以叫无锁,不是说锁没有价值,而是换了一种更轻量的方式协调共享变量。真正的高手会按需选择:临界区短、单变量更新、并发冲突小,CAS;临界区复杂、需要等待队列、多个状态联动,还是上锁或者并发工具类。
6. 写在最后:从面试考点到工程直觉的一点体会
见过很多候选人,CAS八股背得滚瓜烂熟,一到实际项目就只会用AtomicInteger。原因不是知识不够,而是缺少“从问题反推方案”的视角。
我的个人体会是:不要盯着CAS本身,要盯两个问题——当前共享数据到底是什么粒度,以及并发冲突到底有多严重。粒度是单变量还是多字段,冲突是高是低,直接决定你是用Atomic系列、LongAdder、AtomicReference,还是干脆用锁。CAS不是目的,它是“避免阻塞、减少上下文切换”的一种手段。手段适不适合,取决于场景,而不是因为听起来厉害。
如果你下周就要面试,建议花一小时做三件事:把我认为的四个层次讲一遍给同事听;手写一个CAS自旋计数器并想想它和AtomicInteger的差别;再想想你项目里有没有解决过ABA问题,用自己的例子包装一下。面试官最怕背概念的,最喜欢听完一句话就能继续追问的。CAS这个考点,背得来是及格,说得清边界和取舍才是优秀,这也是无锁编程真正的价值所在。