3分钟讲透死锁!
一、简介
在之前的文章中,我们介绍了synchronized
同步锁关键字的作用以及相关的用法,它能够保证同一时刻最多只有一个线程执行修饰的代码段,以实现线程安全执行的效果。
但是如果过度的使用synchronized
等方式进行加锁,程序可能会出现死锁现象。
那什么是死锁呢?它有什么危害?
我们知道被synchronized
修饰的代码,当一个线程持有一个锁,其它线程尝试去获取这个锁未获取到时,那么其它线程会进入阻塞状态,直到线程释放锁才能再次拥有获取锁的条件。假如线程 A 持有锁 L 并且想获取锁 R,线程 B 持有锁 R 并且想获取锁 L,那么这两个线程将会永久等待下去,这种情况就是最简单的死锁现象。
如果程序出现了死锁,会给系统功能带来非常严重的问题,轻则导致程序响应时间变长,系统吞吐量变小;重则导致应用中的某一个功能直接失去响应能力无法提供服务,因此我们应该及时发现并避规这些问题。
当然发生死锁的软件应用,不仅限于 Java 程序,还有数据库等,不同的是:数据库系统中设计了死锁的检测以及从死锁中恢复的机制,数据库如果检测到一组事务中发生了死锁,将选择一个牺牲者并放弃这个事务。
而 Java 虚拟机解决死锁问题并没有数据库那么强大,在 Java 程序中,采用synchronized
加锁的代码如果发生死锁,两个线程就不能再使用了,并且这两个线程所在的同步代码/代码块也无法再运行了,除非杀掉服务,然后重启服务!
在实际的软件项目开发过程中,死锁其实是编程设计上的 bug,问题也比较隐晦,即使通过压力测试也不一定能找到程序上的死锁问题。死锁的出现,往往是在高负载的情况下产生,这种场景下比较难定位。
二、死锁复现
下面我们先来看一个比较经典的产生死锁示例代码。
public class DeadLock {
private final Object right = new Object();
private final Object left = new Object();
/**
* 加锁顺序从left -> right
*/
public void leftRight() throws Exception {
synchronized (left) {
// 模拟某个业务操作耗时
Thread.sleep(1000);
synchronized (right) {
System.out.println(Thread.currentThread().getName() + " left -> right lock.");
}
}
}
/**
* 加锁顺序right -> left
*/
public void rightLeft() throws Exception {
synchronized (right) {
// 模拟某个业务操作耗时
Thread.sleep(1000);
synchronized (left) {
System.out.println(Thread.currentThread().getName() + " right -> left lock.");
}
}
}
}
public class MyThreadA extends Thread {
private DeadLock deadLock;
public MyThreadA(DeadLock deadLock) {
this.deadLock = deadLock;
}
@Override
public void run() {
try {
deadLock.leftRight();
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class MyThreadB extends Thread {
private DeadLock deadLock;
public MyThreadB(DeadLock deadLock) {
this.deadLock = deadLock;
}
@Override
public void run() {
try {
deadLock.rightLeft();
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class MyThreadTest {
public static void main(String[] args) {
DeadLock deadLock = new DeadLock();
MyThreadA threadA = new MyThreadA(deadLock);
MyThreadB threadB = new MyThreadB(deadLock);
threadA.start();
threadB.start();
}
}
运行测试类观察控制台,你会发现服务一直在运行,什么都没有输出,并且无法关闭,因为程序已经死锁了!
发生这个现象的原因,其实也很简单。
- 1.线程 A 启动之后,先获取了
left
对象锁,然后紧接着尝试获取right
对象锁,因为right
对象锁被其它线程占有,只能进入阻塞状态 - 2.线程 B 启动之后,先获取了
right
对象锁,然后紧接着尝试获取left
对象锁,因为left
对象锁被其它线程占有,只能进入阻塞状态 - 3.两个线程互相等待对方释放锁,程序进入永久等待状态,因此都无法进入打印方法体
如何定位死锁问题呢?
我们可以通过 Java 自带的 jps 和 jstack 工具,查看 java 进程 id 和相关的线程堆栈信息。
定位过程如下!
2.1、通过 jps 获得当前 Java 虚拟机进程的 pid
左边的是当前 Java 虚拟机进程 ID,后边是进程名称,其中MyThreadTest
就是我们当前运行的测试类服务。
2.2、通过 jstack 查看进程中的线程信息
在 jstack 后面输入对应的 java 进程 ID,然后回车即可查询到进程中的线程情况,前面的部分,可以很清晰的看到,两个线程都处于阻塞状态,等待获取对应的锁。
因为线程的信息比较多,直接滑倒最底部,可以看到 JVM 给出的死锁报告信息。
遇到这种情况,只能强制终止服务才能解除死锁!
三、避免死锁的方式
上面我们复现了死锁的发生,总结下来你会发现死锁的产生,总共有四个共同特点:
- 1.互斥使用,即当资源被一个线程占用时,别的线程不能使用
- 2.不可抢占,资源请求者不能强制从资源占有者手中抢夺资源,资源只能由占有者主动释放
- 3.请求和保持,当资源请求者在请求其他资源的同时保持对原有资源的占有
- 4.循环等待,多个线程存在环路的锁依赖关系而永远等待下去,例如 T1 占有 T2 的资源,T2 占有 T3 的资源,T3 占有 T1 的资源,这种情况可能会形成一个等待环路
这四个特点是死锁产生的必要条件,只要系统发生死锁,这些条件必然成立,只要能破坏其中一条即可让死锁消失,当然条件一是基础,不能被破坏。
理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免产生死锁和解除死锁。
在软件编程中,我们如何避免死锁呢?
关于死锁的避免,主要有以下几种方式:
- 1.尽可能使用无锁编程,使用开放调用的编码设计
- 2.设计时考虑清楚锁的顺序,尽量减少嵌在的加锁交互数量
- 2.尽可能的缩小锁的范围,防止锁住的资源过多引发阻塞
- 4.使用定时锁,比如
Lock
类中的tryLock
方法去尝试获取锁,这个方法支持在指定时间内获取锁,如果等待超时会返回一个失败信息,死锁会自动解除。
对于死锁的诊断,主要有以下几种方式:
- 1.对代码进行全局分析,找出代码中什么地方会出现死锁
- 2.通过线程转储(Thread Dump)信息来分析死锁,比如 jstack、jvisualvm、jconsole 等工具
至于死锁的解除,主要有以下几种方式:
- 1.直接强制终止并重启服务,如果代码上的风险没有消除,可能还会再次出现
- 2.采用定时锁方案,虽然
synchronized
不具备这个功能,但是Lock
类中的tryLock
方法具备,实际编程中采用Lock
中的超时机制进行加锁,应用的比较多
四、小结
本文主要围绕多线程编程中常见的死锁问题,从现象复现到方案解决进行了一次知识总结,内容难免有所遗漏,欢迎网友留言指出!
五、参考
作者:潘志的技术笔记
出处:https://pzblog.cn/
版权归作者所有,转载请注明出处