在网上刷到很多人吐槽,说现在Java面试卷到离谱,问个并发编程,上来就是“CAS知道吧?ABA问题怎么解决?”你要是只会背个“compare and swap”,接下来的追问基本就凉了。我做了十年Java开发,面过人也被人面过,可以负责任地讲:CAS这个问题,面试官不是真指望你背概念,他是想通过你答CAS,判断你懂不懂并发底层、有没有真正处理过高并发场景下的实际坑。
这篇文章就把CAS从“面试角度”和“实战角度”两层串起来讲透。先搞清楚CAS到底在解决什么问题,再看它在CPU层面是怎么实现的,然后扒一遍Java原子类的源码,再把所谓的“优缺点”掰开揉碎讲明白,最后重点说ABA问题——它发生的场景、为什么会对业务造成影响、用什么手段解决,以及我在实际项目里是怎么取舍的。
1. 面试官为什么总爱问CAS:先搞清楚它到底解决了什么
1.1 synchronized的困局与无锁的转机
要理解CAS,你得先回到并发编程最核心的矛盾:多个线程同时操作一个共享变量,怎么保证结果是对的?
老方案是加锁。Java里最朴素的就是synchronized。早期版本的synchronized是重量级锁,线程拿不到锁就进入阻塞状态,涉及到操作系统内核态的用户态与内核态切换,代价很大。后来JVM做了锁升级优化(偏向锁->轻量级锁->重量级锁),性能好了很多,但“阻塞”这个本质没有变:一旦锁竞争激烈,大量线程排队等待,上下文切换成本就会飙升。
这就产生了一个很实际的需求:有没有一种方式,不阻塞线程也能保证共享变量的修改是安全的?
CAS就是答案。它全称是Compare And Swap,中文叫比较并交换。名字很直白:先比较,再交换。你准备把一个变量从旧值A改成新值B,操作前先检查一下当前内存里的值是不是还是A,如果是,说明在你操作期间没人动过,那就放心改成B;如果已经不是A了,说明别人改过了,那你的计算就作废,重新来一遍。
整个过程不需要线程阻塞等待,所以叫无锁。严格说叫无阻塞同步,或者乐观锁——因为它乐观地认为冲突很少,先干再说,出错了再重试。
1.2 CAS不是锁,它是一种“乐观”策略
面试中有个高频误区,很多人把CAS和锁对立起来,其实不对。CAS不是锁,它是一种同步策略,是对“怎么检查-怎么更新”这个动作的原子性保障。
这么说吧:synchronized是悲观策略,觉得一定会冲突,所以先锁上门再操作;CAS是乐观策略,觉得冲突是小概率事件,直接操作,如果发现被动了,就重来。两种策略没有谁绝对好,关键看场景。
我在实际项目中习惯这样界定:对热点数据的并发写,比如秒杀库存扣减、订单状态流转,如果冲突率低,CAS效果非常好;如果冲突率极高(比如所有线程都同时抢同一个值),CAS会陷入疯狂自旋重试,CPU空转,这时候反而吃亏,得回退到加锁或分段思想。
我推荐你把这个“乐观 vs 悲观”对照记在心里,面试官问“什么时候用CAS什么时候用锁”,就从冲突概率和任务粒度两个维度回答,比背标准答案稳得多。
2. CAS如何做到原子性:一个CPU指令背后的机制
2.1 cmpxchg指令:比较并交换的最小单元
很多人背了CAS的概念,但不知道为什么CAS是原子的。“比较-交换”明明是两步操作,怎么就能保证不被人打断?这里必须下沉到CPU指令层面。
其实CAS在硬件上的实现是一条原子指令,在x86架构上叫cmpxchg(Compare and Exchange)。CPU执行这条指令时,它在单条指令周期内完成“读内存值-与预期值比较-条件满足则写入新值”整个流程。指令本身就是原子的,不允许被线程调度中途打断。
但这里有个细节:cmpxchg在多核CPU环境下,比如两个核心同时在同一个内存地址上执行CAS,依然可能出现竞争。所以CPU又加了一层保障:
- 在单核CPU上,指令周期内不会被打断,天然安全。
- 在多核CPU上,处理器通过锁总线(Lock prefix)或缓存锁(Cache Lock)机制,保证该指令在多个核心之间也是原子的。总线锁就是锁住总线,让其他核心不能访问内存;缓存锁更轻量,锁住的是该核心的高速缓存行,直到指令执行完。
理解到这一层,你就会明白为什么说“CAS是硬件级别的原子操作”。这也是面试里一个很好的加分点:你不仅知道CAS,还知道它靠的是CPU的cmpxchg指令和多核环境下的总线锁/缓存锁机制。追问道这里,大多数候选人就答不上来了。
2.2 从指令级到Java:Unsafe的直通通道
Java层面的CAS并没有直接调CPU指令的语法,它是通过sun.misc.Unsafe类暴露的native方法实现的。
Unsafe类里有一组关键的native方法,典型的是:
public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x); public final native boolean compareAndSwapLong(Object o, long offset, long expected, long x); public final native boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);参数含义分别是:目标对象、目标字段在对象内存中的偏移地址、预期值、要设置的新值。方法返回boolean,表示这次CAS是否成功。
java.util.concurrent包下的所有原子类,底层走的都是这一套。说到这里必须提醒一句:Unsafe这个类名字就说明了它有风险,它不是给业务代码用的,官方明确不建议直接调用,因为它的方法不受Java语言安全性约束,用不好可能直接搞崩JVM。我们常规开发通过AtomicInteger这些原子类间接使用就够了,不要自己写Unsafe调用。
2.3 自旋:拿不到就再试一次
CAS还有一个重要配套机制,就是自旋。
当你的CAS操作失败——也就是说内存里的当前值和预期值不一致了——你该怎么办?有两种策略:一种是立刻放弃,返回失败;另一种是循环重试,把最新的值再读出来,重新计算,再尝试CAS,直到成功为止。后者就是自旋。
Java的很多原子类默认走自旋路线。比如AtomicInteger的incrementAndGet,底层就是一个do-while循环,不断CAS,直到成功跳出。
public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) + 1; }继续往下看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; }这里有个细节值得玩味:它先读取当前值var5,然后尝试CAS,把var5更新为var5 + var4。如果CAS失败,说明在读取和尝试之间,有人改了这个值,那就重新读取最新值,再做一次CAS。这个循环就是自旋。
自旋带来的好处是不阻塞、不切换上下文;代价是如果竞争过于激烈,多个线程反复失败、反复重试,CPU就会空转,导致自旋开销很大。这个代价具体有多大,第4部分我会专门讲。
3. 从AtomicInteger源码看CAS的完整运作
3.1 原子类的设计骨架
动手写代码之前,先看一遍java.util.concurrent.atomic包的设计骨架,你会发现所有原子类结构都差不多:
- 声明一个
volatile修饰的字段,保证字段的可见性。这个很关键,变量值改了之后,别的线程要能立刻看见最新值。 - 在静态代码块里,通过Unsafe的
objectFieldOffset方法拿到这个字段在对象里的内存偏移量valueOffset。 - 所有修改操作(增、减、更新)都通过Unsafe的CAS方法实现,配合do-while自旋。
AtomicInteger核心字段就是这样的:
private static final Unsafe unsafe = Unsafe.getUnsafe(); private static final long valueOffset; static { try { valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } } private volatile int value;volatile负责可见性,CAS负责原子性,两者配合,才构成一个线程安全的变量操作闭环。缺了volatile,CAS判断“当前值等于预期值”时可能读到陈旧数据,整个机制就失效了。
3.2 一段简单的CAS代码实战
光说理论不过瘾,我给你写一段能直接跑的代码,模拟多线程并发累加的场景。
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.CountDownLatch; public class CasDemo { static AtomicInteger counter = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { int threadCount = 8; int perThreadCount = 100000; CountDownLatch latch = new CountDownLatch(threadCount); long start = System.currentTimeMillis(); for (int i = 0; i < threadCount; i++) { new Thread(() -> { try { for (int j = 0; j < perThreadCount; j++) { counter.incrementAndGet(); } } finally { latch.countDown(); } }).start(); } latch.await(); long cost = System.currentTimeMillis() - start; System.out.println("最终结果: " + counter.get()); System.out.println("预期结果: " + (threadCount * perThreadCount)); System.out.println("耗时: " + cost + "ms"); } }8个线程各累加10万次,结果始终是80万。这就是CAS加volatile加自旋三个机制协作的结果。如果换成普通的int变量,结果一定小于80万,而且每次运行结果都不同——这就是面试里常说的“线程不安全”。
3.3 引用类型原子类:AtomicReference
AtomicInteger只能原子化地操作一个整数,如果要原子化更新一个对象的引用,就得用AtomicReference。这在实现无锁链表、无锁栈、状态机流转时非常常用。
一个典型场景:配置对象的热更新。多个线程读配置,一个线程改配置,要求读取线程要么看到旧配置整体,要么看到新配置整体,不能出现中间态。用AtomicReference<Config>就能做到。
public class ConfigHolder { private static final AtomicReference<Config> configRef = new AtomicReference<>(new Config("", "")); public static Config get() { return configRef.get(); } public static void update(Config newConfig) { while (true) { Config current = configRef.get(); if (configRef.compareAndSet(current, newConfig)) { return; } // 竞争失败就重读重试 } } }这里有一个实战进阶点:更新操作如果只是set(直接赋值),不需要CAS也能保证可见性,volatile就够了。但如果是“先读后写”的复合操作——比如你先基于当前值算出新值再覆盖回去——就必须用CAS,因为set没法保证“中间没人改过”。这个区分,面试里也是高频追问点。
4. 看清CAS的真面目:优点和代价同样明显
4.1 优势:无锁不等于无能,反而更强
先把CAS的好处讲清楚。这些在面试里就是“CAS的优缺点”答案的主干:
- 非阻塞,无锁:线程不用挂起、不用排队,天然避免了上下文切换和线程阻塞带来的性能损耗。
- 精简的原子性保障:只针对一个变量或对象引用,不需要像锁那样管理临界区,实现简单,不易死锁。死锁这个事在锁方案里是老大难,CAS天生免疫。
- 在低竞争场景下性能极佳:冲突少,CAS基本一次成功,比synchronized、ReentrantLock都快。我们线上有个计数器场景,每秒千万级打点,用的就是AtomicLong,实测比加锁方案快很多。
面试问到“为什么用了CAS之后AtomicInteger比synchronized快”,回答核心就是:线程没有进入阻塞状态,省去了操作系统级别的线程调度和上下文切换开销,这些开销在高并发下非常昂贵。
4.2 三个必须警惕的缺点
再看代价,这部分是我专门想讲的。我见过不少候选人只背优点,一追问缺点就卡壳,其实缺点才是面试官真正想看你有没踩过坑的地方。
第一,ABA问题。这个问题单独拎出来讲,放在第5章细说。
第二,自旋开销。CAS失败后循环重试,不会主动让出CPU。如果冲突率高,大量线程在同一时刻反复失败、反复重试,CPU空转率飙升,性能反而可能比加锁还差。补救手段是自适应自旋(JVM层面对锁的优化思路,不是CAS独有的):自旋次数少就暂停,次数多就让出CPU或休眠。但CAS本身是没有这个机制的,所以在高竞争场景下,无脑用CAS不一定划算。
第三,只能保证单个共享变量的原子性。CAS的操作对象是“一个变量的值”,当你需要同时更新两个或以上的变量,比如“账户余额和交易流水”“订单状态和库存数量”,单个CAS就无能为力了。要组合多个变量,要么用锁,要么把多个变量封装成一个对象,塞进AtomicReference里做整体替换。实战里封装成不可变对象(Immutable Object)是更清爽的方案。
4.3 什么场景更适合CAS,什么场景不适合
这个问题别只看“性能”两个字,得结合业务场景来判断。我总结了一套选型参考:
| 场景特征 | 推荐方案 | 原因 |
|---|---|---|
| 单个计数器热点,冲突率低 | CAS(AtomicLong/AtomicInteger) | 无锁、快、简单 |
| 多个共享变量必须同时更新 | 锁(synchronized/ReentrantLock) | CAS无法同时保证多个变量的原子性 |
| 临界区代码执行时间长 | 锁(阻塞式) | CAS自旋等待会让CPU空转很久 |
| 临界区代码执行时间极短 | CAS | 自旋代价远小于线程切换代价 |
| 冲突率极高的热点写 | 锁或分段思想 | 高竞争下CAS自旋开销大 |
| 状态机流转(如订单状态) | CAS配合版本号 | 天然防ABA,适合状态收敛 |
表格里最后一行是很多项目里常用的思路,后面讲ABA问题的解决方案时正好用上。
5. ABA问题的坑:从发生到解决的完整链路
5.1 ABA问题到底怎么发生的
先讲清楚ABA的定义,用一个具体的例子。
假设内存中有一个共享变量初始值为A。线程1对它进行了一个非常耗时的计算,在此期间线程2把值从A改成了B,又改回了A。当线程1的CAS操作执行时,它发现“当前值还是A”,与预期值一致,于是成功更新。但从线程1的视角看,它以为这个值从头到尾没变过,实际上中间已经被别人动过了——这就是ABA问题。
问题在哪里?在一些场景下“值没变”确实等同于“没人动过”,没关系;但在另一些场景下,中间被改过这一事实本身会影响业务正确性。
举个例子:无锁栈操作。你要从栈顶弹出一个节点,先读取栈顶节点A,然后准备把栈顶指针从A移到A的下一个节点N。如果在你读取和CAS之间,另一个线程弹出了A,又把它压回去——注意,还是同一个A对象,但它的next可能已经被改了——此时你的CAS会发现栈顶还是A,于是你以为状态没变,实际上栈的结构已经变了,你的CAS操作就会把整个栈搞坏。
再举个例子:账户金额操作。你从余额里扣款100元,先读取余额是200,预期还是200时调用CAS改成100。如果中间有人充了100又退了100,余额回到200,你的CAS认为没人动过,照常扣款成功。表面上结果没错,但如果你的系统需要在扣款时记录“本次操作基于的余额版本”,或者需要审计中途变化,ABA带来的“信息丢失”就会出问题。
5.2 两个经典修复方案:AtomicStampedReference与AtomicMarkableReference
解决ABA问题的核心思路就一句话:比较的不只是“值”,还要加上“版本号或标志位”。值相同但版本号不同,就说明中间被动过。
Java官方提供了两个类:
AtomicStampedReference:内部维护[reference, stamp]一个二元组,stamp可以理解为版本号,每次修改必须同时更新值和一个递变的stamp。AtomicMarkableReference:内部维护[reference, boolean]二元组,只用一个boolean标记是否被改过,适用于“只关心有没有人动过,不关心动了几次”的场景。
用AtomicStampedReference重写上面的扣款场景:
AtomicStampedReference<Integer> balance = new AtomicStampedReference<>(200, 0); // 线程1准备从200扣到100 int[] stampHolder = new int[1]; Integer current = balance.get(stampHolder); int currentStamp = stampHolder[0]; // 如果期间其他人改了值,stamp会变,下面CAS会失败 boolean success = balance.compareAndSet( current, current - 100, currentStamp, currentStamp + 1);看到没,compareAndSet从四参数变成了四个参数:预期引用、新引用、预期版本号、新版本号。版本号必须同步递增,才能感知中间的任何改动。
AtomicMarkableReference的用法类似,区别在版本号只是一个boolean:
AtomicMarkableReference<Integer> ref = new AtomicMarkableReference<>(200, false); // 比较并设置时带上标记 ref.compareAndSet(expected, newValue, expectedMark, newMark);5.3 面试里的坑:ABA问题真的那么致命吗
这里我想说点面试官经常埋的坑。
很多候选人一提到ABA就紧张,觉得它在任何场景下都会出问题。实际上,ABA是否致命,取决于业务语义到底以“值”为唯一依据,还是必须以“过程”为依据。
- 如果你的操作只看结果值,比如一个计数器只求最终数字,中间被别的线程改来改去最后回到原值,最终结果没影响,那ABA问题就不会造成功能错误。这种情况用
AtomicInteger就够了,不用上版本号。 - 如果你的操作依赖“中间状态发生了多少次变化”或者“引用对象内部状态被改动过”,那ABA就是必须解决的。比如无锁数据结构、状态机推进、基于引用内容做业务判断的场景。
所以在面试中最好直说:ABA是CAS带来的一种理论缺陷,实际是否出事故,要看变量的语义。这样回答既显专业又很务实。
还有一个加分点:在项目里,我还见过用状态机配合CAS解决ABA的做法。订单状态从“待支付”到“已支付”再到“已完成”,每一步都是单向推进,本来就不允许回退,你再叠加一个递增值做校验版本号,就能彻底消除ABA的影响。这是个非常实用的设计,建议你在简历项目描述里备一嘴。
6. 面试追问链路:把这些知识点串成一条线
6.1 CAS与volatile的关系
面试官问CAS时,很可能顺嘴补一句“volatile知道吧?和CAS什么关系?”
这俩是搭档,但负责的工作不同:
volatile解决可见性和指令重排序。它保证一个线程对变量的修改,能立刻被其他线程看到,并且不会因重排序导致“看起来先改后写”的不一致。- CAS解决原子性。它保证“比较-交换”这个复合操作不可分割。
AtomicInteger里的value就是volatile的,所以每个线程都能读到最新值;通过CAS写入成功后,新值也会立刻对其他线程可见。两者缺一不可。面试里你可以补充一句:如果没有volatile,CAS在读取“当前值”时可能读到旧值,导致本来应该失败的比较误判为成功,所以原子类的底层字段必须是volatile。
6.2 CAS与synchronized的取舍
这是面试后半程的经典问题。我的建议是别急着二选一,先用问题拆解:
- 你们要保护的是一个变量,还是一段代码块?变量级用CAS,代码块级用锁。
- 操作的执行时间是多长?极短(比如计数器累加)适合CAS,执行时间较长(比如复杂计算、IO操作)适合锁。
- 冲突概率高不高?低冲突用CAS,高冲突用锁。
- 是否允许等待?CAS自旋会让当前线程占用CPU等待,锁可以让线程阻塞并让出CPU。
真实项目里,我还会补充一条经验:优先用现成的并发工具类,而不是自己去实现无锁方案。Java的LongAdder、ConcurrentHashMap、StampedLock内部都用了CAS,而且经过了大量优化和测试,你直接选合适的就行。自己造轮子做CAS有个巨大的隐性成本:并发bug极难复现、极难定位,哪怕你理论再熟,线上偶发问题也够喝一壶的。
6.3 一段我面试时梳理过的思考路径
最后把整条链路串起来分享一下。我自己带新人或者准备面试时,习惯按这个顺序过一遍,你可以参考:
- synchronized的阻塞模型有什么代价?(上下文切换、线程调度开销)
- CAS怎么避免阻塞?(先比较再更新,失败就重试)
- CAS为什么是原子的?(CPU指令cmpxchg,多核下靠总线锁/缓存锁)
- Java里怎么用?(Unsafe,Atomic包)
- CAS的优点是啥?(无锁、低竞争高性能、避免死锁)
- CAS的缺点是啥?(ABA、自旋开销、只能管一个变量)
- ABA怎么产生、怎么解决?(版本号/标记位,AtomicStampedReference/AtomicMarkableReference)
- 什么时候一定不能用CAS?(高冲突、长临界区、多变量联合更新)
这条链路你能完整走下来,面试官基本不会再往下逼问你CAS了,因为足以证明你不仅理解原理,而且有工程化判断力。
说句实在的,我见过太多人死记硬背“CAS的优缺点”模板,被追问两句就露馅。真正稳妥的办法,还是像我前面这样,从CPU指令一直推到项目选型,把每一层的“为什么”都搞明白。这个题目看着小,其实是一张并发编程知识图谱的入口,把它吃透,你对Java并发的理解会上一个台阶。