前言
在并发编程时,常常会出现线程安全问题,那么如何保证原子性呢?常用的方法就是加锁。在Java语言中可以使用 Synchronized和CAS实现加锁效果。
Synchronized关键字保证同步的,这会导致有锁,但是锁机制存在以下问题:
在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
一个线程持有锁会导致其它所有需要此锁的线程挂起。
如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。
而volatile是不错的机制,但是volatile不能保证原子性。因此对于同步最终还是要回到锁机制上来。
独占锁是一种悲观锁,synchronized就是一种独占锁,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。乐观锁用到的机制就是CAS,Compare and Swap。
一、什么是CAS?
1.1 概念:
CAS全称Compare and swap,字面意思:"比较并交换",它是一条 CPU 并发原语,用于判断内存中某个值是否为预期值,如果是则更改为新的值,这个过程是原子的。
1.2 实现步骤
具体步骤如下所示:
- 一个初始值变量V,值为5;一开始先读取V实际内存中的值赋值给E。
- 比如我们需要给最原始的V+1操作,那么此时用E+1来进行操作(这是防止V在其他线程已经被改变),这样完成了U=E+1的操作。
- 判断E和V的值是否一致,如果一致则证明在以上操作过程中V没有被其他线程改变则将U的值赋值给V,如果不一致那V就被其他改变了,这样给U的+1操作就不成立,返回当前的V。
1.3 CAS 伪代码
bash
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
1.4 实现自旋锁
基于 CAS 实现更灵活的锁,获取到更多的控制权。
bash
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
二、CAS中的问题
2.1 ABA问题
ABA 是 CAS 操作的一个经典问题,假设有一个变量初始值为 A,修改为 B,然后又修改为 A,这个变量实际被修改过了,但是 CAS 操作可能无法感知到。
假设存在两个线程 t1 和 t2,有一个共享变量 num,初始值为 A。接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要
- 先读取 num 的值, 记录到 oldNum 变量中;
- 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是, 在 t1 执行这两个操作之间,t2 线程可能把 num 的值从 A 改成了 B,又从 B 改成了 A。
如果是整形还好,不会影响最终结果,但如果是对象的引用类型包含了多个变量,引用没有变实际上包含的变量已经被修改,这就会造成大问题
2.2 ABA问题带来的BUG
大部分的情况下,t2 线程这样的一个反复横跳改动,对于 t1 是否修改 num 是没有影响的,但是不排除一些特殊情况。
我们接下来举一个银行取款大的例子:
假设 小明 有 100 存款,小明想从 ATM 取 50 块钱。取款机创建了两个线程, 并发的来执行 -50 操作。我们期望一个线程执行 -50 成功,另一个线程 -50 失败。如果使用 CAS 的方式来完成这个扣款过程就可能出现问题。
正常过程:
- 存款 100,线程1 获取到当前存款值为 100,期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50;
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中;
- 轮到线程2 执行了,发现当前存款为 50, 和之前读到的 100 不相同, 执行失败。
异常的过程
- 存款 100,线程1 获取到当前存款值为 100,期望更新为 50; 线程2 获取到当前存款值为 100,期望更新为 50;
- 线程1 执行扣款成功,存款被改成 50,线程2 阻塞等待中;
- 线程2 执行之前,小明的朋友正好给小明转账 50,账户余额变成 100;
- 轮到线程2 执行了,发现当前存款为 100,和之前读到的 100 相同,再次执行扣款操作。
这个时候, 扣款操作被执行了两次! 都是 ABA 问题导致的结果。
2.3 ABA解决方案
给要修改的值,引入版本号,在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。
- CAS 操作在读取旧值的同时,也要读取版本号。
- 真正修改的时候,
如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 +1;
如果当前版本号高于读到的版本号,就操作失败(认为数据已经被修改过了)。
我们依然举一个银行取款的例子:
为了解决 ABA 问题, 给余额搭配一个版本号, 初始设为 1:
- 存款 100, 线程1 获取到 存款值为 100,版本号为 1,期望更新为 50; 线程2 获取到存款值为 100,版本号为 1,期望更新为 50。
- 线程1 执行扣款成功,存款被改成 50,版本号改为2。线程2 阻塞等待中。
- 在线程2 执行之前, 小明的朋友正好给小明转账 50, 账户余额变成 100, 版本号变成3.
- 轮到线程2 执行了,发现当前存款为 100,和之前读到的 100 相同, 但是当前版本号为 3,之前读 到的版本号为 1,版本小于当前版本, 认为操作失败。
总结
讲解下自己理解的 CAS 机制
全称 Compare and swap, 即 "比较并交换", 相当于通过一个原子的操作, 同时完成 "读取内存, 比较是否相等, 修改内存" 这三个步骤. 本质上需要 CPU 指令的支撑。
ABA问题如何解决
给要修改的数据引入版本号, 在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。如果发现当前版本号和之前读到的版本号一致,就真正执行修改操作,并让版本号自增;如果发现当前版本号比之前读到的版本号大, 就认为操作失败。