你了解 CAS(Compare and swap) 吗? ABA 问题又是什么 ?

183 阅读6分钟

一、什么是 CAS

  CAS(Compare and swap),即“比较并交换”。它就是计算机编程中的原子操作,没错,就是“比较 + 交换”的 原子操作

  对于一个 CAS 操作:比较内存中的一个值和给定的值,如果相同,就把内存中的值替换为新的值。比如,有 A、B、C三个数,A 为内存中的值,B 为预期的值,C 为要赋给 A 的值。将会有下面的三个步骤:

  1. 比较 A 与 B 是否相等。
  2. 如果 A 与 B 相等,则 A = C(交换)。
  3. 返回一个代表“是否成功”的值。

二、CAS 实现的伪代码

  CAS 是怎么实现的呢?由于它是原子的硬件指令完成的,这里只能使用伪代码来辅助了解整个过程。

boolean CAS(A ,B , C){
    if(A == B){
        A == C;
        return true;
    }
    return false;
}

  在实际操作中,上面执行的过程是原子的,就是一次性梭哈,不存在线程安全问题。但是问题来了,这有啥用呢?这咋一看也就这样,其实它有很多实际应用的。

三、CAS 的应用

(一)实现原子类

  在Java中的标准库中有一个包: java.util.concurrent.atomic。这个包中提供了各种的原子类,这里的原子类大都是用 CAS 来实现的,有的时候使用这些类就不用加锁了。其中典型的就是 AtomicInteger比如:

public class ThreadDemo1 {

    public static void main(String[] args) throws InterruptedException {

        //预期:对 count 实现自加操作 100000 次
        AtomicInteger count = new AtomicInteger(0);

        Thread thread1 =new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    //自加
                    count.getAndIncrement();
                }
            }
        });

        Thread thread2 =new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    //自加
                    count.getAndIncrement();
                }
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();

        System.out.println(count.get());
    }
}

image.png

  上面是通过两个线程来实现自加操作。这要是直接使用 “int” 类型的 “++”操作,最后的结果就不会是 100000 了。具体的原因就是“++”操作不是原子的。(以前的文章介绍过:Java中的线程安全 与 synchronized、volatile关键字 - 掘金 (juejin.cn)

(ps:这里就不介绍AtomicInteger的使用了,去查查文档就好了。)

  下面给出用 CAS 实现原子自增伪代码

class AtomicInteger {
    
	private int value;
    
    public AtomicInteger(int value){
        this.value = value;
    }
    
	public int getAndIncrement() {
        
		int oldValue = value;
		while ( CAS(value, oldValue, oldValue+1) != true) {
            //如果 CAS 失败就将 oldValue 更新,达到 oldValue 与 value 一致。
			oldValue = value;
		}
		return oldValue;
	}
}

  上面代码看起来很复杂,不过别怕,下面咱们一步步来看:(两个线程同时调用getAndIncrement()方法)

  1. CAS(value, oldValue, oldValue+1)的意思是:当 value == oldValue 时,value = oldValue + 1

  2. 两个线程都读取 value 的值到 oldValue 中。

image.png

  1. 线程 1 先执行 CAS 操作,由于 oldValuevalue 的值相同,直接进行对 value 赋值。

image.png

  1. 线程 2 再执行 CAS 操作,第一次 CAS 的时候发现 oldValuevalue 不相等,不能进行赋值。因此需要 进入循环,在循环里重新读取 value 的值赋给 oldValue

image.png

  1. 线程 2 接下来第二次执行 CAS,此时 oldValuevalue 相同,于是直接执行赋值操作。

image.png

  1. 一直执行下去,直到 value = 100000

  你会发现上面流程的关键就是 CAS 操作,当 value == oldValue 时必能让 value++ ,这正是因为它是原子的;而当value != oldValue时,则通过循环来确保 value == oldValue

(二)实现自旋锁

  下面时自旋锁的伪代码:

public class SpinLock {
    private Thread owner = null;
    public void lock(){
    // 通过 CAS 看当前锁是否被某个线程持有.
    // 如果这个锁已经被别的线程持有, 那么就自旋等待.
    // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
        while(!CAS(this.owner, null, Thread.currentThread())){
            
        }
    }
    public void unlock (){
    	this.owner = null;
    }
}

  CAS(this.owner, null, Thread.currentThread())表示:检测当前的 owner 是否为 null,如果是 null 就进行交换,就是把当前线程的引用赋值给 owner,这时加锁成功;

  反之,如果当 owner != null 的时候,说明该锁已经被其它线程获取了,这时开始循环(自旋),直到 owner 为空(被其它线程释放)。

四、CAS 的 ABA 问题

  要知道世界上没有十全十美的东西,任何事都有其缺陷。 CAS 同样如此,我们再来想一想 CAS 的执行流程:检测 value 和 oldValue 是否一致。如果一致,就视为 value 中途没有被修改过,就进行下一步交换操作。但是!这里的一致是否就真的代表没有被修改过?

  假设存在两个线程 t1 和 t2,有一个共享变量 value,初始值为 A。接下来,线程 t1 想使用 CAS 把 value 值改成 Z, 那么就需要:

  • 先读取 value 的值, 记录到 oldNum 变量中。
  • 使用 CAS 判定当前 value 的值是否为 oldNum = A, 如果相等, 就修改成 Z。

  但是,在 t1 执行这两个操作之间,t2 线程可能把 value 的值从 A 改成了 B,又从 B 改成了 A 。这时 value 其实已经被修改过了,只不过又变回来了,这就是 ABA 问题

  这时可能有人会说:既然都已经变回来了,值反正都没变,t1 还是可以修改的呀。

  真的可以修改吗?

(一)ABA 问题带来的 BUG

  假设 小明 银行卡有 100 元,我们假定银行转账操作就是一个 CAS 命令。现在 小明小红100元CAS(100,100,0)),这时候因为网络原因 ATM1 卡住了,小明就换了第二台 ATM2 继续转。现在 小明 已经转了 100 元给小红,他的银行卡剩 0 元。这时 小明的妈妈 转给 小明 100元,现在银行卡剩 100 元。但好巧不巧,ATM1 网络恢复了,执行 CAS 操作:CAS(100,100,0),发现银行卡剩 100 元,执行成功,小明实际上就给 小红 转了 200 元。

  以上就是 ABA 问题可能带来的 BUG。我们如何规避上面的事情发生呢?

(二)解决方案:引入版本号

  给要修改的值,引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。 CAS 操作在读取旧值的同时,也要读取版本号。

  • CAS 操作在读取旧值的同时,也要读取版本号。
  • 修改的时候:
    • 如果当前版本号和读到的版本号相同,则修改数据,并把版本号加 1。
    • 如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)。

  对于上面的案例,引入一个版本号 ver = 1,并将它与余额绑定。

  1. ATM1 读取 余额 100,ver = 1;ATM2 读取 余额100,ver= 1;
  2. 小明第一次成功转给小红 100,ver = 2;
  3. 小明妈妈转给小明 100,ver = 3;
  4. ATM2 网络恢复尝试转账,虽然余额一致,但是发现 ver 不一致,拒绝转账。

(可以把 ATM1、ATM2 看成线程)

这就是解决 ABA 问题的方法。