CAS原理及应用
本文讲解CAS(比较并交换)原理:一种乐观锁技术,通过原子指令实现无锁并发。它在更新变量时,先比较内存值是否与预期值相等,若相等则更新,否则重试。它性能高,但存在ABA问题和高并发下的CPU消耗。
我们来深入浅出地讲解一下 CAS 的原理。
CAS(Compare-and-Swap,比较并交换)是现代 CPU 广泛支持的一种原子指令。它是一种实现并发算法时用到的技术,通常被认为是乐观锁(Optimistic Locking)的一种实现方式。
1. 核心思想:先比较,再交换
CAS 操作包含三个核心操作数:
- V (Variable): 内存地址中的变量值。
- E (Expected): 预期的旧值。
- N (New): 准备要更新的新值。
执行过程如下:
当一个线程准备更新变量 V 时,它会执行一个 CAS 操作。这个操作是原子的,意味着它在执行过程中不会被其他线程打断。
- CPU 会首先读取内存地址
V处的当前值。 - 然后,将这个当前值与线程提供的预期值
E进行比较。 - 如果 当前值与预期值
E相等,说明从上次读取到准备写入的这段时间里,没有其他线程修改过这个变量。这时,CPU 就会将内存地址V的值更新为新值N。操作成功。 - 如果 当前值与预期值
E不相等,说明这个变量已经被其他线程修改了。这时,CPU 不会执行任何更新操作。操作失败。
整个“比较并交换”的过程由硬件保证其原子性,你不需要担心在这个过程中出现竞态条件。
2. 生活中的比喻
想象一下你在网上抢购最后一张演唱会门票:
- 读取库存: 你看到系统显示“库存:1”。(这相当于读取内存值
V,此时你的预期值E是 1)。 - 准备下单: 你准备提交订单,要把库存减到 0。(你的新值
N是 0)。 - 提交(CAS操作): 系统在处理你的订单时,会进行一次 CAS 操作:
- 检查库存是否还是 1? (比较
V和E) - 如果是,说明没人跟你抢,系统就把库存更新为 0。你抢票成功!
- 如果不是(比如变成了 0),说明就在你提交订单的瞬间,另一个人手速更快,已经买走了。你的操作失败,系统会提示你“票已售罄”。
- 检查库存是否还是 1? (比较
3. CAS 的应用:自旋锁(Spin Lock)
单独一次 CAS 操作如果失败了,通常不会就此罢休。在实际应用中,CAS 通常与一个循环结合使用,形成一种“自旋”或“重试”的机制。
基本模式(伪代码):
do {
// 1. 读取内存中的当前值
oldValue = memory.get();
// 2. 根据当前值计算出新值
newValue = calculateNewValue(oldValue);
// 3. 尝试用CAS更新
// 如果内存值仍然是oldValue,就更新为newValue
// success 会返回 true 或 false
success = cas(memory, oldValue, newValue);
} while (!success); // 如果不成功,就不断循环重试
这个循环过程就是所谓的自旋。线程不会被挂起(像 synchronized 那样),而是在原地“打转”,不断尝试更新,直到成功为止。
Java 中的例子:
Java 的 java.util.concurrent.atomic 包下的原子类,如 AtomicInteger、AtomicLong、AtomicReference 等,都是基于 CAS 实现的。
以 AtomicInteger 的 incrementAndGet() 方法为例,其内部实现(简化后)就是这样的:
public class AtomicInteger {
private volatile int value; // value 必须是 volatile 的,保证可见性
public final int incrementAndGet() {
for (;;) { // 无限循环,即“自旋”
int current = get(); // 1. 读取当前值
int next = current + 1; // 2. 计算新值
if (compareAndSet(current, next)) { // 3. CAS操作
return next; // 如果成功,返回新值
}
// 如果失败,循环会继续,重新读取、计算、尝试CAS
}
}
// compareAndSet 是一个native方法,底层调用CPU指令
public final native boolean compareAndSet(int expect, int update);
}
4. CAS 的优缺点
优点(为什么用它?):
- 高性能/乐观锁: 在并发冲突不激烈的情况下,线程不需要被挂起和唤醒。传统的锁(如
synchronized)涉及线程的上下文切换,开销较大。CAS 是一种“无锁”操作,线程可以一直保持运行状态,因此性能很高。 - 避免死锁: 由于没有“锁定”资源,只是在更新时进行比较,所以天然地避免了死锁问题。
缺点(有什么问题?):
ABA 问题(重要):
- 问题描述: 一个变量的值原来是 A,变成了 B,然后又变回了 A。当一个线程用 CAS 去检查时,它发现值仍然是 A(符合预期),于是执行了更新。但实际上,这个值已经被改变过了。在某些场景下,这会导致严重的问题。
- 例子: 你有 100 元,想取 50 元。你读取到余额是 100。在你准备操作时,有人给你转了 50 元(余额变 150),然后又取走了 50 元(余额变回 100)。你再执行 CAS 时,发现余额还是 100,操作成功,余额变成 50。但实际上,账户发生过变动,你可能错过了某些中间状态。
- 解决方案: 版本号机制。在变量旁边增加一个版本号。每次变量更新时,版本号也加一。CAS 操作不仅要比较值,还要比较版本号。Java 中的
AtomicStampedReference就是用来解决 ABA 问题的,它将值和“戳(stamp)”绑定在一起。
CPU 开销大: 如果并发冲突非常激烈,会导致大量线程反复“自旋”重试,这会持续消耗 CPU 资源,但并没有做有效工作。在这种情况下,性能可能还不如让线程挂起的传统锁。
只能保证单个共享变量的原子操作: CAS 只能对一个内存地址的变量进行原子操作。如果需要同时对多个变量进行原子更新,就需要使用更复杂的机制,比如将多个变量封装成一个对象,然后使用
AtomicReference来对这个对象进行 CAS 操作,或者直接使用synchronized或Lock。
总结
| 特性 | CAS (乐观锁) | synchronized (悲观锁) |
|---|---|---|
| 核心思想 | 假设没有冲突,直接尝试操作,如果失败再重试。 | 假设总有冲突,先获取锁,再执行操作。 |
| 性能 | 冲突少时性能高,无线程上下文切换。 | 冲突多时性能稳定,线程会休眠,让出CPU。 |
| CPU消耗 | 冲突多时,自旋会持续消耗CPU。 | 线程挂起,不消耗CPU,但切换有开销。 |
| 适用场景 | 读多写少,并发冲突不激烈的场景。 | 写操作多,并发冲突激烈的场景。 |
| 主要问题 | ABA问题,高并发下自旋的CPU开销。 | 可能导致死锁,锁的获取和释放有开销。 |
CAS 是构建现代高性能并发库(如 Java 的 JUC 包)的基石,理解它的原理对于深入学习并发编程至关重要。