【多线程篇21】:深入浅出理解Java死锁
文章目录
- 1. 什么是死锁?
- 2. 死锁的“案发现场”:代码复现
- 3. 死锁产生的四个“必要条件”
- 4. 如何“破解”死锁?
- 方案一:破坏“循环等待”条件(最常用)
- 方案二:破坏“请求与保持”条件
- 方案三:破坏“不可剥夺”条件
- 总结
1. 什么是死锁?
用一句话来概括:死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们将都无法再向下推进。
听起来有点抽象,我们来看一个生活中的例子:
想象一下一座只能容纳一个人通过的独木桥。
线程 A 从桥的左边走到了中间,此时它持有了“桥的左半边”,并等待“桥的右半边”空出来。
线程 B 同时从桥的右边走到了中间,此时它持有了“桥的右半边”,并等待“桥的左半边”空出来。结果呢?A 等着 B 后退,B 等着 A 后退,谁也不肯让步,两个人就永远僵持在了桥中间。
在 Java 中,这个“独木桥”就是锁(Lock)。当一个线程需要同时获取多把锁时,就极易发生死锁。
- t1 线程:获得了 A 对象的锁,接下来想获取 B 对象的锁。
- t2 线程:获得了 B 对象的锁,接下来想获取 A 对象的锁。
此时,t1 因等待 B 锁而阻塞,t2 因等待 A 锁而阻塞,两个线程都在互相等待对方释放自己需要的锁,最终导致了死锁。
2. 死锁的“案发现场”:代码复现
我们用一段经典的代码来复现死锁的“案发现场”。
public class Deadlock {public static void main(String[] args) {// 创建两个锁对象Object lockA = new Object();Object lockB = new Object();// 线程 t1Thread t1 = new Thread(() -> {synchronized (lockA) {System.out.println("t1: 已持有 lockA,准备获取 lockB...");try {// 确保 t2 有机会获取 lockBsleep(200);} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (lockB) {System.out.println("t1: 已持有 lockB");System.out.println("t1: 执行操作...");}}}, "t1");// 线程 t2Thread t2 = new Thread(() -> {synchronized (lockB) {System.out.println("t2: 已持有 lockB,准备获取 lockA...");try {} catch (InterruptedException e) {throw new RuntimeException(e);}synchronized (lockA) {System.out.println("t2: 已持有 lockA");System.out.println("t2: 执行操作...");}}}, "t2");t1.start();try {// 确保 t2 有机会获取 lockBsleep(100);} catch (InterruptedException e) {throw new RuntimeException(e);}t2.start();}
}
执行流程分析:
t1
启动,立刻获得lockA
的锁,打印 “t1: 已持有 lockA…”,然后sleep(1000)
。- 在
t1
睡眠期间,t2
启动,立刻获得lockB
的锁,打印 “t2: 已持有 lockB…”,然后sleep(500)
。 - 500 毫秒后,
t2
醒来,尝试获取lockA
。但此时lockA
仍被t1
持有,因此t2
进入阻塞状态,等待lockA
。 - 1000 毫秒后,
t1
醒来,尝试获取lockB
。但此时lockB
仍被t2
持有,因此t1
进入阻塞状态,等待lockB
。
控制台输出:
t1: 已持有 lockA,准备获取 lockB...
t2: 已持有 lockB,准备获取 lockA...
此时,程序光标会一直闪烁,但不会有任何新的输出,也不会结束。这就是典型的死锁现象!t1
等着 t2
释放 lockB
,而 t2
等着 t1
释放 lockA
,形成了一个完美的“死亡闭环”。
3. 死锁产生的四个“必要条件”
一个问题之所以发生,必然有其底层的原因。死锁的发生,需要同时满足以下四个缺一不可的条件:
-
互斥条件(Mutual Exclusion)
- 定义:一个资源在同一时刻只能被一个线程占用。
- 解释:锁的基本特性。如果资源可以被共享,也就不会有争抢和等待了。
-
请求与保持条件(Hold and Wait)
- 定义:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
- 解释:就像上面例子中的
t1
,它已经持有了lockA
,在请求lockB
失败后,它并不会释放lockA
。
-
不可剥夺条件(No Preemption)
- 定义:线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能在使用完后由自己释放。
- 解释:
synchronized
锁就是不可剥夺的。操作系统不会因为t2
需要lockA
,就强行从t1
手里把lockA
抢过来。
-
循环等待条件(Circular Wait)
- 定义:发生死锁时,必然存在一个线程—资源的循环等待链。
- 解释:
t1
等待t2
的资源,t2
等待t1
的资源,形成t1 -> t2 -> t1
的等待环路。
核心要点:这四个条件必须同时满足才会发生死锁。因此,只要我们能破坏其中任意一个条件,就能有效预防死锁。
4. 如何“破解”死锁?
了解了死锁的成因,我们就可以对症下药,通过破坏上述四个条件来避免死锁。
方案一:破坏“循环等待”条件(最常用)
这是最常用也是最有效的避免死锁的方法。其核心思想是:规定所有线程必须按照相同的顺序来获取锁。
在我们的例子中,t1
的加锁顺序是 A -> B
,而 t2
的顺序是 B -> A
,这正是导致循环等待的元凶。我们只需将 t2
的加锁顺序也改为 A -> B
即可。
修改 t2
的代码:
// 线程 t2 (修改后)
Thread t2 = new Thread(() -> {// 同样先尝试获取 lockAsynchronized (lockA) {System.out.println("t2: 已持有 lockA,准备获取 lockB...");try {sleep(500);} catch (InterruptedException e) {throw new RuntimeException(e);}// 然后再获取 lockBsynchronized (lockB) {System.out.println("t2: 已持有 lockB");System.out.println("t2: 执行操作...");}}
}, "t2");
这样修改后,无论是 t1
还是 t2
,谁先抢到 lockA
,谁就能继续获取 lockB
并顺利执行完毕,然后释放所有锁。另一个线程则会等待 lockA
,并在前者执行完后获得锁,继续执行。这样就将**“并发”变为了“串行”**,彻底避免了死锁。
方案二:破坏“请求与保持”条件
这个方案要求线程一次性申请所有需要的资源(锁),如果无法全部获得,则一个也不占有。
这种方式在 synchronized
语法层面不易直接实现,因为它无法一次性获取多个锁。但在实际业务中,可以把多个需要加锁的资源封装成一个对象,只对这个大对象加锁,从而间接实现。不过,这会降低程序的并发度。
方案三:破坏“不可剥夺”条件
synchronized
是不可剥夺的,但 java.util.concurrent.locks
包下的 Lock
接口提供了更灵活的加锁方式。例如,lock.tryLock()
方法。
tryLock(long time, TimeUnit unit)
方法会尝试在指定时间内获取锁:
- 如果成功获取,返回
true
。 - 如果在超时前仍未获取到锁,返回
false
,而不是一直阻塞。
利用这个特性,线程在获取锁失败后,可以选择释放自己已经持有的锁,然后重试,从而打破了“不可剥夺”的僵局。
总结
死锁是多线程编程中一个经典且重要的话题。通过本文,我们应该掌握以下核心知识:
- 死锁定义:多个线程因互相等待对方持有的资源而陷入的无限阻塞状态。
- 死锁复现:最经典的场景就是两个线程以相反的顺序获取两把锁。
- 死锁四要素:互斥、请求与保持、不可剥夺、循环等待,四者缺一不可。
- 死锁预防:最关键、最简单的策略就是保证所有线程以相同的顺序获取锁,从而破坏“循环等待”条件。