解析CAS算法原理

什么是CAS?

CAS,全称Compare And Swap,顾名思义,比较再交换。它是一种常用于解决并发编程问题的一种思想。
它是一条CPU并发原语,并且原语的执行过程不允许被打断,不会造成数据不一致问题。它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。

CAS有三个操作数:V内存值、A旧的预期值、B需要修改的值。当且仅当预期值A和内存值V相同时,将内存值V修改为B, 否则什么都不做。

CAS原理

概念

在JUC并发编程的过程中,加锁可以实现线程之间数据同步,但是线程之间对加锁的代码的资源的使用是互斥的,使的运行效率很低;从而引入了volatile关键字,被volatile关键字修饰的变量对所有线程都是共享的,没有了互斥的关系,从而解决了加锁运行效率低的问题,但是volatile关键字不具备原子性,可能会出现同一线程操作同一变量不一致的问题;而CAS思想就可以很好的解决volatile关键字不具备原子性的问题。

实现形式

这是源码对 i++ 自增的实现:
在这里插入图片描述
其通用的实现形式为:

Object var5;
do {
var5 = this.getObjectVolatile(var1, var2);
} while(!this.compareAndSwapObject(var1, var2, var5, var4));
return var5;

var1: 变量对象本身
var2:该对象值的引用地址
var4: 需要改动的数量
var5:通过var1和var2来找出主内存中真实的地址

比较当前工作内存中的值和主内存中的值,如果相同则执行规定操作,
否则继续比较直到主内存和工作内存中的值一致为止。

底层原理

CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。

由于Java方法无法直接访问底层系统,需要通过本地(native) 方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因此Java中CAS操作的执行依赖于Unsafe类的方法。

在sun.misc.Unsafe下,我们就以getAndAddInt()方法为例:
在这里插入图片描述
这是一个原子性自增的方法;var2就是通过下面的方法来获取变量var1值的内存地址。然后通过getIntVolatile(var1, var2)方法将该地址下的变量的值复制给var5
在这里插入图片描述
然后在while()中进行条件判断compareAndSwapInt(var1, var2, var5, var4),这个方法就是CAS的典型,比较再交换。将var1变量地址var2下所存放**变量的值(最新值)**与 **var5(旧值)**比较,如果相等,那么var5进行自增;否者再次进入循环,直到旧值与新值一致,也就是再它修改之前没有其他线程将变量修改了,为止。

这就是CAS的思想,通过不断的比较、不断的获取值,以自旋的方式,当旧址与新值相等时,再进行操作,从而保证了操作的原子性。

案例

我们知道volatile关键字不具备原子性,之前我提到AtomicInteger数据类型可以解决这个问题,而AtomicInteger数据类型其实就是利用了CAS思想来解决的,接下来我们来看看CAS是如何解决这一问题的。
如下案例:

 AtomicInteger atomicInteger=new AtomicInteger(5);//
 System.out.println(atomicInteger.compareAndSet(5, 2019)+"\t current data : "+ atomicInteger.get());//修改成功
        
 System.out.println(atomicInteger.compareAndSet(5, 1024)+"\t current data : "+ atomicInteger.get());//修改失败

运行结果如下:
在这里插入图片描述
首先创建一个原子整型变量atomicInteger,初始值为5;接下来是atomicInteger.compareAndSet(5, 2019),我们先看看该方法的源码:
在这里插入图片描述
expect表示期望值,即我们希望他是什么值,或者以为他是什么值。
update表示要修改为什么值。

接着看compareAndSwapInt方法,这是sun.misc包下的Unsafe类里面的方法,该类里面含有大量的native修饰的方法,可以直接操作特定内存的数据,就像cup系统原子指令在执行任务。所以这些方法都不可被打断,从而保证数据的一致性。
在这里插入图片描述
里面有4个参数;

var1: 原子变量本身
var2: 地址偏移量
var4: 期望值
var5: 要修改为的值

前面我们说过地址偏移量是原子变量内部实现valueOffset = unsafe.objectFieldOffset得来的。那么从一开始我们调用的atomicInteger.compareAndSet(5, 2019)方法只传入了期望值和修改值,最后到方法compareAndSwapInt(this,valueOffset ,5, 2019);
最后方法是什么意思呢?

1.根据this(原子变量本身)和地址偏移量(valueOffset )来获取此时主内存中最新的原子变量值;
2.再将最新的原子变量的值与期望值(5)进行比较;
3.如果相等,那么将修改值(2019)赋值给原子变量,并返回true;
4.如果不相等,那么不进行操作,并返回false;

因此,我们再来看这两条打印语句就很清楚了

第一条,期望值是5,因为没有其他线程来修改变量,那么此时真实值就是5,所有将5修改为2019最后返回true;

第二条,期望值也是5,但是前面的操作已经将变量值改为2019,此时从内存获取出来的值就是2019,与期望值5不等,不再进行赋值操作,并返回false。

那么将这种先比较再复制的思想(CAS)与do/while进行结合,就可以保证每一次的操作都是原子操作,并且再期望值与真实值不等的现况下,他会不断的从内存中获取最新值作为期望值,期望值与真实值一致,才能进行接下来的操作。

CAS的缺点

当然CAS也有缺点:

  1. 循环时间长开销大。我们可以看到当getAndAddInt()方法执行时,其中有个do/while,如果CAS一致失败,那么会一直进行尝试。如果长时间的不成功,可能会给CPU带来巨大的开销。

  2. 只能保证一个共享变量的原子性操作。从方法的参数Object var可见,CAS只能对一个共享变量执行原子性操作;对于多个原子性的操作循环CAS就无法保证原子性的操作,这个时候可以用锁来保证原子性。

  3. ABA问题。假如原子变量初始值为A,在获取期望值与获取最新值的期间,有其他线程将原子变量修改为B,然后又修改为A,对于CAS来说,此时期望值与最新值时一致的,所以可以通过,但是其他线程的修改中可能会存在潜在的问题。

ABA问题

ABA问题如何产生的?

CAS算法实现一个重要前提需要取出内存中某时刻的数据并在当下时刻比较并替换,那么在这个时间差内会导致数据的变化。

比如说一个线程one从内存位置V中取出A,这时候另一个线程two也从内存中取出A,并且线程two进行了一些操作将值变成了B,然后线程two又将V位置的数据变成A,这时候线程one进行CAS操作发现内存中仍然是A,然后线one操作成功。
尽管线程one的CAS操作成功,但是不代表这个过程就是没有问题的。

原子的引用

前面的例子中,我们知道AtomicInteger类是对Integer类型进行了原子性封装,当然也对Boolean类进行了封装(AtomicBoolean);那么我想对其他类型或者是自定义类型执行原子性操作应该怎么做呢?原子引用。

通过原子引用AtomicReference可以对其他类型的变量执行原子性操作,如:

//创建一个Integer类型的原子引用atomicReference 
AtomicReference<Integer> atomicReference = new AtomicReference<>( initialValue: 100) ;
//执行CAS操作
atomicReference.compareAndset( expect: 100, update: 101);

其有参构造:
在这里插入图片描述
volatile V value 变量有volatile修饰,其他方法由CAS思想实现,那么就保证了原子性、有序性、可见性。

时间戳原子的引用

时间戳原子的引用AtomicStampedReference<>,顾名思义,带有时间戳的原子引用。
在这里插入图片描述
其有参构造如下:
在这里插入图片描述
initialRef表示要初始化原子引用的值,initialStamp要初始的时间戳;

那么时间戳有什么用呢?———解决ABA问题

利用AtomicStampedReference解决ABA问题

我们知道CAS在执行共享变量的原子性操作时,保证不会受其他线程抢断,但是其他线程可以随意的修改主内存的变量值;那么在CAS获取旧址和获取最新值的期间其他线程可能会操作CAS要操作的变量值,但最终变量值又被改回。

要解决这问题,我们必须知道在那个期间变量到底有没有没其他线程修改过,因此我们对这个变量加了一个新的属性时间戳(可以理解为版本号);每修改了这个变量的值我们就对时间戳+1,如果在那期间有线程对该变量进行了操作那么时间戳就会增加,而执行CAS操作的这个线程执行完后也要对时间戳进行+1,但是我们拿到的时间戳是旧的,所以如果期间没有其他线程进行操作,那么我们自增后的时间戳会比主内存中该变量的时间戳要大,否则就被其他线程修改过。

案例
static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 1);
public static void main(String[] args) {
        System.out.println("======ABA问题的解决======");
        new Thread(() -> {
            int stamp = atomicStampedReference.getStamp();
            System.out.println(Thread.currentThread().getName() + "\t第一次版本号: " + stamp);
            try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
            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);
            try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
            boolean result=atomicStampedReference.compareAndSet(100,2019,
                    stamp,stamp+1);
            System.out.println(Thread.currentThread().getName()+"\t修改成功与否:"+result+"  当前最新版本号"+atomicStampedReference.getStamp());
            System.out.println(Thread.currentThread().getName()+"\t当前实际值:"+atomicStampedReference.getReference());
        }, "t4").start();
    }
  1. stamp = atomicStampedReference.getStamp();首先让t3,t4线程获取当前的版本号;在让t3睡1秒,t4睡3秒,以保证获取的版本号为初始版本号以及让t3线程运行结束后t4再运行;
  2. t3先将初始值100改为101,版本号+1为2,再将101改回100,版本号+1为3;
  3. t4线程开始,期望值100与真实值100相等没问题,而版本号(1)+1为2,比真实的版本号3要小,所以不能执行修改,返回false。

运行结果如下:
在这里插入图片描述

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值