什么是 CAS
CAS: 全称Compare and swap,字面意思:”比较并交换“,一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。
上述这个 CAS 过程并非是通过一段代码实现的,而是通过一条 CPU指令(是原子性的)完成的
CAS 伪代码
下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解
CAS 的工作流程.
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue; return true;
}
return false;
}
CAS 有哪些应用
1) 实现原子类
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的. 典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
伪代码的实现:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
原子类这里的实现,每次修改之前,都要确认这个值是否符合要求。
假设两个线程同时调用 getAndIncrement
1) 两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
2) 线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.
注意:
CAS 是直接读写内存的, 而不是操作寄存器.
CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的.
3) 线程2 再执行 CAS 操作, 在进行 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环.
在循环里重新读取 value 的值赋给 oldValue
4) 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.
5) 线程1 和 线程2 返回各自的 oldValue 的值即可.
通过形如下述代码就可以实现一个原子类. 不需要使用重量级锁, 就可以高效的完成多线程的自增操作.
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo28 {
public static void main(String[] args) throws InterruptedException {
// 这些原子类, 就是基于 CAS 实现了 自增, 自减等操作. 此时进行这类操作不需要加锁, 也是线程安全的.
AtomicInteger count = new AtomicInteger(0);
// 使用原子类, 来解决线程安全问题.
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// 因为 java 不支持运算符重载, 所以只能使用普通方法来表示自增自减.
count.getAndIncrement(); // count++
// count.incrementAndGet(); // ++count
// count.getAndDecrement(); // count--
// count.decrementAndGet(); // --count
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
2) 实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
伪代码的实现:
public class SpinLock {
private Thread owner = null; //表示当前的锁是谁加的
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())) {
// 在 Java 中并不是直接提供了一个方法叫 CAS,而是一种简化表达方式
// 监测当前的 owner 是否为 null,如果是那么就进行交换,即赋值,循环结束
// 如果不是就返回 false,循环继续,并进行下次判定
}
}
public void unlock (){
this.owner = null;
}
}
CAS 的 ABA 问题
ABA 的问题:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A. 接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要先读取 num 的值, 记录到 oldNum 变量中. 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z. 但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这
个时候 t1 究竟是否要更新 num 的值为 Z 呢? 这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机.
ABA 问题引来的 BUG
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.
假设 光头强 有 100 存款. 他想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50
操作. 我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
使用 CAS 的方式来完成这个扣款过程
正常的过程
1) 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
望更新为 50.
2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
3) 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.
异常的过程
1) 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
望更新为 50.
2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
3) 在线程2 执行之前, 光头强的朋友正好给他转账 50, 账户余额刚好又变成 100 !
4) 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
这个时候, 扣款操作被执行了两次!!!
解决方案
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. CAS 操作在读取旧值的同时, 也要读取版本号. 真正修改的时候, 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1. 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).
这就好比, 判定这个手机是否是翻新机, 那么就需要收集每个手机的数据, 第一次挂在电商网站上的手机记为版本1, 以后每次这个手机出现在电商网站上, 就把版本号进行递增. 这样如果买家不在意这是翻新机, 就买. 如果买家在意, 就可以直接略过.
对比理解上面的转账例子
假设 光头强 有 100 存款. 他想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作. 我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1.
1) 存款 100. 线程1 获取到 存款值为 100, 版本号为 1, 期望更新为 50; 线程2 获取到存款值为 100,版本号为 1, 期望更新为 50.
2) 线程1 执行扣款成功, 存款被改成 50, 版本号改为2. 线程2 阻塞等待中.
3) 在线程2 执行之前, 光头强的朋友正好给他转账 50, 账户余额变成 100, 版本号变成3.
4) 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 但是当前版本号为 3, 之前读
到的版本号为 1, 版本小于当前版本, 认为操作失败.
相关面试题
1) 讲解下你自己理解的 CAS 机制
全称 Compare and swap, 即 "比较并交换". 相当于通过一个原子的操作, 同时完成 "读取内存, 比 较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑.
2) ABA问题怎么解决?
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期. 如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败.