news 2026/10/15 9:58:29

深入理解CAS与ABA问题:从无锁编程原理到AtomicStampedReference实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深入理解CAS与ABA问题:从无锁编程原理到AtomicStampedReference实战

1. 从一次诡异的并发Bug说起:CAS凭什么成为无锁编程的地基

几个月前,我被拉去帮某团队看一个并发问题。场景很朴素:一套配置开关服务,某个开关的值需要满足"当前值是100才更新为50"的精确条件,改动频率也不高。代码写得相当精简,用的是JDK自带的原子类,大家对线程安全都有很强的信心。可线上就是出现了极小概率的异常——明明别的线程已经动过这个值,更新操作却依然执行成功了。

排查到最后,问题锁定在CAS一个非常经典、又很容易被忽略的盲区上:ABA问题。

CAS全程是Compare-And-Swap,也叫Compare-And-Set,是并发编程中最重要的乐观并发原语。它的核心逻辑一句话就能讲完:只有当目标内存位置的当前值与调用者预期的值一致时,才把新值写入,而且"比较+写入"这个整体过程是原子的。你日常使用的AtomicInteger、AtomicLong、AtomicBoolean,底层全都靠它撑腰;无锁队列、并发栈、分布式锁的某些实现,同样绕不开这层机制。

这篇文章不打算把概念复读一遍,我更想从一个真实项目复盘的角度,把CAS为什么会出现、ABA问题到底是怎么产生的、以及工程上如何彻底解决它,一层层拆开来讲。适合正在啃并发编程的读者,也适合那些用了很多年原子类、却还没想明白"值一样为什么也算篡改"的开发者。

1.1 锁得罪了谁:从悲观互斥到乐观竞争

要理解CAS,得先知道它是来替换谁的。传统做法是加锁,synchronized或者各种重入锁,思路是悲观的:我假设别的线程一定会来抢,所以先把自己关进小黑屋,等操作完成再放别人进来。这个模型在临界区很长、写操作很重的场景下没有问题,但它有几笔难以忽视的开销:

  • 线程从运行态切换到阻塞态,涉及内核态上下文切换,一次切换通常要花到微秒级,在高频的小操作面前相当奢侈;
  • 持锁线程被调度走、等待线程被唤醒,中间这条"锁被释放"的通知链路并不便宜;
  • 稍不注意就会引入死锁、锁饥饿、锁粒度选择不当等一系列衍生问题。

于是就有了乐观的思路:我不把小屋锁上,直接做修改。别人到底改没改?我不知道,所以我把"修改前先确认"这个动作做成原子操作。如果发现当前值和预期不一致,就重试或者放弃。这正是CAS的设计哲学——用"轻量级的重试"换掉"重量级的阻塞"。在高并发、短临界区的场景下,这套打法通常能比锁低一个甚至两个数量级的开销。

1.2 一条CPU指令撑起来的原子性

CAS在硬件层面不是什么黑科技。x86架构提供了专门的Compare-And-Swap指令(CMPXCHG),处理器保证这条指令在执行期间不会被其他核心打断,配合内存屏障还能保证对多核的可见性。JVM在实现原子类时,就是通过Unsafe类暴露的compareAndSwap系列方法,直接调用底层CPU指令,把开销压到极低。

JDK里的原子类可以理解成三层结构:最上层是你直接调用的API,中间是Unsafe的native方法,最底下才是CPU的原子指令。这个链路非常短,所以AtomicInteger做一次原子自增,通常只需要几十纳秒,比锁快了一个数量级以上。这也是为什么计数器、序号生成器、轻量级状态标记这些场景里,大家普遍偏爱原子类。

但成也CAS,败也CAS。它保证的"原子性"只覆盖"比较+交换"这一个瞬间,至于"比较之前读到的值"和"交换之后的世界",它一概不管。ABA问题的根源,恰恰就藏在这个边界里。

2. ABA问题的完整现场:一个无锁栈的"幽灵"Bug

先给ABA下一个标准定义:线程A读到一个值(假设叫A),在它准备执行CAS的间隙里,线程B把这个值从A改成B,然后又从B改回A。等线程A真正执行compareAndSet时,它看到目标值仍然等于A,于是得出"没人动过"的结论,CAS成功。可在这段时间里,数据结构的拓扑、对象之间的引用关系、业务状态流转,可能已经面目全非了。

ABA不像是死锁那样每次都能撞见,它属于概率极小、破坏力极大的问题。不主动构造特定场景,可能跑很久都碰不上;一旦碰上,日志里通常又找不到任何明显线索,排查难度极高。

2.1 用无锁栈复现一次节点丢失

我最喜欢用单链无锁栈来解释ABA,因为它足够直观,而且真的会丢失数据。先看一段典型的无锁栈实现:

public class LockFreeStack { private final AtomicReference<Node> top = new AtomicReference<>(); static class Node { final int value; final Node next; Node(int value, Node next) { this.value = value; this.next = next; } } public void push(Node node) { Node oldTop; do { oldTop = top.get(); node.next = oldTop; } while (!top.compareAndSet(oldTop, node)); } public Node pop() { Node oldTop; Node newTop; do { oldTop = top.get(); if (oldTop == null) { return null; } newTop = oldTop.next; } while (!top.compareAndSet(oldTop, newTop)); return oldTop; } }

假设当前栈里有三个节点,从上到下依次是 A -> B -> C。注意下面这个时序:

  1. 线程T1准备执行pop(),它读到了oldTop = A,接着读到newTop = A.next = B。就在它马上要执行compareAndSet(A, B)的前一刻,它被切换出CPU;
  2. 线程T2开始执行pop(),第一次CAS成功,弹出A,top指向B;紧接着第二次CAS成功,弹出B,top指向C。随后T2把一个与A是同一个对象的节点重新push进栈,top重新指向A;
  3. 线程T1恢复运行。它手里拿着的oldTop引用依然是A,于是执行compareAndSet(oldTop, newTop),期望把top从A改成B。关键就在这里:此刻top确实还是A,CAS判断"当前值 == 预期值"成立,修改成功。

然而top在T2的操作中已经经历过A -> B -> C -> A的完整轮回。T2重新压入的A节点,它的next字段指向的是C,而不是B。T1这一改,直接把top从A指向了B,而B在T2弹出时已经被移出栈,真正还留在栈里的C反而被丢掉了。一个本应存在的节点,就这么静悄悄地从栈里消失了。

2.2 值相等,不代表状态没变

很多人的第一反应是:T2最后不是把值改回A了吗?那跟没改有什么区别?

区别非常大。CAS只检查"目标位置当前的值是否等于预期值",它完全不关心这中间发生过多少次修改。可对于栈来说,"栈顶是A"和"栈顶一直是A"是两个截然不同的语义。前者只描述某个时间点的快照,后者才代表整个栈的拓扑结构没有被干扰过。

用一个生活化的类比:你在ATM上看到余额是1000元,于是准备存1000元进去,并默认这期间没人碰过这张卡。但实际上有人取走了500元又存回了500元,余额恰好还是1000。单纯看余额数字,存1000在账面上依然成立;可如果那500元已经被人转过一轮再还回来,那"没有动过"这个前提就已经被破坏。ABA的本质,就是我们把"值没变"当成了"状态没变"的证据,而CAS恰恰没有能力去验证这个证据。

3. 给CAS加"时间戳":版本号方案为什么是正统解

ABA问题的全部麻烦,都源于CAS只认"值"不认"历史"。所以最直接、也最主流的解法,就是让CAS在比较值的同时,再比较一个只会严格递增、不会重复的"代际标记"。只要标记变了,哪怕值回到原样,也能一眼识破。

3.1 版本号的核心思路

思路不复杂:每次有人成功修改目标位置,就把版本号加1。执行CAS时传入两个预期值——期望的当前值,还有期望的版本号。只有当这两个条件同时满足时才执行更新,并且更新时把版本号一起更新。

还是用余额的例子。线程1读到"余额=1000且版本号=3",然后暂停等待。线程2取走500,余额变成500,版本号变成4;再存进500,余额回到1000,版本号变成5。此时线程1恢复,它期望的版本号是3,实际已经是5,值虽然对上了,版本号对不上,CAS直接失败。这就是"值没变,但历史变了"被精确捕捉到的过程。

从原理上来说,版本号把所有比较的对象从"一个值"扩展成了"值+整数"这一对。即使业务值可能发生回绕、复用,只要版本号在每次修改时都严格递增,就永远不会出现两次修改携带同一个代际标记。

3.2 AtomicStampedReference的实战用法

JDK在java.util.concurrent.atomic包里提供了对应的工具类,内部维护了一个引用变量和一个整数戳记(stamp),并保证两者的读写是原子关联的。还是用无锁栈来演示修复之后的写法:

public class SafeStack { private final AtomicStampedReference<Node> top = new AtomicStampedReference<>(null, 0); static class Node { final int value; volatile Node next; Node(int value) { this.value = value; } } public void push(Node node) { int oldStamp; Node oldTop; int newStamp; do { oldTop = top.getReference(); oldStamp = top.getStamp(); node.next = oldTop; newStamp = oldStamp + 1; } while (!top.compareAndSet(oldTop, node, oldStamp, newStamp)); } public Node pop() { int oldStamp; Node oldTop; Node newTop; int newStamp; do { oldTop = top.getReference(); oldStamp = top.getStamp(); if (oldTop == null) { return null; } newTop = oldTop.next; newStamp = oldStamp + 1; } while (!top.compareAndSet(oldTop, newTop, oldStamp, newStamp)); return oldTop; } }

关键点在于compareAndSet的四个参数:expectedReference(期望的旧引用)、newReference(新引用)、expectedStamp(期望的旧版本号)、newStamp(新版本号)。四个条件必须全部匹配,CAS才成功,成功后引用和版本号会被一起更新。这样即使值重新变回了A,只要版本号对不上,T1修改栈顶的企图就会失败,然后进入下一轮循环重新读取,事情就不会出错。

3.3 一个意外的坑:compareAndSet用的是引用相等

这里要专门提醒一个容易踩的细节:AtomicStampedReference内部的比较用的是reference == expectedReference,也就是引用相等,不是equals()。

这个特性和平时的直觉有冲突。AtomicInteger比较的是数值,而AtomicReference、AtomicStampedReference、AtomicMarkableReference走的是引用比较。也就是说,即使两个对象内容完全一样,只要不是同一个实例,compareAndSet就会失败。如果业务上确实需要"内容相等就算相等",你得提前做归一化处理,比如用不可变对象加上常量池管理,保证相同业务值对应同一个实例;或者干脆换普通锁来同步,不要硬用CAS。

顺带提一下AtomicMarkableReference,它的原理类似,只不过戳记是布尔类型,只记录"有没有被修改过"。如果变量只有两态,并且你只关心"是否被碰过",用它就足够了;如果状态演化需要区分先后次序,就必须用整数戳记的AtomicStampedReference。

3.4 数据库乐观锁:同一个思想,另一个战场

理解了版本号以后你会发现,数据库里的乐观锁跟这个完全是一个套路。给表加一列version,每次UPDATE都带上WHERE version = ?,成功之后把version加1。如果有人在你读取之后提交了变更,version就会变大,你的UPDATE影响行数为0,于是你知道冲突了,重新读取、重新决策。

CAS的版本号方案,就是把数据库乐观锁这一套经典的工程实践搬进了内存。所以如果你已经理解乐观锁,那ABA的版本号解法对你来说基本没有新知识成本,区别仅在于一个作用在数据库行上,一个作用在内存对象上而已。

4. CAS在工程里最常踩的三个坑:自旋、单变量与隐性ABA

解决了ABA,并不代表CAS就能高枕无忧。实际项目中,我见过太多"CAS用得对但依然出事"的情况。这里把三个高频坑集中讲一下,每一个都有真实场景支撑。

4.1 高竞争下的自旋风暴

无锁并不是免费的午餐。CAS失败后通常要重试,这个重试循环就叫自旋。竞争低的时候,自旋几乎不耗费什么资源;但一旦多个线程同时抢同一个地址,失败的线程就会反复循环重试,CPU占用率飙升,甚至可能比用锁更差。

我见过一个限流组件在热点KEY上做CAS自增,压测时CPU直接拉满,QPS反而跌了不少。解决方案一般有三条路:一是给重试加上限或超时,失败就放弃,让调用方决定是降级还是换策略;二是在循环里加退避,失败后短暂让出CPU,降低无效争抢;三是极端热点场景下不要硬扛单点CAS,改用分段计数再汇总的思路。

退避的具体写法通常是这样的:重试失败后,在循环体里先判断一下失败次数,超过阈值就调用Thread.onSpinWait()或Thread.yield(),把宝贵的CPU让给真正可能成功的线程。这个细节看似微小,在高竞争的线上环境里却能显著降低无意义的自旋开销。

4.2 单变量原子性的边界

CAS再怎么强,一次也只能管一个内存位置。如果先对变量X做CAS,再对变量Y做CAS,中间一定存在一个非原子间隙,别的线程可以趁虚而入。最常见的错误出现在"两个字段必须一起更新"的代码里,比如账户余额与冻结额度、对象状态与其归属人。

标准做法是用一个不可变对象把多个字段包起来,统一放在一个AtomicReference里。每次更新时,先读出旧对象,基于它构建一个新对象,再对引用做一次CAS替换。这样一来,多个字段就变成了一个引用位,CAS一次搞定,不会再拆成两次。

class AccountState { final long balance; final long frozen; AccountState(long balance, long frozen) { this.balance = balance; this.frozen = frozen; } } AtomicReference<AccountState> account = new AtomicReference<>(new AccountState(0L, 0L)); // 扣减时,构造新对象并整体替换引用 account.updateAndGet(old -> new AccountState( old.balance - amount, old.frozen));

这种写法比"两个AtomicLong各自CAS"要可靠得多,代价仅仅是每次操作多创建一个对象。对于绝大多数内存内的状态更新来说,这点分配成本完全值得。

4.3 业务语义里的"隐性ABA"

最隐蔽的一种情况是:数据结构本身没有出错,业务语义却已经出错。比如订单状态机里,PENDING状态允许被改成CANCELLED,但CANCELLED不应该再回退成PENDING。假如某个粗心的实现允许这种回退,那么下面的场景就会很麻烦:线程A读到订单处于PENDING,准备执行超时关闭;线程B在它执行CAS之前把订单改成CANCELLED,又因为某种错误逻辑改回PENDING;线程A的CAS发现值还是PENDING,认为一切正常,于是把一个已经被人工处理过的订单再次关闭。

这个案例里代码层面没有任何问题,问题在于你用来CAS比对的字段本身,根本无法代表"订单有没有被人工动过"。这种时候要么给关键状态加版本号,要么靠数据库乐观锁,要么从设计上把状态机变成单向不可回退的,从根上杜绝ABA产生的可能。

5. 把ABA问题做成可复现用例:测试与压测心得

ABA问题之所以难处理,很大程度是因为它难以复现。靠自然并发去撞,可能跑几百万次都碰不上一次。我的经验是:别靠运气,直接把线程时序卡死,让每一次运行都稳定走进ABA发生的那条路径。

5.1 用CountDownLatch精确卡住ABA瞬间

以最初的计数场景为例,我可以让线程T1停在"已经读到100、还没有执行CAS"的瞬间,等T2完整地把100改成70再改回100之后,再放T1去执行CAS。这样一来,每次运行都能稳定复现ABA。

public class AbaReproducer { private static final AtomicInteger VALUE = new AtomicInteger(100); private static final AtomicStampedReference<Integer> SAFE = new AtomicStampedReference<>(100, 0); public static void main(String[] args) throws Exception { CountDownLatch t1HasRead = new CountDownLatch(1); CountDownLatch releaseT1 = new CountDownLatch(1); Thread t1 = new Thread(() -> { int expected = VALUE.get(); t1HasRead.countDown(); try { releaseT1.await(); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } boolean ok = VALUE.compareAndSet(expected, 50); System.out.println("普通CAS结果: " + ok + ", 当前值: " + VALUE.get()); }); Thread t2 = new Thread(() -> { try { t1HasRead.await(); } catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } VALUE.compareAndSet(100, 70); VALUE.compareAndSet(70, 100); releaseT1.countDown(); }); t1.start(); t2.start(); t1.join(); t2.join(); } }

运行这段代码,输出一定是"普通CAS结果: true"。你明明看到T2改过两次值,T1的CAS却依然成功,这就把ABA问题钉死在案板上了。接着你把同样的流程换成带版本号的写法,T1在恢复之后执行SAFE.compareAndSet(100, 50, 0, 1)时,版本号早就不是0,结果必然为false。同一个场景、同一段时序,一个成功一个失败,对比效果非常直观。

把这段代码整理成一个JUnit测试类以后,可以直接作为回归用例放进CI。以后谁再改动相关的并发逻辑,只要这个用例失败,说明ABA防御被破坏,问题能被及时发现,不需要靠线上环境来背锅。

5.2 压测时怎么验证修复真的生效

写完单测还不够,ABA在真实并发下还可能以不同变体出现。我习惯在压测中加入两种校验,缺一不可。

第一种是数据完整性校验。针对无锁栈做压测时,启动多个线程同时入栈和出栈,压测结束后统计弹出的元素总数,必须和压入总数完全相等。只要少了一个,都说明ABA还在肆虐。

第二种是状态一致性校验。模拟大量线程同时执行"条件更新+非法更新记录",把不满足条件却成功更新的次数统计下来。修复前这个数字必然非零,修复后必须稳定为零。统计方式很简单,用一个普通AtomicLong累计非法更新次数,压测结束后检查它是否保持为0。

压测时还有一个容易被忽略的点:AtomicStampedReference的版本号要留足空间。如果业务更新频率很高,而stamp范围很小,回绕之后就会让防御失效。虽然整数溢出在这种场景下现实中几乎不可能发生,但我确实见过有人为了省内存用一个较小的数字范围,高频压测下很快撞回原值。条件允许的话,把stamp当成"累计修改次数"来设计,不要吝啬它的取值范围。

最后说一个我自己的习惯:凡是被多个线程并发读写、而且每个线程都会先读后写的共享引用,我默认它一定会发生ABA,除非有充分理由证明不会。这个习惯帮我挡掉了不少线上事故。CAS确实很好用,但它只保证"这一刻没人抢",却不保证"这段时间没人动过"。把这句话记清楚,你才算真正会用原子类。

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

Qt自定义控件:手写滑动选择器的完整实现与避坑指南

简介&#xff1a;基于Qt自定义的滑动选择器是一套面向Qt开发者的自定义控件示例&#xff0c;用于解决系统自带控件无法满足个性化交互需求的问题&#xff0c;适合有基础Qt编程知识、希望提升自定义控件设计能力的开发者。压缩包共8个文件&#xff0c;大小仅9KB&#xff0c;包含…

作者头像 李华
网站建设 2026/10/15 9:57:54

Plotly数据可视化:交互式图表与实战应用

1. 为什么选择Plotly进行数据可视化第一次接触Plotly是在三年前的一个客户项目中&#xff0c;当时需要展示一组复杂的销售趋势数据。传统的静态图表根本无法满足需求——市场部门的同事需要能够自由缩放查看季度细节&#xff0c;又需要随时切换不同产品线的对比。当我将第一个P…

作者头像 李华
网站建设 2026/10/15 9:57:39

H5网站SEO前端优化:预渲染+语义强化实战指南

1. 项目概述&#xff1a;为什么H5网站的SEO前端处理不是“加个title标签”就完事了 “H5网站SEO前端处理优化”——这八个字背后&#xff0c;藏着大量开发者踩过坑、交过学费才明白的现实逻辑。我从2014年开始做移动端H5项目&#xff0c;经手过电商落地页、教育课程页、本地生…

作者头像 李华
网站建设 2026/10/15 9:56:50

系统级I/O探秘:从文件描述符到重定向与缓冲的工程实践

我刚学操作系统的时候&#xff0c;最不以为然的章节就是CSAPP第10章《系统级I/O》&#xff0c;觉得自己天天写printf和fread&#xff0c;I/O有什么好讲的&#xff1f;后来真到工作上处理日志文件、网络包、进程间通信&#xff0c;才回过头一页一页重新啃&#xff0c;越啃越冒冷…

作者头像 李华
网站建设 2026/10/15 9:56:31

SpringBoot+Vue3全栈开发扶贫助农系统实践

1. 项目背景与核心价值扶贫助农系统作为连接城乡资源的数字化桥梁&#xff0c;在乡村振兴战略中扮演着重要角色。这个基于Java SpringBootVue3MyBatis的全栈项目&#xff0c;通过前后端分离架构实现了农产品供应链的数字化管理。我在实际开发中发现&#xff0c;这类系统需要特别…

作者头像 李华
网站建设 2026/10/15 9:56:06

Android开发中的传统历法算法实现与优化

1. 项目背景与核心价值这个名为"卦气正元历"的Android应用项目&#xff0c;乍看标题可能让人有些摸不着头脑&#xff0c;但作为一名在历法算法领域摸爬滚打多年的开发者&#xff0c;我一眼就看出这是个将传统卦气理论与现代公历相结合的创新尝试。所谓"卦气&quo…

作者头像 李华