Skip to main content

什么是CAS?有哪些使用场景?

作者:程序员马丁

在线博客:https://open8gu.com

note

大话面试,技术同学面试必备的八股文小册,以精彩回答应对深度问题,助力你在面试中拿个offer。

回答话术

CAS (Compare And Swap)即比较并交换,我们假设现在某个变量 V 的值为 A,现在我们打算将其更新为 B,那么 CAS 就描述了这样一个原子性的动作

  1. 从指定内存地址获取变量 V 的旧值;
  2. 检查旧值 V 是否等于预期值 A;
  3. 如果等于,则将旧值 V 替换为新值 B。

在多线程环境下,我们通常可以让线程在循环中反复的去 CAS 某个变量直到成功为止,通过这种自旋行为,我们可以通过类似锁的效果来实现并发控制,这种锁机制被称为自旋锁。JUC 包下大部分基于 AQS 实现的锁都是自旋锁。

由于 CAS 操作本身由 CPU 级别的硬件直接提供支持,并且自旋过程中线程仍然处于运行状态,不需要考虑上下文切换问题,因此在一些锁竞争激烈,且每个线程仅会在短时间内持有锁的场景下,自旋锁相比起 Java 提供的基于 synchronized 关键字实现的重量级锁,更加灵活且高效

CAS 通常也用于实现数据库的乐观并发控制策略(即俗称的乐观锁)。具体来说,我们令原本的更新语句附加一个不断递增的版本号,每次更新时前先查询到最新的版本号,随后在更新时比较版本号是否为预期值,仅当匹配时才进行更新并且递增版本号,否则就在业务层不断重试,直到成功为止。在这个过程中,底层的 CAS 操作通过当前读保证版本号的可见性,并且通过行锁保证整个过程的原子性。

除上述特点外,CAS 也存在一些问题,比如,如果一个变量初次读取的时候是 A 值,它的值被改成了 B,然后又其他线程把 B 值改成了 A,而另一个早期线程在对比值时会误以为此值没有发生改变,但其实已经发生变化了,这就是 ABA 问题。它通常可以通过附加一个递增版本号解决,即每次 CAS 时同时 CAS 一个累加的版本号,通过不重复的版本号避免 ABA 问题。

问题详解

1. 什么是 CAS?

CAS(Compare And Swap),即比较并替换,从字面上来说,CAS 描述了这样一组原子性的操作:

  1. 从指定内存地址获取旧值 V;
  2. 检查旧值 V 是否等于预期值 A;
  3. 如果等于,则将旧值 V 替换为新值 B。

image.png

把上面这个步骤转换为代码,大概可以理解成下面这样:

public synchronized boolean cas(Target t, Object a, Object b) {
Object v = t.get();
if (v == a) {
t.set(b);
return true;
}
return false;
}

2. Unsafe.compareAndSwapObject

在 JUC 包下的各种并发工具中,通常会直接通过 Unsafe 类的 API 来进行 CAS 操作,这里我们以大家都比较熟悉的 AtomicInteger 为例:

public class AtomicInteger extends Number implements java.io.Serializable {

// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;

public final boolean compareAndSet(int expect, int update) {
// 通过 Unsafe 类的 compareAndSwapInt 进行 cas 操作
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
}

public final class Unsafe {
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
}

具体来说,就是提前通过 Unsafe 获取特定属性的内存地址,等到允许时直接通过 Unsafe 提供的 compareAndSwapInt 对该内存中的值进行比较并交换。

其中,Unsafe 中的 compareAndSwapInt 是个 native 方法,它将通过调用 JVM 底层的本地方法来保证整个操作过程的原子性。

某种程度来说,它也可以理解为是 Test-And-Set。

3. CAS 与自旋

单纯的 CAS 操作本身并没有意义,但是配合上自旋操作以后它就可以起到类似于锁的效果,从而与锁一样实现并发控制

比如,我们可以让多个线程在循环中反复的去 CAS 某个变量,CAS 成功的线程被视为获得到了锁,可以进行操作,而未成功的线程则在一个循环中继续 CAS,直到成功了为止。

以常见的“多个线程交替顺序打印数字”为例,如果我们使用 synchronized 机制,则代码如下:

private static volatile int COUNT = 0;
public static void main(String[] args) {
// 多线程顺序打印 0 ~ 99
int max = 100;
Object monitor = new Object();
new Worker(max, monitor).start();
new Worker(max, monitor).start();
new Worker(max, monitor).start();
new Worker(max, monitor).start();
ThreadUtil.sleep(1000);
}

@RequiredArgsConstructor
private static class Worker extends Thread {
private final int max;
private final Object monitor;
@Override
public void run() {
while (COUNT < max) {
synchronized (monitor) {
if (COUNT < max) {
System.out.println(COUNT);
COUNT++;
}
}
}
}
}

如果我们不使用 synchronized 机制,而是基于 CAS + 自旋,那么我们可以这么写:

private static volatile int COUNT = 0;
private static volatile int CURRENT = 0;

public static void main(String[] args) {
// 多线程顺序打印 0 ~ 99
int max = 100;
new Worker(max, 0, 1).start();
new Worker(max, 1, 2).start();
new Worker(max, 2, 3).start();
new Worker(max, 3, 0).start();
ThreadUtil.sleep(1000);
}

@RequiredArgsConstructor
private static class Worker extends Thread {
private final int max;
private final int id;
private final int nextId;
@Override
public void run() {
while (COUNT < max) {
if (CURRENT == id) {
System.out.println(COUNT);
COUNT++;
CURRENT = nextId;
}
}
}
}

在上面这段代码中,我们让多个线程反复的去比较 CURRENT 这个变量,当 CURRENT 等于预期值时,就打印数字并修改 CURRENT,否则就在死循环中空转,通过这种方式最终也可以实现与 synchronized 关键字一样的效果。

如果我们将这一段逻辑抽出来,理论上是可以基于此封装一个简单的锁工具,这个锁就是自旋锁。

4. 保证被修改变量的可见性

我们会发现,在上述示例代码中,需要在 CAS 过程中修改的变量往往会被 volatile 关键字修饰,实际上这是为了保证被修改变量的可见性

实际上,我们在程序里面让某个线程修改变量时,它不会直接修改主内存中的值,而是将其从主内存拷贝到线程的工作内存中进行修改,然后再将修改后的值写入主内存。

因此,在多线程环境中,由于它们彼此读写的工作内存互相隔离,所以即使 CAS 指令本身保证了原子性,但是由于无法保证修改后变量的可见性,因此依然可能会出现并发问题。

比如在并发环境下,A 线程要通过 CAS 将一个字符串变量 str 从 "c" 修改为 "a",而 B 线程要通过 CAS 将变量 str 从 "c" 修改为 "b":

private String str;

new Thread(() -> {
// 当 i == c 时,将其修改为 a
while(cas(str, "c", "a")) {
System.out.printlt("c -> a");
}
});

new Thread(() -> {
// 当 i == c 时,将其修改为 b
while(cas(str, "c", "b")) {
System.out.printlt("c -> b");
}
});

按照我们的预期,由于 CAS 保证原子性,因此两个线程理论上只会有一个成功,然而在实际环境下可能并非如此。由于 A 线程和 B 线程读写的实际上都是变量 str 在各自工作内存中的副本,因此两个线程可以同时对各自的变量副本进行 cas,最终两者的操作都成功。

针对这种情况,我们需要为在多个线程间共享的变量添加 volatile 关键字,此后 JVM 将会强制让线程直接从主内存读写变量值。此时 CAS 的原子性将会强制保证两个线程对变量 str 的读写操作顺序进行。

除保证可见性外,volatile 关键字还能用于静止指令重排,关于其具体功能与原理,请参见:volatile 关键字有什么用?

5. 自旋锁

自旋锁是 CAS 的最常见的应用场景,大部分情况下,CAS 总是作为自旋锁机制的一部分出现。

5.1. 监视器锁

在开始聊 Java 中的自旋锁前,我们需要对 Java 的监视器锁/对象锁,也就是 synchronized 的底层机制有所了解。

Java 中的一切对象在创建时就会创建一个对应的 MonitorObject,每个对象都通过各自对象头中的指针指向它。而每个 MonitorObject 都在内部维护了两个重要的属性:

  • _Owner:当前持有该锁的线程。
  • _EntryList:当前正在等待竞争该锁的线程的队列。

当多个线程尝试获取锁时,它们将会进入 _EntryList 队列,这些线程最终只有一个线程可以获得锁,获得锁线程将会把 _Owner 修改为自己,而其他线程则陷入阻塞状态,直到下一次竞争。这一套机制实际上就是 JDK8 之后我们常说的“重量级锁”。

这一套流程的问题在于,当程序运行在多核 CPU 的环境下时,由于被阻塞的线程会放弃 CPU 时间片,此时,如果线程竞争比较激烈,并且线程都只会持有线程很短一段时间时,就会造成频繁的上下文切换,这会带来许多额外的开销

在 JDK8 后,synchronized 也引入的锁升级的机制进行了优化,不再会一开始就加重量级锁,而是会先根据锁竞争情况,从独占锁升级到自旋锁,最后再升级为重量级锁。

5.2. 自旋/忙等待

而为了解决这个问题,人们引入了自旋/忙等待这个概念。简单的来说,就是让线程反复检查某个变量是否可用,由于线程在这一过程中保持运行状态,因此能够避免上下文切换的开销。

当然,有得必有失,由于自旋过程中线程不会释放资源,因此相比起传统的锁,这个行为会占用更多的 CPU 资源。举个例子,比如在多核 CPU 环境下,当有较多线程同时竞争一个“锁”时,如果持有“锁”的线程长时间不释放,那么其他的线程将会占用着 CPU 资源一直空转,如果严重的话就会影响其他线程甚至其他程序的正常运行。

image.png

5.3. 实现原理与特点

首先,CAS 是 CPU 原语,现代的 CPU 架构(比如 X86)普遍都直接提供了对应的指令,操作系统可以通过不同的函数去调用这些指令,而 JVM 则又对这些操作系统的 API 的进行了统一封装。

也就说,当我们在代码中进行 CAS 操作的时候,实际上可以直接从硬件层面获得支持。以英特尔家的 X86 处理器为例,当 CAS 操作开始前,对应执行的汇编指令前会增加 LOCK 前缀来锁定系统总线,使系统总线在汇编指令执行时无法访问相应的内存地址,从而保证仅有执行当前命令的核心可以读写该值。相比起基于 JVM 层面互斥锁实现的 synchronized 来说,基于 CAS 实现的自旋锁更加高效。

其次,由于自旋是非阻塞的,在尝试竞争锁(其实就是 CAS 修改信号量)的时候,即使竞争锁失败以后也不会让线程进入阻塞状态,此时它将处于运行状态继续竞争。因此在一些线程持有锁时间较短且锁竞争较为激烈的情况下,相比起 synchronized ,它能够避免因为线程调度带来诸如上下文切换等额外开销(不过会占用更多 CPU 资源),因此它也显得更加轻量。

虽然自旋是非阻塞的,但是不代表自旋锁会真的让线程一直自旋到拿到锁为止。所有基于 AQS 实现的自旋锁,在线程竞争锁失败进入等待队列后,都会通过 LockSupport.park 方法将其挂起,此时线程会进入 WAITING 状态。

实际上,在 JUC 包下就有一个大名鼎鼎的 AQS 抽象类,它就是一个基于 CAS 与自旋实现的同步工具模板类,JUC 下大部分 Lock 接口的实现类,实际上都是基于它实现的自旋锁。

总而言之,在 JDK8 之前,自旋锁们在上述特定的场景下表现出了比 synchronized 的重量级锁更好的效率和灵活性。

关于 AQS,请参见:什么是 AQS?

6. 乐观锁

我们常常会将乐观锁与自旋锁放在一起讨论,实际上这里的“乐观锁”并不是一种真的锁,按 Wiki 的说法,它实际上是一种 “乐观并发控制策略”,即在操作前并不先加锁,而是在失败后重试直到成功。这种策略通常也通过 CAS + 自旋来完成。

以常见的数据库乐观锁的解决方案来说,我们需要分为两步:

6.1. 基于版本号字段进行 CAS

首先,和大多数方案一样,我们总是需要为数据库表添加一个版本号或时间戳,当更新的时候,比较版本号是否一致,如果一致就更新,否则就失败。对应的 SQL 大致如下:

UPDATE 
example t
SET
t.name = ${name},
t.version = t.version + 1
WHERE
t.id = ${id}
AND t.version = ${version}

当前数据行的版本号与预期不一致的时候,返回的影响数据行数就会是 0,我们可以根据此判断是否成功。

上述的 SQL 实际上是一个非常典型的 CAS 操作。假如这是在 MySQL,那么这条 SQL 通过行锁保证了整个操作的原子性,又通过当前读保证了 version 字段的可见性,这是整个方案的能够实现的前提。

站在业务层的角度,整个流程是没有加锁的,但是如果要深入到数据库层面,那么为了保证 CAS 操作的原子性,行锁还是必要的。

6.2. 当更新失败时进行自旋

当 CAS 失败时,我们需要进行自旋,这意味着我们可能需要类似这样一段代码:

Integer id = 10086;
while(true) {
// 每次都查询最新的版本号
Example example = exampleDAO.getById(id);
example.setName("foo");
// 当成功更新时返回结果,否则进行自旋
if (tableDAO.updateById(table) > 0) {
return "success";
}
}

我们将会在一个循环总反复尝试更新,直到成功为止。

当然,在实际业务中,我们不会真的无限制的重试,而是会指定一个最大重试次数,当超过这个次数时直接失败,并根据情况进行补偿或通知。

7. ABA 问题

CAS 会面临 ABA 问题。简单的来说,即一个变量初次读取的时候是 A 值,它的值被改成了 B,然后又其他线程把 B 值改成了 A,而另一个早期线程在对比值时会误以为此值没有发生改变,但其实已经发生变化了,这就是 ABA 问题。

ABA 问题的危害在简单的测试代码中可能无法体现,但是在诸如转账或其他实际的业务场景可能会造成严重的问题。

关于此类问题,通常可以通过额外添加一个递增的版本号解决,具体可参见:如何解决 CAS-ABA 问题?