文章目录
- 一、常见锁策略
-
- 1.乐观锁和悲观锁
- 2.读写锁
- 3.重量级锁和轻量级锁
- 4.挂起等待锁和自旋锁
- 5.公平锁和非公平锁
- 6.可重入锁和不可重入锁
- 7.synchronized是什么锁?
- 二、CAS(Compare and swap)
-
- 1.CAS是什么?
- 2.CAS如何解决线程安全问题
- 3.CAS 的 ABA 问题
- 4.相关面试题
- 三、最后
一、常见锁策略
其实这个锁策略跟我们以后当程序猿没啥关系,跟“实现锁”的人才有关系,那我们为啥还有了解呢?
问就是面试需要。
1.乐观锁和悲观锁
(1)乐观锁:预期锁冲突的概率很低
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
(2)悲观锁:预期锁冲突的概率很高
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁
为了更好的理解什么是悲观锁,什么是乐观锁,这里举一个例子:
假如我今年毕业了,面临找工作,我可能会有两种态度,一种乐观,一种是悲观。
乐观的态度:现在大环境虽然不怎么好,但是找工作还是很容易的,就不必为毕业找工作做过多的准备,也就是平时不用那么卷了。
悲观的态度:现在大环境不好,不卷就找不到工作,毕业即失业,所以得抓紧时间卷了
这两种态度意味着要准备的东西多少不同,悲观的态度,要卷得东西更多,准备也更多;而乐观的态度也做的事情少一点。
因此:
乐观锁:做的工作更少,付出的成本更低,更高效
悲观锁:做的工作更多,付出的成本更高,更低效
2.读写锁
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
这个读写锁一般与普通互斥锁是区分开的。
对于普通的互斥锁来说,只有两个操作:
加锁和解锁。因此这里只要两个线程针对同一个对象加锁,就会产生互斥。
对于读写锁来说,则分成了三个操作:
加读锁、加写锁、解锁。如果代码只是进行读操作,就加读锁;如果代码中进行了修改,就加写锁。
针对读锁和读锁之间,是不存在互斥关系的;
读锁和写锁之间,才需要互斥。
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据
- 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
- 两个线程都要写一个数据, 有线程安全问题.
- 一个线程读另外一个线程写, 也有线程安全问题
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁:
- ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
- ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
其中
- 读加锁和读加锁之间, 不互斥.
- 写加锁和写加锁之间, 互斥.
- 读加锁和写加锁之间, 互斥
【注意】
只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多 久了. 因此尽可能减少 “互斥” 的机会,就是提高效率的重要途径
读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的),比如说学校的教务系统。
Synchronized 不是读写锁
3.重量级锁和轻量级锁
这里的重量级锁和轻量级锁与上面的悲观锁和乐观锁有一定的重叠(相似)。
重量级锁:就是做的事情更多了,开销更大
轻量级锁:做的事情更少了,开销更小。
锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的。
- CPU 提供了 “原子操作指令”.
- 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
- JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和ReentrantLock 等关键字和类.
【注意】synchronized 并不仅仅是对 mutex 进行封装, 在 synchronized 内部还做了很多其他的工作。
(1)重量级锁: 加锁机制重度依赖了 OS 提供了 mutex
- 大量的内核态用户态切换
- 很容易引发线程的调度
这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 “沧海桑田”.
(2)轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.
- 少量的内核态用户态切换.
- 不太容易引发线程调度
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
4.挂起等待锁和自旋锁
- 挂起等待锁往往是通过内核的一些机制来实现的,往往较重。这是重量级锁的一种典型实现
- 自旋锁往往是通过用户态代码来实现的,往往较轻。这是轻量级锁的一种典型实现。
自旋锁的伪代码:
while (抢锁(lock) == 失败) {}
解释:如果获取锁失败,就会立即再次尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。
一旦锁被其他线程释放,就能第一时间获取到锁了。
理解自旋锁 vs 挂起等待锁:
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意,这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.
自旋锁是一种典型的轻量级锁的实现方式
- 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
- 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的)
5.公平锁和非公平锁
- 公平锁:遵守先来后到的规则。当多个线程等待一把锁的时候,先等待的,先获取到这个锁
- 非公平锁:不遵循先来后到规则,每一个等待的线程获取到锁的机会是均等的。
【注意】
- 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
- 公平锁和非公平锁没有好坏之分, 关键还是看适用场景
6.可重入锁和不可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。而 Linux 系统提供的 mutex 是不可重入锁.
理解可重入锁最重要的是理解什么是死锁,之前文章详细介绍过死锁,这里可以借鉴一下:死锁。
7.synchronized是什么锁?
- 既是一个乐观锁,也是一个悲观锁,根据锁竞争的激烈程度进行自适应;
- 不是读写锁,只是一个普通的互斥锁;
- 既是一个轻量级锁,也是一个重量级锁,这也是根据锁竞争的激烈程度进行自适应;
- synchronized的轻量级锁是基于自旋锁来实现的,而其重量级锁是基于挂起等待锁来实现的;
- 是一个非公平锁;
- 是一个可重入锁。
二、CAS(Compare and swap)
1.CAS是什么?
CAS: 全称Compare and swap,字面意思:”比较并交换“。
简单来说,CAS就是拿着寄存器/内存中的值和另外一个内存的值进行比较。如果值相同了,就把另一个寄存器/内存的值和当前的这个内存进行交互。
一个CAS的具体操作涉及到以下步骤:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
我们通过CAS的伪代码来了解一下:
注意:这里的伪代码不是原子的,真实的CAS是一个原子的硬件指令完成的,这个伪代码只是辅助理解CAS的工作流程。
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
此处的CAS指的是,CPU提供一个单独的CAS指令,通过这一条指令,就可以完成上面伪代码的过程。
那么想想,如果“一条指令”就可以完成上述的过程,那么是不是就说明了此时已经是线程安全的呢?
为什么呢?因为我们的指令是不可分割的最小单位,我们CPU上的指令是一条一条执行的。上述的操作如果也只是一条指令的化,是不是就相当于原子的了。
基于上述的思考,所以我们可以得出,CAS的最大意义就是可以帮助我们完成一个线程安全的代码,这种线程安全不同于用锁来实现的线程安全。
2.CAS如何解决线程安全问题
(1)基于CAS能够实现原子类
Java标准库中提供了一组原子类,针对常用的一些int,long,int array…进行了封装,我们可以基于CAS的方式进行修改,这个操作是线程安全的。
仔细看一下一下代码:
public class Demo8 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
//这个方法讲相当于num++
num.getAndIncrement();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
num.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
//通过get方法得到原子类内部的数值
System.out.println(num.get());
}
}
上述代码中AtomicInteger 类中其实还有很多其他的方法:
// 等同于++num
num.incrementAndGet();
// 等同于--num
num.decrementAndGet();
//等同于 num--
num.getAndDecrement();
//等同于 += 10
num.getAndAdd(10);
那么这个原子类具体背后是如何实现的呢?我们来分析一下下面的伪代码:
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
我们下面来分析一下,为什么上述实现的操作是线程安全。
(2)实现自旋锁:基于 CAS 实现更灵活的锁, 获取到更多的控制权。
自旋锁的伪代码:
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;
}
}
自旋锁是一个轻量级锁,也可以视为一个乐观锁。当前这把锁虽然没能立即拿到,但是预期很快就会拿到(假设锁冲突不激烈),那么短暂的自旋几次,浪费一点CPU,问题都不大;而好处就是只要锁一释放,就能立即的拿到锁。
3.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 呢?
到这一步, t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程才得到的A。
这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机。
举一个典型的例子,了解一下ABA 问题引来的 BUG。
滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
正常情况下:
异常情况下,也就是引入了ABA问题,这里假设去取钱的时候,有一个朋友转了50块过来,此时就会触发ABA问题。
上面的两次巧合导致了存在BUG的ABA问题。
那么我们该如何解决ABA问题呢?
我们在这里引入一个版本号,这个版本号只能变大,不能变小,那么在修改变量的时候,比较就不是比较变量本身了,而是比较版本号。
- CAS 操作在读取旧值的同时, 也要读取版本号
- 真正修改的时候:
(1)如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1;
(2)如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了)。
此外,还能采用时间戳的方式,因为时间也是一直往前进的。
4.相关面试题
-
讲解下你自己理解的 CAS 机制。
全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比
较是否相等, 修改内存” 这三个步骤. 本质上需要 CPU 指令的支撑. -
ABA问题怎么解决?
给要修改的数据引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败
三、最后
事常与人违,事总在人为,加油。