欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 新闻 > 资讯 > 多线程—— CAS 机制

多线程—— CAS 机制

2024/10/27 4:31:00 来源:https://blog.csdn.net/HKJ_numb1/article/details/143252872  浏览:    关键词:多线程—— CAS 机制

目录

·前言

一、CAS 概述

二、CAS 的应用

1.实现原子类

2.实现自旋锁

三、ABA 问题

1.ABA 问题概述

2.ABA 问题引发的 BUG

3.解决方案

·结尾


·前言

        在前面文章中介绍了线程安全这一个话题,其中多个线程对同一个变量进行修改的时候就很可能引发线程安全问题,为了保证在多线程环境下,对同一个数据修改不产生线程安全问题,我们使用到的策略就是加锁,在本篇文章中会介绍的 CAS 机制,就能以无锁的特性来保证多个线程修改同一个变量时的线程安全,那么 CAS 是什么,它是怎么实现的,又是如何做到不加锁及可保证线程安全,以及引入 CAS 会出现什么问题就是本文要重点讲解的内容了。

一、CAS 概述

        CAS 的全称是:Compare and swap,意思是“比较并交换”,它所要完成的工作就是比较和交换,下面我来用一段伪代码来表示一下 CAS 工作流程的逻辑,如下所示:

// 下面代码是一段伪代码(不能编译执行,只是用来表示逻辑)
// address 表示内存地址
// expected 表示一个寄存器
// swap 表示一个寄存器
// expectedValue 表示寄存器中的值
// swapValue 表示寄存器中的值
boolean CAS(address, expectedValue, swapValue) {// 比较 address 内存地址中的值是否和 expected 寄存器中的值相同if (&address == expectedValue) {// 相同,就把 swap 寄存器中的值和 address 内存地址中的值进行交换&address = swapValue;// 返回 truereturn true;}// 不相同,什么都不做,直接返回 falsereturn false;
}

         上面的代码用画图来表示,执行流程如下图所示:

        其实按上述代码的逻辑,swap 与 address 中的值并没有交换,这么表示也是因为我们往往只关注内存中最终的值是多少,至于寄存器中的值,一般用完之后就不要了。 

        那么以上的伪代码和图中内容就可以很好的体现了 CAS 工作流程的逻辑了,只不过我们需要注意的是,我们上述的代码并不是原子的,但是真实的 CAS 却是一条 CPU 指令就完成了上述工作,因为单个 CPU 指令本身就是原子的,所以使用 CAS 不涉及加锁,不会阻塞,在合理使用下也能保证线程安全,这属于多线程编程中的一种特殊技巧,也可以称为“无锁编程”。

二、CAS 的应用

        操作系统会对 CPU 指令进行封装,JVM 又会对操作系统提供的 API 再进行一层封装,由于 CAS 本身就是 CPU 指令,所以在 Java 中也有关于 CAS 的 API,关于 CAS 的 API 放在了 unsafe 类里,Java 的标准库中又对 CAS 进行了进一步的封装,并且提供了一些工具类,可以让我们直接使用。

1.实现原子类

        原子类,就是 Java 标准库对 CAS 进行进一步封装后提供的一种工具类,如下图所示:

        在原子类中可以看到,它对 Integer 和 Long 进行了封装,此时针对这样的对象再进行多线程修改,就是线程安全的了,不知道大家还记不记得前面介绍线程安全时的一个代码示例,就是利用两个线程分别对同一个变量分别进行自增 50000 次,当时用这个代码示例来演示了线程不安全的效果,后来我们通过加锁的方式解决了这样的问题,下面我就来使用原子类来写一个用两个线程对同一变量分别进行 50000 次自增的代码,来看看此时会不会出现线程安全问题,代码及运行结果如下所示:

import java.util.concurrent.atomic.AtomicInteger;public class ThreadDemo15 {// 不使用 int 而是使用原子类创建 resultpublic static AtomicInteger result = new AtomicInteger(0);public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for (int i = 0; i < 50000; i++) {// result.getAndIncrement() 就相当于 result++result.getAndIncrement();}});Thread t2 = new Thread(()->{for (int i = 0; i < 50000; i++) {result.getAndIncrement();}});// 启动 t1 与 t2 线程t1.start();t2.start();// 等待 t1 与 t2 线程执行结束t1.join();t2.join();// 输出得到的结果System.out.println("result = " + result);}
}

        如上图所示,使用原子类不会出现之前的问题,这是因为之前使用的 result++ 是三个指令,在多线程中的三个指令会穿插执行,所以会引起线程不安全,此处的 getAndIncrement 对变量的修改是一个 CAS 指令,天生就是原子的,就不会出现穿插执行这种问题了,并且这个代码不涉及加锁,不需要阻塞等待,更高效。

         那么上面的这段代码是如何做到把自增操作变成原子的呢?我们可以进入源码来尝试找寻一下答案,如下图所示:

        经过我的层层点击进入,发现涉及到自增操作核心代码的方法都由 native 关键字进行声明了,这里需要解释一下,使用 native 关键字声明的方法就表示当前方法的实现不在当前文件,而是使用其他语言(如 C 或 C++)实现的文件中,这样的话我们就无法看见其内部的一个具体的实现细节了,不过我可以写一个伪代码配合画图来简单描述一下其中的逻辑,如下所示:

// 下面代码是一段伪代码(不能编译执行,只是用来表示逻辑)
class AtomicInteger {// value 表示我们要进行自增操作的变量private int value;public int getAndIncrement() {// oldValue 表示放到寄存器中的值int oldValue = value;// CAS 进行比较交换while (CAS(value, oldValue, oldValue + 1) != true) {// value 与 oldValue 的值不相等,说明在此期间有其他线程修改了 value 的值// 更新 oldValue 的值与 value 相等,再次进行循环oldValue = value;}// 当 value 与 oldValue 相等,就将 oldValue+1 的值赋值给 value// 以此来实现 value 的自增操作return value;}
}

         伪代码中的 CAS 执行的逻辑在上面已经进行介绍过,下面我来以画图的形式来描述一下这段代码在多线程环境下如何保证的线程安全,如下图所示:

        之前我们写的 result++ 的代码之所以线程不安全是因为内存中 result 变了但是寄存器中的值没有跟着变,导致下面的修改操作出现错误,而使用 CAS 这种方式,就能确保识别出,内存的值是不是变了,只有没变才能进行修改,变了,就需要重新读取内存中的值,确保当前是基于内存中最新的值再进行修改,这种方式也就巧妙的解决了之前线程安全的问题。 

        为了确保当前读取到的值是内存中最新值付出的代价就是“自选”,如果不是最新值就不断的循环判断,直到是最新值后再进行修改,此时就会消耗更多的 CPU 资源。

2.实现自旋锁

        上面我们介绍了 CAS 保证线程安全是通过自旋的方式不断读取内存的值来实现的,下面我继续用一个伪代码来介绍一下 CAS 是如何实现的自旋锁,如下所示:

class SpinLock {// owner 锁对象private Thread owner = null;// 加锁操作public void lock() {// 通过 CAS 来查看当前锁是否被其他线程持有// 如果当前这个锁已经被其他线程持有,就会进入自旋等待// 如果当前这个锁没有被其他线程持有,就把 owner 设为当前尝试加锁的线程while (CAS(this.owner, null, Thread.currentThread())) {}}// 解锁操作public void unlock() {// 将 owner 的引用指向 null,表示当前没有线程持有 ownerthis.owner = null;}
}

         自旋锁一般用于锁竞争不激烈的情况下,在上述代码中,当 owner 不为 null 的时候,循环就会一直执行,通过这样的“忙等”来完成等待的效果,此处自旋式的等没有放弃 CPU,不会参与到调度中,省去了调度开销,但是会消耗更多 CPU 的资源。

三、ABA 问题

1.ABA 问题概述

        ABA 问题描述的是如下图所示的场景:

        图中表示的场景就是 t1 线程想通过 CAS 操作将 num 的值修改为 100 ,执行 CAS 操作就需要经过以下两个步骤:

  1. 先读取 num 的值,记录到 oldNum 变量中;
  2. 使用 CAS 判断当前 num 的值是否为 0,如果为 0 就修改成 100。

        但是在此期间,还有一个线程 t2 ,在 t1 获取到 num 的值之后先对 num 修改到 100 然又将 num 的值改回到 0,此时对于线程 t1 执行的 CAS 操作中是无法区分当前这个 num 是否被修改过的,此时就出现了 ABA 问题,因为线程 t1 执行 CAS 操作的初衷是期望这里 num 的值不变就修改,但是 num 的值已经被 t2 线程修改了,只不过又给改回去了,此时 t1 线程就不清楚是否要修改 num 的值为 100 了。这种问题就好比我们买了一款新手机,无法分辨这是由别人使用过重新翻新的还是他原本就是新的。 

2.ABA 问题引发的 BUG

        关于 ABA 问题引发的 BUG 可以举一个非常极端的例子,比如我们在学校使用一卡通进行买饭操作,假设当前一卡通余额有 100 元,买饭需要扣除 20 元,刷卡时刷了第一遍卡机没反应,然后又刷了一遍,此时卡机产生了两个线程去执行将一卡通中 100 元减去 20 元的操作,如果这个操作使用的是 CAS 的方式来完成就会出现问题,此时正常的流程如下所示:

  1. 一卡通中余额 100 ,线程 t1 获取到当前余额 100,期望修改为 80;线程 t2 获取到当前一卡通中余额 100,期望修改为 80;
  2. 线程 t1 执行扣款成功,余额被修改为80,此时线程 t2 正在阻塞等待;
  3. 轮到线程 t2 执行,发现当前余额为 80 和之前的 100 不相同,执行失败。

        按正常的流程,不会出现什么问题,但是如果在执行扣款的过程中,你的同学张三因为上次借你一卡通吃饭,没给你钱,索性直接在手机上往你一卡通中充钱,并且金额正好就是 20,此时执行扣款的流程就会出现问题,那么异常的流程如下所示:

  1. 一卡通中余额 100 ,线程 t1 获取到当前余额 100,期望修改为 80;线程 t2 获取到当前一卡通中余额 100,期望修改为 80;
  2. 线程 t1 执行扣款成功,余额被修改为80,此时线程 t2 正在阻塞等待;
  3. 在线程 t2 执行之前,张三给你的一卡通中充了 20 元,此时一卡通余额变成 100;
  4. 轮到线程 t2 执行,发现当前余额为 100 与之前获取到的余额相同,再次执行扣款操作。

        如上述流程所示,此时扣款操作就被执行了两次,这就是 ABA 问题引起的。 

3.解决方案

        对于 ABA 问题的解决方案有以下两种:

  1. 约定数据变化是单向的(只能增加或者只能减少),不能是双向的(又能增加又能减少),这样就不会出现修改一个数据反复横跳的场景了;
  2. 对于本身就必须双向变化的数据,可以引入一个版本号,版本号这个数字只能增加不能减少,此时就可以根据版本号来判断当前数字是不是第一次被修改。

        引入版本号之后,对于上述异常的流程就会变成以下的执行过程:

  1.  一卡通中余额 100 ,线程 t1 获取到当前余额 100,版本号为 1,期望修改为 80;线程 t2 获取到当前一卡通中余额 100,版本号为 1,期望修改为 80;
  2. 线程 t1 执行扣款成功,余额被修改为80,版本号为 2,此时线程 t2 正在阻塞等待;
  3. 在线程 t2 执行之前,张三给你的一卡通中充了 20 元,此时一卡通余额变成 100,版本号变成 3;
  4. 轮到线程 t2 执行,发现当前余额为 100 与之前获取到的余额相同,但是当前版本号为 3,之前读到的版本号 1,版本号小于当前版本,就认为当前操作失败。

        此时就不会再出现扣款两次的 bug 了。 

·结尾

        文章到此就要结束了,CAS 机制在我们进行多线程编程中有着重要的作用,它可以使我们在多个线程修改同一变量的过程中不加锁就可以达到线程安全的效果,从而达到无锁编程和提高系统性能的效果,了解 CAS 的机制可以拓宽我们的编程思维,但是在使用 CAS 的过程中也不要忽略他可能会引起的 ABA 问题,理解 ABA 问题出现的原因,也可以为我们提供一种检查代码问题的角度,如果本篇文章对你有所帮助,还是希望能得到你的三连支持,如果对文章内容还有所疑惑,可以在评论区进行留言,我们下一篇文章再见吧~~~

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com