从快递柜到并发编程:深入理解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在保持高性能的同时,具备识别幽灵操作的能力。理解这些原理,能帮助我们在实际开发中选择合适的并发控制策略。

相关推荐
TayTay的学习笔记1 小时前
LinkedList底层结构和源码分析(JDK1.8)
java·笔记·学习
无际单片机编程1 小时前
学习单片机需要多长时间才能进行简单的项目开发?
java·stm32·单片机·嵌入式硬件·嵌入式
lmryBC491 小时前
golang-type关键字
java·数据结构·golang
keep one's resolveY1 小时前
Tomcat线程池详解,为什么SpringBoot最大支持200并发?
java·开发语言
无问8172 小时前
SpringBoot配置文件
java·spring boot·后端
爱吃喵的鲤鱼2 小时前
MySQL——数据类型
java·数据库·mysql
子非衣2 小时前
Java解析多层嵌套JSON数组并将数据存入数据库示例
java·数据库·json
bamboolm2 小时前
java 动态赋值写入word模板
java·word
阿梦Anmory2 小时前
【spring boot 实现图片验证码 前后端】
java·spring boot·后端
爱的叹息3 小时前
java使用(Preference、Properties、XML、JSON)实现处理(读写)配置信息或者用户首选项的方式的代码示例和表格对比
xml·java·json