从快递柜到并发编程:深入理解CAS与ABA问题

一、什么是CAS?

想象你在快递柜前取快递:输入取件码后,系统会检查取件码是否正确(比如123456),如果匹配,柜门自动打开;如果不匹配,系统会拒绝操作CAS(Compare And Swap) 就是这个"检查-操作"的原子过程,但它操作的是内存中的变量值。

  • 操作原理 :当需要修改共享变量时,先比对内存中的当前值(Current Value)与预期值(Expected Value)
    • 如果一致 → 原子性地更新为新值(New Value)
    • 如果不一致 → 放弃操作或重试
  • 原子性:整个过程不可分割,避免多线程竞争导致数据错乱
  • 应用场景 :Java的AtomicInteger、无锁队列、自旋锁等并发控制

核心原理

CAS操作需要三个参数------内存地址V预期原值A新值B

  1. 读取内存中的当前值V。
  2. 如果内存值V等于预期值A,则将V更新为新值B。
  3. 如果内存值V不等于预期值A,说明有其他线程修改过,放弃更新。

举个栗子 🌰:

两个线程同时给计数器count加1:

  • 线程A读取count=0,准备改为1。
  • 线程B也读取count=0,准备改为1。
  • 线程A的CAS操作成功,count=1
  • 线程B的CAS失败(此时count=1≠0),于是它重新读取count=1,再次尝试更新为2。

最终,count=2,整个过程无需加锁,实现高效并发。

CAS操作流程图

graph TD A[开始] --> B[读取内存值V和预期值A] B --> C{比较 V == A?} C -->|是| D[更新为新值B] D --> E[成功] E --> F[结束] C -->|否| G[自旋重试或放弃] G --> |重新读取V| B

二、CAS的三大核心问题

  1. ABA问题(幽灵修改)

    • 案例一 :你的银行账户有100元
      • 线程A读取余额为100,准备转账
      • 线程B中途扣款100(余额=0),后又存入100(余额=100)
      • 线程A执行CAS(预期值100→新值50),操作"成功"但实际中间发生过变动
    • 案例二:朋友借走你的书,读完后又放回原处。虽然书还在原位,但内容可能被批注过。类似地,线程A读取值X,线程B将X→Y→X,当A执行CAS时,误认为数据未被修改。
    • 风险1 :数据状态被隐形修改,可能导致逻辑错误
    • 风险2 :在链表、栈等数据结构中尤为危险(如节点被删除后又恢复)。
  2. 高竞争下的CPU性能消耗

    • 当大量线程频繁CAS失败时,会导致CPU空转(类似反复刷新网页抢票)
    • CAS失败时,线程会循环重试(自旋)。在高并发场景下,如果大量线程竞争同一个变量,CPU可能被长时间占用,性能急剧下降。
  3. 只能操作单个变量

    • 只能保证一个共享变量的原子操作,复合操作需额外手段
    • CAS无法保证多个变量的原子性更新。例如,转账需要同时修改两个账户的余额,单纯用CAS无法实现。

三、ABA问题的解决方案

方法1:版本号标记(类似快递单更新)
  • 核心思想:每次修改给变量加上"版本号",比对值+版本号。
  • 就像同一本书再版多次,内容可能一样,但版本号不同。CAS不仅要检查值,还要检查版本号是否匹配。
  • Java实现AtomicStampedReference(Java中的版本号原子类)
java 复制代码
AtomicStampedReference<Integer> account = new AtomicStampedReference<>(100, 0);

// 转账操作
int oldStamp = account.getStamp();
int oldValue = account.getReference();
if (account.compareAndSet(oldValue, 50, oldStamp, oldStamp+1)) {
    System.out.println("转账成功");
}
java 复制代码
// 初始值:100,初始版本号:0
AtomicStampedReference<Integer> money = new AtomicStampedReference<>(100, 0);

// 读取当前值和版本号
int[] stampHolder = new int[1];
int oldMoney = money.get(stampHolder);
int oldStamp = stampHolder[0];

// 尝试更新值(110)和版本号(+1)
boolean success = money.compareAndSet(oldMoney, 110, oldStamp, oldStamp + 1);

流程对比

普通CAS 带版本号的CAS
检查值是否相等 检查值 和版本号 是否一致
值相同则更新 值且版本号均相同才更新
版本号的作用:

即使变量的值从A→B→A ,版本号也会从1→2→3,CAS操作能识别出中间的变化,彻底规避ABA问题。

方法2:时间戳记录(适合分布式系统)
  • 每次修改记录时间戳,比对时间戳的先后顺序
graph LR A[读取值+版本号] --> B{值&版本号匹配?} B -->|是| C[更新值并增加版本号] B -->|否| D[操作失败/重试]

四、应用技巧

  1. 优先使用带版本号的原子类

    在Java中,涉及状态变化的场景(如金额、订单状态 )优先使用AtomicStampedReference

  2. 避免滥用自旋

    高并发时,若CAS竞争激烈,可结合锁或退避策略 (如CAS失败后随机等待,避免CPU风暴)减少CPU压力。

  3. 多变量原子操作

    如需同时修改多个变量,可封装为对象,或用AtomicReference保证整体一致性。

  4. 降低竞争粒度 :如ConcurrentHashMap分段锁设计

  5. LongAdder替代方案:Java8+针对高并发场景的优化类


五、总结

graph TD CAS原理 -->|问题| ABA问题 CAS原理 -->|问题| 高竞争消耗 CAS原理 -->|问题| 单一变量限制 ABA问题 -->|解决方案| 版本号标记 ABA问题 -->|解决方案| 时间戳记录

CAS是无锁编程的利器,但ABA问题如同"程序世界的时光倒流",需用版本号精准识别数据变化。

  • 简单场景:单变量无状态循环 → 直接用CAS。
  • 复杂场景:涉及状态流转 → 必须加版本号或时间戳。

通过版本号等机制,我们可以让CAS在保持高性能的同时,具备识别幽灵操作的能力。理解这些原理,能帮助我们在实际开发中选择合适的并发控制策略。

相关推荐
我喜欢山,也喜欢海15 分钟前
Jenkins Maven 带权限 搭建方案2025
java·jenkins·maven
明天更新21 分钟前
Java处理压缩文件的两种方式!!!!
java·开发语言·7-zip
铁锚27 分钟前
一个WordPress连续登录失败的问题排查
java·linux·服务器·nginx·tomcat
yychen_java33 分钟前
上云API二开实现三维可视化控制中心
java·无人机
理智的煎蛋34 分钟前
keepalived+lvs
java·开发语言·集成测试·可用性测试
CopyLower1 小时前
Java与AI技术结合:从机器学习到生成式AI的实践
java·人工智能·机器学习
生命不息战斗不止(王子晗)1 小时前
mybatis中${}和#{}的区别
java·服务器·tomcat
.生产的驴1 小时前
Docker 部署Nexus仓库 搭建Maven私服仓库 公司内部仓库
java·运维·数据库·spring·docker·容器·maven
橙子199110161 小时前
Kotlin 中的 Unit 类型的作用以及 Java 中 Void 的区别
java·开发语言·kotlin
yours_Gabriel2 小时前
【登录认证】JWT令牌
java·开发语言·redis