目录
2. 悲观锁(Pessimistic Concurrency Control)
2. 悲观锁(Pessimistic Concurrency Control)
3. 乐观锁(Optimistic Concurrency Control)
讲解一:锁
在代码中多个线程需要同时操作共享变量,这时需要给变量上把锁,保证变量值是线程安全的。
-------------------------
讲解一:死锁 & 活锁
学习前言
在多核时代中,多线程、多进程的程序虽然大大提高了系统资源的利用率以及系统的吞吐量,但并
发执行也带来了新的一系列问题:死锁、活锁与锁饥饿。
死锁、活锁与锁饥饿都是程序运行过程中的一种状态,而其中死锁与活锁状态在进程中也是可能存
在这种情况的,以下就是对死锁、活锁、锁机饿的介绍。
一、死锁(DeadLock)
1. 基本介绍
死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作
用的情况下,这些线程会一直相互等待而无法继续运行下去。
由图可知,线程A已经持有了资源2,它同时还想申请资源1,线程B已经持有了资源1,它同时还想
申请资源2,所以线程1和线程2就因为相互等待对方已经持有的资源,而进入了死锁状态。
举个栗子:
某一天竹子和熊猫在森林里捡到一把玩具弓箭,竹子和熊猫都想玩,原本说好一人玩一次的来,但
是后面竹子耍赖,想再玩一次,所以就把弓一直拿在自己手上,而本应该轮到熊猫玩的,所以熊猫
跑去捡起了竹子前面刚刚射出去的箭,然后跑回来之后便发生了如下状况:
熊猫道:竹子,快把你手里的弓给我,该轮到我玩了.... 竹子说:不,你先把你手里的箭给我,我
再玩一次就给你.... 最终导致熊猫等着竹子的弓,竹子等着熊猫的箭,双方都不肯退步,结果陷入
僵局场面....。 相信这个场景各位小伙伴多多少少都在自己小时候发生过,这个情况在程序中发生
时就被称为死锁状况,如果出现后则必须外力介入,然后破坏掉死锁状态后推进程序继续执行。如
上述的案例中,此时就必须第三者介入,把“违反约定”的竹子手中的弓拿过去给熊猫......
2. 死锁产生原因/如何避免死锁、排查死锁
关于锁饥饿和活锁前面阐述的内容便已足够了,不过对于死锁这块的内容,无论在面试过程中,还
是在实际开发场景下都比较常见,所以再单独拿出来分析一个段落。
在前面提及过死锁的概念:死锁是指两个或两个以上的线程(或进程)在运行过程中,因为资源竞
争而造成相互等待的现象。而此时可以进一步拆解这句话,可以得出死锁如下结论:
① 参与的执行实体(线程或进程)必须要为两个或两个以上。
② 参与的执行实体都需要等待资源方可执行。
③ 参与的执行实体都均已占据对方等待的资源。
④ 死锁情况下会占用大量资源而不工作,如果发生大面积的死锁情况可能会导致程序或系统崩溃。
3. 死锁产生的四个必要条件
而诱发死锁的根本从前面的分析中可以得知:是因为竞争资源引起的。当然,产生死锁存在四个必要条件,如
条件一:互斥条件
指分配到的资源具备排他使用性,即在一段时间内某资源只能由一个执行实体使用。
如果此时还有其它执行实体请求资源,则请求者只能等待,直至占有资源的执行实体使用完成后释
放才行。
条件二:不可剥夺条件
指执行实体已持有的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
条件三:请求与保持条件
指运行过程中,执行实体已经获取了至少一个资源,但又提出了新的资源请求,而该资源已被其它
实体占用,此时当前请求资源的实体阻塞,但在阻塞时却不释放自己已获得的其它资源,一直保持
着对其他资源的占用。
条件四:环状等待条件
指在发生死锁时,必然存在一个执行实体的资源环形链。
比如:线程T1等待T2占用的一个资源,线程在等待线程T3占用的一个资源,而线程则在等待占用
的一个资源,最终形成了一个环状的资源等待链。
知识小结
以上就是死锁发生的四个必要条件,只要系统或程序内发生死锁情况,那么这四个条件必然成立,
只要上述中任意一条不符合,那么就不会发生死
4. 资源引发的死锁问题
4.1. 前言
前面曾提到过一句,死锁情况的发生必然是因为资源问题引起的,而在上述资源中,竞争临时性资
源和不可剥夺性资源都可能引起死锁发生,也包括如果资源请求顺序不当也会诱发死锁问题,如两
条并发线程同时执行,持有资源M1,线程持有M2,而又在请求,又在请求,两者都会因为所需资
源被占用而阻塞,最终造成死锁。
当然,也并非只有资源抢占会导致死锁出现,有时候没有发生资源抢占,就单纯的资源等待也会造
成死锁场面,如:服务A在等待服务B的信号,而服务恰巧也在等待服务的信号,结果也会导致双
方之间无法继续向前推进执行。
不过从这里可以看出:A和B不是因为竞争同一资源,而是在等待对方的资源导致死锁。
对于这个例子有人可能会疑惑,这不是活锁情况吗? 答案并非如此,因为活锁情况讲究的是一个
“活”字,而上述这个案例,双方之间都是处于相互等待的“死”
4.2. 系统资源的分类
操作系统以及硬件平台上存在各种各样不同的资源,而资源的种类大体可以分为永久性资源、临时
性资源、可抢占式资源以及不可抢占式
分类一:永久性资源
永久性资源也被称为可重复性资源,即代表着一个资源可以被执行实体(线程/进程)重复性使
用,它们不会因为执行实体的生命周期改变而发生变化。
比如所有的硬件资源就是典型的永久性资源,这些资源的数量是固定的,执行实体在运行时即不能
创建,也不能销毁,要使用这些资源时必须要按照请求资源、使用资源、释放资源这样的顺序
分类二:临时性资源
临时性资源也被称为消耗性资源,这些资源是由执行实体在运行过程中动态的创建和销毁的,如硬
件中断信号、缓冲区内的消息、队列中的任务等,这些都属于临时性资源,通常是由一个执行实体
创建出来之后,被另外的执行实体处理后销毁。比如典型的一些消息中间件的使用,也就是生产
者-消费者
分类三:可抢占式资源
可抢占式资源也被称为可剥夺性资源,是指一个执行实体在获取到某个资源之后,该资源是有可能
被其他实体或系统剥夺走的。
可剥夺性资源在程序中也比较常见,如:
- 进程级别:CPU、主内存等资源都属于可剥夺性资源,系统将这些资源分配给一个进程之后,系统是可以将这些资源剥夺后转交给其他进程使用的。
- 线程级别:比如 Java 中的 ForkJoin 框架中的任务,分配给一个线程的任务是有可能被其他线程窃取的。可剥夺性资源还有很多,诸如上述过程中的一些类似的资源都可以被称为可剥夺性
分类四:不可抢占式资源
同样,不可抢占式资源也被称为不可剥夺性资源,不可剥夺性是指把一个执行实体获取到资源之
后,系统或程序不能强行收回,只能在实体使用完后自行释放。如:
- 进程级别:磁带机、打印机等资源,分配给进程之后只能由进程使用完后自行释放。
- 线程级别:锁资源就是典型的线程级别的不可剥夺性资源,当一条线程获取到锁资源后,其他线程不能剥夺该资源,只能由获取到锁的线程自行释放。
5. 死锁案例分析
上述对于死锁的理论进行了大概阐述,下来来个简单例子感受一下死锁情景:
/**
* @projectName: 02JUC
* @ClassName DeadLock
* @description:
* @author: CodingW
* @create: 2025.01.16.14:31
* @Version 1.0
**/
public class DeadLock implements Runnable {
public boolean flag = true;
// 静态成员属于class,是所有实例对象可共享的
private static Object o1 = new Object(), o2 = new Object();
public DeadLock(boolean flag) {
this.flag = flag;
}
@Override
public void run() {
if (flag) {
synchronized (o1) {
System.out.println("线程:" + Thread.currentThread().getName() + "持有o1....");
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "等待o2....");
synchronized (o2) {
System.out.println("true");
}
}
}
if (!flag) {
synchronized (o2) {
System.out.println(Thread.currentThread().getName() + "持有o2....");
try {
Thread.sleep(500);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "等待o1....");
synchronized (o1) {
System.out.println("false");
}
}
}
}
public static void main(String[] args) {
Thread t1 = new Thread(new DeadLock(true), "T1");
Thread t2 = new Thread(new DeadLock(false), "T2");
// 因为线程调度是按时间片切换决定的,
// 所以先执行哪个线程是不确定的,也就代表着:
// 后面的t1.run()可能在t2.run()之前运行
t1.start();
t2.start();
}
}
// 运行结果如下:
/*
线程:T1持有o1....
线程:T2持有o2....
线程:T2等待o1....
线程:T1等待o2....
*/
如上是一个简单的死锁案例,在该代码中:
- 当flag==true时,先获取对象o1的锁,获取成功之后休眠500ms,而发生这个动作的必然是t1,因为在main方法中,我们将任务的flag显式的置为了true。
- 而当线程睡眠时,t2线程启动,此时任务的flag=false,所以会去获取对象o2的锁资源,然后获取成功之后休眠。
- 此时线程睡眠时间结束,线程被唤醒后会继续往下执行,然后需要获取对象的锁资源,但此时已经被持有,此时会阻塞等待。
- 而此刻线程也从睡眠中被唤醒会继续往下执行,然后需要获取对象的锁资源,但此时已经被持有,此时会阻塞等待。
- 最终导致线程t1、t2相互等待对象的资源,都需要获取对方持有的资源之后才可继续往下执行,最终导致死锁产生。
6. 死锁处理
对于死锁的情况一旦出现都是比较麻烦的,但这也是设计并发程序避免不了的问题,当你想要通过
多线程编程技术提升你的程序处理速度和整体吞吐量时,对于死锁的问题也是必须要考虑的一项,
而处理死锁问题总的归纳来
说可以从如下四个角度出发:
① 预防死锁:通过代码设计或更改配置来破坏掉死锁产生的四个条件其中之一,以此达到预防死
锁的目的。
② 避免死锁:在资源分配的过程中,尽量保证资源请求的顺序性,防止推进顺序不当引起死锁问
题产生。
③ 检测死锁:允许系统在运行过程中发生死锁情况,但可设置检测机制及时检测死锁的发生,并
采取适当措施加以清除。
④ 解除死锁:当检测出死锁后,便采取适当措施将进程从死锁状态中解脱
6.1. 预防死锁
处理方式
前面提过,预防死锁的手段是通过破坏死锁产生的四个必要条件中的一个或多个,以此达到预防死
锁的目的。
方式一:破坏“互斥”条件
在程序中将所有“互斥”的逻辑移除,如果一个资源不能被独占使用时,那么死锁情况必然不会发
生。但一般来说在所列的四个条件中,“互斥”条件是不能破坏的,因为程序设计中必须要考虑线程
安全问题,所以“互斥”条件是必需的。因此,在死锁预防里主要是破坏其他几个必要条件,不会去
破坏“互斥”条件。
方式二:破坏“不可剥夺”条件
破坏“不可剥夺性”条件的含义是指取消资源独占性,一个执行实体获取到的资源可以被别的实体或
系统强制剥夺,在程序中可以这样设计:
- ① 如果占用资源的实体下一步资源请求失败,那么则释放掉之前获取到的所有资源,后续再重新请求这些资源和另外的资源(和分布式事务的概念有些类似)。
- ② 如果一个实体需要请求的资源已经被另一个实体持有,那么则由程序或系统将该资源释放,然后让给当前实体获取执行。这种方式在Java中也有实现,就是设置线程的优先级,优先级高的线程是可以抢占优先级低的资源先执行的。
方式三:破坏“请求与保持”条件
破坏“请求与保持”条件的意思是:系统或程序中不允许出现一个执行实体在获取到资源的情况下再
去申请其他资源,主要有两种方案:
- ① 一次性分配方案:对于执行实体所需的资源,系统或程序要么一次性全部给它,要么什么都不给。
- ② 要求每个执行实体提出新的资源申请前,释放它所占有的资源。
但总归来说,这种情况也比较难满足,因为程序中难免会有些情况下要占用多个资源后才能一起操
作,就比如最简单的数据库写入操作,在Java程序这边需要先获取到锁资源后才能通过连接对象进
行操作,但获取到的连接对象在往DB表中写入数据的时候还需要再和DB中其他连接一起竞争DB
那边的锁资源方可真正写表。
方式四:破坏“环状等待链”条件
破坏“环状等待链”条件实际上就是要求控制资源的请求顺序性,防止请求顺序不当导致的环状等待
链闭环出现。
这个点主要是在编码的时候要注意,对于一些锁资源的获取、连接池、RPC调用、MQ消费等逻
辑,尽量保证资源请求顺序合理,避免由于顺序性不当引起死锁问题出现。
知识小结
因为预防死锁的策略需要实现会太过苛刻,所以如果真正的在程序设计时考虑这些方面,可能会导
致系统资源利用率下降,也可能会导致系统/程序整体吞吐量降低。
总的来说,预防死锁只需要在系统设计、进程调度、线程调度、业务编码等方面刻意关注一下:如
何让死锁的四个必要条件不成立避免死锁
避免死锁是指系统或程序对于每个能满足的执行实体的资源请求进行动态检查,并且根据检查结果
决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,反之则给予资源分配,这是一种
保证系统不进入死锁状态的动态策略。
6.2. 避免死锁
方式一:有序资源分配法
有序资源分配法:这种方式大多数被操作系统应用于进程资源分配。
假设此时有两个进程
P1、P2
进程 P1
P1
需要请求资源顺序为
R1、R2
而进程 P2
P2
使用资源的顺序则为
R2、R1
如果这个情况下两个进程并发执行,采用动态分配法的情况下是有一定几率发生死锁的,所以可以
采用有序资源分配法,把资源分配的顺序改为如下情况,从而做到破坏环路条件,避免死锁发生。
P1:R1,R2
P2:R1,R2
方式二:银行家算法
银行家算法顾名思义是来源于银行的借贷业务,有限的本金要应多个客户的借贷周转,为了防止银
行家资金无法周转而倒闭,对每一笔贷款,必须考察其借贷者是否能按期归还。在操作系统中研究
资源分配策略时也有类似问题,系统中有限的资源要供多个进程使用,必须保证得到的资源的进程
能在有限的时间内归还资源,以供其他进程使用资源,确保整个操作系统能够正常运转。如果资源
分配不得到就会发生进程之间环状等待资源,则进程都无法继续执行下去,最终造成死锁现象。
OS实现:把一个进程需要的、已占有的资源情况记录在进程控制块中,假定进程控制块PCB其中
“状态”有就绪态、等待态和完成态。当进程在处于等待态时,表示系统不能满足该进程当前的资源
申请。
“资源需求总量”表示进程在整个执行过程中总共要申请的资源量。显然,每个进程的资源需求总量
不能超过系统拥有的资源总数,通过银行家算法进行资源分配可以避免死锁。
上述的两种算法更多情况下是操作系统层面对进程级别的资源分配算法,而在程序开发中又该如何
编码才能尽量避免死锁呢?
大概有如下两种方式:
① 顺序加锁
② 超时加
知识小结
对于上述中的两种方式从字面意思就可以理解出:
前者是保证锁资源的请求顺序性,防止请求顺序不当引起资源相互等待,最终造成死锁发生。
而后者则是获取锁超时中断的意思,在JDK级别的锁,如ReetrantLock、Redisson等,都支持该方
式,也就是在指定时间内未获取到锁资源则放弃获取锁
6.3. 检测死锁
6.3.1. 简介
检测死锁这块也分为两个方向来谈,也就是分别从进程和线程两个角度出发。进程级别来说,操作
系统在设计的时候就考虑到了进程并行执行的情况,所以有专门设计死锁的检测机制,该机制能够
检测到死锁发生的位置和原因,如果检测到死锁时会暴力破坏死锁条件,从而使得并发进程从死锁
状态中恢复。
而对于Java程序员而言,如果在线上程序运行中发生了死锁又该如何排查检测呢?我们接着来进行
详细分析。
6.3.2. 线上排查死锁的方式(Java)
- 通过 jps+jstack 工具排查
- 通过 jconsole 工具排查
- 通过 jvisualvm 工具排查
6.3.3. 其他工具
当然你也可以通过其他一些第三方工具排查问题,但前面两种都是JDK自带的工具。
6.4. 解除死锁
6.4.1. 基本介绍
当排查到死锁的具体发生原因和发生位置之后,就应立即釆取对应的措施解除死锁,避免长时间的
资源占用导致最终拖