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

简介: 锁是解决并发问题的万能钥匙,可是并发问题只有锁能解决吗?显然不是,无锁也可以!今天要出场一个大BOSS:CAS无锁算法,可谓是并发编程核心中的核心!

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

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

_1

温故

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

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

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

_2

本文来源于微信公众号【胖滚猪学编程】、以漫画形式让编程so easy and interesting!转载请注明出处

CAS概念

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

CAS算法包含 3 个参数 CAS(V,E,N),V表示要更新变量在内存中的值,E表示旧的预期值,N表示新值。
仅当 V值等于E值时,才会将V的值设为N
如果V值和E值不同,则说明已经有其他线程做两个更新,那么当前线程不做更新,而是自旋。

_3

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

image.png

原子类的使用可谓非常简单,相信只要看一下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代码:

image.png
image.png

个人认为没必要深究,毕竟术业有专攻,你只需要知道其实核心代码就是一条 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

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

相关文章
|
SQL 关系型数据库 数据管理
Datahub实践——Sqllineage解析Sql实现端到端数据血缘
Datahub实践——Sqllineage解析Sql实现端到端数据血缘
2664 1
|
消息中间件 Java Spring
SpringBoot实现RabbitMQ的简单队列(SpringAMQP 实现简单队列)
SpringBoot实现RabbitMQ的简单队列(SpringAMQP 实现简单队列)
208 1
|
12月前
|
消息中间件 安全 Java
云消息队列RabbitMQ实践解决方案评测
一文带你详细了解云消息队列RabbitMQ实践的解决方案优与劣
304 115
|
11月前
|
存储 NoSQL 算法
阿里面试:亿级 redis 排行榜,如何设计?
本文由40岁老架构师尼恩撰写,针对近期读者在一线互联网企业面试中遇到的高频面试题进行系统化梳理,如使用ZSET排序统计、亿级用户排行榜设计等。文章详细介绍了Redis的四大统计(基数统计、二值统计、排序统计、聚合统计)原理和应用场景,重点讲解了Redis有序集合(Sorted Set)的使用方法和命令,以及如何设计社交点赞系统和游戏玩家排行榜。此外,还探讨了超高并发下Redis热key分治原理、亿级用户排行榜的范围分片设计、Redis Cluster集群持久化方式等内容。文章最后提供了大量面试真题和解决方案,帮助读者提升技术实力,顺利通过面试。
|
12月前
|
安全 Java 数据库
shiro学习一:了解shiro,学习执行shiro的流程。使用springboot的测试模块学习shiro单应用(demo 6个)
这篇文章是关于Apache Shiro权限管理框架的详细学习指南,涵盖了Shiro的基本概念、认证与授权流程,并通过Spring Boot测试模块演示了Shiro在单应用环境下的使用,包括与IniRealm、JdbcRealm的集成以及自定义Realm的实现。
264 3
shiro学习一:了解shiro,学习执行shiro的流程。使用springboot的测试模块学习shiro单应用(demo 6个)
|
负载均衡 算法 应用服务中间件
Nginx入门 -- 理解 Nginx 的请求处理流程
Nginx入门 -- 理解 Nginx 的请求处理流程
680 1
|
11月前
|
Java 数据库连接 数据库
如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面
本文介绍了如何构建高效稳定的Java数据库连接池,涵盖连接池配置、并发控制和异常处理等方面。通过合理配置初始连接数、最大连接数和空闲连接超时时间,确保系统性能和稳定性。文章还探讨了同步阻塞、异步回调和信号量等并发控制策略,并提供了异常处理的最佳实践。最后,给出了一个简单的连接池示例代码,并推荐使用成熟的连接池框架(如HikariCP、C3P0)以简化开发。
287 2
|
11月前
|
Java 数据库连接 数据库
深入探讨Java连接池技术如何通过复用数据库连接、减少连接建立和断开的开销,从而显著提升系统性能
在Java应用开发中,数据库操作常成为性能瓶颈。本文通过问题解答形式,深入探讨Java连接池技术如何通过复用数据库连接、减少连接建立和断开的开销,从而显著提升系统性能。文章介绍了连接池的优势、选择和使用方法,以及优化配置的技巧。
189 1
|
12月前
|
Java 编译器 Android开发
Kotlin教程笔记(28) -Kotlin 与 Java 混编
本系列教程笔记详细讲解了Kotlin语法,适合希望深入了解Kotlin的开发者。对于需要快速学习Kotlin的小伙伴,推荐查看“简洁”系列教程。本篇笔记重点介绍了Kotlin与Java混编的技巧,包括代码转换、类调用、ProGuard问题、Android库开发建议以及相互调用时的注意事项。
120 3
|
SQL Java 数据库连接
【Java笔记+踩坑】MyBatisPlus基础
MyBatisPlus简介、标准数据层开发CRUD、业务层继承IService、ServiceImpl、条件查询、LambdaQueryWrapper、id生成策略、逻辑删除、乐观锁@Version、代码生成器、ActiveRecord
【Java笔记+踩坑】MyBatisPlus基础

热门文章

最新文章