一、案例
在介绍 CAS 之前先看一段代码
/** * @Author summer * @Description * @CreateDate 2023-04-13 15:58 */ @Slf4j public class VolatileDemo { // 定义 volatile 变量保证可见性、禁止指令重排 private volatile static int race; // 定义线程数组个数 private static final int THREAD_COUNT = 20; // 定义循环次数 private static final int CIRCLE_COUNT = 1000; public static void increase() { race++; } public static void main(String[] args) throws InterruptedException { Thread[] threadGroup = new Thread[THREAD_COUNT]; for (int i = 0; i < threadGroup.length; i++) { // 初始化线程组的每一个线程元素 threadGroup[i] = new Thread(() -> { for (int j = 0; j < CIRCLE_COUNT; j++) { increase(); } }); threadGroup[i].start(); } // 主线程等待线程组中的每个线程执行完任务 for (int i = 0; i < threadGroup.length; i++) { threadGroup[i].join(); } log.info("race: {}", race); } }
测试结果
20 个线程,每个线程执行 1000 次 race++ 操作,预期值应该是 20000,但是经过多次测试, race 都是一个小于 20000 的数值(有极小概率等于 20000)
这个时候大家可能有疑惑,race 变量使用了 volatile 关键字修饰了呀,为什么结果总是不符合预期呢?
volatile 的语义可以保证可见性、禁止指令重排,但是它不能保证原子性
看一下 increase() 方法的字节码指令
public static void increase(); descriptor: ()V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=0, args_size=0 0: getstatic #2 // 将静态变量 race 的值压入操作数栈顶 3: iconst_1 // 将常量值 1 压入操作数栈的栈顶,此时 race 的值会从栈顶的位置变成次栈顶 4: iadd // 将操作数栈栈顶的操作数 1 和 次栈顶的操作数(race 的值) 弹出操作数栈,并进行相加操作,把相加得到的结果重新存放在操作数栈的栈顶位置 5: putstatic #2 // 将操作数栈栈顶的元素弹出操作数栈,刷新到主内存中 8: return LineNumberTable: line 20: 0 line 21: 8
在代码层面 race++ 只有一行,但是对应的字节码指令却有 4 条,假设各个线程的执行顺序如下
时间 | 线程 1 | 线程 2 |
t1 | 从内存中获取 race 的值(此时 race 为 0) | |
t2 | 从内存中获取 race 的值(此时 race 为 0) | |
t3 | 将 race 的值压入操作数栈栈顶 | |
t4 | 将 race 的值压入操作数栈栈顶 | |
t5 | 将常量 1 压入操作数栈栈顶 | |
t6 | 将常量 1 压入操作数栈栈顶 | |
t7 | 将操作数栈栈顶和次栈顶元素弹出操作数栈并执行相加操作 | |
t8 | 将操作数栈栈顶和次栈顶元素弹出操作数栈并执行相加操作 | |
t9 | 将得到的结果 1 刷新到主内存 | |
t10 | 将得到的结果 1 刷新到主内存 |
从上面的结果可以看出,两个线程执行两次 race++ 操作,预期的结果是 2,但是最终的结果是 1,导致我们不能获取到正确结果的原因是 race++ 不是原子性操作
那么如何解决呢,我们首先想到的是使用 synchronized 来修饰 increase() 方法,使整个 race++ 操作保证原子性,但是 synchronized 是基于线程阻塞来实现的,在并发场景下会有大量的 CPU 上下文切换,性能较差
那有没有比较好的解决方案呢,答案是有的,这就是我们下面要说的 CAS
二、CAS 概述
CAS(Compare And Swap) 即比较相同并交换,它是一条 CPU 并发原语,用于判断内存中某个位置的值是否与预期值相等,如果相等则将原来的值更改为新的值,如果不相等则不进行任何操作,由于整个过程是通过原语来实现的,具有原子性,不可被中断,并且整个过程是比较高效的,因为不会涉及到锁冲突与线程等待等问题
CAS 操作过程中包含三个运算值
V: 要读写的变量内存地址
A: 旧的预估值
B: 准备要设置成的新值
如果 V 这个内存地址上存放的变量的值等于旧的预估值 A,那么返回 true,然后把 V 这个内存地址上的值重新设置为新值 B
如果 V 这个内存地址上存放的变量的值不等于旧的预估值 A,那么返回 false
整个比较并替换的操作是一个原子性操作(由 CPU 并发原语保证)
其实 CAS 底层调用的是操作系统提供的 Atomic::cmpxchg 函数来实现功能的,该函数会根据当前系统是否是多处理器系统,如果是多处理器系统,那么将会添加 lock 前缀
三、CAS 使用场景
1、原子类
// AtomicInteger 原子类中方法 public final int getAndIncrement() { // this 为 AtomicInteger 的对象实例,valueOffset 为内存地址偏移量 return unsafe.getAndAddInt(this, valueOffset, 1); } // Unsafe 类中方法 public final int getAndAddInt(Object var1, long var2, int var4) { int var5; do { // 根据原子类的对象实例地址 var1 以及内存地址偏移量 var2 获取到变量值 var5 = this.getIntVolatile(var1, var2); // 1、根据对象内存地址 var1、偏移量 var2 重新获取一次变量的值 // 2、如果该值与上一次获取的变量值 var5 相同,则进行加 1 操作,退出循环 // 3、如果该值与上一次获取的变量值 var5 不同,则重新循环执行一次上述步骤 } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4)); return var5; }
2、自旋锁
四、CAS 优缺点
CAS 优点
既然是优点,那么可以从 synchronized 的缺点来反推出 CAS 的优势,下面就来说一下使用 synchronized 有如下缺点
1、synchronized 是基于线程阻塞来实现的,当某个线程试图访问共享资源时,如果临界资源被其它线程获取(其它线程获取到锁),当前线程只能阻塞等待,直到其它线程释放锁为止
2、对于线程频繁而短暂持有锁的场景,CPU 将会消耗大量时间进行上下文切换,而真正用在控制和运算的时间很少,浪费 CPU 性能
而 CAS 则更加的灵活与轻量,对于执行很短的任务来说,CPU 只需要多次自旋就可以获取到锁,从而进行可靠的操作,整个过程不会有锁冲突、线程阻塞等待的问题
CAS 缺点:
1、只能保证一个共享变量的原子操作
传统 CAS 只能对一个变量保证原子操作,而对多个变量则要使用锁
解决此类问题,可以通过把多个共享变量合并成一个共享变量来操作,如 i = 2,j = a,可以合并为 ij = 2a,ij 既可用 CAS 来操作,也可以使用 jdk 提供的 AtomicReference 工具
2、循环开销大
CAS 通常是配合无限循环来一起使用的,我们可以看到 getAndAddInt 方法执行时,如果 CAS 失败,会一直进行尝试,如果 CAS 长时间一直不成功,可能会给 CPU 带来很大的开销
注意: CAS 适用于多核 CPU、任务执行时间短、竞争不激烈的场景
3、ABA 问题
假设 t1 时间节点我们观测到变量 A 的值是 3,t4 时间节点观测到变量 A 的值也是 3,我们无法确定变量 A 的值是否曾经发生过变化(也就是不能确定是上面两种情况的哪一种),这样便会导致一些问题,下面以银行转账为例
时间节点 | 线程 1 | 线程 2 | 线程 3 |
t1 | 查询余额 1000 | ||
t2 | 查询余额 1000 | ||
t3 | 查询余额 1000 | ||
t4 | CAS 比较余额 1000 成功,扣款 300,余额 700 | ||
t5 | CAS 比较余额 1000 失败,余额 700 | ||
t6 | 外部账户转入 300 | ||
t7 | CAS 比较余额 1000 成功,扣款 300,余额 700 |
从上面的例子中可以看出,实际余额 1000,外部转入 300 之后,线程 3 还是可以扣款成功,这就是典型的由于 ABA 问题导致的重复扣款
那么怎么解决 ABA 问题呢
Java 并发包为了解决这个问题,提供了一个带有标记的原子引用类 AtomicStampedReference,它可以通过控制变量值的版本号来保证 CAS 的正确性,因此在使用 CAS 前要考虑清楚 ABA 问题是否会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能比原子类更加高效
标签:操作数,变量,CAS,int,race,线程,概述 From: https://www.cnblogs.com/xiaomaomao/p/17284536.html