☰
手写必然死锁:从四个必要条件到jstack排查全解析
2026/10/10 9:08:54 网站建设 项目流程

面试场上手撕题千千万,偏偏“手写一个必然死锁的例子”出镜率极高,翻车率也极高。我见过不少人在白板上唰唰写了一大屏,信誓旦旦说“肯定死锁”,结果被面试官一句“你这只是碰巧阻塞,不算必然”直接怼到沉默。这题表面考手写代码,实际考的是你对死锁四个必要条件、线程调度、锁对象竞争模型的理解深度,能不能把代码写得既简洁、又可复现、还能应对连环追问。

这篇文章就当一次复盘,我会从手写代码开始,一路聊到怎么验证、怎么避免、以及生产环境里死锁为什么比教科书案例复杂得多。无论你是准备实习面试、社招跳槽,还是单纯被线上“卡死”问题折磨过,这篇内容都能帮你把死锁这滩水彻底趟明白。

1. 面试官到底在考什么:不是你会写代码,是你能不能稳定复现

1.1 死锁到底是什么,先一句话说清楚

死锁,英文叫 Deadlock,指的是两个或两个以上的执行单元,各自都持有一部分资源,同时在等待对方手里那部分资源,谁都不肯先放手,于是一起卡死。

举个例子:你手里拿着一个手机,你同事手里拿着一个充电器。你想用充电器,但他想要你的手机。你俩都坚持“你先把东西给我,我再给你”,最后谁也用不成。程序世界里的资源换成了锁对象,场景却一模一样:线程 A 持有着锁 L1,等着锁 L2;线程 B 持有着锁 L2,等着锁 L1,于是两个线程永远停在原地。

有时候面试官还会顺嘴问一句“死锁和阻塞有什么区别”。阻塞可以是一个线程单纯在等一把当前被占用的锁,等持有者释放就能继续;死锁则是多个阻塞的线程互相等,形成一个谁也解不开的环。你可以把死锁理解成阻塞的“死局版本”,光看单个线程的状态,根本看不出问题,必须把整个等待链串起来。

1.2 死锁的四个必要条件,不是背出来就完事

教科书里把死锁的产生条件归纳为四条,任何死锁都逃不过这四座大山:

必要条件含义生活中的类比
互斥(Mutual Exclusion)资源同一时刻只能被一个线程占用一把钥匙只能开一把锁,给了你就不能给我
持有并等待(Hold and Wait)线程已经持有一把锁,还去争抢另一把锁已经拿着驾驶证,还非要求你掏出身份证
不可剥夺(No Preemption)锁只有被持有者主动释放,外面不能强行抢你占着座位,别人再着急也不能把你拽起来
循环等待(Circular Wait)多个线程形成环形等待链A 等 B,B 等 C,C 又等 A

面试官一般会让你先默背一遍这四条。但真正拉差距的是下一句:如果你能指出“四者缺一不可,只要破坏任意一条,死锁就不可能发生”,那你已经不是在背概念,而是在用工程思维答题。后面避免死锁的所有方案,本质上都是对着这四条做减法。

1.3 为什么强调“必然”,而不是“可能”

这个问题我专门提出来,是因为很多人会犯同一个错误:写一个死锁例子,靠 Thread.sleep 硬凑时间,或者依赖某个极难出现的调度时机,然后告诉面试官“死锁了”。

面试官要的是“必然”,意思是这段代码只要跑起来,不需要靠运气、不需要调参、不依赖特定机器,就一定能进入死锁状态。为什么会有这个要求?因为面试官想筛选出真正理解资源竞争的人。一个碰运气才能触发的死锁,连你自己都无法稳定复现,到了线上出了故障,你又凭什么判断它是死锁?真正的工程能力,是能在需要时让死锁确定发生,也能在不需要时让它确定不发生。

2. 手写一个必然死锁的例子:经典版和加强版都给你

2.1 经典版答案:两个线程、两把锁、顺序相反

最保险的写法,是用两个锁对象 A 和 B,线程 1 先拿 A 再拿 B,线程 2 先拿 B 再拿 A。只要两个线程都走到第二步,就形成经典的循环等待。直接上代码:

public class ClassicDeadlock { private static final Object lockA = new Object(); private static final Object lockB = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (lockA) { System.out.println("t1 持有 lockA,准备获取 lockB"); synchronized (lockB) { System.out.println("t1 同时拿到两把锁,正常结束"); } } }, "t1"); Thread t2 = new Thread(() -> { synchronized (lockB) { System.out.println("t2 持有 lockB,准备获取 lockA"); synchronized (lockA) { System.out.println("t2 同时拿到两把锁,正常结束"); } } }, "t2"); t1.start(); t2.start(); } }

这个版本是面试里的标准答案结构,代码量少,逻辑一眼就能看懂。两个线程同时启动,线程 1 锁住 lockA,线程 2 锁住 lockB,然后线程 1 去等 lockB,线程 2 去等 lockA,互相等对方释放,程序永远跑不完。

但先说清楚:这个版本从“并发理论”上算不算严格必然?严格说不算百分之百必然。因为调度器极端情况下可能让线程 1 一口气把两把锁全拿完,线程 2 再开始执行,这样就没有死锁了。不过在真实运行环境里,两个线程几乎同时进入争抢,很快就会卡住“等”这个点,所以面试官普遍接受它作为标准答案。如果你想让面试官眼前一亮,下面这个加强版更合适。

2.2 加强版:用 CountDownLatch 保证“数学意义上必然死锁”

为了打消“可能不死锁”的疑虑,我们可以用并发工具把一个“同时起跑”的屏障做出来,再通过锁区间内停顿确保交叉顺序,这样死锁就不是概率事件,而是确定性事件。代码如下:

import java.util.concurrent.CountDownLatch; public class DeterministicDeadlock { private static final Object lockA = new Object(); private static final Object lockB = new Object(); public static void main(String[] args) throws InterruptedException { CountDownLatch ready = new CountDownLatch(2); CountDownLatch start = new CountDownLatch(1); Thread t1 = new Thread(() -> { ready.countDown(); try { start.await(); synchronized (lockA) { System.out.println("t1 已持有 lockA,等待 lockB"); Thread.sleep(1000); synchronized (lockB) { System.out.println("t1 成功执行,这句永远不会打印"); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "t1"); Thread t2 = new Thread(() -> { ready.countDown(); try { start.await(); synchronized (lockB) { System.out.println("t2 已持有 lockB,等待 lockA"); Thread.sleep(1000); synchronized (lockA) { System.out.println("t2 成功执行,这句永远不会打印"); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "t2"); t1.start(); t2.start(); ready.await(); start.countDown(); } }

这段代码的逻辑把死锁的关键点全包进去了:ready 这个门闩保证两个线程都准备就绪,start 统一放行,谁也不会比谁早太多。之后线程 1 百分百先拿到 lockA,线程 2 百分百先拿到 lockB,两把锁同时被不同线程持有。紧接着各自 sleep 一秒,目的不是制造死锁,而是给另一个线程留足时间去拿对方手里的第二把锁,形成“持有并等待 + 循环等待”的确定组合。等 sleep 结束,线程 1 去拿 lockB,发现 lockB 在线程 2 手里;线程 2 去拿 lockA,发现 lockA 在线程 1 手里,于是双双永久阻塞。

2.3 逐行拆解:为什么这两段代码一定会走到死锁局面

把第一版和第二版放在一起看,你会发现核心套路完全一致:两个锁对象必须独立,两个线程获取锁的顺序必须相反,而且每个线程必须在一个锁内去抢另一个锁。这三点缺一不可。

为什么要用两个独立的 Object 而不是同一个锁?因为同一个锁是可重入的,同一线程在内层 synchronized 里会直接通过,根本不会形成等待。为什么获取顺序必须相反?因为顺序相同的话,比如两个线程都先拿 lockA 再拿 lockB,第一个线程拿完两把锁之后第二个线程再执行,那就只是普通的互斥排队,永远构不成环。为什么必须在一个锁的临界区里面去拿另一个锁?因为只有持有一个锁的情况下再申请另一个锁,才满足“持有并等待”的条件,否则两个线程只是各自等一把没人持有的锁,等锁释放就行了,算不上死锁。

第二版里我还加了一件事:在拿到第一把锁之后故意让它停留一段时间。很多人不理解这一步,觉得是 sleep 魔法。其实在这里 sleep 是为了制造确定的时间窗口,让两个线程一定会在“各自持有第一把锁”的交叉状态下相遇。如果你觉得在面试官面前写 sleep 会被怀疑,可以换个说法:我这不是在赌运气,而是用协调工具把两个线程的推进节奏锁死,保证它们都停在各自持有第一把锁的状态上。

3. 现场验证:手写完了,怎么证明它的确死锁了

3.1 运行后看现象:程序没结束、不报错、CPU 不高

面试现场通常不能真的跑代码,但你心里要有数:死锁的程序运行起来,最典型的现象是主线程永远不会退出,控制台停在最后一行打印,后面没有输出,也没有异常。因为线程都 BLOCKED 了,JVM 里没有活动任务,虚拟机的非守护线程还在,进程就不会结束。

注意,CPU 占用不高是死锁的一个重要区分点。死锁线程不是忙等,它们是挂起的,等锁期间不消耗 CPU,所以进程整体占用特别低。如果你的代码跑起来 CPU 疯狂上涨,那更可能是自旋锁或者活锁,不是教科书意义上的死锁。这个细节我建议你在面试时主动提出来,专业度一下就上去了。

3.2 用 jstack 取证:一行命令让死锁现原形

工作中排查死锁,jstack 才是真正的主力工具。找到 Java 进程 PID,直接执行:

jstack <pid>

输出里会有一大段线程快照。如果存在死锁,jstack 会在最显眼的位置打印这样一段信息:

Found one Java-level deadlock: ============================= "t2": waiting to lock <0x00000007fc7ea860> (a java.lang.Object) locked <0x00000007fc7ea878> (a java.lang.Object) "t1": waiting to lock <0x00000007fc7ea878> (a java.lang.Object) locked <0x00000007fc7ea860> (a java.lang.Object)

这就是死锁的实锤。你既能看到哪个线程锁住了哪个对象,也能看到它在等哪个对象,还能顺着地址把环形等待链拼出来。遇到线上问题别瞎猜,先 jstack 抓现场,再看等锁链拓扑,比看日志有效率得多。

3.3 为什么“靠 Thread.sleep 拼时间”的死锁写法会扣分

在面试时写死锁,千万别靠一个随意的Thread.sleep(100)去等对方线程。原因很简单:sleep 并不能保证对方线程一定执行到“抢锁”那一步。如果对方线程被调度器推迟,或者你的 sleep 时间太短,或者机器上负载太高,那这段代码这次可能死锁,下次就不死锁,面试官一句“你复现一下”就露馅了。

最重要的是,sleep 不属于并发编程的正确抽象。真正控制时序的,是 CountDownLatch、CyclicBarrier、Semaphore 这类显式同步工具。能用门闩解决的“必然”,就不要用睡眠去赌。工程师的思维是确定优先,宁可代码多几行,也不要一个解释不清的时间窗口。

4. 手写之后,面试官最可能连环追问的五个问题

4.1 “你这代码不加 sleep 也算必然死锁吗?”

这是把我前面说的矛盾直接抛出来的送命题。如果你写的是经典双线程双锁版,这时候要诚恳承认:严格数学意义上不算,因为调度器可能让一个线程一口气跑完;但实际争抢中大概率死锁。如果你写的是带 CountDownLatch 的加强版,这个问题就变成了你的加分展示点:start 门闩统一放行,模拟确定性交叉,死锁成为必然事件。

这里有个小技巧:面试官追问的时候,先把两类写法的区别讲清楚,再说自己选了哪种,为什么这么选。流畅的表述顺序,比瞎编一个“一定死锁”的解释更让人信服。

4.2 “只有一把锁,两个线程会死锁吗?”

很多人在高压下脱口而出“会”。正确答案是不会,除非你强行设计一个“线程 A 在持锁中无限等待线程 B 释放某个条件”的场景,但经典互斥锁本身不会。Java 的 synchronized 和 ReentrantLock 都支持可重入,同一个线程第二次进入是允许的;两个线程争用同一把锁,最多就是排队,先来后到,最后都能拿到,不存在循环等待。

说实话,很多面试官问这个问题是想确认你有没有真的理解“锁”和“死锁”的边界。一把锁只能产生互斥,产生不了死锁,因为死锁至少要两条资源等待链。回答时直接把四个必要条件套上去,指出“不存在持有一把锁再等另一把锁的环节”,逻辑就无懈可击。

4.3 “怎么区分死锁、活锁和饥饿?”

这是一个高配追问,答出来能让面试官跟你多聊十分钟。死锁的特点是没有进展、线程全部 BLOCKED、不消耗 CPU;活锁的特点是没有阻塞,但线程一直在重复动作,互相谦让,谁也完成不了,CPU 可能飙升;饥饿则是资源长期被别的线程抢走,某线程一直拿不到锁,但它本身不是死锁链上的一环。

我习惯用一个例子解释活锁:两个人在窄巷子里迎面相遇,都想给对方让路,结果你往左他也往左,你往右他也往右,来回好几次,路还是让不开。这跟死锁最大的区别是,他们没有互相抓着手不放,而是“可以放手但永远走不到终点”。生产环境里活锁比死锁更难查,因为线程不阻塞、堆栈上也没有等待链。

4.4 “发生死锁了,怎么恢复?”

这是实战向的问题。对 Java 的 synchronized 锁,外部无法强制剥夺,也没有所谓“解开死锁”的 API,线程会永远等下去。你只能停掉进程,重启服务;如果想保现场,就趁进程还活着赶紧 jstack 抓堆栈,把证据留下来再决定恢复策略。

如果是用 ReentrantLock,可以把lock.lock()换成lock.lockInterruptibly(),让线程可以被外部 interrupt 唤醒并抛出 InterruptedException,但这种做法也只是让线程退出,不等于自动解决资源竞争,后续业务状态得自己处理。一般生产中我会尽量加tryLock带超时,超过时间就释放已经拿到的锁并重试,从源头上把死锁变成“可恢复的争抢失败”。

4.5 “能不能写一个不死锁的版本?”

这个问题通常放在最后,本质是让你把“避免死锁”落成代码。最直接的方案是锁排序:让所有线程都按同一个顺序拿锁。比如全局都先拿 lockA、再拿 lockB,那就不可能存在一个线程等你、你也在等它的环。示例:

public class SafeLockOrder { private static final Object lockA = new Object(); private static final Object lockB = new Object(); public static void main(String[] args) { Runnable task = () -> { synchronized (lockA) { System.out.println(Thread.currentThread().getName() + " 拿到 lockA"); synchronized (lockB) { System.out.println(Thread.currentThread().getName() + " 拿到 lockB,任务完成"); } } }; new Thread(task, "t1").start(); new Thread(task, "t2").start(); } }

两个线程的加锁顺序一致,即使有竞争,也只会退化成排队等待,永远成不了环。这就是破坏“循环等待”条件最粗暴也最有效的方法。

5. 生产环境里的死锁,比面试题复杂十倍

5.1 数据库死锁:你以为只有 Java 锁才会有死锁吗

面试手写死锁通常是内存锁对象,但线上更常见的是数据库死锁。两个事务各更新不同的行,然后又交叉更新对方锁定的行,数据库检测到死锁后,会立刻选择牺牲一个事务回滚,让它释放全部锁。所以数据库场景你看到的往往是一条“Deadlock found when trying to get lock”异常,而不是进程永久卡死。

这说明一个工程观念:不同中间件的死锁表现不同,但底层成因完全一致。你在面试里把四个必要条件讲透,到哪都能用。比如 MySQL 里事务 A 先更新表一的一行,再更新表二的一行;事务 B 反过来,先更新表二再更新表一,交互式操作一多,死锁就出现了。解决的思路还是那个:所有事务按同一顺序访问资源,或者用锁超时让事务尽快回滚。

5.2 锁排序虽好,但团队协作里最难落地

我在《代码里的锁排序,必须当成团队约定,不能当个人习惯》这个观点里吃了不少亏。单机上一个模块的锁顺序很容易定,但跨模块、跨服务、跨团队时,A 团队按“先用户锁、再订单锁”,B 团队按“先订单锁、再用户锁”,两边代码一集成,死锁立刻暴雷。这种问题最尴尬的是,单测一定测不出来,压测到高并发才冒出来,然后一 jstack 发现循环等待链跨越了好几个类。

我的经验是,锁排序要写进代码评审的检查清单。只要发现有人在一个锁区间内嵌套了另一把锁,就停下来问一句:全局锁顺序是什么?新加的这个锁排在哪个位置?如果锁全是私有对象还好说,一旦锁对应的是业务维度(比如用户维度、订单维度、库存维度),顺序就必须有硬性约定,甚至把维度号写死成一个常量表。

5.3 tryLock 是兜底方案,但不能滥用

ReentrantLock的tryLock是生产环境里比较实用的兜底手段。写法很简单:

ReentrantLock lockA = new ReentrantLock(); ReentrantLock lockB = new ReentrantLock(); boolean gotA = lockA.tryLock(200, TimeUnit.MILLISECONDS); if (!gotA) { return; // 拿不到就先放弃,不硬等 } try { boolean gotB = lockB.tryLock(200, TimeUnit.MILLISECONDS); if (!gotB) { // 拿到 A 但拿不到 B,立刻释放 A,避免持锁等锁 lockA.unlock(); return; } try { // 双锁都拿到了,做业务 } finally { lockB.unlock(); } } finally { // 这里要小心:如果拿到 B 后做了释放 A,这里不能重复释放 }

很多新手在 tryLock 这步容易写错,最大的坑就是释放重复。正常逻辑应该是:先尝试拿 A,成功后再尝试拿 B;B 失败时,必须手动释放 A;成功时,锁的释放顺序通常与获取顺序相反,而且要用 try/finally 包裹。如果你的业务不能在超时后直接放弃,就需要加入重试机制,比如退回队列稍后再试,绝对不能原地死循环。

5.4 降低锁粒度:把锁的对象变细、范围变小

死锁最怕的不是锁多,而是锁的范围太大、持有时间太长。锁持有时间越长,两个线程在交叉路径上相遇的概率就越大。所以工作里我一直在推动一个原则:能锁单个对象,就不要锁整个集合;能锁半个方法,就不要锁整个方法。

比如更新用户余额,你只需要锁用户 ID 对应的UserAccount对象,而不是把整个用户表锁住;做库存扣减,用ConcurrentHashMap加单 SKU 锁,而不是一个全局锁。锁范围变小以后,两个线程的临界区重叠概率会陡然下降,死锁自然减少。当然,锁粒度太细也有风险,会出现锁顺序更难统一、代码逻辑更复杂、死锁排查更费劲的问题。所以这是个平衡题,面试官有时候会追问“锁粒度小了,那锁的数量变多,更容易死锁怎么办”,你要答出“配合锁排序和超时兜底”才算完整。

6. 一次面试,把并发基础全盘活

写这篇文章时我一直在想,为什么这道题能成为大厂面试高频题。因为一个好的死锁示例,浓缩了并发编程里的核心矛盾:资源竞争、锁的可重入性、线程调度、同步工具、避免策略、排查方法。面试官只要围绕这个例子连续追问五六次,你到底是背过答案,还是真的懂并发原理,立刻分得清清楚楚。

我个人强烈建议你,在面试前不要只背这一段代码,而是把它当成一座桥:从桥走过去,左手边是 JMM 和 synchronized 底层实现,右手边是 JUC 框架和分布式锁。花一个晚上把手写死锁、jstack 定位、锁排序、tryLock 隔离这四个能力串起来,比刷五十道并发面经都管用。

最后分享一个多年踩坑得到的体会:写并发代码,永远先想“这段代码会不会死锁”,再想“这段代码性能快不快”。线上一次死锁故障的代价,足够抹掉你之前所有的性能优化收益。每一次加锁之前,问问自己:锁的顺序全局一致吗?如果不一致,我能保证超时回退吗?不能保证,就停下来先解决这个问题。顺序对了,锁写多少都不太容易出大事;顺序错了,一把锁就能让你凌晨三点爬起来抓线程栈。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询