zoukankan      html  css  js  c++  java
  • 什么是CAS机制?(转)

    围绕下面四个点展开叙述:

      一:什么是CAS机制?

      二:Java当中CAS的底层实现

      三:CAS的ABA问题和解决方法

      四:java8对CAS的优化


    一:什么是CAS机制?

    我们先看一段代码:

    启动两个线程,每个线程中让静态变量count循环累加100次。

    public class Test4 {
        public static int count =0;
        
        public static void main(String[] args) {
            for(int i = 0; i < 2; i++) {
                new Thread(
                            new Runnable(){
                                @Override
                                public void run() {
                                    try {
                                        Thread.sleep(10);
                                    } catch (InterruptedException e) {
                                        e.printStackTrace();
                                    }
                                    for(int j = 0; j < 100; j++) {
                                        count++;
                                    }
                                }}
                        ).start();
            }
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            
            System.out.println("count="+ count);
        }
    
    }

    多次console输出结果:小于200的值

    我们再加上synchronized同步锁,再来看一下。

    console结果: 200

      加了同步锁之后,count自增的操作变成了原子性操作,所以最终输出一定是count=200,代码实现了线程安全。虽然synchronized确保了线程安全,但是在某些情况下,这并不是一个最有的选择。

    关键在于性能问题。

      synchronized关键字会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。

      尽管JAVA 1.6为synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过过度,但是在最终转变为重量级锁之后,性能仍然比较低。所以面对这种情况,我们就可以使用java中的“原子操作类”。

      所谓原子操作类,指的是java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。如AtomicBoolean,AtomicUInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子性操作。

    现在我们尝试使用AtomicInteger类:

     

    使用AtomicInteger之后,最终的输出结果同样可以保证是200。并且在某些情况下,代码的性能会比synchronized更好。

    Atomic操作类的底层正是用到了“CAS机制”。

    CAS是英文单词Compare and Swap的缩写,翻译过来就是比较并替换。

    CAS机制中使用了3个基本操作数内存地址V旧的预期值A要修改的新值B

    更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

    我们看一个例子:

    1. 在内存地址V当中,存储着值为10的变量。

    2. 此时线程1想把变量的值增加1.对线程1来说,旧的预期值A=10,要修改的新值B=11。

    3. 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值率先更新成了11。

    4. 线程1开始提交更新,首先进行A和地址V的实际值比较,发现A不等于V的实际值,提交失败。

    5. 线程1 重新获取内存地址V的当前值,并重新计算想要修改的值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋。

    6. 这一次比较幸运,没有其他线程改变地址V的值。线程1进行比较,发现A和地址V的实际值是相等的。

    7. 线程1进行交换,把地址V的值替换为B,也就是12.

      从思想上来说,synchronized属于悲观锁,悲观的认为程序中的并发情况严重,所以严防死守,CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去重试更新。

      在java中除了上面提到的Atomic系列类,以及Lock系列类夺得底层实现,甚至在JAVA1.6以上版本,synchronized转变为重量级锁之前,也会采用CAS机制。

    CAS的缺点: 

    1) CPU开销过大

      在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很到的压力。

    2) 不能保证代码块的原子性

      CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。

        解决方法:

        1) 可以加锁来解决。

        2) 封装成对象类解决(从JAVA1.5开始,JDK的Atomic包里面提供了一个类AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里面来进行CAS操作)。

    3) ABA问题

      这是CAS机制最大的问题所在。(后面有介绍)

    二:Java当中CAS的底层实现

      先来一个小例子:i++自增操作

    public class CASTest {
        static int i = 0;
    
        public static void increment() {
            i++;
        }
    }

    有没有其他方法来代替 synchronized 对方法的加锁,并且保证 increment() 方法是线程安全呢?

    大家看一下,如果我采用下面这种方式,能否保证 increment 是线程安全的呢?步骤如下:

      1、线程从内存中读取 i 的值,假如此时 i 的值为 0,我们把这个值称为 k 吧,即此时 k = 0。

      2、令 j = k + 1。

      3、用 k 的值与内存中i的值相比,如果相等,这意味着没有其他线程修改过 i 的值,我们就把 j(此时为1) 的值写入内存;如果不相等(意味着i的值被其他线程修改过),我们就不把j的值写入内存,而是重新跳回步骤 1,继续这三个操作。

    翻译成代码的话就是这样:

    public static void increment() {
        do{
            int k = i;
            int j = k + 1;
        }while (compareAndSet(i, k, j))
    }

      如果你去模拟一下,就会发现,这样写是线程安全的。

      这里可能有人会说,第三步的 compareAndSet 这个操作不仅要读取内存,还干了比较、写入内存等操作,,,这一步本身就是线程不安全的啊?

      如果你能想到这个,说明你是真的有去思考、模拟这个过程,不过我想要告诉你的是,这个 compareAndSet 操作,他其实只对应操作系统的一条硬件操作指令,尽管看似有很多操作在里面,但操作系统能够保证他是原子执行的。

      对于一条英文单词很长的指令,我们都喜欢用它的简称来称呼他,所以,我们就把 compareAndSet 称为 CAS 吧。

      所以,采用 CAS 这种机制的写法也是线程安全的,通过这种方式,可以说是不存在锁的竞争,也不存在阻塞等事情的发生,可以让程序执行的更好。

      在 Java 中,也是提供了这种 CAS 的原子类,例如:

    1. AtomicBoolean
    2. AtomicInteger
    3. AtomicLong
    4. AtomicReference

    我们再来看看jdk中AtomicInteger当中常用的自增方法incrementAndGet:

    public final int incrementAndGet() {
    
        for (;;) {
    
            int current = get();
    
            int next = current + 1;
    
            if (compareAndSet(current, next))
    
                return next;
    
        }
    
    }
    
    private volatile int value; 
    
    public final int get() {
    
        return value;
    
    }

    这段代码是一个无限循环,也就是CAS的自旋,循环体中做了三件事:

      1. 获取当前值

      2. 当前值+1,计算出目标值

      3. 进行CAS操作,如果成功则跳出循环,如果失败则重复上述步骤

    这里需要注意的重点是get方法,这个方法的作用是获取变量的当前值。

    如何保证获取的当前值是内存中的最新值?很简单,用volatile关键字来保证(保证线程间的可见性)。我们接下来看一下compareAndSet方法的实现:

    compareAndSet方法的实现很简单,只有一行代码。这里涉及到两个重要的对象,一个是unsafe,一个是valueOffset。

      什么是unsafe呢?Java语言不像C,C++那样可以直接访问底层操作系统,但是JVM为我们提供了一个后门,这个后门就是unsafe。unsafe为我们提供了硬件级别的原子操作

      至于valueOffset对象,是通过unsafe.objectFiledOffset方法得到,所代表的是AtomicInteger对象value成员变量在内存中的偏移量。我们可以简单的把valueOffset理解为value变量的内存地址

      我们上面说过,CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。

      而unsafe的compareAndSwapInt方法的参数包括了这三个基本元素:valueOffset参数代表了V,expect参数代表了A,update参数代表了B。

      正是unsafe的compareAndSwapInt方法保证了Compare和Swap操作之间的原子性操作。

    三:CAS的ABA问题和解决方法

    我们现在来说什么是ABA问题。假设内存中有一个值为A的变量,存储在地址V中。

    此时有三个线程想使用CAS的方式更新这个变量的值,每个线程的执行时间有略微偏差。线程1和线程2已经获取当前值,线程3还未获取当前值。

    接下来,线程1先一步执行成功,把当前值成功从A更新为B;同时线程2因为某种原因被阻塞住,没有做更新操作;线程3在线程1更新之后,获取了当前值B。

    在之后,线程2仍然处于阻塞状态,线程3继续执行,成功把当前值从B更新成了A。

    最后,线程2终于恢复了运行状态,由于阻塞之前已经获得了“当前值A”,并且经过compare检测,内存地址V中的实际值也是A,所以成功把变量值A更新成了B。

    看起来这个例子没啥问题,但如果结合实际,就可以发现它的问题所在。

    我们假设一个提款机的例子。假设有一个遵循CAS原理的提款机,小灰有100元存款,要用这个提款机来提款50元。

    由于提款机硬件出了点问题,小灰的提款操作被同时提交了两次,开启了两个线程,两个线程都是获取当前值100元,要更新成50元。

    理想情况下,应该一个线程更新成功,一个线程更新失败,小灰的存款值被扣一次。

    线程1首先执行成功,把余额从100改成50.线程2因为某种原因阻塞。这时,小灰的妈妈刚好给小灰汇款50元。

    线程2仍然是阻塞状态,线程3执行成功,把余额从50改成了100。

    线程2恢复运行,由于阻塞之前获得了“当前值”100,并且经过compare检测,此时存款实际值也是100,所以会成功把变量值100更新成50。

    原本线程2应当提交失败,小灰的正确余额应该保持100元,结果由于ABA问题提交成功了。

    怎么解决呢?加个版本号就可以了。

    真正要做到严谨的CAS机制,我们在compare阶段不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致。

    我们仍然以刚才的例子来说明,假设地址V中存储着变量值A,当前版本号是01。线程1获取了当前值A和版本号01,想要更新为B,但是被阻塞了。

    这时候,内存地址V中变量发生了多次改变,版本号提升为03,但是变量值仍然是A。

    随后线程1恢复运行,进行compare操作。经过比较,线程1所获得的值和地址的实际值都是A,但是版本号不相等,所以这一次更新失败。

      由于ABA问题带来的隐患,各种乐观锁的实现中通常都会用版本戳version来对记录或对象标记,避免并发操作带来的问题,在Java中,AtomicStampedReference<E>也实现了这个作用,它通过包装[E,Integer]的元组来对对象标记版本戳stamp,从而避免ABA问题,例如下面的代码分别用AtomicInteger和AtomicStampedReference来对初始值为100的原子整型变量进行更新,AtomicInteger会成功执行CAS操作,而加上版本戳的AtomicStampedReference对于ABA问题会执行CAS失败:

    import java.util.concurrent.TimeUnit;
    import java.util.concurrent.atomic.AtomicInteger;
    import java.util.concurrent.atomic.AtomicStampedReference;
    
    public class ABAForAtomicStampedReference {
        private static AtomicInteger atomicInt = new AtomicInteger(100);
        private static AtomicStampedReference atomicStampedRef = new AtomicStampedReference(100, 0);
        
        public static void main(String[] args) throws InterruptedException {
            Thread intT1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    atomicInt.compareAndSet(100, 101);
                    atomicInt.compareAndSet(101, 100);
                }
                
            });
            
            Thread intT2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                    }
                    boolean intFlag = atomicInt.compareAndSet(100, 101);
                    System.out.println("intFlag:"+ intFlag);
                }
                
            });
            
            intT1.start();
            intT2.start();
            intT1.join();
            intT2.join();
            
            Thread refT1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                    }
                    atomicStampedRef.compareAndSet(100, 101, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
                    atomicStampedRef.compareAndSet(101, 100, atomicStampedRef.getStamp(), atomicStampedRef.getStamp() + 1);
                }
                
            });
            
            Thread refT2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    int stamp = atomicStampedRef.getStamp();
                    try {
                        TimeUnit.SECONDS.sleep(2);
                    } catch (InterruptedException e) {
                    }
                    boolean refFlag = atomicStampedRef.compareAndSet(100, 101, stamp, stamp + 1);
                    System.out.println("refFlag: "+refFlag); // false
                }
                
            });
            
            refT1.start();
            refT2.start();
        }
    }

    console输出:

    总结:

      1. java语言CAS底层如何实现?

        利用unsafe提供的原子性操作方法。

      2.什么事ABA问题?怎么解决?

        当一个值从A变成B,又更新回A,普通CAS机制会误判通过检测。

        利用版本号比较可以有效解决ABA问题。

    四:java8对CAS的优化  

      由于采用这种 CAS 机制是没有对方法进行加锁的,所以,所有的线程都可以进入 increment() 这个方法,假如进入这个方法的线程太多,就会出现一个问题:每次有线程要执行第三个步骤的时候,i 的值老是被修改了,所以线程又到回到第一步继续重头再来。

      而这就会导致一个问题:由于线程太密集了,太多人想要修改 i 的值了,进而大部分人都会修改不成功,白白着在那里循环消耗资源。

      为了解决这个问题,Java8 引入了一个 cell[] 数组,它的工作机制是这样的:假如有 5 个线程要对 i 进行自增操作,由于 5 个线程的话,不是很多,起冲突的几率较小,那就让他们按照以往正常的那样,采用 CAS 来自增吧。

      但是,如果有 100 个线程要对 i 进行自增操作的话,这个时候,冲突就会大大增加,系统就会把这些线程分配到不同的 cell 数组元素去,假如 cell[10] 有 10 个元素吧,且元素的初始化值为 0,那么系统就会把 100 个线程分成 10 组,每一组对 cell 数组其中的一个元素做自增操作,这样到最后,cell 数组 10 个元素的值都为 10,系统在把这 10 个元素的值进行汇总,进而得到 100,二这,就等价于 100 个线程对 i 进行了 100 次自增操作。

      当然,我这里只是举个例子来说明 Java8 对 CAS 优化的大致原理,具体的大家有兴趣可以去看源码,或者去搜索对应的文章哦。

    ----------------------------------------------------------------------------------------------------------------------------------------------

    另一篇关于CAS整体思想的解读

    CAS的无锁思想
      众所周知,Java中对并发控制的最常见方法就是锁,锁能保证同一时刻只能有一个线程访问临界区的资源,从而实现线程安全。然而,锁虽然有效,但采用的是一种悲观的策略。它假设每一次对临界区资源的访问都会发生冲突,当有一个线程访问资源,其他线程就必须等待,所以锁是会阻塞线程执行的。

      当然,凡事都有两面,有悲观就会有乐观。而无锁就是一种乐观的策略,它假设线程对资源的访问是没有冲突的,同时所有的线程执行都不需要等待,可以持续执行。如果遇到冲突的话,就使用一种叫做CAS (比较交换) 的技术来鉴别线程冲突,如果检测到冲突发生,就重试当前操作到没有冲突为止。

    Unsafe类
      简单讲一下这个类。Java 无法直接访问底层操作系统,而是通过本地(native)方法来访问。不过尽管如此,JVM 还是开了一个后门,JDK 中有一个类 Unsafe,它提供了硬件级别的原子操作。

      这个类尽管里面的方法都是 public 的,但是并没有办法使用它们,JDK API 文档也没有提供任何关于这个类的方法的解释。总而言之,对于 Unsafe 类的使用都是受限制的,只有授信的代码才能获得该类的实例,当然 JDK 库里面的类是可以随意使用的。

      从第一行的描述可以了解到Unsafe 提供了硬件级别的操作,比如说获取某个属性在内存中的位置,比如说修改对象的字段值,即使它是私有的。不过 Java 本身就是为了屏蔽底层的差异,对于一般的开发而言也很少会有这样的需求。

    举个例子,比如:

    public native long staticFieldOffset(Field paramField);

    这个方法可以用来获取给定的 paramField 的内存地址偏移量,这个值对于给定的 field 是唯一的且是固定不变的。

    CAS概述
      CAS的全称是 Compare-and-Swap,也就是比较并交换,是并发编程中一种常用的算法。设计并发算法时常用到的一种技术,java.util.concurrent 包全完建立在CAS之上,没有CAS也就没有此包,可见CAS的重要性。当前的处理器基本都支持CAS,只不过不同的厂家的实现不一样罢了。

      CAS 有三个参数:V,A,B。内存值 V、旧的预期值 A、要修改的值 B,当且仅当预期值 A 和内存值 V 相同时,将内存值修改为 B 并返回 true,否则什么都不做并返回 false。CAS指令执行时,当且仅当V的值等于预期值A时,才会将V的值设为B。在多线程的情况下,当多个线程同时使用CAS操作一个变量时,只有一个会成功并更新值,其余线程均会失败,但失败的线程不会被挂起,而是不断的再次循环重试。正是基于这样的原理,CAS即时没有使用锁,也能发现其他线程对当前线程的干扰,从而进行及时的处理。可见CAS 其实是一个乐观锁。

    CAS操作大概有如下几步:

      1) 读取旧值为一个临时变量
      2) 对旧值的临时变量进行操作或者依赖旧值临时变量进行一些操作
      3) 判断旧值临时变量是不是等于旧值,等于则没被修改,那么新值写入;不等于则被修改,此时放弃或者从步骤1重试。
      那么步骤3实际上就是比较并替换,这个操作需要是原子性的,不然无法保证比较操作之后还没写入之前有其他线程操作修改了旧值。那么这一步实际上就是CAS(CompareAndSwap),其是需要操作系统底层支持,对于操作系统会转换为一条指令。

    Tips:原子操作,顾名思义,就是说像原子一样不可再细分不可被中途打断。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch(切换到另一个线程)

    CAS 也是通过 Unsafe 实现的,看下 Unsafe 下的三个方法:

    public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);
    public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);
    public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);

    就拿中间这个比较并交换 Int 值为例好了,如果我们不用 CAS,那么代码大致是这样的:

    public int i = 1;
    public boolean compareAndSwapInt(int j) {
        if (i == 1) {
            i = j;
            return true;
        }
        return false;
    }

      当然这段代码在并发下是肯定有问题的,有可能线程1运行完了if判断语句,正准备运行赋值语句时时间片用完,切换到线程2运行了,线程2把i修改为10,然后线程切换回去,线程1由于先前已经满足了if判断,所以导致两个线程同时修改了变量 i。

      解决办法也很简单,给 compareAndSwapInt 方法加锁同步就行了,这样,compareAndSwapInt 方法就变成了一个原子操作。CAS也是一样的道理,比较交换也是一次原子操作,不会被外部打断,先根据 paramLong/paramLong1 获取到内存当中当前的内存值V,在将内存值V和原值A作比较,要是相等就修改为要修改的值B,由于CAS都是硬件级别的操作,因此效率会高一些。

    用CAS分析AtomicInteger原理
    java.util.concurrent.atomic 包下的原子操作类都是基于 CAS 实现的,下面拿 AtomicInteger 分析一下,首先是 AtomicInteger 类变量的定义:

    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); }
    }
    //这里value用volatile关键字修饰,这一点很重要
    private volatile int value;

    关于这段代码中出现的几个成员属性:

    • Unsafe是CAS的核心类,前面已经讲过了。
    • valueOffset表示的是变量值在内存中的偏移地址,因为 Unsafe 就是根据内存偏移地址获取数据的原值的。
    • value 是用volatile修饰的,这是非常关键的。

    下面找一个方法来研究一下 AtomicInteger 是如何实现的,比如我们常用的addAndGet方法:

    public final int addAndGet(int delta) {
        for (;;) {
            int current = get();
            int next = current + delta;
            if (compareAndSet(current, next))
                return next;
        }
    }
    public final int get() {
             return value;
    }

    这段代码如何在不加锁的情况下通过 CAS 实现线程安全,我们不妨考虑一下方法的执行:

    1. 假设AtomicInteger 里面的value原始值为 3,即主内存中AtomicInteger的value为3,根据Java内存模型,线程1和线程2各自持有一份value的副本,值为3。
    2. 线程1运行到第三行获取到当前的value为3,线程切换。
    3. 线程2开始运行,获取到value 为3,利用CAS对比内存中的值也为3,比较成功,修改内存,此时内存中的value 改变比方说是 4,线程切换。
    4. 线程1恢复运行,利用CAS比较发现自己的value为3,内存中的value为4,得到一个重要的结论:此时 value 正在被另外一个线程修改,所以我不能去修改它。
    5. 线程1的compareAndSet失败,循环判断,因为value是volatile修饰的,所以它具备可见性的特性,线程2对于value 的改变能被线程1看到,只要线程1发现当前获取的value是4,内存中的value也是4,说明线程2对于value的修改已经完毕并且线程1可以尝试去修改它。
    6. 最后说一点,比如说此时线程 3 也准备修改value了,没关系,因为比较交换是一个原子操作不可被打断,线程3修改了value,线程1进行 compareAndSet的时候必然返回的 false,这样线程1会继续循环去获取最新的value并进行compareAndSet,直至获取的value和内存中的value 一致为止。

    整个过程中,利用 CAS 机制保证了对于 value 的修改的线程安全性。

    CAS的缺点
    CAS 看起来很美,但这种操作显然无法涵盖并发下的所有场景,并且 CAS 从语义上来说也不是完美的,

    • ABA的问题,就是一个值从A变成了B又变成了A,使用CAS操作不能发现这个值发生变化了,处理方式是可以使用携带类似时间戳的版本AtomicStampedReference
    • 性能问题,我们使用时大部分时间使用的是 while true 方式对数据的修改,直到成功为止。优势就是相应极快,但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。

    总结
      CAS是整个编程重要的思想之一。整个计算机的实现中都有CAS的身影。微观上看汇编的CAS是实现操作系统级别的原子操作的基石。从编程语言角度来看 CAS是实现多线程非阻塞操作的基石。宏观上看,在分布式系统中,我们可以使用CAS的思想利用类似Redis的外部存储,也能实现一个分布式锁。
      从某个角度来说架构就将微观的实现放大,或者底层思想就是将宏观的架构进行微缩。计算机的思想是想通的,所以说了解底层的实现可以提升架构能力,提升架构的能力同样可加深对底层实现的理解。计算机知识浩如烟海,但是套路有限。抓住基础的几个套路突破,从思想和思维的角度学习计算机知识。

  • 相关阅读:
    海康威视复赛题
    [转] A*寻路算法C++简单实现
    [转]程序进行性能分析工具gprof使用入门
    [转]KMP 算法
    boolalpha的用法和作用
    python与数据科学有多少“暧昧情事”?14个Q&A告诉你
    Python来袭,教你用Neo4j构建“复联4”人物关系图谱!
    深入理解BERT Transformer ,不仅仅是注意力机制
    Python开发者年度调研,结果出乎意料!
    R和Python,对抗or融合?
  • 原文地址:https://www.cnblogs.com/myseries/p/10773664.html
Copyright © 2011-2022 走看看