或许我们经常会在面试中或者听别人说,老师讲课也都会经常听见死锁,并且在实际并发的线程中很容易遇到这种情况,那么究竟什么是死锁,死锁如何产生的,又如何修复呢?不仅仅是为了面试,在实际开发中,也是十分重要的知识点。
简述
死锁是一种特定的,在程序中的一种状态,在我们的程序中,由于循环依赖,导致彼此一直处于等待之中,没有任何个体可以继续前进,停滞住了,就会产生死锁。但是,死锁也不仅仅是会在线程中产生的现象,在资源独占的进程之间,也同样会产生死锁的现象。但是通常来讲,我们更加关注多线程场景产生的死锁,意思很简单,两个或者多个线程持有对方需要的锁,而永久处于阻塞状态。
而找到死锁具体位置的方法,是使用jstack等工具获取到线程栈,然后确定之间的依赖关系,进而找到死锁的位置。如果死锁的位置做够明显,我们使用jstack或者jconsole就可以直接在图形界面进行有限的死锁检测。
但是如果程序运行的时候发生了死锁,基本无法在线解决,只能stop进程去修复。所以,代码开发阶段要互相审查,或者利用工具手动排查。
从面试角度来说,面试官经常会问:
- 手写一个可能死锁的程序
- 诊断死锁有哪些工具,分布式环境是否有相关的工具
- 如何避免死锁
扩展
在分析开始之前,先手写一个会死锁的程序,在这里只用两个嵌套的synchronized去获取锁,具体如下:
我们可以发现 ,这里先输出了下面的Thread2,然后才输出Thread1,这是因为程序里的线程调度依赖于操作系统,虽然你可以通过优先级之类进行影响但是具体情况不好说。
然后我们用jstack捕捉到线程栈,得出结果如下(来源于极客时间):
然后分析我们的线程栈信息,找到处于Blocked状态的进程,按照视图获取的锁去定位,就可以很快定位到死锁的位置。但类死锁的情况未必会显示得这么清楚,但是从整体上可以理解为:
区分线程状态 -> 查看等待目标 -> 对比Monitor等持有信息
所以,理解线程基本状态和并发相关元素是定位问题的关键,然后配合程序调用栈,基本就可以定位的问题代码。
同时我们也可以自己设计寻找死锁位置的程序,java官方也提供了相关的API,ThreadMXBean,其直接提供了findDeadlockedThreads()方法用于定位。修改以上代码如下:
但是这种修改方法对于线程本身是一个相对重量级的操作,所以慎用,找bug用还是可以的。
那么如何在编程中尽量预防死锁呢?
首先,我们来总结一下前面产生死锁的原因。
- 互斥条件,就比如说Java中的Monitor只能被一个线程所使用。
- 循环依赖,两个或者多个个体之间出现了锁的链条环
所以我们分析出避免死锁的思路和方法如下:
- 第一种方法
尽量避免使用多种锁,并且只有有需要的时候才用锁,尽量不适用嵌套的synchronized或者lock。
举个例子:java.nio包下以锁多而著称,尤其是NIO2,在设计的时候考虑到又要顾及非阻塞模式,又要顾及阻塞模式,所以本身模型十分复杂。导致的结果就是,一个简单的api背后就是好几把锁来控制,所以非常容易死锁。
截取极客时间的两张图:
从程序设计的调度来说,我们往往考虑到“既要…又要…”的功能设计的时候,往往会涉及到使用锁,那么设计思路或者目的就很重要了,因为其基础,共享的定位也是十分重要的,这比应用开发更加重要,需要仔细斟酌平衡。
- 第二种方法
如果必须要使用多种锁了,尽量设计好锁的获取顺序,可以参照银行家算法
银行家算法
:银行家算法(Banker’s Algorithm)是一个避免死锁(Deadlock)的著名算法,是由艾兹格·迪杰斯特拉在1965年为T.H.E系统设计的一种避免死锁产生的算法。它以银行借贷系统的分配策略为基础,判断并保证系统的安全运行。
一般情况下,可以采用简单的辅助手段:
- 将方法和锁之间的关系,用图形化的方式抽取出来,因为调用了同一个线程,所以更加简单。
- 然后根据对象之间组合、调用的关系和对比组合,考虑可能调用时序。
- 按照可能时序合并,发现可能死锁的场景。
- 第三种办法
使用.wait,为线程带来更多可控性。4await()都支持所谓的time wait,我们完全可以假定该锁不一定获得,指定超时时间,并为无法获得锁的时候退出逻辑。
并发Lock实现,如之前专栏提到的ReentrantLock还支持非阻塞的锁获取操作tryLock(),这是一个插队行为(barging),并不在乎等待的公平性,如果wait的时候资源没有被占用,就会直接抢过来用。
使用方法如下:
if (lock.tryLock() || lock.tryLock(timeout, unit)) { // ... }