20

CAS原理分析!无锁原子类也能解决并发问题!

 4 years ago
source link: http://www.cnblogs.com/liuyanling/p/12913356.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本文来源于微信公众号【胖滚猪学编程】、转载请注明出处

在漫画并发编程系统博文中,我们讲了N篇关于锁的知识,确实,锁是解决并发问题的万能钥匙,可是并发问题只有锁能解决吗?今天要出场一个大BOSS:CAS无锁算法,可谓是并发编程核心中的核心!

emMBziu.jpg!web

温故

首先我们再回顾一下原子性问题的原因,参考 【漫画】JAVA并发编程 如何解决原子性问题

iuYF3uV.png!web

两个线程同时把count=0加载到自己的工作内存,线程B先执行count++操作,此时主内存已经变化成了1,但是 线程A依旧以为count=0,这是导致问题的根源

所以解决方案就是: 不能让线程A以为count=0 ,而是要和主内存进行一次 compare(比较) ,如果内存中的值是0,说明没有其他线程更新过count值,那么就 swap(交换) ,把新值写回主内存。如果内存中的值不是0,比如本案例中,内存中count就已经被线程B更新成了1,比较0!=1,因此compare失败,不把新值写回主内存。

fqMNfiy.jpg!web

本文来源于微信公众号【胖滚猪学编程】。一个集颜值与才华于一身的女程序媛、以漫画形式让编程so easy and interesting!转载请注明出处

CAS概念

CAS (compareAndSwap),中文叫比较交换,一种 无锁原子算法

CAS算法包含 3 个参数 CAS(V,E,N),V表示要更新变量在内存中的值,E表示旧的预期值,N表示新值。

仅当 V值等于E值时,才会将V的值设为N。

如果V值和E值不同,则说明已经有其他线程做两个更新,那么当前线程不做更新,而是自旋。

2YJnqqj.jpg!web

模拟CAS实现

既然我们了解了CAS的思想,那可以手写一个简单的CAS模型:

// count必须用volatile修饰 保证不同线程之间的可见性
    private volatile static int count;
    
    public void addOne() {
        int newValue;
        do {
            newValue = count++;
        } while (!compareAndSwapInt(expectCount, newValue)); //自旋 循环
    }

    public final boolean compareAndSwapInt(int expectCount, int newValue) {
        // 读目前 count 的值
        int curValue = count;
        // 比较目前 count 值是否 == 期望值
        if (curValue == expectCount) {
            // 如果是,则更新 count 的值
            count = newValue;
            return true;

        }
        //否则返回false 然后循环
        return false;
    }

这个简单的模拟代码,其实基本上把CAS的思想体现出来了,但实际上CAS原理可要复杂很多哦,我们还是看看JAVA是怎么实现CAS的吧!

原子类

要了解JAVA中CAS的实现,那不得不提到大名鼎鼎的原子类,原子类的使用非常简单,而其中深奥的原理就是CAS无锁算法。

Java 并发包里提供的原子类内容很丰富,我们可以将它们分为五个类别: 原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器。

JFnUFbi.png!web

原子类的使用可谓非常简单,相信只要看一下api就知道如何使用,因此不过多解释,如有需要可以参考本人github代码。

此处只以AtomicInteger为例子,测试一下原子类是否名副其实可以保证原子性:

private static AtomicInteger count = new AtomicInteger(0);
    private static int count1 = 0;
    //省略代码 同时启动10个线程 分别测试AtomicInteger和普通int的输出结果
    private static void add10K() {
        int idx = 0;
        while (idx++ < 10000) {
            //使用incrementAndGet实现i++功能
            count.incrementAndGet();
        }
        countDownLatch.countDown();
    }
    private static void add10K1() {
        int idx = 0;
        while (idx++ < 10000) {
            count1++;
        }
        countDownLatch.countDown();
    }

通过测试可以发现,使用AtomicInteger可以保证输出结果为100000,而普通int则不能保证。

本文来源于微信公众号【胖滚猪学编程】。一个集颜值与才华于一身的女程序媛、以漫画形式让编程so easy and interesting!转载请注明出处

CAS源码分析

据此,我们又可以回归正题,JAVA是怎么实现CAS的呢?跟踪一下AtomicInteger中的incrementAndGet()方法,相信就会有答案了。

首先关注一下AtomicInteger.java中这么几个东西:

private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;//数据在内存中的地址偏移量,通过偏移地址可以获取数据原值

    static {
        try {
            //计算变量 value 在类对象中的偏移量
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;//要修改的值 volatile保证可见性

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

Unsafe,是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。

变量valueOffset,表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。

变量value必须用volatile修饰,保证了多线程之间的内存可见性。

当然具体实现我们还是得瞧瞧getAndAddInt方法:

//内部使用自旋的方式进行CAS更新(while循环进行CAS更新,如果更新失败,则循环再次重试)
    public final int getAndAddInt(Object var1, long var2, int var4) {
         //var1为当前这个对象,如count.getAndIncrement(),则var1为count这个对象
        //第二个参数为AtomicInteger对象value成员变量在内存中的偏移量
        //第三个参数为要增加的值
        int var5;
        do {
            //var5 获取对象内存地址偏移量上的数值v 即预期旧值
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));//循环判断内存位置的值与预期原值是否相匹配

        return var5;
    }

此时我们还想继续了解compareAndSwapInt的实现,点进去看,首先映入眼帘的是四个参数:1、当前的实例 2、实例变量的内存地址偏移量 3、预期的旧值 4、要更新的值

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

还想继续刨根问底,会发现点不动了。因为用native修饰的方法代表是底层方法,当然如果你非得一探究竟你也可以找找对应的unsafe.cpp 文件进行深度解析C代码:

rQrqumY.png!webRB3EniV.png!web

个人认为没必要深究,毕竟术业有专攻,你只需要知道其实核心代码就是一条 cmpxchg 指令

cmpxchg: 即“比较并交换”指令。与我们上面说的思想是一样的:将 eax 寄存器中的值(compare_value)与 [edx] 双字内存单元中的值进行对比,如果相同,则将 ecx 寄存器中的值(exchange_value)存入 [edx] 内存单元中。

总之: 你只需要记住:CAS是靠硬件实现的,从而在硬件层面提升效率。实现方式是基于硬件平台的汇编指令,在intel的CPU中,使用的是cmpxchg指令。 核心思想就是: 比较要更新变量的值V和预期值E(compare),相等才会将V的值设为新值N(swap)。

CAS真有这么好吗?

CAS和锁都解决了原子性问题,和锁相比,由于其 非阻塞 的,它对死锁问题天生免疫,并且, 线程间的相互影响也非常小 。更为重要的是,使用无锁的方式完全 没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销 ,因此,他要比基于锁的方式拥有 更优越的性能

但是,CAS真的有那么好吗?又到挑刺时间了!

要让我们失望了,CAS并没有那么好,主要表现在三个方面:

  • 1、循环时间太长
  • 2、只能保证一个共享变量原子操作
  • 3、ABA问题。

循环时间太长

如果CAS长时间地不成功,我们知道会持续循环、自旋。必然会给CPU带来非常大的开销。在JUC中有些地方就限制了CAS自旋的次数,例如BlockingQueue的SynchronousQueue。

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

看了CAS的实现就知道这只能针对一个共享变量,如果是多个共享变量就只能使用锁了,当然如果你有办法把多个变量整成一个变量,利用CAS也不错。例如读写锁中state的高低位。

ABA问题

这可是个面试重点问题哦!认真听好!

CAS需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是A,变成了B,然后又变成了A,那么在CAS检查的时候会发现没有改变,但是实质上它已经发生了改变,这就是所谓的ABA问题。

某些情况我们并不关心 ABA 问题,例如数值的原子递增,但也不能所有情况下都不关心,例如原子化的更新对象很可能就需要关心 ABA 问题,因为两个 A 虽然相等,但是第二个 A 的属性可能已经发生变化了。

对于ABA问题其解决方案是加上版本号,即在每个变量都加上一个版本号,每次改变时加1,即A —> B —> A,变成1A —> 2B —> 3A。

原子类之AtomicStampedReference可以解决ABA问题,它内部不仅维护了对象值,还维护了一个Stamp(可把它理解为版本号,它使用整数来表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新版本号。当AtomicStampedReference设置对象值时,对象值以及版本号都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要版本号发生变化,就能防止不恰当的写入。

// 参数依次为:期望值 写入新值 期望版本号 新版本号
    public boolean compareAndSet(V expectedReference, V
            newReference, int expectedStamp, int newStamp);

    //获得当前对象引用
    public V getReference();

    //获得当前版本号
    public int getStamp();

    //设置当前对象引用和版本号
    public void set(V newReference, int newStamp);

说理论太多也没用,还是亲自实验它是否能解决ABA问题吧:

private static AtomicStampedReference<Integer> count = new AtomicStampedReference<>(10, 0);

    public static void main(String[] args) {
        Thread main = new Thread(() -> {
            int stamp = count.getStamp(); //获取当前版本

            log.info("线程{} 当前版本{}",Thread.currentThread(),stamp);
            try {
                Thread.sleep(1000); //等待1秒 ,以便让干扰线程执行
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            boolean isCASSuccess = count.compareAndSet(10, 12, stamp, stamp + 1);  //此时expectedReference未发生改变,但是stamp已经被修改了,所以CAS失败
            log.info("CAS是否成功={}",isCASSuccess);
        }, "主操作线程");

        Thread other = new Thread(() -> {
            int stamp = count.getStamp(); //获取当前版本
            log.info("线程{} 当前版本{}",Thread.currentThread(),stamp);
            count.compareAndSet(10, 12, stamp, stamp + 1);
            log.info("线程{} 增加后版本{}",Thread.currentThread(),count.getStamp());

            // 模拟ABA问题 先更新成12 又更新回10
            int stamp1 = count.getStamp(); //获取当前版本
            count.compareAndSet(12, 10, stamp1, stamp1 + 1);
            log.info("线程{} 减少后版本{}",Thread.currentThread(),count.getStamp());
        }, "干扰线程");

        main.start();
        other.start();
    }

输出结果如下:

线程Thread[主操作线程,5,main] 当前版本0
[干扰线程] INFO - 线程Thread[干扰线程,5,main] 当前版本0
[干扰线程] INFO  - 线程Thread[干扰线程,5,main] 增加后版本1
[干扰线程] INFO - 线程Thread[干扰线程,5,main] 减少后版本2
[主操作线程] INFO  - CAS是否成功=false

总结

JAVA博大精深,解决并发问题可不仅仅是锁才能担此大任。CAS无锁算法对于解决原子性问题同样是势在必得。而原子类,则是无锁工具类的典范,原子类包括五大类型(原子化的基本数据类型、原子化的对象引用类型、原子化数组、原子化对象属性更新器和原子化的累加器)。

CAS 是一种乐观锁,乐观锁会以一种更加乐观的态度对待事情,认为自己可以操作成功。而悲观锁会让线程一直阻塞。因此CAS具有很多优势,比如性能佳、可以避免死锁。但是它没有那么好,你应该考虑到ABA问题、循环时间长的问题。因此需要综合选择,适合自己的才是最好的。

附录: 并发编程全系列代码github

本文来源于微信公众号【胖滚猪学编程】。一个集颜值与才华于一身的女程序媛、以漫画形式让编程so easy and interesting!欢迎关注与我一起交流!

本文转载自公众号【胖滚猪学编程】 用漫画让编程so easy and interesting!欢迎关注!形象来源于微信表情包【胖滚家族】喜欢可以下载哦~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK