CAS 概述

发布时间 2023-04-14 15:18:54作者: 变体精灵

一、案例

在介绍 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 问题,改用传统的互斥同步可能比原子类更加高效