【JavaEE】多线程 -- CAS机制(比较并交换)
目录
- CAS是什么
- CAS的应用
- 实现原子类
- 实现自旋锁
- ABA问题
- ABA问题概述
- ABA问题引起的BUG
- 解决方案
CAS是什么
- CAS (compare and swap) 比较并交换,CAS 是物理层次支持程序的原子操作。说起原子性,这就设计到线程安全问题,在代码的层面为了解决多线程并发处理同一共享资源造成的线程安全问题,我们常常会使用 synchronized 修饰代码块,变量等,将程序背后的指令封装成原子性(事务的最小单位,不可再分),当一个线程执行 synchronized 修饰的代码块时获取指定对象的对象锁(一个对象只有一把锁),其他并发处理同一代码块的线程因无法获取对象锁就会进入阻塞等待对象锁释放,然后继续竞争对象锁,此时 synchronized 修饰的代码块就具有原子性,具有互斥性,不可抢占性。
- CAS 是CPU 物理层次支持的原子操作(一条指令).
读取内存数据(V),预期原值(A)和新值(B)。
如果内存位置的值与预期原值相等,就将新值(B)更新到内存中,替换掉原内存数据,
如果内存位置的值与预期原值不相等,处理器不会做任何操作,
CAS 对数据操作后会返回一个 boolean 值判断是否成功。
- 以上指令集合,可以视为CPU 物理层次支持的一条指令,同样可以解决多线程并发处理同一共享资源造成的线程安全问题。
CAS 使用Java伪代码理解含义:
// address 原内存值
// expectValue 旧的预期值
// swapValue 需要修改的新值
// & 代表取地址,这里主要是理解这层含义,java 语法不支持
boolean CAS(address, expectValue, swapValue) {if (&address == expectedValue) {&address = swapValue;return true;}return false;
}
- 这么表示, 那么我们最终关心的是内存中的值, 至于寄存器的值一般都不要了
CAS的应用
- 操作系统会对 CPU 指令进行封装,JVM 又会对操作系统提供的 API 再进行一层封装,由于 CAS 本身就是 CPU 指令,所以在 Java 中也有关于 CAS 的 API,关于 CAS 的 API 放在了 unsafe 类里,Java 的标准库中又对 CAS 进行了进一步的封装,并且提供了一些工具类,可以让我们直接使用。
实现原子类
- 原子类,就是 Java 标准库对 CAS 进行进一步封装后提供的一种工具类,如下图所示:
- 在原子类中可以看到,它对 Integer 和 Long 进行了封装,此时针对这样的对象再进行多线程修改,就是线程安全的了,不知道大家还记不记得前面介绍线程安全时的一个代码示例,就是利用两个线程分别对同一个变量分别进行自增 50000 次,当时用这个代码示例来演示了线程不安全的效果,后来我们通过加锁的方式解决了这样的问题,下面我就来使用原子类来写一个用两个线程对同一变量分别进行 50000 次自增的代码,来看看此时会不会出现线程安全问题,代码及运行结果如下所示:
public class demo36 {private static AtomicInteger count = new AtomicInteger();public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(()->{for(int i = 0; i < 50000; i++) {count.getAndIncrement(); //前缀自增//count.incrementAndGet(); //后缀自增}});Thread t2 = new Thread(() -> {for (int i = 0; i < 50000; i++) {// count++;count.getAndIncrement();}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}
}
-
如上图所示,使用原子类不会出现之前的问题,这是因为之前使用的 result++ 是三个指令,在多线程中的三个指令会穿插执行,所以会引起线程不安全,此处的 getAndIncrement 对变量的修改是一个 CAS 指令,天生就是原子的,就不会出现穿插执行这种问题了,并且这个代码不涉及加锁,不需要阻塞等待,更高效。
- 那么上面的这段代码是如何做到把自增操作变成原子的呢?我们可以进入源码来尝试找寻一下答案,如下图所示:
- 层层点入到了native修饰的代码, 表示是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后这个线程是允许t2线程指令插队在t1线程指令前面的, 个时候他插队了那么肯定内存的值和寄存器的值一定不一样这, 那么我们就需要重新加载寄存器的值到内存中让寄存器和内存中的值保证一样. 这就和我们加锁不让t2线程插队, 执行的结果一样了.
- 为了确保当前读取到的值是内存中最新值付出的代价就是“自旋”,如果不是最新值就不断的循环判断,直到是最新值后再进行修改,此时就会消耗更多的 CPU 资源。
实现自旋锁
- 自旋锁一般用于锁竞争不激烈的情况下,在上述代码中,当 owner 不为 null 的时候,循环就会一直执行,通过这样的“忙等”来完成等待的效果,此处自旋式的等没有放弃 CPU,不会参与到调度中,省去了调度开销,但是会消耗更多 CPU 的资源。
ABA问题
ABA问题概述
- 图中的场景就是t1线程想把num值改成100, 这个时候执行过程如下
- 先读取 num 的值,记录到 oldNum 变量中;
- 使用 CAS 判断当前 num 的值是否为 0,如果为 0 就修改成 100。
- 但是在这个时候, t1线程修改到num之前, t2线程优先被调度了, t2线程把num修改到100后, 又修改为0 .这个时候t1线程希望改的num是没有变过的, 结果t2改变了又改回去了. t1线程就无法判断到底这个num值有没有被改动过.这种问题就好比我们买了一款新手机,无法分辨这是由别人使用过重新翻新的还是他原本就是新的。
ABA问题引起的BUG
解决方案
- 我们从上面的例子中看见, ABA问题出现的核心原因就是另一个线程对同一个变量进行修改, 进行改动(如加操作), 然后又改回去(如减操作), 那么当前线程就并不知道他是进行了对这个变量进行改动(已经执行了减操作)的. 所以再去进行减操作.
- 为了解决这个问题. 我们约定不允许进行又进行加操作, 又进行减操作.这个时候另外一个线程对一个变量进行改动(如加操作), 就不可能还原原来变量的值(如减操作)对于本身就必须双向变化的数据,可以引入一个版本号,版本号这个数字只能增加不能减少,此时就可以根据版本号来判断当前数字是不是第一次被修改。
- 这个时候我们再进行上面的扣款操作
t1线程获取到当前余额是1000, 版本号为1, 期望扣款500. 也就是把余额修改为500. 这个时候调度到t2线程
t2线程获取当前余额是1000, 版本号为2, 余额修改为500(扣款成功). 调度到t3线程
t3线程获取当前余额是500, 版本号为3, 想对余额里面转账500. 修改余额为1000. 调度到t1
t1线程当前余额为1000, 与之前获取的余额相同. 但是当前版本号为3, 与之前的版本号1不同. 这个时候期望扣款500就失败了.