在多线程编程中,死锁(Deadlock)是一个经典而又棘手的问题。它如同程序的"交通堵塞",让多个线程互相等待对方释放资源,最终谁也无法继续执行,导致系统挂起。理解死锁的原理、学会避免和排查死锁,是每一位并发程序员的必修课。
本文将从死锁的定义开始,通过生动的代码示例演示死锁的产生,剖析其产生的四个必要条件,并给出多种预防和检测方法,助你远离死锁的困扰。
一、什么是死锁?
死锁是指两个或两个以上的线程,在执行过程中因争夺资源而造成的一种互相等待的现象。若无外力干预,它们都将无法推进下去。简单来说,就是:
线程 A 持有资源 1,等待资源 2;线程 B 持有资源 2,等待资源 1。双方都得不到想要的资源,于是永远阻塞。
二、死锁的四个必要条件
死锁的发生必须同时满足以下四个条件,缺一不可:
-
互斥条件:资源一次只能被一个线程占用,如果其他线程请求该资源,只能等待直到释放。
-
持有并等待条件:线程已经持有了至少一个资源,又在等待其他资源,且不释放已持有的资源。
-
不可剥夺条件:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能由自己主动释放。
-
循环等待条件:存在一组线程的循环等待链,即 T0 等待 T1 持有的资源,T1 等待 T2 持有的资源,......,Tn 等待 T0 持有的资源。
只要破坏其中任何一个条件,死锁就不会发生。
三、死锁代码示例
下面是一个经典的死锁示例,两个线程分别持有一个锁,并试图获取对方的锁:
java
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("线程1 获得 lock1,等待 lock2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock2) {
System.out.println("线程1 同时持有 lock1 和 lock2");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("线程2 获得 lock2,等待 lock1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
synchronized (lock1) {
System.out.println("线程2 同时持有 lock2 和 lock1");
}
}
});
t1.start();
t2.start();
}
}
运行结果:
java
线程1 获得 lock1,等待 lock2...
线程2 获得 lock2,等待 lock1...
之后程序便停滞不前,两个线程互相等待,形成死锁。
四、如何避免死锁?
根据死锁的四个必要条件,我们可以采取相应的预防策略:
1. 破坏"持有并等待"
方法 :一次性申请所有资源,如果申请失败则释放已持有的所有资源。在 Java 中,可以使用 ReentrantLock 的 tryLock 方法尝试获取锁,超时则放弃。
java
import java.util.concurrent.locks.ReentrantLock;
public class AvoidDeadlock {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (true) {
if (lock1.tryLock()) {
try {
if (lock2.tryLock()) {
try {
System.out.println("线程1 成功获取两把锁");
break;
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
// 短暂等待后重试
try { Thread.sleep(10); } catch (InterruptedException e) {}
}
});
// 线程2 类似
// ...
}
}
2. 破坏"不可剥夺"
如果线程在申请新资源时失败,可以主动释放已持有的资源。上面 tryLock 的方式实际上也体现了这一思想。
3. 破坏"循环等待"
最常用的方法:对所有资源进行统一排序,规定线程必须按顺序申请资源。例如,总是先获取 lock1,再获取 lock2,这样就不会出现循环等待。
java
synchronized (lock1) {
synchronized (lock2) {
// 安全
}
}
4. 使用更高层次的并发工具
例如使用 java.util.concurrent 包中的 Semaphore、CountDownLatch、BlockingQueue 等,它们内部已经处理好资源竞争,减少了手写锁导致死锁的机会。
五、如何检测死锁?
即使代码编写时注意了预防,死锁仍可能因复杂逻辑而偶然出现。我们需要学会在运行时检测死锁。
1. jstack 命令
使用 jstack <pid> 可以打印出 Java 进程的线程堆栈,其中会明确提示死锁信息:
Found one Java-level deadlock:
=============================
"线程2":
waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
which is held by "线程1"
"线程1":
waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
which is held by "线程2"
2. JConsole / VisualVM
这些图形化工具可以直接连接到 JVM,在"线程"选项卡中点击"检测死锁"按钮,即可快速定位。
3. 编程方式检测
通过 ThreadMXBean 可以在程序中主动检测死锁:
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
if (deadlockedThreads != null) {
// 有死锁发生,可以记录日志或尝试恢复
}
六、死锁恢复策略
一旦发生死锁,通常的恢复手段有:
-
重启应用(最直接,但可能导致数据丢失)。
-
杀死其中一个线程(强行释放资源),但 Java 并没有提供直接杀死线程的安全方法,通常通过中断来协作。
-
使用超时机制 :如
tryLock(long, TimeUnit)在等待一定时间后放弃,然后释放已持有锁,让其他线程有机会执行。
七、总结
死锁是多线程编程中难以完全避免的问题,但通过理解其产生的四个必要条件,我们可以有针对性地采用预防策略:按顺序申请资源、使用超时锁、减少锁持有时间等。同时,掌握 jstack、JConsole 等工具能帮助我们在问题发生后快速定位。