CAS机制专篇,超详细讲解!(比较与交换,compareAndSwap,CAS原理,Unsafe是什么?valueOffset是什么?CAS的缺点,CAS的应用场景,ABA问题,ABA问题的解决办法)

CAS专篇

前言

​ 日常编码过程中,基本不会直接用到 CAS 操作,都是通过一些JDK 封装好的并发工具类来使用的,在 java.util.concurrent 包下。但是面试时 CAS 还是个高频考点,所以呀,你还不得不硬着头皮去死磕一下这块的技能点,总比一问三不知强吧?

一、为什么要用无锁?

​ 我们一想到在多线程下保证安全的方式头一个要拎出来的肯定是锁,不管从硬件、操作系统层面都或多或少在使用锁。锁有什么缺点吗?当然有了,不然 JDK 里为什么出现那么多各式各样的锁,就是因为每一种锁都有其优劣势。

图片

​ 使用锁就需要获得锁、释放锁,CPU 需要通过上下文切换和调度管理来进行这个操作,对于一个 独占锁 而言一个线程在持有锁后没执行结束其他的哥们就必须在外面等着,等到前面的哥们执行完毕 CPU 大哥就会把锁拿出来其他的线程来抢了(非公平)。锁的这种概念基于一种悲观机制,它总是认为数据会被修改,所以你在操作一部分代码块之前先加一把锁,操作完毕后再释放,这样就安全了。其实在 JDK1.5 使用 synchronized 就可以做到 。

图片

​ 但是像上面的操作在多线程下会让 CPU 不断的切换,非常消耗资源,我们知道可以使用具体的某一类锁来避免部分问题。那除了锁的方式还有其他的吗?当然,有人就提出了无锁算法,比较有名的就是我们今天要说的 CAS(compare and swap),和锁不同的是它是一种乐观的机制,它认为别人去拿数据的时候不会修改,但是在修改数据的时候去判断一下数据此时的状态,这样的话 CPU 不会切换,在读多的情况下性能将得到大幅提升。当前我们使用的大部分 CPU 都有 CAS 指令了,从硬件层面支持无锁,这样开发的时候去调用就可以了。不论是锁还是无锁都有其优劣势。

二、CAS

1、CAS 的概述

​ CAS (Compare And Swap,比较与交换),是一种支持硬件层次的原子性操作的经典无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。

CAS算法涉及到三个操作数:

  1. 需要读写的内存位置(V)

  2. 进行比较的旧预期值(A)

  3. 准备写入的新值(B)

2、CAS 的原理

CAS的原理,具体修改数据过程如下:

  1. 用CAS操作数据时,将原始值和要修改的值一并传递给方法;
  2. 比较当前内存中的目标变量值与传进去的原始值是否相同?
  3. 如果相同,表示内存中的目标变量值没有被其他线程修改过,可以直接修改内存种的目标变量值;
  4. 如果不同,那么证明内存种的目标变量值已经被其他线程修改过,则修改失败,CAS会做自旋操作,不断循环重试。

3、CAS 会产生并发安全问题吗?

​ 实际应用中这种情况不会发生。例如:Java中的CAS就体现在 sun.misc.Unsafe 类中的各个方法里。

//下面是Unsafe类中的方法
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

​ 可以看到,这些都是native本地方法,调用过程中,JVM会借助C来调用CPU底层指令实现硬件级别的CAS比较和交换。 看出这是一种完全依赖于底层硬件的操作,通过它来实现原子操作,所以并不会带来并发问题。

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

三、Java中的CAS

1、代码实例

​ 我们使用java.util.concurrent.atomic.AtomicInteger类中的compareAndSet()方法,此方法的作用是:将原始值与期待值进行比较,如果相等则将原始值设为要修改的新值(让新值成为原始值),并返回true;不相等则修改失败,返回false。

import java.util.concurrent.atomic.AtomicInteger;

public class CASDemo {
  public static void main(String[] args) {
    AtomicInteger atomicInteger = new AtomicInteger(5); //初始化原子类的值为0
    //将初始值0与预期值5进行比较,如果相等,则将预期值5改为新值2020
    atomicInteger.compareAndSet(5,2020); //此方法比较成功返回true,比较失败返回false
    System.out.println(atomicInteger.get()); //比较成功,说明原始值没被更改过,输出2020。
    atomicInteger.compareAndSet(5,1024); //假如此语句不知道初始值5已被更改为2020,那么会比较失败返回false
    System.out.println(atomicInteger.get()); //因为没有更改成功,所以输出2020。
 }
}

2、实例分析

​ 上面代码实例,我们用到了java.util.concurrent.atomic.AtomicInteger类。那么这个类的compareAndSet()方法是怎样实现比较并设置的功能呢?我们来看下AtomicInteger类的源码吧:

2.1、AtomicInteger源码
public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long valueOffset; //内存偏移量,下面会将它干嘛的。
    private static final Unsafe unsafe = Unsafe.getUnsafe(); //给Unsafe类的初始化,方便方法中调用。
    
	static { //AtomicInteger类中的静态代码块
        try {
            //给valueOffset初始化
            valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value")); 
        } catch (Exception ex) { throw new Error(ex); }
    }
    
    public final boolean compareAndSet(int expect, int update) { //主角在这里!
        //发现原来是调用了Unsafe类的compareAndSwapInt()方法
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update); 
    }
    
    //省略很多代码......
}
Unsafe 是什么?

​ UnSafe是Java中CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地方法来访问,UnSafe相当于JVM提供的一个后门,基于该类可以直接操作特定内存的数据,Unsafe类存在于 sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存。所以我们使用Java中的CAS时,离不开Unsafe类的方法。

​ 注意:Unsafe类中的所有方法都是Native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务。

valueOffset 是什么?

​ 从上面AtomicInteger类的源码中,我们得知valueOffset是初始化时通过:unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField(“value”)) 得来的。

​ valueOffset 所代表的是AtomicInteger对象的value成员变量在内存中的偏移量。我们可以简单的把valueOffset理解为value变量的内存当前值

也就是说,valueOffset 是当前AtomicInteger对象初始化时的原始值的内存地址。

例如:AtomicInteger atomicInteger = new AtomicInteger(5); 这个5就是原始值,即valueOffset 是5的内存地址。

我们知道了上面 unsafe.compareAndSwapInt(this, valueOffset, expect, update) 中4个参数的具体含义,即:

this:当前要操作的对象

valueOffset:当前要操作的变量偏移量(变量的内存当前值)

expect:期望内存中的值

update:要修改的新值

根据这四个传递的参数,我们顺水摸鱼,去看看Unsafe类的源码吧:

2.2、Unsafe源码
public final class Unsafe {
	//省略很多代码......
    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
    //省略很多代码......
}

我们发现compareAndSwapInt(…)是个native本地方法,该方法具体的实现位于 unsafe.cpp 中:

image-20210427115045815

此方法大致意思就是:

  1. 读取传入对象this(var1)在内存中偏移量为valueOffset(var2)位置的值与期望值expect(var4)作比较。

  2. 相等,就把update(var5)值赋值给valueOffset(var2)位置的值,方法返回true。

  3. 不等,就取消赋值,方法返回false。

四、CAS 的缺点

1、循环时间长导致CPU开销大

​ 在并发量比较高的情况下,自旋CAS(不成功就一直循环执行,直到成功) 如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

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

​ CAS机制保证的只有一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证多个变量共同进行原子性的更新,循环CAS就无法保证操作的原子性,就不得不使用Synchronized了。或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

3、CAS带来的ABA问题

​ ABA问题就是,假如Thread1和Thread2先后从内存中拿到值A。这时,Thread1把内存中的值A改为了值B,突然又来了个Thread3,它拿到内存中的值B后,然后又将内存中的值B改为了值A。这过程中,Thread2全然不知。 尽管Thread2的CAS操作成功,但是不代表这个过程就是没有问题的。

实际应用中ABA问题的例子:
  1. 小明拿着余额为200元的银行卡去银行取钱,他想取100元出来;

  2. 小明第1次提交"取款100元"的请求,这时ATM机硬件出了问题,没做出反应。于是小明第2次提交了“取款100元”的请求;

  3. 这时,第1次请求得到"取款成功"的反应,这时卡上余额为100元;

  4. 这时,小明的妈妈往小明的银行卡里打了100元过去。由于没出现问题,小明的卡上余额瞬间变为200元;

  5. 这时,小明提交的第2次请求有了反应。经过CAS检验,当前卡里余额200元与之前拿到的预期值200元相等,CAS操作成功,于是小明卡里的余额变为了100元。

  6. 小明拿到取出来的100元现金后,看到卡上余额还有100元,于是他认为中间操作没出现问题,符合逻辑。

  7. 但是小明妈妈期间往他卡上打了100元,减去小明取走的100元,真正卡上的余额应该为200元, 即200-100+100=200。小明期间全然不知,这就是CAS带来的ABA问题!

引发ABA问题的本质:

​ ABA问题的根本在于cas在修改变量的时候,无法记录变量的状态,比如修改的次数,否修改过这个变量。这样就很容易在一个线程将A修改成B时,另一个线程又会把B修改成A,造成cas多次执行的问题。

ABA问题解决原理:

​ 解决原理就是: 在CAS检验期间,不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致。 假设地址V中存储着变量值A,当前版本号是01。线程1获取了当前值A和版本号01,想要更新为值B,但是被阻塞了。 这时候,内存地址V中变量发生了多次改变,版本号提升为03,但是变量值仍然是A。 随后线程1恢复运行,进行compare操作。经过比较,线程1所获得的值和地址的实际值都是A,但是版本号不相等,所以这一次更新失败。

​ 在JDK中有就提供了java.util.concurrent.atomic.AtomicStampedReference类。AtomicStampReference在cas的基础上增加了一个标记stamp,使用这个标记可以用来觉察数据是否发生变化,给数据带上了一种实效性的检验。它有以下几个参数:

//参数代表的含义分别是 期望值,写入的新值,期望标记,新标记值
public boolean compareAndSet(V expected,V newReference,int expectedStamp,int newStamp);
public V getRerference();
public int getStamp();
public void set(V newReference,int newStamp);

AtomicStampReference使用实例:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;
/**
* ABA 问题的解决  AtomicStampedReference
* 注意变量版本号修改和获取问题。不要写错
*/
public class ABADemo {
  static AtomicStampedReference<Integer> atomicStampedReference = new
AtomicStampedReference<>(100,1);
  public static void main(String[] args) {
    new Thread(()->{
      int stamp = atomicStampedReference.getStamp(); // 获得版本号
      System.out.println("T1 stamp 01=>"+stamp);
      // 暂停2秒钟,保证下面线程获得初始版本号
      try {
        TimeUnit.SECONDS.sleep(1);
     } catch (InterruptedException e) {
        e.printStackTrace();
     }
      atomicStampedReference.compareAndSet(100, 101,
atomicStampedReference.getStamp()
                        ,
atomicStampedReference.getStamp()+1);
    
      System.out.println("T1 stamp
02=>"+atomicStampedReference.getStamp());
     
      atomicStampedReference.compareAndSet(101, 100,
atomicStampedReference.getStamp()
atomicStampedReference.getStamp()+1);
     
      System.out.println("T1 stamp
03=>"+atomicStampedReference.getStamp());
   },"T1").start();
    new Thread(()->{
      int stamp = atomicStampedReference.getStamp(); // 获得版本号
      System.out.println("T2 stamp 01=>"+stamp);
      // 暂停3秒钟,保证上面线程先执行
      try {
        TimeUnit.SECONDS.sleep(3);
     } catch (InterruptedException e) {
        e.printStackTrace();
     }
      boolean result = atomicStampedReference.compareAndSet(100, 2019,
stamp, stamp + 1);
      System.out.println("T2 是否修改成功 =>"+ result);
      System.out.println("T2 最新stamp
=>"+atomicStampedReference.getStamp());
      System.out.println("T2 当前的最新值
=>"+atomicStampedReference.getReference());
   },"T2").start();
 }
}  

五、CAS的使用场景

	CAS 适用于读多写少的情况下,这样冲突一般较少;而synchronized 适用于写多读少的情况下,冲突一般较多。 

	>1. 对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞,唤醒切换,以及用户态内核态间的切换操作,都会额外消耗 cpu 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
	>2. 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。

S的使用场景

	CAS 适用于读多写少的情况下,这样冲突一般较少;而synchronized 适用于写多读少的情况下,冲突一般较多。 

	>1. 对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞,唤醒切换,以及用户态内核态间的切换操作,都会额外消耗 cpu 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
	>2. 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
  • 4
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值