news 2026/10/6 14:06:16

CAS与ABA问题详解:从CPU原子指令到Java并发实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CAS与ABA问题详解:从CPU原子指令到Java并发实战

在网上刷到很多人吐槽,说现在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包的设计骨架,你会发现所有原子类结构都差不多:

  1. 声明一个volatile修饰的字段,保证字段的可见性。这个很关键,变量值改了之后,别的线程要能立刻看见最新值。
  2. 在静态代码块里,通过Unsafe的objectFieldOffset方法拿到这个字段在对象里的内存偏移量valueOffset。
  3. 所有修改操作(增、减、更新)都通过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的取舍

这是面试后半程的经典问题。我的建议是别急着二选一,先用问题拆解:

  1. 你们要保护的是一个变量,还是一段代码块?变量级用CAS,代码块级用锁。
  2. 操作的执行时间是多长?极短(比如计数器累加)适合CAS,执行时间较长(比如复杂计算、IO操作)适合锁。
  3. 冲突概率高不高?低冲突用CAS,高冲突用锁。
  4. 是否允许等待?CAS自旋会让当前线程占用CPU等待,锁可以让线程阻塞并让出CPU。

真实项目里,我还会补充一条经验:优先用现成的并发工具类,而不是自己去实现无锁方案。Java的LongAdder、ConcurrentHashMap、StampedLock内部都用了CAS,而且经过了大量优化和测试,你直接选合适的就行。自己造轮子做CAS有个巨大的隐性成本:并发bug极难复现、极难定位,哪怕你理论再熟,线上偶发问题也够喝一壶的。

6.3 一段我面试时梳理过的思考路径

最后把整条链路串起来分享一下。我自己带新人或者准备面试时,习惯按这个顺序过一遍,你可以参考:

  1. synchronized的阻塞模型有什么代价?(上下文切换、线程调度开销)
  2. CAS怎么避免阻塞?(先比较再更新,失败就重试)
  3. CAS为什么是原子的?(CPU指令cmpxchg,多核下靠总线锁/缓存锁)
  4. Java里怎么用?(Unsafe,Atomic包)
  5. CAS的优点是啥?(无锁、低竞争高性能、避免死锁)
  6. CAS的缺点是啥?(ABA、自旋开销、只能管一个变量)
  7. ABA怎么产生、怎么解决?(版本号/标记位,AtomicStampedReference/AtomicMarkableReference)
  8. 什么时候一定不能用CAS?(高冲突、长临界区、多变量联合更新)

这条链路你能完整走下来,面试官基本不会再往下逼问你CAS了,因为足以证明你不仅理解原理,而且有工程化判断力。

说句实在的,我见过太多人死记硬背“CAS的优缺点”模板,被追问两句就露馅。真正稳妥的办法,还是像我前面这样,从CPU指令一直推到项目选型,把每一层的“为什么”都搞明白。这个题目看着小,其实是一张并发编程知识图谱的入口,把它吃透,你对Java并发的理解会上一个台阶。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 14:06:15

从答谢会看品牌渠道建设:供应链协同与长期主义如何落地

1. 从“答谢”到“共创”&#xff1a;这场晚会到底在答什么1.1 为什么选在2026&#xff1a;行业节点的选择逻辑暴雨装备把年度合作伙伴答谢会定在2026年初&#xff0c;这个时间点不是随手翻日历翻出来的。防护装备这个行业有很强的周期性&#xff0c;汛期、台风季、户外活动旺季…

作者头像 李华
网站建设 2026/10/6 14:05:56

Polar Si9000阻抗计算全流程:从叠层参数到板厂确认的实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 14:03:46

USS超声波雷达原理与工业级接口选型实战指南

1. 这不是“倒车雷达”那么简单&#xff1a;USS超声波雷达的真实能力边界很多人第一次听说USS&#xff0c;是在4S店师傅指着车尾那几个小圆点说&#xff1a;“这是超声波雷达&#xff0c;倒车用的。”——这话没错&#xff0c;但就像说“显微镜就是看蚂蚁的工具”一样&#xff…

作者头像 李华
网站建设 2026/10/6 14:01:15

基于非合作博弈的居民负荷分层调度双层鲸鱼算法实现

搞居民负荷分层调度那会儿&#xff0c;我一度被非合作博弈和双层优化搞得头大。电网侧想通过分时电价引导用户错峰&#xff0c;用户侧又不愿意被强行控制用电习惯&#xff0c;双方目标冲突&#xff0c;这种关系用单层优化根本说不清。后来我把模型拆成上下两层&#xff0c;再用…

作者头像 李华
网站建设 2026/10/6 14:01:11

系统集成项目避坑指南:从字段映射到故障排查的完整实战笔记

1. 不先把这三件事想清楚&#xff0c;系统集成项目多半会烂尾做开发这些年&#xff0c;我见过太多系统集成项目最后做成了“缝合怪”。大家一开始都觉得&#xff0c;系统集成嘛&#xff0c;不就是把A系统的接口接到B系统上&#xff0c;字段映射一下、调通就完事了。等真正上手才…

作者头像 李华