目录
对于多线程相关的有疑问滴~欢迎观看我之前滴博客:
直达:
多线程(超详细) (ε≡٩(๑>₃<)۶ 一心向学)_多线程怎么用-CSDN博客
让我们共同进步吧~
一、常见的锁策略
1. 悲观锁 vs 乐观锁
这里的 悲观锁和乐观锁 并不是针对某一中具体的锁,而是某个具体锁具有 "悲观" 或者 "乐观" 的特性。
1)悲观:
加锁的时候,预测接下来的锁竞争的情况非常激烈。就需要针对这样的计类情况额外做一下工作。
比如:
有一把锁 有二十个线程尝试获取锁,每个线程加锁的频率都很高。一个线程加锁的时候,很可能锁被另一个线程占用着。
2)乐观:
加锁的是偶,预测接下来的锁竞争的情况不激烈。就不需要进行额外的工作。
比如:
有一把锁 有两个线程尝试获取锁,每个线程加锁的频率都很低。一个线程加锁的时候,大概率另一个线程没有和他竞争。
悲观 和 乐观 描述的是加锁的时候遇到的场景。
2. 重量级锁 vs 轻量级锁
锁的核心特性 "原⼦性",这样的机制追根溯源是 CPU 这样的硬件设备提供的。
• CPU 提供了 "原子操作指令"
• 操作系统基于 CPU 的原子指令,实现了 mutex 互斥锁
• JVM基于操作系统提供的互斥锁,实现了 synchronized 和 ReentrantLock 等关键字和类
重量级锁:加锁机制重度依赖了 OS 提供了 mutex
• 大量的内核态用户态切换
• 很容易引发线程的调度
在 悲观 的场景下,此时就要付出更多的代价。-> 更低效
轻量级锁:加锁机制尽可能不使用 mutex,而是尽量在用户态代码完成。实在搞不定了,再使用 mutex。
• 少量的内核态用户态切换。
• 不太容易引发线程调度。
在 乐观 的场景下,此时付出的代价就会更小。-> 更高效
synchronized 开始是一个轻量级锁。如果锁冲突比较严重,就会变成重量级锁。
3. 挂起等待锁 vs 自旋锁
挂起等待锁:(典型的重量级锁)
操作系统内核级别的。加锁的时候发现竞争,就会是线程进入阻塞等待状态,后序需要内和进行唤醒。
自旋锁:(典型的轻量级锁)
应用程序级别的。加锁的时候发现竞争,一般也不是进行阻塞,而是通过忙等的形式来进行等待。
• 优点:没有放弃CPU,不涉及线程阻塞和调度,⼀旦锁被释放,就能第一时间获取到锁。
• 缺点:如果锁被其他线程持有的时间比较久,那么就会持续的消耗CPU资源。(而挂起等待的时候是不消耗 CPU 的)
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的。
4. 普通互斥锁 vs 读写锁
synchronized就是普通的互斥锁,只是进行 加锁、解锁操作。
读写锁:(读方式加锁,写方式加锁,解锁操作)
一个线程对于数据的访问,主要存在两种操作:读数据和写数据。
• 两个线程都只是读一个数据,此时并没有线程安全问题。直接并发的读取即可。
• 两个线程都要写一个数据,有线程安全问题。
• 一个线程读另外一个线程写,也有线程安全问题。
读写锁就是把读操作和写操作区分对待。Java标准库提供了 ReentrantReadWriteLock类,实现了读写锁。
• ReentrantReadWriteLock.ReadLock 类表示一个读锁。这个对象提供了 lock/unlock方法进行加锁解锁。
• ReentrantReadWriteLock.WriteLock 类表示一个写锁。这个对象也提供了 lock/unlock方法进行加锁解锁。
其中:
• 读加锁和读加锁之间,不互斥。
• 写加锁和写加锁之间,互斥。
• 读加锁和写加锁之间,互斥。
读写锁特别适合于 "频繁读,不频繁写" 的场景中。
5. 可重入锁 vs 不可重入锁
可重入锁的字面意思就是 "可以重新进入的锁" ,即 允许同一个线程多次获取同一把锁。
比如 一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是 可重入锁(因为这个原因可重入锁也叫做 递归锁)。反之,则是不可重入锁。
Java里面只要以 Reentrant 开头命名的锁都是可重入锁,而且 JDK提供的所有现成的Lock实现类,包括synchronized关键字都是可重入的。
在Linux系统提供的 mutex 是不可重入锁。
不可重入锁也就是 "把自己锁死",那么什么是把自己锁死呢?
一个线程没有释放锁,然后有尝试再次加锁。
// 第一次加锁,加锁成功
lock();
// 第二次加锁,锁已经被占用,阻塞等待
lock();
那么对于第二次加锁,需要等待第一次加锁释放后才可以进行加锁,但是释放第一个锁也需要有改线程完成,结果这个线程在加锁无法解锁,导致现在什么也干不了,就会造成 死锁。
可重入锁的核心要点:
1.锁要记录当前是哪个线程拿到的这把锁。
2.使用计数器,记录当前加锁了多少次,在合适的时候进行解锁。
6. 公平锁 vs 非公平锁
如:有A、B、C三个线程。A先尝试锁,当A释放锁的时候,出现:
公平锁:遵守 "先来后到"。B比C先来的。当A释放锁的之后,B就能先于C获取到锁。
非公平锁:不遵守 "先来后到"。B和C都有可能获取到锁。
注意:
• 操作系统内部的线程调度就可以视为是随机的。如果不做任何额外的限制,锁就是非公平锁。如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序。
• 公平锁和非公平锁没有好坏之分,关键还是看适用场景。
synchronized 是非公平锁。
7. 面试题
1、你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
悲观锁认为多个线程访问同一个共享变量冲突的概率较大,会在每次访问共享变量之前都去真正加锁。
乐观锁认为多个线程访问同一个共享变量冲突的概率不大。并不会真的加锁,而是直接尝试访问数据,在访问的同时识别当前的数据是否出现访问冲突。
悲观锁的实现就是先加锁(比如借助操作系统提供的mutex),获取到锁再操作数据。获取不到锁就等待。
乐观锁的实现可以引入一个版本号。借助版本号识别出当前的数据访问是否冲突。
2、介绍一下读写锁
读写锁 就是把读操作和写操作分别进行加锁。
读锁和读锁之间不互斥。
写锁和写锁之间互斥。
写锁和读锁之间互斥。
读写锁最主要用在 "频繁读,不频繁写" 的场景中。
3、 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
如果获取锁失败,立即在尝试获取锁,无限循环,直至获取到锁为止。第一次获取失败,第二次的尝试会在极短的时间内到来,一旦锁被其他线程释放,就能第一时间获取到锁。
相比于挂起等待锁:
优点:没有放弃CPU资源,一旦锁被释放就能第一时间获取到锁,更高效。在锁持有时间比较短的场景下非常有用。
缺点:如果锁的持有时间较长,就会浪费CPU资源
二、synchronized的原理
1. 基本特点
结合上面的锁策略,我们就可以总结出,synchronized 具有以下特性:
1)开始时是乐观锁,如果发生锁冲突频繁,就会转换成悲观锁
2)开始的时候是轻量级锁实现,如果锁被持有的时间较长,就转换成重量级锁
3)实现轻量级锁的时候大概路用到的是自旋锁策略
4)是一种不公平锁
5)是一种可重入锁
6)不是读写锁
2. 加锁工作过程
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行一次升级。
1)偏向锁:
第一个尝试加锁的线程,优先进入偏向锁状态。
偏向锁刚开始并不是说真的进行加锁,而是简单做一个标记(记录这个锁是哪个线程的),这个标记是非常轻量的,相比于加锁解锁来说,效率会高很多。
如果没有其他线程来竞争这个锁,最终当前线程执行到解锁代码,也就只是简单的清除上述的标记即可,不涉及真加锁,真解锁
如果有其他锁来竞争,就抢先一步,在另一个线程拿到锁之前,抢先拿到锁。一般情况下进入到轻量级锁状态。
所以偏向锁 本质上相当于是 "延迟加锁"。能不加锁就不加锁,尽量避免不必要的加锁开销。
2)轻量级锁:
随着其他线程进入竞争,偏向锁状态被消除,进入轻量级锁状态(自适应的自旋锁)。
此时的轻量级锁就是通过CAS来实现的。(这里的CAS后面进行介绍)
通过CAS检查并更新一块内存
如果更新成功,则认为加锁成功
如果更新失败,则认为锁被占用,继续自旋式的等待(并不放弃CPU)
但是自旋操作不会一直持续进行,而是到达一定的时间/重试次数就不在自旋了,也就是"自适应"。
3)重量级锁:
如果竞争进一步激烈,自旋不能快速获取到锁状态,就会膨胀为重量级锁
此处的重量级锁就是值用到内核提供的 mutex
执行几所从挨揍,先进行内核态
在内核态判定当前锁是否已经被占用
如果该锁没有占用,则加锁成功,并切换回用户态
如果该锁被占用,则加锁失败,此时线程进入锁的等待队列,挂起,等待被操作系统唤醒
经历了一系列的操作后,这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒这个线程,尝试重新获取锁。
总结:
无锁 => 偏向锁:代码进入 synchronized 的代码块
偏向锁 => 轻量级锁:拿到偏向锁的线程运行过程中,遇到了其他线程尝试竞争这个锁
轻量级锁 => 重量级锁:JVM发现,当前竞争锁的情况非常激烈
目前JVM 中,只提供了 锁升级 不能 锁降级
3. 其他的优化操作
1) 锁消除:
锁消除编译器优化的体现。
编译器判定,当前这个代码逻辑是否真的需要加锁,如果不需要加锁,但是写了 synchronized锁,就会自动把 synchronized 给去掉。
2)锁粗化:
也就是对于 锁的粒度 来说,加锁和解锁之间,包含的代码越多,就认为锁的粒度就越粗,如果包含的代码越少,就会认为锁的粒度就越细。
一段代码中,反复针对细粒度的代码加锁,就可能被优化成更粗粒度的加锁。
在实际开发中,使用细粒度锁,是期望释放锁的时候其他线程就能使用锁。
但是实际上可能并没有其他线程来抢占这个锁。这个情况 JVM 就会自动把锁粗化,避免频繁申请释放锁。
4. 相关面试题
1. 什么是偏向锁?
偏向锁不是真的加锁,只是在锁的对象头中记录⼀个标记(记录该锁所属的线程)。如果没有其他线程参与竞争锁,那么就不会真正执行加锁操作,从而降低程序开销。一旦真的涉及到其他的线程竞争,再取消偏向锁状态,进入轻量级锁状态。
三、CAS
1. 什么是CAS
全称为:compare and swap,也就是 "比较并交换",一个CAS涉及下述操作:
1)比较 A 和 V 是否相等。(比较)
2)如果比较相等,将 B 写入 V。(交换)
3)返回操作是否成功。
CAS 的伪代码:
(注意是伪代码,并不是一个真实的CAS实现,真实的是一个原子的硬件指令完成的)
当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
2. CAS的应用
1)实现原子类
标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的。典型的就是 AtomicInteger 类。其中的 getAndIncrement 相当于 i++ 操作。
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
伪代码:
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 的值即可
通过形如上述代码就可以实现一个原子类,不需要使用重量级锁,就可以高效的完成多线程的自增操作。
2)实现自旋锁
基于 CAS 实现更灵活的锁,获取到更多的控制权。
自旋锁的伪代码:
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就⾃旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
// 伪代码
private boolean CAS(Thread owner, Object o, Thread currentThread) {
if (owner == o) {
owner = currentThread;
return true;
}
return false;
}
public void unlock (){
this.owner = null;
}
}
3. CAS 的 ABA 问题
1)什么是 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,还是经历了一个变化过程。
2)ABA 问题引出的BUG
大部分情况下,t2线程这样的一个反复横跳的改动,对于 t1 是否修改 num 是没有影响的。但是不排除一下特殊的情况。
比如:小明有 100的存款,他想从 ATM 取50块钱。ATM 创建了两个线程,并发的来执行 -50操作
我们期望一个线程执行 -50成功后,另一个线程的 -50操作就执行失败。
但是如果使用 CAS 的方式来完成这个扣款操作,就会出现问题(会出现扣款两次的情况)。
异常:
1. 存款 100。线程1 获取到当前存款值为100,期望更新为 50;线程2 获取到当前存款值为100,期望更新为 50。
2. 线程1 执行扣款成功,存款被改成50,。线程2 阻塞等待中。
3. 在线程2 执行之前,小明的朋友整改给其转账50,账户余额变成100
4. 轮到线程2 执行时,发现当前存款为100,和之前读取到的100 相同,再次执行扣款操作
这个时候,扣款操作执行了两次,就是ABA问题引起的。
3)解决ABA引起BUG的方案
给要修改的值引入版本号。在CAS比较数据当前值和旧值的同时,也要比较版本号是否符合预期。
• CAS 操作在读取旧值的同时,也要读取版本号
• 真正修改的时候:
○ 如果当前版本号和读取到的版本号相同,则修改数据,并把版本号+1
○ 如果当前版本号高于读到的版本号。就操作失败(认为数据已经被修改过了)。
对比上述遇到BUG的例子:
解决:
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,版本小于当前版本,认为操作失败。
4. 相关面试题
1. 讲解下你自己理解的 CAS 机制
CAS 就是 "比较并交换"。相当于通过一个原子的操作,同时完成 "读取内存,比较是否相等,修改内存" 这三个步骤。本质上需要 CPU 指令的支撑。
2. ABA 问题要怎样解决
给修改的数据引入版本号。在 CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期。如果发现当前版本号和之前读到的版本号一致,就真正指向修改操作,并让版本号自增;如果发现当前版本号比之前读到的版本号大,就认为操作失败。
这次的分享到这里就结束了,感觉文章不错的话,期待你的一键三连哦,你的鼓励就是我的动力,让我们一起加油,顶峰相见。拜拜喽~~我们下次再见💓💓💓💓