【多线程 六】彻彻底底搞懂CAS,解决ABA问题

  • Post author:
  • Post category:其他




前言:


如果不知道JMM(java 内存模型),还请看博主的上一篇博客:

volatile关键字与内存可见性详解

,因为本博客还需要JMM的理论基础。



博客内容导图

在这里插入图片描述



1、CAS是什么


CAS(Compare-And-Swap)是一种硬件对并发的支持,针对多处理器操作而设计的处理器中的一种特殊指令,用于管理对共享数据的并发访问.

CAS 是一种无锁的非阻塞算法的实现。


以上是一本书给出的定义。其实我们在上篇博客已经提过CAS了,AtomicInteger 实现num++,AtomicInteger的底层就是用了CAS的思想。


CAS是一条CPU并发原语.并且原语的执行必须是连续的,在执行过程中不允许中断,也即是说CAS是一条原子指令,不会造成所谓的数据不一致(线程安全)的问题.




2、CAS解决了什么问题


对于多线程而言,实现线程安全是最要的也是最基本的,我们之前通过同步锁synchronized()实现,它保证了线程安全,也就是数据的一致性,但是它的并发性下降,因为每次在都要对代码块进行加锁,线程得到锁才可以运行。但是CAS是一种无锁的非阻塞的算法实现,如果线程数不多(并发量小),他的性能要比synchronized()高的多,但是线程数过多,就会过分的消耗cpu资源(具体为啥后面会解释)。



3、CAS的原理


CAS 包含了 3 个操作数:

需要读写的内存值 V

进行比较的值 A

拟写入的新值 B



当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新 V 的 值,否则不会执行任何操作

。接下来用源码来验证以上理论。



3.1 atomic包中的类


且看AtomicInteger,AtomicInteger位于java.util.concurrent.atomic包下,如下图:(本图截取自JDK API 1.6中文版)

在这里插入图片描述


从上图可知道,atomic包下都是原子性的操作,原子性就是说无论是几行代码,要么这些代码一块执行,要么就不执行,中间不可以 加塞(比如还没执行完,发生线程调度),这就完美的解决num++线程不安全的问题。



3.2 根据atomicInteger.getAndIncrement()源码验证CAS理论


(1)从下面的代码可以看出,用到了Unsafe这个类,

UnSafe类在sun.misc包中

,UnSafe是CAS的核心类 ,由于Java 方法无法直接访问底层 ,需要通过本地(native)方法来访问,

基于该类可以直接操作特额定的内存数据,其内部方法操作可以像C的指针一样直接操作内存。



UnSafe类中所有的方法都是native修饰的,也就是说UnSafe类中的方法都是直接调用操作底层资源执行响应的任务



看到这里的value是用关键字

volatile修饰的

,保证了内存可见性

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }
    private volatile int value;


(2)如下面的代码:

unsafe.getAndAddInt(this, valueOffset, 1),传了三个参数,分别是当前对象this、该对象在内存中的偏移地址,还有表示每次都加1的数字1。其中UnSafe就是根据内存偏移地址获取数据的偏移地址

    /**
     * Atomically increments by one the current value.
     *
     * @return the previous value
     */
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }


(3)如下代码,进入到方法getAndAddInt,发现是一个do while 循环,接下来进行说明:


var1 AtomicInteger对象本身

var2 该对象值的引用地址

var4 需要变动的数量

var5 是通过var1 var2找出内存中真实的值


this.compareAndSwapInt(var1, var2, var5, var5 + var4),表示的是该对象var1在内存中的偏移量var2位置的值与期望值var5进行比较,如果相同,更新var5+var4的值并且返回true,如果不同,继续循环,将期望值var5改为内存中真正的值,然后在进行比较,直到更新完成。

综上也体现出了它的缺点,就是如果线程过多,其他的线程可能会一直改内存中的实际值,那么期望值var5就会很难和内存中的实际值相等,这样就会导致一直循环,效率下降,所以如果线程多,并发大,用锁比较合适。

    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;
    }



(4)CAS原理分析




1.假设线程A和线程B两个线程同时执行getAndAddInt操作(分别在不同的CPU上)且AtomicInteger里面的value原始值为3,即主内存中AtomicInteger的value为3,根据JMM模型,线程A和线程B各自持有一份值为3的value的副本分别到各自的工作内存.


2.线程A通过getIntVolatile(var1,var2) 拿到value值3,这时线程A被挂起.线程B也通过getIntVolatile(var1,var2) 拿到value值3,

在这里插入图片描述


3.此时刚好线程B没有被挂起并执行compareAndSwapInt方法比较内存中的值也是3 ,成功修改内存的值为4 线程B走完收工 一切OK。

在这里插入图片描述


4.这时线程A恢复,执行compareAndSwapInt方法比较,发现自己手里的数值和内存中的数值4不一致,说明该值已经被其他线程抢先一步修改了,那A线程修改失败,只能重新来一遍了.

在这里插入图片描述


5.线程A重新获取value值,因为变量value是volatile修饰,所以其他线程对他的修改,线程A总是能够看到,线程A继续执行compareAndSwapInt方法进行比较替换,直到成功.



4、CAS的缺点


(1)根据上面所说的原理,如果并发量特别大或者说开启的线程数过多,就可能会存在内存中的共享的值经常被修改,导致unsafe底层匹配不成功,会一直do-while下去,加大了cpu 的压力


(2)只能保证一个共享变量的原子操作。(比如声明两个原子变量a和b 具体操作是a+b,此时只能枷锁了)


(3)引发ABA问题(狸猫换太子)

ABA问题举例:


场景1:


一个线程1从内存中取出A,这个时候另一个线程2也从内存中取出A,并且线程2进行了一些操作将值变成了B,线程1此时还被阻塞,线程2又进行了一些操作,然后将B又变成了A,此时线程1获得资源,开始执行,但是在进行cas操作的时候发现内存中还是A,然后线程1执行成功。(

说白了就是可能存在一个线程根本不知道数值发生了变化




场景2:

ABA问题:内存值V=100;
threadA 将100,改为50;
threadB 将100,改为50;
threadC 将50,改为100


小牛取款,由于机器不太好使,多点了几次取款操作。后台threadA和threadB工作,此时threadA操作成功(100->50),threadB,取完值100,然后阻塞。正好牛妈打款50元给小牛(50->100),threadC执行成功,之后threadB运行了,又改为(100->50)。

牛气冲天,lz钱哪去了???



5、如何解决ABA问题:


对内存中的值加个版本号,在比较的时候除了比较值还的比较版本号。

AtomicStampedReference就是用版本号实现cas机制,再讲AtomicStampedReference之前,先讲解AtomicReference原子引用。



5.1 AtomicReference原子引用


AtomicReference和AtomicInteger非常类似,不同之处就在于AtomicInteger是对整数的封装,底层采用的是compareAndSwapInt实现CAS,比较的是数值是否相等,而AtomicReference则对应普通的对象引用,底层使用的是compareAndSwapObject实现CAS,比较的是两个对象的地址是否相等。也就是它可以保证你在修改对象引用时的线程安全性。我们可以自定义对象。

代码如下:

public class TestAtomic {
    public static void main(String[] args) {
        AtomicReference<User> atomicUser=new AtomicReference<>();
        User zhangsan=new User("张三",21);
        User lisi =new User("李四",25);
        atomicUser.set(zhangsan);
        System.out.println(atomicUser.compareAndSet(zhangsan,lisi)+"\t"+atomicUser.get().toString());
    }
}

class User{
    private String username;
    private int age;

    public User(String username,int age){
        this.username=username;
        this.age=age;
    }
    @Override
    public String toString() {
        return "User{" +
                "username='" + username + '\'' +
                ", age=" + age +
                '}';
    }
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

执行结果:

在这里插入图片描述

// 分析:


定义了对象User,利用方法compareAndSet进行比较和判断,内存中存入了张三,然后和compareAndset的第一个参数进行比较,发现都是张三,然后修改为lisi。



5.2 版本号原子引用 AtomicStampedReference解决ABA 问题

AtomicStampedReference 增加了版本号,解决了ABA问题。


思想:

内存值V=100;

threadA 获取值100 版本号1,改为50 版本号2;

threadB 获取值100 版本号1

threadC 将50 版本号2 改为100 版本号3


场景:小牛取款,由于机器不太好使,多点了几次取款操作。后台threadA和threadB工作,此时threadA操作成功(100->50) 版本号1->版本号2,threadB取完值100,版本号是1,然后阻塞。正好牛妈打款50元给小牛(50->100) 版本号2->版本号3,threadC执行成功,之后threadB获取资源,开始执行,发现内存的值为100, 但是版本号不对应, 所以重新从内存中读取值,然后再进行判断。这次牛就不会冲天了,哈哈哈哈哈!


以上场景对应代码:

public class ABADemo {
    // 表示内存里初始值是100,版本好号为1
  private static AtomicStampedReference atomicStampedReference=new AtomicStampedReference(100,1);
    public static void main(String[] args) {
        new Thread(()->{
            // 获得版本号
            int stamp = atomicStampedReference.getStamp();
            // 这里延迟一秒不是为了说为了线程阻塞,而是为了让线程B得到版本号
            System.out.println(Thread.currentThread().getName()+"\t第一次版本号:"+stamp);
            try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
            // 期望值为100 和内存中的值进行比较,如果一样,且版本号stamp也和内存中一样,则改为50
            atomicStampedReference.compareAndSet(100,50,stamp,stamp+1);
            System.out.println(Thread.currentThread().getName()+"\t第二次版本号:"+atomicStampedReference.getStamp());
//            atomicStampedReference.compareAndSet(100,50,stamp,stamp+1);
        },"A").start();

        new Thread(()->{
            // 获得版本号
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName()+"\t第一次版本号:"+stamp);
            // 线程阻塞
            try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
            // 期望值为100 和内存中的值进行比较,如果一样,且版本号stamp也和内存中一样,则改为50
            boolean result = atomicStampedReference.compareAndSet(100, 50, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName()+"\t修改是否成功"+result+"\t当前实际版本号:"+atomicStampedReference.getStamp());
            System.out.println("当前实际最新值:"+atomicStampedReference.getReference());
        },"B").start();

        new Thread(()->{
            // 这里延迟两秒不是为了说为了线程阻塞,而是为了让线程A 执行完毕
            try { TimeUnit.SECONDS.sleep(2); } catch (InterruptedException e) { e.printStackTrace(); }
            // 获得版本号
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName()+"\t第一次版本号:"+stamp);
            // 期望值为100 和内存中的值进行比较,如果一样,且版本号stamp也和内存中一样,则改为50
            atomicStampedReference.compareAndSet(50,100,stamp,stamp+1);
            System.out.println(Thread.currentThread().getName()+"\t第二次版本号:"+atomicStampedReference.getStamp());
        },"C").start();
    }
}

运行结果:

在这里插入图片描述



后记:


总结不易,如果对你有帮助,请点个赞欧!

·



版权声明:本文为jerry11112原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。