在多线程并发编程中,死锁(Deadlock)是一个非常经典且棘手的问题。继之前的线程安全总结之后,本文聚焦于“死锁”,总结它的产生条件、代码表现、排查方式以及预防策略。
一、什么是死锁?
死锁指的是:
两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力干涉,它们都将无法推进下去。
简单来说,就是:
- 线程 A 持有锁 1,等待锁 2;
- 线程 B 持有锁 2,等待锁 1;
- 两者都在等待对方释放锁,结果谁也拿不到,程序永久卡死。
二、死锁产生的四个必要条件
死锁的发生必须同时满足以下四个条件(Coffman 条件),只要破坏其中任意一个,死锁就无法形成:
互斥条件
- 资源同一时间只能被一个线程占用。
- 例如:
synchronized锁、ReentrantLock锁。
请求与保持条件(持有并等待)
- 线程已经持有了至少一个资源,但又提出了新的资源请求,而该资源被其他线程持有。
- 线程此时不释放已持有的资源,而是处于等待状态。
不可剥夺条件(不可抢占)
- 线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺。
- 只能由持有线程主动释放。
循环等待条件
- 存在一个线程与资源的循环等待链。
- 例如:A 等待 B 持有的资源,B 等待 A 持有的资源。
三、死锁代码示例
下面是一个最经典的死锁演示:
publicclassDeadlockDemo{privatestaticfinalObjectlockA=newObject();privatestaticfinalObjectlockB=newObject();publicstaticvoidmain(String[]args){Threadt1=newThread(()->{synchronized(lockA){System.out.println(Thread.currentThread().getName()+" 获取到 lockA");try{Thread.sleep(1000);}catch(InterruptedExceptione){}System.out.println(Thread.currentThread().getName()+" 等待 lockB...");synchronized(lockB){System.out.println(Thread.currentThread().getName()+" 获取到 lockB");}}},"线程A");Threadt2=newThread(()->{synchronized(lockB){System.out.println(Thread.currentThread().getName()+" 获取到 lockB");try{Thread.sleep(1000);}catch(InterruptedExceptione){}System.out.println(Thread.currentThread().getName()+" 等待 lockA...");synchronized(lockA){System.out.println(Thread.currentThread().getName()+" 获取到 lockA");}}},"线程B");t1.start();t2.start();}}运行结果:程序会卡住,两个线程互相等待,永远不会结束。
四、如何排查死锁?
1. 使用 jstack 命令
jstack是 JDK 自带的线程堆栈分析工具,可以快速发现死锁。
# 1. 找到 Java 进程 PIDjps# 2. 输出线程堆栈jstack<pid>如果发生死锁,jstack 输出末尾会明确提示:
Found one Java-level deadlock: ============================= "线程B": waiting to lock monitor 0x00007f... (object 0x000000076ab..., a java.lang.Object), which is held by "线程A" "线程A": waiting to lock monitor 0x00007f... (object 0x000000076ac..., a java.lang.Object), which is held by "线程B"2. 使用 jconsole / VisualVM
- 打开
jconsole,连接到进程; - 切换到“线程”选项卡;
- 点击“检测死锁”,即可可视化查看死锁线程。
3. 使用 ThreadMXBean 代码检测
可以在代码中主动检测死锁:
importjava.lang.management.ManagementFactory;importjava.lang.management.ThreadMXBean;publicclassDeadlockDetector{publicstaticvoidmain(String[]args){ThreadMXBeanmxBean=ManagementFactory.getThreadMXBean();long[]deadlockedThreads=mxBean.findDeadlockedThreads();if(deadlockedThreads!=null){System.out.println("检测到死锁线程数:"+deadlockedThreads.length);}else{System.out.println("未检测到死锁");}}}五、如何预防和避免死锁?
1. 破坏循环等待条件:固定加锁顺序
所有线程都按照相同的顺序获取锁,这是最常见、最简单的预防方式。
// 统一按照 lockA -> lockB 的顺序加锁synchronized(lockA){synchronized(lockB){// ...}}2. 破坏请求与保持条件:一次性申请所有资源
要么一次性获取所有需要的锁,要么一个都不获取。
// 使用一个全局锁来保护多个资源的申请synchronized(globalLock){// 获取所有需要的资源}3. 破坏不可剥夺条件:使用 tryLock 超时
使用ReentrantLock.tryLock()设置超时时间,获取不到锁就放弃,避免无限等待。
ReentrantLocklock1=newReentrantLock();ReentrantLocklock2=newReentrantLock();publicvoidmethod(){try{if(lock1.tryLock(1,TimeUnit.SECONDS)){try{if(lock2.tryLock(1,TimeUnit.SECONDS)){try{// 执行业务}finally{lock2.unlock();}}}finally{lock1.unlock();}}}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}4. 减少锁的使用范围与粒度
- 尽量缩小同步代码块的范围;
- 避免在持有锁时调用外部方法;
- 避免嵌套锁。
5. 使用无锁数据结构
使用AtomicInteger、ConcurrentHashMap等并发工具,从根本上减少锁的使用。
6. 使用带超时机制的锁
尽量使用tryLock(timeout),而不是无条件阻塞的lock()。
六、死锁、活锁与饥饿的区别
| 概念 | 说明 |
|---|---|
| 死锁 | 线程互相等待对方释放资源,永远阻塞,无法推进 |
| 活锁 | 线程没有被阻塞,但不断重试、回退,始终无法取得进展(如互相礼让) |
| 饥饿 | 某些线程长期得不到资源或 CPU 调度,导致一直无法执行 |
- 死锁:你等我,我等你,大家都卡住。
- 活锁:你让我,我让你,结果谁都没走。
- 饥饿:低优先级线程永远抢不到锁或 CPU 时间。
七、常见死锁场景总结
| 场景 | 说明 |
|---|---|
| 加锁顺序不一致 | 线程 A 先锁 1 再锁 2,线程 B 先锁 2 再锁 1 |
| 嵌套锁 | 持有锁 A 时请求锁 B,持有锁 B 时请求锁 A |
| 数据库事务 | 两个事务互相等待对方持有的行锁 |
| 线程池与任务依赖 | 任务 A 等待任务 B 的结果,而任务 B 又在等待任务 A |
| 资源分配 | 多个线程竞争多个不可抢占的资源 |
八、总结
死锁是多线程编程中必须警惕的问题。它的产生需要同时满足四个条件:
- 互斥
- 请求与保持
- 不可剥夺
- 循环等待
预防死锁的核心思路就是破坏其中任意一个条件:
- 固定加锁顺序(破坏循环等待)
- 一次性申请所有资源(破坏请求与保持)
- 使用 tryLock 超时(破坏不可剥夺)
- 减少锁的使用和范围(降低发生概率)
排查死锁的常用工具:
jstackjconsoleVisualVMThreadMXBean
实际开发中,应尽量:
能不共享就不共享,能无锁就无锁,必须加锁就统一顺序、缩小范围、设置超时。
只有这样,才能写出既高效又安全的多线程程序。