CAS和ABA问题


CAS

概念

CAS 的全称是 Compare-And-Swap,它是 CPU 并发原语,功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值

CAS 并发原语体现在 Java 语言中就是 sun.misc.Unsafe 类的各个方法。调用 UnSafe 类中的 CAS 方法,JVM 会帮我们实现出 CAS 汇编指令,这是一种完全依赖于硬件的功能,通过它实现了原子操作。由于 CAS 是一种系统原语,原语属于操作系统使用范畴,是由若干条指令组成,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说 CAS 是一条 CPU 的原子指令,不会造成所谓的数据不一致的问题,是线程安全的。

CAS代码及底层原理

这里我们需要创建一个 AtomicInteger 示例并初始化,然后调用 compareAndSet() 方法来体验 CAS 操作,具体代码如下:

AtomicInteger atomicInteger = new AtomicInteger(5);
// 这行代码可以执行成功,因为主内存存的数据为5
System.out.println(atomicInteger.compareAndSet(5, 1024) + "\t current data: " + atomicInteger.get());
// 这行代码执行不成功,因为上一步操作将主内存存的数据改为1024
System.out.println(atomicInteger.compareAndSet(5, 2023) + "\t current data: " + atomicInteger.get());

这里我们查看 AtomicInteger 源码(如下图所示)可以发现其实现中定义了一个 Unsafe 变量,Unsafe 是 CAS 的核心类,Unsafe 类的所有方法都是 native 修饰的,也就是说 Unsafe 类中的方法都直接调用操作系统底层资源执行相应的任务,保证了操作的原子性。此外源码中 value 变量用 volatile 进行修饰,保证了该变量的可见性

接下来我们通过讲解 AtomicIntegergetAndIncrement() 方法来细说 CAS 具体实现原理,首先我们看该方法的定义:

public final int getAndIncrement() {
	return unsafe.getAndAddInt(this, valueOffset, 1);
}

上述代码中 this 指的是当前对象,valueOffset 表示该变量值在内存中的偏移地址,Unsafe 是根据内存偏移地址获取数据的。上述代码的意思是先通过 valueOffset 获取内存偏移地址,再通过内存偏移地址获取到值,然后进行加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:就是我们从主内存中拷贝到工作内存中的值

该代码的意思是每次都要从主内存拿到最新的值到自己的本地内存,然后执行 compareAndSwapInt() 方法和主内存的值进行比较,不同则返回 false,取非就是 true,就一直执行 while 方法,直到期望的值和真实值一样,然后执行加1操作。

CAS缺点

CAS 不加锁,保证原子性,但是需要多次比较,具有以下缺点:

  • 循环时间长,开销大

  • 只能保证一个共享变量的原子操作

    • 当对一个共享变量执行操作时,我们可以通过循环 CAS 的方式(自旋)来保证原子操作
    • 但是对于多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候只能用锁来保证原子性
  • 引出来 ABA 问题

ABA问题

概念

所谓 ABA(见维基百科的ABA词条),问题基本是这个样子:

  • 进程 P1 在共享变量中读到值为 A
  • P1 被抢占了,进程 P2 执行
  • P2 把共享变量里的值从A改成了 B,再改回到 A,此时被 P1 抢占
  • P1 回来看到共享变量里的值没有被改变,于是继续执行

ABA 问题说的直白点就是在进行获取主内存值的时候,该内存值在我们写入主内存的时候,已经被修改了 N 次,但是最终又改成原来的值了

CAS 出现 ABA 问题的根本是在修改变量的时候,无法记录变量的状态,比如修改的次数,CAS 只管开头和结尾,也就是头和尾一样,就修改成功,但中间的这个过程可能会被人修改过

解决方法

利用AtomicStampedReference (时间戳原子引用)来解决 ABA 问题,它解决 ABA 问题的原理是引入版本号,每次修改都会改相应的版本号(类似于乐观锁),同时每次更新的时候,不但比较期望值和当前值,还比较期望版本号和当前版本号

下面代码是 ABA 问题的产生以及用AtomicStampedReference 来解决 ABA 问题:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicStampedReference;

public class ABADemo {

    /**
     * 普通的原子引用包装类
     */
    static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);

    // 传递两个值,一个是初始值,一个是初始版本号
    static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 1);

    public static void main(String[] args) {

        System.out.println("============以下是ABA问题的产生==========");

        new Thread(() -> {
            // 把100 改成 101 然后在改成100,也就是ABA
            atomicReference.compareAndSet(100, 101);
            atomicReference.compareAndSet(101, 100);
        }, "t1").start();

        new Thread(() -> {
            try {
                // 睡眠一秒,保证t1线程,完成了ABA操作
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 把100 改成 101 然后在改成100,也就是ABA
            System.out.println(atomicReference.compareAndSet(100, 2023) + "\t" + atomicReference.get());

        }, "t2").start();

        try {
            // 睡眠一秒,保证t1线程,完成了ABA操作
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("============以下是ABA问题的解决==========");

        new Thread(() -> {

            // 获取版本号
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp);

            // 暂停t3一秒钟
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 传入4个值,期望值,更新值,期望版本号,更新版本号
            atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(), atomicStampedReference.getStamp()+1);

            System.out.println(Thread.currentThread().getName() + "\t 第二次版本号" + atomicStampedReference.getStamp());

            atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp()+1);

            System.out.println(Thread.currentThread().getName() + "\t 第三次版本号" + atomicStampedReference.getStamp());

        }, "t3").start();

        new Thread(() -> {

            // 获取版本号
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + "\t 第一次版本号" + stamp);

            // 暂停t4 3秒钟,保证t3线程也进行一次ABA问题
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            boolean result = atomicStampedReference.compareAndSet(100, 2023, stamp, stamp+1);

            System.out.println(Thread.currentThread().getName() + "\t 修改成功否:" + result + "\t 当前最新实际版本号:" + atomicStampedReference.getStamp());

            System.out.println(Thread.currentThread().getName() + "\t 当前实际最新值" + atomicStampedReference.getReference());


        }, "t4").start();

    }
}

运行结果为:

我们能够发现,线程 t3,在进行 ABA 操作后,版本号变更成了 3,而线程 t4 在进行操作的时候,就出现操作失败了,因为版本号和当初拿到的不一样

参考

  • 尚硅谷Java大厂面试题第2季

文章作者: 不才叶某
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 不才叶某 !
评论
  目录