深入理解线程死锁:从概念到 Java 实战
在多线程编程的世界里,我们追求利用并发来提高程序的性能和响应速度。然而,并发也带来了一系列挑战,其中最令人头疼的问题之一就是——死锁 (Deadlock)。一旦发生死锁,程序可能完全“卡住”,无法继续执行,对系统稳定性和用户体验造成严重影响。
本文将带你深入理解什么是死锁,并通过一个经典的 Java 代码示例来直观演示死锁是如何发生的,最后探讨如何预防或避免死锁。
什么是死锁?
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或全部都在等待某个只有其它阻塞线程才能释放的资源。由于线程被无限期地阻塞,程序无法正常终止,也无法继续执行后续任务。
想象一个简单的场景:有两条单行道在一个狭窄的路口交汇,每条道上都有一辆车想要通过路口到达对方的道路。如果两辆车同时驶入路口,互相挡住了对方的去路,并且谁也不愿意后退(释放路口资源),那么这两辆车就陷入了“死锁”状态,交通完全瘫痪。
在多线程环境中,资源通常指的就是锁 (Lock),例如 Java 中的 synchronized 关键字或者 ReentrantLock 对象。
图解经典死锁场景
一个非常典型的死锁场景涉及两个线程和两个资源:
- 线程 A 持有 资源 1,并尝试获取 资源 2。
- 线程 B 持有 资源 2,并尝试获取 资源 1。
如下图所示:
graph LR A((线程 A)) -- 持有 --> R1(资源 1); B((线程 B)) -- 持有 --> R2(资源 2); A -- 等待 --> R2; B -- 等待 --> R1;
由于线程 A 必须等待线程 B 释放资源 2,而线程 B 又必须等待线程 A 释放资源 1,两者互相等待,形成了一个循环依赖,谁也无法前进,死锁就此产生。
Java 代码示例:模拟死锁
下面的 Java 代码精确地模拟了上述的死锁情况:
public class DeadLockDemo {// 共享资源 1 (使用 Object 作为锁对象)private static final Object resource1 = new Object();// 共享资源 2private static final Object resource2 = new Object();public static void main(String[] args) {// 线程 1new Thread(() -> {// 1. 线程 1 获取 resource1 的锁synchronized (resource1) {System.out.println(Thread.currentThread().getName() + " 获取 resource1");try {// 2. 休眠 1 秒,给线程 2 足够的时间去获取 resource2 的锁Thread.sleep(1000);} catch (InterruptedException e) {Thread.currentThread().interrupt(); // 恢复中断状态e.printStackTrace();}System.out.println(Thread.currentThread().getName() + " 等待获取 resource2...");// 3. 线程 1 尝试获取 resource2 的锁 (此时 resource2 可能已被线程 2 持有)synchronized (resource2) {System.out.println(Thread.currentThread().getName() + " 获取 resource2");}}System.out.println(Thread.currentThread().getName() + " 执行完毕"); // 如果发生死锁,这行不会打印}, "线程 1").start();// 线程 2new Thread(() -> {// 4. 线程 2 获取 resource2 的锁synchronized (resource2) {System.out.println(Thread.currentThread().getName() + " 获取 resource2");try {// 5. 休眠 1 秒,给线程 1 足够的时间去尝试获取 resource2Thread.sleep(1000);} catch (InterruptedException e) {Thread.currentThread().interrupt();e.printStackTrace();}System.out.println(Thread.currentThread().getName() + " 等待获取 resource1...");// 6. 线程 2 尝试获取 resource1 的锁 (此时 resource1 已被线程 1 持有)synchronized (resource1) {System.out.println(Thread.currentThread().getName() + " 获取 resource1");}}System.out.println(Thread.currentThread().getName() + " 执行完毕"); // 如果发生死锁,这行不会打印}, "线程 2").start();System.out.println("主线程启动子线程完毕");}
}
代码执行流程分析:
- 线程 1 启动,获取 resource1 的锁,打印信息,然后休眠。
- 在 线程 1 休眠期间,线程 2 启动,获取 resource2 的锁,打印信息,然后休眠。
- 线程 1 醒来,尝试获取 resource2 的锁。但是 resource2 的锁正被 线程 2 持有,所以 线程 1 进入阻塞状态,等待 线程 2 释放 resource2。
- 线程 2 醒来,尝试获取 resource1 的锁。但是 resource1 的锁正被 线程 1 持有,所以 线程 2 也进入阻塞状态,等待 线程 1 释放 resource1。
此时,线程 1 等待 线程 2,线程 2 等待 线程 1,形成循环等待,死锁发生!程序的输出会停留在:
线程 1 获取 resource1
线程 2 获取 resource2
线程 1 等待获取 resource2...
线程 2 等待获取 resource1...
后续的 "获取 resource2"、"获取 resource1" 以及 "执行完毕" 的信息将永远不会打印出来。
死锁产生的四个必要条件
死锁的发生并非偶然,它需要同时满足以下四个条件(也被称为 Coffman 条件):
- 互斥条件 (Mutual Exclusion): 资源不能被共享,一次只能被一个线程使用。 (示例中 synchronized 保证了这一点)。
- 请求与保持条件 (Hold and Wait): 线程至少已经持有了一个资源,并且正在请求获取其他线程持有的资源。(示例中,线程 1 持有 R1 等待 R2,线程 2 持有 R2 等待 R1)。
- 不可剥夺条件 (No Preemption): 资源只能由持有它的线程自愿释放,不能被其他线程强行剥夺。(示例中,线程不会主动放弃已获得的锁)。
- 循环等待条件 (Circular Wait): 存在一个线程资源的循环等待链,即线程 T1 等待 T2 的资源,T2 等待 T3 的资源,...,Tn 等待 T1 的资源。(示例中形成了 T1 -> R2 -> T2 -> R1 -> T1 的循环)。
只有当这四个条件同时满足时,才会发生死锁。 因此,预防死锁的策略就是尝试破坏其中一个或多个条件。
如何预防和避免死锁?
知道了死锁的成因,我们就可以采取相应的措施来避免它:
-
破坏“请求与保持”条件:
- 一次性申请所有资源: 线程在开始执行前,尝试一次性获取所有需要的资源。如果无法全部获取,则不持有任何资源,稍后重试。这种方式可能导致资源利用率降低和线程饥饿。
- 获取不到即释放: 线程在尝试获取另一个资源失败时,主动释放自己当前持有的资源。
-
破坏“不可剥夺”条件:
- 使用可中断锁或超时锁: Java 的 Lock 接口提供了 tryLock(long time, TimeUnit unit) 和 lockInterruptibly() 等方法。如果一个线程在指定时间内无法获取锁,或者在等待时被中断,它可以选择放弃获取,并可能释放已持有的锁,从而打破循环。
-
破坏“循环等待”条件(最常用):
- 按序申请资源: 对所有共享资源进行排序,并规定所有线程必须按照这个固定的顺序来申请资源。例如,在我们的示例中,可以规定所有线程必须先申请 resource1,再申请 resource2。这样,即使 线程 1 持有 resource1,线程 2 也必须先尝试获取 resource1,此时它会被阻塞,但不会持有 resource2,线程 1 最终能获取到 resource2,完成后释放 resource1,线程 2 就能继续执行。
修复示例代码 (按序申请):
// 让线程 2 也先尝试获取 resource1 new Thread(() -> {synchronized (resource1) { // 先获取 R1System.out.println(Thread.currentThread().getName() + " 获取 resource1");try {Thread.sleep(1000); // 模拟耗时} catch (InterruptedException e) { /* ... */ }System.out.println(Thread.currentThread().getName() + " 等待获取 resource2...");synchronized (resource2) { // 再获取 R2System.out.println(Thread.currentThread().getName() + " 获取 resource2");}}System.out.println(Thread.currentThread().getName() + " 执行完毕"); }, "线程 2").start();
通过强制所有线程按 resource1 -> resource2 的顺序获取锁,循环等待条件被打破,死锁就不会发生。
-
死锁检测与恢复 (不常用):
- 这是一种更复杂的方法,通常在数据库系统或操作系统层面实现。系统允许死锁发生,但会周期性地检测是否存在死锁环,如果检测到,则采取策略进行恢复,例如强制终止(剥夺)某个线程的资源或回滚事务。
总结
死锁是并发编程中一个潜在的严重问题,它源于多个线程对共享资源的竞争和不当的获取顺序。理解死锁的定义、产生的四个必要条件是诊断和预防死锁的基础。在实际开发中,最常用且有效的预防策略是按序申请资源,打破循环等待条件。同时,合理使用 JUC 包提供的锁工具(如 tryLock)也能帮助我们构建更健壮、更能避免死锁的并发程序。
编写并发代码时,时刻警惕可能发生的死锁,并采取适当的预防措施,是每一位开发者都需要具备的重要技能。