JAVA多线程死锁问题判断、定位与三种有效规避策略
大家好,今天我们来聊聊Java多线程中一个常见且棘手的问题:死锁。死锁会导致程序停滞不前,资源无法释放,严重影响系统的可用性。我们将深入探讨死锁的判断、定位以及三种有效的规避策略,希望能帮助大家更好地理解和应对这个问题。
一、 什么是死锁?
死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的僵局,若无外力作用,这些线程都将无法继续执行下去。 简单来说,就是线程A拿着资源1等待资源2,线程B拿着资源2等待资源1,彼此互相等待,导致程序卡死。
死锁产生的四个必要条件(缺一不可):
- 互斥条件: 资源必须处于独占模式,即一个资源每次只能被一个线程占用。其他线程想使用该资源,必须等待当前线程释放。
- 请求与保持条件: 线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占用。
- 不可剥夺条件: 线程已获得的资源在未使用完之前不能被剥夺,只能由占有该资源的线程自己释放。
- 循环等待条件: 发生死锁时,必然存在一个线程-资源的循环等待链,例如线程A等待线程B占用的资源,线程B等待线程C占用的资源,线程C等待线程A占用的资源。
二、 如何判断是否存在死锁?
判断是否存在死锁,主要有以下几种方法:
-
人工代码审查: 这是最直接也是最基础的方法。仔细检查代码中涉及多线程、锁和资源的部分,重点关注多个线程访问相同资源时的加锁顺序。尤其要注意嵌套锁的使用,这往往是死锁的根源。这种方法适用于代码量较小、逻辑相对简单的场景。
-
线程转储 (Thread Dump): 当程序出现停滞现象时,可以生成线程转储文件。线程转储包含了所有线程的当前状态,包括线程的名称、ID、优先级、堆栈信息、持有的锁以及正在等待的锁。通过分析线程转储文件,可以找出相互等待的线程以及它们所持有的锁,从而确定是否存在死锁。
-
如何生成Thread Dump:
- JDK自带工具
jstack:jstack <pid>(其中<pid>是Java进程的进程ID) - VisualVM: 一款图形化的JVM监控工具,可以方便地生成线程转储。
- JConsole: 也是JDK自带的图形化监控工具,可以用来生成线程转储。
- Linux命令
kill -3 <pid>: 这个命令会向JVM发送一个信号,使其生成线程转储信息到标准输出或日志文件中。
- JDK自带工具
-
Thread Dump 分析示例:
"Thread-1" #12 prio=5 os_prio=0 tid=0x00007fb168e6a000 nid=0x703 waiting for monitor entry [0x00007fb15df08000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadlockExample.methodA(DeadlockExample.java:15) - waiting to lock <0x000000076b8d9e48> (a java.lang.String) at com.example.DeadlockExample.run(DeadlockExample.java:25) at java.lang.Thread.run(Thread.java:748) "Thread-2" #13 prio=5 os_prio=0 tid=0x00007fb168e6b000 nid=0x704 waiting for monitor entry [0x00007fb15e009000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadlockExample.methodB(DeadlockExample.java:20) - waiting to lock <0x000000076b8d9e70> (a java.lang.String) at com.example.DeadlockExample.run(DeadlockExample.java:30) at java.lang.Thread.run(Thread.java:748) Locked ownable synchronizers: - None分析:从上面的 Thread Dump 可以看到 Thread-1 正在等待锁
<0x000000076b8d9e48>,而Thread-2 正在等待锁<0x000000076b8d9e70>。 需要结合代码来确定哪个线程拥有哪个锁,从而判断是否存在循环等待。
-
-
死锁检测工具: 某些IDE或代码分析工具(如FindBugs、SonarQube)可以静态分析代码,检测潜在的死锁风险。这些工具通过识别常见的死锁模式,例如循环依赖、不一致的锁顺序等,帮助开发者在开发阶段发现问题。
-
JVM监控工具: JDK自带的VisualVM、JConsole等工具,可以实时监控JVM的线程状态,并提供死锁检测功能。这些工具可以自动检测死锁,并提供相关的线程信息,方便开发者定位问题。
三、 死锁定位:一个示例
我们来看一个简单的死锁示例:
public class DeadlockExample implements Runnable {
private String lock1 = "lock1";
private String lock2 = "lock2";
private int threadId;
public DeadlockExample(int threadId) {
this.threadId = threadId;
}
public void methodA() {
synchronized (lock1) {
System.out.println("Thread-" + threadId + ": Holding lock1...");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread-" + threadId + ": Waiting for lock2...");
synchronized (lock2) {
System.out.println("Thread-" + threadId + ": Holding lock1 and lock2...");
}
}
}
public void methodB() {
synchronized (lock2) {
System.out.println("Thread-" + threadId + ": Holding lock2...");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread-" + threadId + ": Waiting for lock1...");
synchronized (lock1) {
System.out.println("Thread-" + threadId + ": Holding lock2 and lock1...");
}
}
}
@Override
public void run() {
if (threadId == 1) {
methodA();
} else {
methodB();
}
}
public static void main(String[] args) {
DeadlockExample deadlock1 = new DeadlockExample(1);
DeadlockExample deadlock2 = new DeadlockExample(2);
Thread thread1 = new Thread(deadlock1);
Thread thread2 = new Thread(deadlock2);
thread1.start();
thread2.start();
}
}
在这个例子中,Thread-1 先获取 lock1,然后尝试获取 lock2;Thread-2 先获取 lock2,然后尝试获取 lock1。由于两个线程获取锁的顺序相反,导致了循环等待,从而发生死锁。
如何定位这个死锁?
-
运行程序: 运行上面的代码,会发现程序卡住,没有任何输出。
-
生成Thread Dump: 使用
jstack命令生成线程转储文件:jstack <pid>(假设进程ID是12345,则命令为jstack 12345)。 -
分析Thread Dump: 在生成的线程转储文件中,查找
BLOCKED状态的线程。你会发现类似下面的信息:"Thread-1" #12 prio=5 os_prio=0 tid=0x00007fb168e6a000 nid=0x703 waiting for monitor entry [0x00007fb15df08000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadlockExample.methodA(DeadlockExample.java:15) - waiting to lock <0x000000076b8d9e48> (a java.lang.String) at com.example.DeadlockExample.run(DeadlockExample.java:25) at java.lang.Thread.run(Thread.java:748) "Thread-2" #13 prio=5 os_prio=0 tid=0x00007fb168e6b000 nid=0x704 waiting for monitor entry [0x00007fb15e009000] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadlockExample.methodB(DeadlockExample.java:20) - waiting to lock <0x000000076b8d9e70> (a java.lang.String) at com.example.DeadlockExample.run(DeadlockExample.java:30) at java.lang.Thread.run(Thread.java:748) Locked ownable synchronizers: - None从Thread Dump中可以清晰地看到,
Thread-1正在等待lock2,而Thread-2正在等待lock1。结合代码,就可以确定发生了死锁。
四、 死锁规避策略
针对死锁的四个必要条件,我们可以采取相应的策略来规避死锁的发生。以下介绍三种有效的规避策略:
-
避免循环等待:统一加锁顺序
这是最常用的也是最有效的死锁规避策略。通过为所有需要访问多个共享资源的线程制定统一的加锁顺序,可以打破循环等待的条件。
-
实现方式:
- 使用全局排序: 为所有锁定义一个全局的排序规则(例如,基于锁对象的哈希值),线程必须按照这个顺序获取锁。
- 使用锁层次结构: 将锁组织成一个层次结构,线程必须按照层次结构从上到下的顺序获取锁。
-
示例代码(修改上面的死锁示例):
public class DeadlockAvoidanceExample implements Runnable { private String lock1 = "lock1"; private String lock2 = "lock2"; private int threadId; public DeadlockAvoidanceExample(int threadId) { this.threadId = threadId; } public void methodA() { // 统一加锁顺序:先 lock1 后 lock2 synchronized (lock1) { System.out.println("Thread-" + threadId + ": Holding lock1..."); try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Thread-" + threadId + ": Waiting for lock2..."); synchronized (lock2) { System.out.println("Thread-" + threadId + ": Holding lock1 and lock2..."); } } } public void methodB() { // 统一加锁顺序:先 lock1 后 lock2 synchronized (lock1) { // 修改这里,先获取lock1 System.out.println("Thread-" + threadId + ": Holding lock1..."); try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("Thread-" + threadId + ": Waiting for lock2..."); synchronized (lock2) { System.out.println("Thread-" + threadId + ": Holding lock1 and lock2..."); } } } @Override public void run() { if (threadId == 1) { methodA(); } else { methodB(); } } public static void main(String[] args) { DeadlockAvoidanceExample deadlock1 = new DeadlockAvoidanceExample(1); DeadlockAvoidanceExample deadlock2 = new DeadlockAvoidanceExample(2); Thread thread1 = new Thread(deadlock1); Thread thread2 = new Thread(deadlock2); thread1.start(); thread2.start(); } }在修改后的代码中,
methodB也按照先获取lock1后获取lock2的顺序加锁,从而避免了循环等待,消除了死锁的风险。 -
优点: 简单易懂,容易实现。
-
缺点: 需要对所有锁进行全局管理,当锁的数量很多时,维护成本较高。可能会降低程序的并发性,因为线程必须按照固定的顺序获取锁,即使某些情况下可以并行获取锁。
-
-
避免请求与保持:一次性获取所有资源
线程在执行任务前,一次性获取所有需要的资源,避免在持有部分资源的情况下请求新的资源。
-
实现方式:
- 使用
tryLock()方法:ReentrantLock提供了tryLock()方法,可以尝试获取锁,如果获取失败,立即返回,避免阻塞。线程可以循环尝试获取所有需要的锁,直到全部获取成功。 - 使用
Lock接口的lock()方法,但结合超时机制: 如果一段时间内无法获取所有需要的锁,则释放已经获取的锁,并稍后重试。
- 使用
-
示例代码:
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.TimeUnit; public class AvoidRequestAndHoldExample implements Runnable { private Lock lock1 = new ReentrantLock(); private Lock lock2 = new ReentrantLock(); private int threadId; public AvoidRequestAndHoldExample(int threadId) { this.threadId = threadId; } public void methodA() { try { if (lock1.tryLock(1, TimeUnit.SECONDS) && lock2.tryLock(1, TimeUnit.SECONDS)) { try { System.out.println("Thread-" + threadId + ": Holding lock1 and lock2..."); // 执行业务逻辑 Thread.sleep(100); } finally { lock2.unlock(); lock1.unlock(); } } else { System.out.println("Thread-" + threadId + ": Failed to acquire both locks, retrying..."); // 稍后重试 Thread.sleep(100); methodA(); // 递归调用,或者使用循环 } } catch (InterruptedException e) { e.printStackTrace(); } } public void methodB() { try { if (lock1.tryLock(1, TimeUnit.SECONDS) && lock2.tryLock(1, TimeUnit.SECONDS)) { try { System.out.println("Thread-" + threadId + ": Holding lock1 and lock2..."); // 执行业务逻辑 Thread.sleep(100); } finally { lock2.unlock(); lock1.unlock(); } } else { System.out.println("Thread-" + threadId + ": Failed to acquire both locks, retrying..."); // 稍后重试 Thread.sleep(100); methodB(); // 递归调用,或者使用循环 } } catch (InterruptedException e) { e.printStackTrace(); } } @Override public void run() { if (threadId == 1) { methodA(); } else { methodB(); } } public static void main(String[] args) { AvoidRequestAndHoldExample deadlock1 = new AvoidRequestAndHoldExample(1); AvoidRequestAndHoldExample deadlock2 = new AvoidRequestAndHoldExample(2); Thread thread1 = new Thread(deadlock1); Thread thread2 = new Thread(deadlock2); thread1.start(); thread2.start(); } }在这个例子中,线程尝试同时获取
lock1和lock2,如果任何一个锁获取失败,则释放所有已获取的锁,并稍后重试。这样可以避免线程在持有部分资源的情况下请求新的资源,从而避免死锁。 -
优点: 可以提高程序的并发性,因为线程可以并行尝试获取锁。
-
缺点: 实现较为复杂,需要处理锁获取失败的情况。可能会导致线程饥饿,因为线程可能一直无法获取所有需要的锁。
-
-
避免不可剥夺:使用可中断锁
允许线程在等待锁的过程中被中断,从而释放已经持有的资源,避免死锁。
-
实现方式:
- 使用
ReentrantLock的lockInterruptibly()方法: 该方法允许线程在等待锁的过程中响应中断信号。当线程被中断时,会抛出InterruptedException异常,线程可以捕获该异常并释放已经持有的资源。
- 使用
-
示例代码:
import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class AvoidNonPreemptionExample implements Runnable { private Lock lock1 = new ReentrantLock(); private Lock lock2 = new ReentrantLock(); private int threadId; public AvoidNonPreemptionExample(int threadId) { this.threadId = threadId; } public void methodA() { try { lock1.lockInterruptibly(); try { System.out.println("Thread-" + threadId + ": Holding lock1..."); Thread.sleep(10); System.out.println("Thread-" + threadId + ": Waiting for lock2..."); lock2.lockInterruptibly(); try { System.out.println("Thread-" + threadId + ": Holding lock1 and lock2..."); // 执行业务逻辑 } finally { lock2.unlock(); } } finally { lock1.unlock(); } } catch (InterruptedException e) { System.out.println("Thread-" + threadId + ": Interrupted while waiting for lock, releasing resources..."); // 处理中断,释放已持有的资源 } } public void methodB() { try { lock2.lockInterruptibly(); try { System.out.println("Thread-" + threadId + ": Holding lock2..."); Thread.sleep(10); System.out.println("Thread-" + threadId + ": Waiting for lock1..."); lock1.lockInterruptibly(); try { System.out.println("Thread-" + threadId + ": Holding lock2 and lock1..."); // 执行业务逻辑 } finally { lock1.unlock(); } } finally { lock2.unlock(); } } catch (InterruptedException e) { System.out.println("Thread-" + threadId + ": Interrupted while waiting for lock, releasing resources..."); // 处理中断,释放已持有的资源 } } @Override public void run() { if (threadId == 1) { methodA(); } else { methodB(); } } public static void main(String[] args) throws InterruptedException { AvoidNonPreemptionExample deadlock1 = new AvoidNonPreemptionExample(1); AvoidNonPreemptionExample deadlock2 = new AvoidNonPreemptionExample(2); Thread thread1 = new Thread(deadlock1); Thread thread2 = new Thread(deadlock2); thread1.start(); thread2.start(); // 主线程在一段时间后中断其中一个线程,模拟外部干预 Thread.sleep(100); thread1.interrupt(); } }在这个例子中,线程使用
lockInterruptibly()方法获取锁。如果线程在等待锁的过程中被中断,会抛出InterruptedException异常。线程可以在catch块中释放已经持有的资源,从而避免死锁。 -
优点: 可以避免线程长时间阻塞,提高程序的响应性。
-
缺点: 需要处理中断信号,代码较为复杂。
-
五、 如何选择规避策略
选择哪种死锁规避策略,取决于具体的应用场景和需求。
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 统一加锁顺序 | 锁的数量较少,且容易确定全局排序规则的场景。 | 简单易懂,容易实现。 | 需要对所有锁进行全局管理,维护成本较高。可能会降低程序的并发性。 |
| 一次性获取所有资源 | 线程需要同时访问多个资源,且可以容忍锁获取失败的情况。 | 可以提高程序的并发性。 | 实现较为复杂,需要处理锁获取失败的情况。可能会导致线程饥饿。 |
| 使用可中断锁 | 需要保证程序的响应性,且可以容忍线程被中断的情况。 | 可以避免线程长时间阻塞,提高程序的响应性。 | 需要处理中断信号,代码较为复杂。 |
六、 总结
死锁是多线程编程中一个需要认真对待的问题。通过理解死锁的产生条件,掌握死锁的判断和定位方法,以及灵活运用各种死锁规避策略,我们可以有效地避免死锁的发生,提高程序的稳定性和可靠性。
关键在于:
- 理解死锁的四个必要条件。
- 熟练使用线程转储工具进行死锁定位。
- 根据实际情况选择合适的死锁规避策略。
希望今天的分享对大家有所帮助。谢谢!