🥰🥰🥰来都来了,不妨点个关注叭!
👉博客主页:欢迎各位大佬!👈
文章目录
- 1. CAS 是什么
- 1.1 CAS 具体步骤
- 1.2 CAS 伪代码
- 2. CAS 的应用
- 2.1 实现原子类
- 2.1.1 AtomInteger 类
- 2.1.2 伪代码实现原子类
- 2.2 实现自旋锁
- 2.2.1 自旋锁是什么
- 2.2.2 伪代码实现自旋锁
- 3. CAS 的 ABA 问题
- 3.1 ABA 问题
- 3.2 ABA 问题引起的 BUG
- 3.2 ABA 问题的解决方案 —— 使用版本号
1. CAS 是什么
【CAS】全称为 Compare and swap,即"比较并交换",相当于通过一个原子操作,同时完成"读取内存,比较是否相等,修改内存"这三个步骤,本质上需要 CPU 指令支持~
CAS 是并发编程中一个重要的概念,相当于是打开了新世界的大门,可以在不加锁的情况下保证线程安全,从而减少线程之间的竞争和开销,通常用于无锁编程,本文将结合 Java 多线程操作讲解 CAS 机制,我们一起来看看吧!
1.1 CAS 具体步骤
CAS 机制的基本思想是,先比较内存 V 中的值与寄存器 A 中的值(旧的预期值),是否相等,如果相等,则将寄存器 B 中的值(需要修改的新值)写入内存中,如果不相等,则不作任何操作,这整个过程是原子的~
CAS 涉及到以下三个操作,假设内存中的原数据V,旧的预期值A,需要修改的新值B
- 读取内存值:将需要修改的值从主内存中读入本地线程缓存或工作内存
- 比较并尝试交换:比较 A 与 V 是否相等,如果比较相等,将 B 写入 V,如果不相等,不作任何操作
- 返回操作结果:如果成功更新了值,则返回成功标志或新值,如果失败更新,则返回失败标志或当前内存中的值
1.2 CAS 伪代码
如果把 CAS 想象成一个函数,可以得到 CAS 的伪代码,但是下述的伪代码,并不是真正的 CAS 代码,事实上,CAS 操作是一条由 CPU 硬件支持、原子的硬件指令,而这一条指令就可以完成下述这一段代码的功能(CAS 本身就是对应一条 CPU 指令,不可拆分的最小单位,此时,CAS 中比较和交换动作是没办法再拆分的)
boolean CAS(address,expectValue,swapValue) {
if(&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
图解如下:
可以知道,上述这一段代码,非原子,运行过程中可能随着线程的调度有概率产生线程安全问题,而原子指令不会有线程安全问题~
同时,CAS也不会有内存可见性的问题,内存可见性是编译器把一系列指令进行调整,把读内存指令调整成直接读寄存器的指令,效率大大提升,可能会误判,从而产生 bug,但是 CAS 本身就是指令级别读取内存的操作,因此,不会有内存可见性带来的线程安全问题
即 CAS 可以不加锁也能一定程度保证线程安全!这样就可以基于 CAS 机制,实现一系列操作!
2. CAS 的应用
2.1 实现原子类
2.1.1 AtomInteger 类
标准库在 java.util.concurrent.atomic
包里提供很多类使用高效的指令来保证操作的原子性,而不是使用加锁来保证,其中提供 AtomInteger
类,能够以原子方式保证一个整数自增或自减操作的线程安全~
AtomInteger
类提供如下 4 个方法:
getAndIncrement(); //后置++
incrementAndGet(); //前置++
getAndDecrement(); //后置--
decrementAndGet(); //前置--
public class ThreadDemo {
public static void main(String[] args) {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(()->{
//num++
num.getAndIncrement();
System.out.println(num.get());
//++num
num.incrementAndGet();
System.out.println(num.get());
//num--
num.getAndDecrement();
System.out.println(num.get());
//--num
num.decrementAndGet();
System.out.println(num.get());
});
t1.start();
}
}
打印结果如下:
public class ThreadDemo35 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(()->{
for(int i = 0; i < 10000; i++) {
//num++
num.getAndIncrement();
}
});
Thread t2 = new Thread(()->{
for(int i = 0; i < 10000; i++) {
//num++
num.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num.get());
}
}
运行结果如下:最终 num 的值为 20000(固定的)
【原因】
getAndIncrement()
方法以原子的方式获得 num 的值,并将 num 进行 ++ 自增操作,即获得值、并将该值增加1,再生成新值,这一整个操作,是原子的,不会被中断,就可以保证在多线程编程环境下,并发地访问同一个实例,计算可以返回正确的值~
我们可以查看源码,发现 getAndIncrement()
方法并没有使用加锁(synchronized)的操作来保证原子性,如下:
1)先点进 getAndIncrement()
方法源码
2)再点进 getAndAddInt()
方法源码,可以看到,其中使用了 CAS 机制
3)再点进 compareAndSwapInt()
方法,可以发现,这是一个由 native 修饰的方法,CAS 机制的实现依赖于底层硬件和操作系统提高的原子操作支持,它是更偏向底层的操作~
上述是线程安全的案例,接着,线程不安全案例,与之形成对比,代码如下:
class Counter {
private int count = 0;
//count++操作
public void add() {
count++;
}
//得到count的值
public int get() {
return count;
}
}
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.get());
}
}
运行的结果,如下:
【原因】
在实际中,线程的调度顺序是无序的,不能确定 t1 和 t2 线程在自增过程中是如何执行的,因此,结果是不确定的~ 归根结底是线程无序调度的锅!(具体分析可以回顾往期内容:线程安全)
2.1.2 伪代码实现原子类
伪代码如下:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while (CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
图解如下:
但是会不会出现这样一个情况:在上述代码中,上面刚刚把 value 赋值给 oldValue,紧接着这里的比较,value 和 oldValue 这两个值会不相等吗?
答案是:绝对会!因为在多线程下,线程是无序调度的,value 是成员变量,如果两个线程同时调用
getAndIncrement()
方法,就有可能出现不相等的情况!
其实,此处的 CAS 就是在确认,看当前 value 是不是变过!如果没变过,才能自增,如果变过了,则先更新,再自增~
之前线程不安全,很大的云因是一个线程不能及时感知到另一个线程对内存的修改,比如 t2 在自增的时候,先读后自增,此时在自增之前,t1 线程已经自增过了,t2 线程在 0 的基础上自增的,导致无效自增,就会出现问题,使用 CAS 后, t2 会在自增之前,先检查一下寄存器的值和内存的值是否一致!只有一致才会执行自增,否则重新将内存中的值更新,与寄存器同步~
这个操作不涉及阻塞等待,因此,会比加锁解决线程不安全问题的方案快很多
2.2 实现自旋锁
2.2.1 自旋锁是什么
【自旋锁】
自旋锁是一种忙等待的锁机制,如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止,第一次获取锁失败,第二次的尝试会在极短的时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁,一般乐观锁的情况下,锁冲突概率低,实现自旋锁比较合适~ 实现自旋,目的就是为了忙等,就是为了能够最快的速度拿到锁!
(可回顾这一期内容:常见的锁策略)
2.2.2 伪代码实现自旋锁
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;
}
}
图解如下:
-
owner 记录当前的锁被哪个线程持有,如果为null,则说明没有线程持有,锁处于空闲状态
-
比较 owner 和 null 是否相等(即判断 owner 是否为 null,锁是否为空闲状态)
如果是相等,owner 是为 null,则进行交换,将当前线程的引用交换到 owner 中,加锁完成,交换成功则返回 true,循环就结束
如果是不相等,意味着 owner 不为 null,锁已经有线程持有了,此时 CAS 就什么都不用做,返回 fasle,循环继续
此时这个循环就会转得飞快,不停地尝试询问这里的锁是不是被释放了,一旦释放,就能立即获取到锁(实现自旋锁),坏处就是造成了 CPU 忙等 -
unlock()方法,解锁操作,即直接把 owner 设置为 null
CAS 机制,解释了自旋锁的实现~
3. CAS 的 ABA 问题
3.1 ABA 问题
CAS 的 ABA 问题,是使用 CAS 时会遇到的一个经典问题
CAS 的关键是对比内存和寄存器的值,看是否相同,就是通过这个比较来检测内存中的值是否发生改变
【ABA 问题】可能存在这样一个情况,对比的时候是相同的,但其值不是没有变过,而是从A值变成B值又变回A值(A->B->A),此时,有一定概率会出问题,CAS 只能对比值是否相同,但不能确定这个值是否中间发生过改变(这就类似于在某鱼上买个手机,结果是个"翻新机"是一样)
图解如下:
3.2 ABA 问题引起的 BUG
ABA 问题,大部分情况下,都没事,但是,小概率下会出现 BUG!!!
假设这样一场景:取钱(这可是不能出现 BUG 的噢,毕竟对钱都很敏感hh)
小万有 1000 元存款,她要从 ATM 机中取出 500 元钱,此时,取款机创建了两个线程 t1、t2,并发执行 账户 -500 的操作
我们期望的是 —— 这两个线程中,一个线程执行 -500 的操作,而另一个线程 -500 失败
如果是 CAS 的方式来完成这个扣款,就可能出现问题,引发 BUG
- 正常情况:
1)存款1000,线程 t1 获取到当前存款 1000,期望值更新为 500;线程 t2 获取到当前存款 1000,期望值更新为 500
2)线程 t1 执行扣款成功,账户减少 500,存款变为 500,线程 t2 阻塞等待
3)线程 t2 执行,当前存款为 500,与之前读到的 1000,对比,不相同,执行失败 - 异常情况:
1)存款1000,线程 t1 获取到当前存款 1000,期望值更新为 500;线程 t2 获取到当前存款 1000,期望值更新为 500
2)线程 t1 执行扣款成功,账户减少 500,存款变为 500,线程 t2 阻塞等待
3)线程 t2 执行前,小万的朋友小丁,转给小万 500,此时小万的账户余额还是 1000!
4)线程 t2 执行,当前存款为 1000,与之前读到的 1000,对比,相同,再次执行扣款操作
此时,扣款 500 的操作被执行了两次!这是 ABA 问题引起的 BUG
如何解决 ABA 问题呢?
3.2 ABA 问题的解决方案 —— 使用版本号
ABA 关键是值会反复横跳,如果约定数据只能单方向变化,即数据只能增加,或者只能减少,问题就迎刃而解了
- Q:如果需求要求该数值,既能增加也能减少,那怎么解决呢?
- A:可以引入另外一个版本号变量,约定版本号只能增加,每次修改,都会增加一个版本号,就能感知值是否发生变化
每次 CAS 对比,就不是对比数值本身,而是对比版本号
只要约定版本号,只能递增,就能保证此时不会出现 ABA 反复横跳问题,以版本号为基准,而不是以变量数值为基准了(即 CAS 在对比的时候,对比的不是数值本身,而是对比版本号,这样其它线程在进行 CAS 操作时,可以检查版本号是否发生变化,从而避免 ABA 问题)
图解如下:
💛💛💛本期内容回顾💛💛💛
✨✨✨本期内容到此结束啦~