文章目录
- 1 常见的锁策略
- 1.1 乐观锁与悲观锁
- 1.2 轻量级锁与重量级锁
- 1.3 自旋锁与挂起等待锁
- 1.4 互斥锁与读写锁
- 1.5 可重入锁与不可重入锁
- 1.6 公平锁与非公平锁
- 2 CAS 操作
- 2.1 CAS 简介
- 2.2 CAS 的应用
- 2.2.1 实现原子类
- 2.2.2 实现自旋锁
- 3 CAS 的 ABA 问题
- 写在最后
1 常见的锁策略
1.1 乐观锁与悲观锁
乐观锁
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式的对数据是否产生并发冲突进行检测,如果发现了并发冲突,则返回错误信息,让用户来决定如何去做。
悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观和悲观的区别主要是在于对锁冲突的预估:如果说,预估锁冲突的概率是比较高的,就比较悲观~ 而如果锁冲突的概率是比较低的,就很乐观了~
像 Java 中 synchronized
和 ReentrantLock
等独占锁就是悲观锁思想的实现。
public void performSynchronisedTask() {
synchronized (this) {
// 需要同步的操作
}
}
private Lock lock = new ReentrantLock();
lock.lock();
try {
// 需要同步的操作
} finally {
lock.unlock();
}
而像 Java 中 java.util.concurrent.atomic
包下面的原子变量类(比如AtomicInteger、LongAdder)就是使用了乐观锁的一种实现方式 CAS
实现的。关于 CAS 我们往后再谈~
那么悲观锁和乐观锁分别适用于什么样的场景呢?
想必大家一定有和辅导员请假的经历,我们可以把乐观锁和悲观锁看成两类人:A 和 B。假设 A 同学 和 B 同学都想要给辅导员请假。
乐观 A 同学:
乐观 A 同学认为,辅导员是比较 空闲 的。于是,想要请假的时候就直接去办公室找辅导员。如果辅导员比较忙,则请假失败,等辅导员空闲的时候再次尝试请假;而如果辅导员真的比较闲,就能直接请假了。(在本次流程中,没有进行加锁操作,而是直接访问资源。就算没有请假成功,也进行了数据冲突的识别~)
悲观 B 同学:
悲观 B 同学认为,辅导员是比较 忙碌 的。因此,B 同学会先给辅导员打电话,询问其是否有时间(相当于加锁操作)。得到辅导员的肯定答复后,才会去辅导员办公室进行请假流程。如果得到了否定回答,则会等待一定时间,下次再和辅导员确定。
两种方式乍一眼看区别不太大,但是实际上,适合的场景是不同的:如果辅导员是真的忙,那么使用悲观锁比较合适~ 如果使用乐观锁,就会像 A 同学那样,每次都跑去办公室确认是否空闲,无形中耗费了很多资源;如果辅导员是比较空闲的,那么使用乐观锁比较合适,如果使用悲观锁,则会让效率更低~
1.2 轻量级锁与重量级锁
轻量和重量则单纯是 从时间消耗 来看的,对于轻量级锁来说,其获取锁的速度会更快;对于重量级锁来说,获取锁的速度会更慢~
对于锁的原子性,追根溯源是 CPU 提供的:
- CPU 提供了原子操作指令;
- 操作系统基于原子操作指令,实现了
mutex
互斥锁; - JVM 又基于操作系统提供的互斥锁实现了
synchronized
和ReentrantLock
等关键字和类。
重量级锁 : 加锁机制重度依赖了操作系统提供的 mutex
- 大量的内核态和用户态的切换
- 容易引发线程的调度
轻量级锁 :与重量级锁不同,其加锁机制尽可能不使用操作系统提供的互斥锁,而是尽量在 用户态 完成,操作系统提供的 mutex
是下下策~
- 少量的内核态和用户态的切换
- 不太容易引发线程的调度
1.3 自旋锁与挂起等待锁
自旋锁是轻量级锁的典型实现,挂起等待锁是重量级锁的典型实现。
自旋锁
一般情况下,线程在尝试获取锁失败后就会进入阻塞状态,而放弃CPU,等待被调度。而自旋锁则不同,其策略是:如果获取锁失败,则立即尝试获取锁,直到获取锁为止!一旦锁被其他线程释放,就能在第一时间获得锁。
伪代码如下:
while(抢锁(lock) == 失败) {
}
那么该如何区别理解自旋锁和挂起等待锁呢?
这就不得不谈一谈“舔狗”的心路历程了!
我们可以把自旋锁看作一个标准的舔狗!怎么舔呢?死皮赖脸!死缠烂打!坚持不懈的追求女神~ 当女神和前任分手后就能抓住一切机会上位!
而挂起等待锁就比较摆烂了,它追求女神的方式比较特别,仅仅是通知妹子: “那啥,我喜欢你嗷~” 于是就摆烂了。一直等到女神回过神儿来,看也没什么意思,要不就和你试试吧:“单着没?要不试试?” 可是,在你“上岸”前,女神到底中途又谈了多少任你是不清楚的。
自旋锁相较挂起等待锁来说,由于没有放弃CPU,不涉及线程的调度与阻塞,一旦锁被释放就能第一时间获取~ 但是,如此一来,也有很大的弊端!舔狗虽好,但是累啊!假设,舔狗在追求妹子的时候,妹子和现任谈的天长地久,你就 需要长时间的自旋,而这会消耗 CPU资源,是需要付出巨大成本的!而挂起等待锁,在挂起等待的时候是不需要消耗 CPU 的~
1.4 互斥锁与读写锁
互斥锁是一种很形象的说法:就像一个房间只能住一个人,任何人进去之后就把门锁上了,其他人都不可以进去,直到进去的人重新解锁,既是释放了这个锁资源为止。
而在多线程中,数据的读取方之间不会产生线程安全问题,但是数据的写入方之间以及和读取方之间都需要进行互斥。 而如果在此两种情景下都使用互斥锁,则会产生很大的性能损耗,而读写锁就是为了解决这一问题~
读写锁就是把读操作和写操作区分对待
- 读加锁和读加锁之间, 不互斥.
- 写加锁和写加锁之间, 互斥.
- 读加锁和写加锁之间, 互斥.
在 Java 标准库,实现了 ReentrantReadWriteLock
类, 实现了读写
锁:
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
注意,上述内容涉及到互斥,而互斥就涉及到了挂起等待了。而线程一旦被挂起,再次被唤醒就是未知的了。所以,尽可能减少互斥是提高效率的有效途径~
1.5 可重入锁与不可重入锁
可重入锁顾名思义就是可以重新进入的锁,允许同一个线程多次获取同一把锁。 连续的两次加锁并不会导致死锁。
Java 中的 Reentrant
开头命名的锁都是可重入锁,synchronized
关键字锁也是可重入的。
不可重入锁该如何理解呢? - > 将自己锁死
伪代码如下(synchronized是可重入锁,这里只是用伪代码举例,并不会真的阻塞):
即一个线程没有释放锁,又尝试再次加锁
// 加锁!
synchronized(locker) {
// 第二次加锁,锁已占用,阻塞等待
synchronized(locker) {
}
}
可重入锁的实现方式
实现的方式是在锁中记录该锁持有的线程身份,以及一个计数器(记录加锁次数)。如果发现当前加锁的线程就是持有锁的线程,则直接计数自增。
1.6 公平锁与非公平锁
假设有三个线程 A B C,A 获取锁成功, B 获取锁失败,阻塞等待, C 获取锁失败,同样阻塞等待,此时 A 释放锁,B C 会发生什么?
公平锁: 遵循 “先来后到”。当 A 释放锁后,B 能先于 C 获取锁。
非公平锁: 不遵循 “先来后到”,当 A 释放锁后,B 和 C 都有可能获取到锁。
2 CAS 操作
2.1 CAS 简介
即Compare and swap,
字面意思:”比较并交换“,相当于通过一个原子的操作, 同时完成 “读取内存, 比较是否相等, 修改内存” 这三个步骤。本质上需要 CPU 指令的支撑。一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
一句话概括,就是将寄存器A的值和内存V的值进行比对,如果值相同,就把寄存器B的值和V的值进行交换。
伪代码如下:
boolean CAS(address, expectValue, swqpValue) {
if (&address == expectValue) {
&address = swqpValue;
return true;
}
return false;
}
address -> 内存地址, expectValue -> 寄存器A,swapValue -> 寄存器B
CAS操作是一条 CPU 指令,具有原子性,伪代码只是用于方便理解。
CAS 可以视为一个乐观锁,当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
2.2 CAS 的应用
2.2.1 实现原子类
标准库中提供了 java.util.concurrent.atomic
包, 里面的类都是基于这种方式来实现的。
典型的就是 AtomicInteger
类. 其中的 getAndIncrement
相当于 i++
操作。
操作示例代码
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
原子类通过CAS操作是如何实现的呢?
伪代码如下:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
由于 Java 没法表示在寄存器中的值,oldValue 可以视为一个寄存器。
如果发现 value 和 oldValue 值相同,就把 oldValue + 1 设置到 value 中,相当于进行了 ++ 操作。
2.2.2 实现自旋锁
即通过 CAS 查看当前锁是否被某个线程所持有,如果已经被持有了则进行自旋等待;如果没有被持有,就把 owner 设置为当前加锁的线程~
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;
}
}
3 CAS 的 ABA 问题
什么是ABA问题呢?
假设有两个线程 t1 和 t2,有一个共享变量 num,其初始值为 A。接下来,线程 t1 想要使用 CAS 把值改成 B 就需要进行下面的步骤:
- 读取 num 的值,记录到 oldNum 变量中
- 使用 CAS 判定当前 num 是否为 A,如果为 A 则修改为 B
但是,在 t1 执行这两个操作之间,线程 t2 可能进行了某种骚操作!t2 可能将 num 的值修改成了 B 又修改成了 A。
需要明确的是,t1 线程使用 CAS 的初衷是期望 num 不变则进行修改,然并卵,t1 线程并不知道 num 是否被 t2 线程更改过并进行了复原~ 虽然,单单对修改 num 值来说,并没什么大问题。
ABA 问题可能引发的 BUG
在大部分情况下,t2 这样反复横跳的骚操作并不会引发什么问题,但是总有些特殊情况~
举个例子~
假设小黄有100块钱,想从 ATM 取款机上取 50 块钱。假设 ATM 创建了两个线程 t1 和 t2 并发执行 -50 的扣款过程~ 我们期望 t1 线程扣款成功,t2 线程扣款失败,使用 CAS 处理扣款过程。
一般情况下:
- t1 和 t2 线程都获取当前存款为 100 块,期望更新为 50,假设 t1 先执行,t2 阻塞等待
- t1 线程执行,扣款成功,当前存款剩余 50 块
- t2 线程执行,发现当前的存款是 50 块,与一开始的 100 块不同,所以扣款失败~
异常情况:
- t1 和 t2 线程都获取当前存款为 100 块,期望更新为 50,假设 t1 先执行,t2 阻塞等待
- t1 线程执行,扣款成功,当前存款剩余 50 块
- 在 t2 线程执行前,七七给小黄进行了转账 50 块的操作
- t2 线程执行,发现当前的存款是 100 块,与一开始的 100 块相同,所以扣款成功,当前存款剩余 50 块~
天呐~ 发现没有!因为 ABA 问题,导致了扣款两次!!!
如果解决 ABA 问题引发的 BUG?
想要解决上述所述问题,其实也很简单,只需要在原来的基础上 引入一个版本号~
具体操作如下:
- CAS 在读取旧值的时候,也需要读取一个版本号。
- 如果需要进行修改数据,则进行版本号的判断。如果当前版本号与之前读到的版本号旧值相同,则进行修改,并且版本号 + 1;如果当前版本号比之前的版本号旧值还要高,则认为已经修改过,不作处理。
对于上述的转账过程中,由于 ABA 问题引发的异常情况,引入版本号后也可以得到解决,具体如下:
- t1 和 t2 线程都获取当前存款为 100 块,期望更新为 50,假设 t1 先执行,t2 阻塞等待。默认读取的 版本号初始为 1。
- t1 线程执行,扣款成功,当前存款剩余 50 块,并进行版本号 + 1 的操作,此时 版本号为 2。
- 在 t2 线程执行前,七七给小黄进行了转账 50 块的操作, 版本号 + 1,此时 版本号为 3。
- t2 线程执行,发现当前的存款是 100 块,与一开始的 100 块相同,但是由于当前版本号为 3,而之前读取的版本号为 1,高于旧值,所以不进行修改操作,余额依然为 100。
写在最后
以上便是本文的全部内容啦!创作不易,如果你有任何问题,欢迎私信,感谢您的支持!
本文被 JavaEE编程之路 收录点击订阅专栏 , 持续更新中。
创作不易,如果你有任何问题,欢迎私信,感谢您的支持!