1. 死锁概述
1.1 什么是死锁?
- 官方解释: 死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或彼此通信而造成的一种阻塞现象。若无外力作用,他们都将无法推进下去。
- 自己的理解:
- 当前进程拥有其他进程等待的资源
- 当前进程等待其他进程已拥有的资源
- 它们都不放弃自己拥有的资源
- 死锁实例: 十字路口的汽车,互相等待其他汽车释放资源,却又不愿意退出路口。
1.2 死锁产生的原因
- 根据死锁的定义,死锁产生的原因如下:
- 由于资源不足而导致的资源竞争: 多个进程所共享的资源不足,引起他们对资源的竞争而产生死锁。
- 由于并发执行的顺序不当: 进程运行过程中,请求和释放资源的顺序不当而导致进程死锁。
1.3 死锁的程序实例
-
线程1和线程2均需要获取对象A和B,线程1先获取对象A,再获取对象B;线程2先获取对象B,再获取对象A。双方都在等地对方释放已拥有的对象,且不放弃自己已拥有的对象。
public class DeadLock { public static String objA = "objA"; public static Integer objB = 1; public static void main(String[] arg) { Thread1 thread1 = new Thread1(); Thread th1 = new Thread(thread1); Thread2 thread2 = new Thread2(); Thread th2 = new Thread(thread2); th1.start(); th2.start(); } } class Thread1 implements Runnable { @Override public void run() { try { System.out.println("Thread1 is running ..."); synchronized (DeadLock.objA) {// Thread1先获取objA System.out.println("Thread1 got the DeadLock.objA"); Thread.sleep(3000); // 等待Thread2获取资源objB synchronized (DeadLock.objB) {// Thread1再获取objB System.out.println("Thread1 got the DeadLock.objB"); } } } catch (InterruptedException e) { e.printStackTrace(); } } } class Thread2 implements Runnable { @Override public void run() { try { System.out.println("Thread2 is running ..."); synchronized (DeadLock.objB) {// Thread2先获取objB System.out.println("Thread2 got the DeadLock.objB"); Thread.sleep(3000);// 等待Thread1获取资源objA synchronized (DeadLock.objA) {// Thread2再获取objA System.out.println("Thread2 got the DeadLock.objA"); } } } catch (InterruptedException e) { e.printStackTrace(); } } }
1.4 资源分配图
- 资源分配图是有向图,其中进程用圆圈表示,资源用方框表示,方框中的点表示资源的数量。
- 进程指向资源,表示进程请求资源;资源指向进程,表示进程占有资源。
- 存在死锁的资源分配图:
注意:
- 资源分配图存在环,并不一定产生死锁;产生死锁,资源分配图必定存在环。
- 需要简化进程之间的资源等待关系,看是否存在死循环等待。
1.5 产生死锁的四个必要条件
- 互斥: 一个资源一次只能分配给一个进程使用。
- 占有且等待: 一个进程在等待其他进程释放资源的同时,继续占有已经拥有的资源。
- 不可抢占: 一个进程不能强行占有其他进程已拥有的资源。
- 循环等待: 若干进程由于等待其他进程释放资源,而形成相互等待的循环。例如,进程A等待进程B,进程B等待进程C,···,进程X等待进程Y,进程Y等待进程A。
一些说明
- 互斥、占有且等待、不可抢占是形成死锁的必要条件;互斥、占有且等待、不可抢占、循环等待是形成死锁的充分必要条件。
- 循环等待是前三个条件的潜在结果,也是死锁的定义。
2. 如何解决死锁?
- 解决死锁的四种办法:鸵鸟策略、死锁的预防、死锁的避免、死锁的检测与恢复。
2.1 鸵鸟策略
- 鸵鸟策略: 把头埋在沙子里,假装根本没有发生问题。
- 为什么可以采用鸵鸟策略?
- 解决死锁的代价很高,采用鸵鸟策略:不采取任何措施,能获得更高的性能。
- 死锁发生的概率很低,就算发生死锁对对用户的影响并不大,所以可以采用鸵鸟策略。
- 大多数操作系统,包括Linux、Unix和Windows,解决死锁的办法仅仅是忽略它。
2.2 死锁的预防
- 死锁的预防是指采取一些措施破坏死锁发生的必要条件,让死锁无法发生。
- 死锁预防的方法:
- 间接方法: 防止前面三个必要条件中任意一个的发生。
- 直接方法: 防止循环等待的发生。
- 破坏互斥
- 互斥不可能被禁止: 如文件,可以允许多个读访问,但不能允许多个写访问。
- 破坏占有且等待
- 要求进程在运行之前一次性获取所有需要的资源,并且阻塞进程直到可以一次性获取所有需要的资源。
- 该方法存在两个缺陷:
- 低效
- 一个进程可能无法事先知道它需要哪些资源。
- 低效的表现1:一个进程在能一次性获取所有需要资源前,可能被阻塞很长时间。
- 低效的表现2: 一个进程一次性获取到所有需要的资源后,有的资源可能在很长一段时间不会使用,而其他进程也无法使用,导致资源利用率降低。
- 破坏不可抢占
- 方法一: 一个进程占有某些资源后进一步申请其他资源,如果遭到拒绝,该进程必须释放已经占有的资源。(被拒绝则释放)
- 方法二: 在两个进程优先级不同的情况下,高优先级的进程申请被低优先级进程占有的资源时,系统要求低优先级的进程释放资源。(优先级)
- 破坏循环等待
- 给资源统一进行编号,进程只能按照编号顺序请求资源。
- 如上面发生死锁的实例中,
objA的编号< objB的编号
,两个线程都因该先获取objA的锁,再获取objB的锁。
2.3 死锁的避免
- 死锁的避免是指在资源的动态分配过程中,采取一些算法防止系统进入不安全状态,从而避免死锁的发生。
- 死锁的避免,不需要事先破坏死锁发生的必要条件。
- 死锁的避免仅仅是预测发生死锁的可能性,并确保不会出现这种可能性。(有苗头就扼杀)
死锁避免的两种方法:
- 进程启动拒绝: 如果一个进程的资源请求会导致死锁,则不启动该进程。
- 资源分配拒绝(银行家算法): 如果一个进程增加资源的请求会导致死锁,则不允许此分配。
n
个进程m
种资源的前提下,相关的概念:- 向量R表示每种资源的总量(
Resource
),R=(R1,R2,...,Rm)R=(R_1,R_2,...,R_m)R=(R1,R2,...,Rm) - 向量V表示每种资源的剩余量(
Available
),V=(V1,V2,...,Vm)V=(V_1,V_2,...,V_m)V=(V1,V2,...,Vm) - Claim矩阵,CijC_{ij}Cij表示进程
i
对资源j
的需求 - Allocation矩阵,AijA_{ij}Aij表示进程
i
已经分配到的资源j
的数量
- 向量R表示每种资源的总量(
进程启动拒绝:
- 第
n+1
个进程对资源j
的需求,应该满足:前n
个进程的最大资源需求加上当第n+1
个进程的资源需求,应该小于等于总资源量。
C(n+1)j+∑inCij≤Rj C_{(n+1)j}+\sum_{i}^{n}C_{ij} \le R_jC(n+1)j+i∑nCij≤Rj - 该方法很难是最优的,因为它总是假设最坏的情况: 所有的进程同时发出其最大资源请求。
资源分配拒绝:
- 安全状态(
safe state
):当进程发出增加资源的请求时,至少有一种资源分配序列不会导致死锁。即至少有一种资源分配序列,能保证所有的进程顺利运行直到结束。 - 不安全状态: 不存在任何一种资源分配序列保证不会死锁。
- 举例: 当进程
P1
、P2
、P3
都发起增加资源的请求时,可以先满足进程P2
的资源请求;当进程P2
运行结束后,释放资源,可用资源增加;接着满足进程P1
的资源请求,最后满足进程P3
的资源请求。 - 定义矩阵C−AC-AC−A,即矩阵C和矩阵A中对应位置的数值相减,记录进程对每种资源的剩余需求。
- 对于进程
i
,它对资源j
增加的请求都满足:Cij−Aij≤VijC_{ij} - A{ij} \le V_{ij}Cij−Aij≤Vij,即需要的量小于等于现存量。
2.4 死锁的检测与恢复
- 死锁的检测与恢复: 操作系统周期性地执行算法检测是否存在循环等待,如果存在,则对死锁进行恢复。
- 自己的理解: 不是试图阻止死锁,而是检测死锁的发生并进行恢复。
每种类型一个资源的的死锁检测:
- 利用资源分配图,检测有向图中是否存在环。如果存在环,表明死锁已经发生,采取措施进行恢复。
- 具体实现:
- 从一个节点出发进行深度优先搜索(
DFS
),对访问过的节点进行标记。 - 如果访问了已经标记的节点,就表示有向图存在环,即检测到死锁的发生。
- 从一个节点出发进行深度优先搜索(
每种类型多个资源的死锁检测:
- 需要使用到死锁避免中的分配矩阵A,新添请求矩阵Q表示每个进程将要请求的资源数量。
- 首先标记矩阵A中全为0的行对应的进程。
- 查找下标
i
,要求进程Pi
未被标记且矩阵Q中对应的行满足:Qi≤VQ_i \le VQi≤V。如果找不到这样的下标i
,则终止算法。 - 找到下标
i
,将进程Pi
已分配的资源加到V中,并标记进程Pi
。重复步骤3和4。 - 算法终止时,如果存在未被标记的进程,说明存在死锁。
死锁的恢复:
- 取消所有死锁的进程,这是操作系统中最常使用的方法。
- 把每个死锁的进程回滚到前面定义的某些检查点(
checkpoint
),重新启动所有进程。虽然存在原来的死锁再次发生的风险,但是并发的不确定性通常能保证这种风险不会出现。 - 连续取消死锁的进程直到不存在死锁:
- 取消的顺序基于某种代价最小原则,每取消一个进程都需要重新执行死锁检测算法,以检查是否存在死锁。
- 连续抢占资源直到不存在死锁
- 抢占的顺序基于某种代价的选择方法
- 每次抢占时,被抢占资源的进程需要回滚到获得这些资源之前的状态,还需要重新执行死锁检测算法。
3. 银行家算法
3.1 什么是银行家算法?
- 实际场景描述:
- 小镇上有一个银行家,他有一群客户。
- 只有当客户的贷款请求被满足,客户才能成功投资并还款。否则,银行家借出去的钱,永远无法收回。
- 银行家为了安抚客户,只会先允许一部分的贷款请求。这时,银行家手里的资金变得不充裕。
- 如果他能找到一种将剩余资金进行分配的有效方法,使得所有的客户的贷款请求都满足,他才能收回自己的钱。
- 银行家算法要做的事情:
- 判断对当前请求的满足,是否会导致银行家进入不安全状态。
- 如果会,则拒绝请求;如果不会,则满足请求。
3.2 银行家算法的过程
如何检查一个状态是否安全?
- 初始化:根据矩阵C和A,计算出对应矩阵C−AC-AC−A。
- 查找矩阵C−AC-AC−A中是否存在一行小于向量V。如果找不到,那么系统将会发生死锁,状态是不安全的。
- 如果找到一行,则将其已分配的资源加入到向量V中,并标记该进程终止:更新矩阵C、A、矩阵C−AC-AC−A中对应的行均为0,表示进程已终止。
- 重复步骤2和3,直到所有的进程都标记为终止,则状态是安全的。
4. 哲学家就餐问题
4.1 问题描述
- 有5个哲学家,他们围坐在一张餐桌旁,餐桌上有5盘意大利面、5把叉子。
- 每个哲学家的动作只有思考和吃饭,并且吃饭时需要使用两只叉子。
- 如果每位哲学家都先拿起左手边的叉子,再拿起右手手边的叉子。吃完面后,这两把叉子又被放到桌子上。
- 这会导致死锁:
- 如果所有的哲学家都同一时间感到饥饿,他们同时先拿起左手边的叉子,然后伸手拿右手边的叉子。
- 而右手边的叉子已经被拿,所有的哲学家都会处于饥饿状态。
- 哲学家就餐问题,实现的算法必须保持互斥(不能有两个哲学家拿到同一把叉子),还要避免死锁(所有的哲学家都吃不上)和避免饥饿(某些哲学家一直吃不上)。
4.2 哲学家就餐问题的解决方法
- 最多允许四个哲学家同时围坐在桌子周围,这样至少有一个哲学家能拿到剩余的叉子
- 同时拿起:哲学家必须确定左右两边的叉子都可用后,才能同时拿起左右两边的叉子。
- 给所有哲学家编号,规定定奇数号的哲学家先拿左边的叉子,再拿右边的叉;而偶数号的哲学家先拿右边的叉子,再拿左边的叉子
4.3 同时拿起的叉子的Java实现
-
设计思想:
- 一个哲学家是一个线程,它不停地进行思考、吃饭。
- 为了模拟思考和吃饭的过程,该线程需要休眠一定时间。
- 共有5把叉子,每把叉子的状态为可用或不可用。
- 吃饭时,要求哲学家左右两边的叉子都是可用的,才能同时拿起叉子(将叉子的状态设置为不可用)。
- 哲学家放下叉子时,需要将叉子状态设置为可用,并通知在等待的其他哲学家。
-
Java实现:
public class PhilosopherProblem { public static void main(String[] arg) { Fork fork = new Fork(); Philosopher philosopher0 = new Philosopher("0", fork); Philosopher philosopher1 = new Philosopher("1", fork); Philosopher philosopher2 = new Philosopher("2", fork); Philosopher philosopher3 = new Philosopher("3", fork); Philosopher philosopher4 = new Philosopher("4", fork); philosopher0.start(); philosopher1.start(); philosopher2.start(); philosopher3.start(); philosopher4.start(); } } class Philosopher extends Thread { private String name; private Fork fork; public Philosopher(String name, Fork fork) { super(name);// 更新自身线程名,同时需要设置自己的name this.name = name; this.fork = fork; } @Override public void run() { while (true) { think(); fork.takeFork(); eat(); fork.putFork(); } } public void think() { System.out.println("Philosopher" + name + ": I'm thinking..."); try { Thread.sleep(1000);// 模拟思考 } catch (InterruptedException e) { e.printStackTrace(); } } public void eat() { System.out.println("Philosopher" + name + ": I'm eating..."); try { Thread.sleep(1000);// 模拟吃饭 } catch (InterruptedException e) { e.printStackTrace(); } } } class Fork { // 初始时,五支叉子都未被使用 private boolean[] fork = new boolean[5]; // 只有当左右两边的叉子都同时可用时,才能拿起叉子 public synchronized void takeFork() { String name = Thread.currentThread().getName(); int i = Integer.valueOf(name); while (fork[i] || fork[(i + 1) % 5]) { try { wait(); } catch (InterruptedException e) { e.printStackTrace(); } } // 同时拿起叉子,更新叉子状态 fork[i] = true; fork[(i + 1) % 5] = true; } // 同时释放左右手的叉子并通知等待的哲学家 public synchronized void putFork() { String name = Thread.currentThread().getName(); int i = Integer.valueOf(name); // 更新叉子的状态 fork[i] = false; fork[(i + 1) % 5] = false; notifyAll(); // 通知在等待的哲学家 } }
5. 死锁的问题总结
使用java代码实现两个线程死锁。
- 两个线程分别访问两种共享数据,加锁顺序不一致导致死锁。
什么是死锁?死锁的必要条件?
- 死锁:
- 两个或两个以上的进程,由于资源竞争或彼此通信而造成的一种阻塞现象。
- 若无外力作用,它们都将无法推进下去。
- 必要条件:互斥、占有且等待、不可抢占、循环等待
如何解决死锁?
- 鸵鸟策略
- 死锁的预防: 破坏死锁的四个必要条件,互斥(无法禁止)、占有且等待(一次性请求所有需要的资源,低效、不太现实)、不可抢占(被拒绝就释放已经占有资源或者允许高优级先抢占低优先级的资源)、循环等待(资源编号,顺序获取)
- 死锁的避免: 采用一些算法避免系统进入不安全状态,两种策略:进程启动拒绝(总是假设最坏情况)、资源分配拒绝(银行家算法)
- 死锁的检测与恢复: 检测死锁的发生并进行恢复,恢复:取消所有死锁的进程、回滚、连续释放资源、连续取消进程。
java如何避免死锁?
- 按顺序加锁: 哲学家就餐问题中,要求同时获取到共享数据。
- 超时放弃锁: 通过带时间参数的
tryLock()
方法,如果超时获取锁失败,则释放已经获取到的锁。
MySQL中的死锁
- 资金A先后分配给借款人1、2,资金B先后分配给借款人2、1。
- 解决办法:一次性锁住所有能分配到资金的借款人。
死锁、活锁、饥饿的区别?
-
死锁:
- 概念:
- 两个或两个以上的进程,由于资源竞争或彼此通信而造成的一种阻塞现象。
- 若无外力作用,它们都将无法推进下去。
- 形象的解释:
- 单向通行的木桥,A和B同时从桥的两端上桥。
- 到了桥上相遇后,却都不愿意退回桥头,导致两个人都在桥上,无法继续前进。
- 概念:
-
活锁:
- 概念: 任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试——失败——尝试——失败的过程
- 活锁多发生于优先级相同的进程之间:
- 进程A明明可以使用资源,他却很绅士让其他进程先使用资源;
- 进程B也明明可以使用资源,却很绅士的让其他进程先使用。
- 这样多个进程你让我,我让你,最后谁都无法使用资源。
- 形象的解释:
- 单向通行的木桥,A和B同时走到桥头。
- A让B先走,B又让A先走,大家相互谦让,结果谁都没有过桥。
-
饥饿
- 概念:一个进程虽然能执行,却被调度器无限期地忽视,而不能被调度执行的情况。
- 饥饿发生的场景:
- 非抢占式的短作业优先调度算法,可能会导致长作业饥饿;
- 优先级调度算法,也可能会导致低优先级的进程饥饿。Java中的
ReentrantLock
的非公平访问,可能会造成线程饥饿。
- 形象的解释:
- 哥哥照顾弟弟妹妹,有食物总是让弟弟妹妹先吃。
- 而邻居家的孩子不断地来,导致哥哥最后没吃上东西,饿得不行!