什么是死锁?
所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。 因此我们举个例子来描述,如果此时有一个线程A,按照先锁a再获得锁b的的顺序获得锁,而在此同时又有另外一个线程B,按照先锁b再锁a的顺序获得锁。
一、"死锁"出现的典型场景
(一)一个线程一把锁连续加锁两次,如果锁是不可重入锁,就会死锁
一个线程一把锁,这个就是之前所介绍的 可重入锁
线程A 针对锁1 连续加锁两次,就构成了死锁 !
第一次加锁 加锁成功,第二次加锁 就需要第一次的锁释放,于是就阻塞等待;
但是,第一次加锁释放,就得依赖第二次加锁成功;
于是,"死锁" 就出现了!!!
(二)两个线程两把锁,相互获取对方锁
此时,不管是不是可重入锁,都会造成 "死锁" 问题 !
可以用一个例子来说明情况:
假设 甲同学 和 乙同学 在一起
甲同学 吃饺子喜欢蘸酱油,乙同学 吃饺子喜欢蘸醋,由于在一起了,生活习性都受到了彼此的影响,两位同学吃饺子的时候 喜欢都蘸上一点了
有一天,吃饺子的时候,甲同学拿起了酱油,乙同学拿起了醋 ~
甲同学说:"你把醋给我,我用完了都给你";乙同学说:"你把酱油给我,我用完了就把醋给你" ~
此时,两者相持不下,这就造成了 "死锁" 问题
此时,甲同学 和 乙同学 就可以看成是两个线程,酱油和醋 就可以看成是两把锁
线程1 获取到锁A,线程2 获取到锁B;
线程1 尝试获取锁B(需要线程2 释放锁B) ,线程2 尝试获取到锁A(需要线程1 释放锁A);
在这种情况下,逻辑上就构成了循环,就构成了 "死锁" ~
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) {
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread(() -> {
synchronized (lock1) {
System.out.println( "t1:已经获取到锁1");
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
System.out.println( "t1:获取到锁2");
}
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (lock2) {
System.out.println( "t2:获取到锁2");
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
System.out.println( "t2:获取到锁1");
}
}
}, "t2");
t2.start();
}
}
一直等待,死锁了
(三)多个线程多个锁,"哲学家就餐问题"
此时,这种情况和 第二种情况 类似,只是更复杂一点而已
在多个线程多把锁的情况下,"死锁"问题 就是一个概率性的问题,但是也绝对不能忽视 !
在谈到 "多个线程多把锁" 的时候,就会引出一个很经典的问题 —— "哲学家就餐问题" !
故事背景:
有 5 个哲学家,相当于有 5 个线程,他们只会做两件事情:
思考人生(相当于是 线程休眠)
吃面条(相当于是 在CPU上运行)
由于多线程的调度是无序的,所以说 这几个哲学家 什么时候去思考人生,什么时候去吃面条,我们是不确定的但是,此时 在餐桌上 一共只有 5 根筷子,并且 这5 根筷子 分别在 两两哲学家 之间
并且,他们之间都不相互嫌弃,吃面条的时候要拿起 左右手两双筷子(这就导致相邻的哲学家需要等待) ~
此处的筷子就视为 两把"锁",只有这两把锁都获取到了,才可以吃面条 ~
出现 "死锁" 问题的情况:
假设 在同一时刻,所有的哲学家都想吃面条 ~
他们同时伸出左手,拿起左边的筷子;然后又同时伸出右手,尝试去拿右边的筷子;此时,右手的筷子都拿不起来,因此都无法吃面
由于哲学家都非常固执,导致即使吃不到面条 也不会放下左手的筷子,这样的情况就会一直持续下去 ,这就构成了死锁
二、产生 "死锁" 的必要条件
鉴于 "死锁"问题,程序员大佬们 总结了 4 个 "死锁"产生的必要条件:
- 互斥使用(线程1 拿到锁A,其他线程就无法获取到 A)
- 不可抢占(线程1 拿到锁A,其他线程只能阻塞等待,等到线程1 主动释放锁,而不是强行把锁抢走)
- 请求和保持(当线程1 拿到锁A 之后,就会一直持有这个获取到锁的状态,直到说主动释放锁)
- 循环等待(线程1 等待线程2,线程2 又尝试等待线程1)
三、解决 "死锁" 问题的办法
- 资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
- 只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
- 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
- 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)
根据 产生 "死锁" 的必要条件,我们可以知道,前三个必要条件 都是在描述锁的基本特点,在实际情况下 我们并不好直接去破坏
但是,第四个必要条件 却是和代码编写密切相关
如果我们能够在编码上做出一些注意和约定,就可以打破 "循环等待",避免死锁 !!!
1、以确定的顺序获得锁
针对两个特定的锁,开发者可以尝试按照锁对象的hashCode值大小的顺序,分别获得两个锁,这样锁总是会以特定的顺序获得锁,那么死锁也不会发生。问题变得更加复杂一些,如果此时有多个线程,都在竞争不同的锁,简单按照锁对象的hashCode进行排序(单纯按照hashCode顺序排序会出现“环路等待”),可能就无法满足要求了,这个时候开发者可以使用银行家算法,所有的锁都按照特定的顺序获取,同样可以防止死锁的发生。
针对多把锁,进行编号:1、2、3、4、......
并且约定在获取多把锁的时候,要明确获取锁的顺序是 从小到大(或者 从大到小) 的顺序
如(此处以从小到大为例):
线程要拿到 1、2 这两把锁,就先获取 1,再获取 2;
线程要拿到 2、4 这两把锁,就先获取 2,再获取 4 ~
只要所有的线程都遵循这个顺序,就不会出现 "循环等待",就不会出现死锁 !!!
我们可以把这个解决办法 带入到上面的 "哲学家就餐问题" 看看 ~
约定:获取所得顺序是:从小到大 ~
之后,最左边的哲学家 就可以得到两把锁了,于是就可以吃到面条了 ~
等到 吃完面条之后,会释放 4、5两把筷子;然后 最上面的哲学家 也可以吃到面条了,......,就这样的话,顺时针旋转,依次 5 位哲学家都可以吃上面条了
2、超时放弃
当使用synchronized关键词提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,然而Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁。通过这种方式,也可以很有效地避免死锁。
避免死锁:
- 预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得 较满意的系统性能。由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全的状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法。
- 银行家算法:首先需要定义状态和安全状态的概念。系统的状态是当前给进程分配的资源情况。因此,状态包含两个向量Resource(系统中每种资源的总量)和Available(未分配给进程的每种资源的总量)及两个矩阵Claim(表示进程对资源的需求)和Allocation(表示当前分配给进程的资源)。安全状态是指至少有一个资源分配序列不会导致死锁。当进程请求一组资源时,假设同意该请求,从而改变了系统的状态,然后确定其结果是否还处于安全状态。如果是,同意这个请求;如果不是,阻塞该进程知道同意该请求后系统状态仍然是安全的。
检测死锁
- 首先为每个进程和每个资源指定一个唯一的号码;
- 然后建立资源分配表和进程等待表。
解除死锁:
当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:
- 剥夺资源:从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态;
- 撤消进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态.消除为止;所谓代价是指优先级、运行代价、进程的重要性和价值等。
死锁检测
1、Jstack命令
jstack是java虚拟机自带的一种堆栈跟踪工具。jstack用于打印出给定的java进程ID或core file或远程调试服务的Java堆栈信息。 Jstack工具可以用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。
2、JConsole工具
Jconsole是JDK自带的监控工具,在JDK/bin目录下可以找到。它用于连接正在运行的本地或者远程的JVM,对运行在Java应用程序的资源消耗和性能进行监控,并画出大量的图表,提供强大的可视化界面。而且本身占用的服务器内存很小,甚至可以说几乎不消耗。