
面试场上手撕题千千万偏偏“手写一个必然死锁的例子”出镜率极高翻车率也极高。我见过不少人在白板上唰唰写了一大屏信誓旦旦说“肯定死锁”结果被面试官一句“你这只是碰巧阻塞不算必然”直接怼到沉默。这题表面考手写代码实际考的是你对死锁四个必要条件、线程调度、锁对象竞争模型的理解深度能不能把代码写得既简洁、又可复现、还能应对连环追问。这篇文章就当一次复盘我会从手写代码开始一路聊到怎么验证、怎么避免、以及生产环境里死锁为什么比教科书案例复杂得多。无论你是准备实习面试、社招跳槽还是单纯被线上“卡死”问题折磨过这篇内容都能帮你把死锁这滩水彻底趟明白。1. 面试官到底在考什么不是你会写代码是你能不能稳定复现1.1 死锁到底是什么先一句话说清楚死锁英文叫 Deadlock指的是两个或两个以上的执行单元各自都持有一部分资源同时在等待对方手里那部分资源谁都不肯先放手于是一起卡死。举个例子你手里拿着一个手机你同事手里拿着一个充电器。你想用充电器但他想要你的手机。你俩都坚持“你先把东西给我我再给你”最后谁也用不成。程序世界里的资源换成了锁对象场景却一模一样线程 A 持有着锁 L1等着锁 L2线程 B 持有着锁 L2等着锁 L1于是两个线程永远停在原地。有时候面试官还会顺嘴问一句“死锁和阻塞有什么区别”。阻塞可以是一个线程单纯在等一把当前被占用的锁等持有者释放就能继续死锁则是多个阻塞的线程互相等形成一个谁也解不开的环。你可以把死锁理解成阻塞的“死局版本”光看单个线程的状态根本看不出问题必须把整个等待链串起来。1.2 死锁的四个必要条件不是背出来就完事教科书里把死锁的产生条件归纳为四条任何死锁都逃不过这四座大山必要条件含义生活中的类比互斥Mutual Exclusion资源同一时刻只能被一个线程占用一把钥匙只能开一把锁给了你就不能给我持有并等待Hold and Wait线程已经持有一把锁还去争抢另一把锁已经拿着驾驶证还非要求你掏出身份证不可剥夺No Preemption锁只有被持有者主动释放外面不能强行抢你占着座位别人再着急也不能把你拽起来循环等待Circular Wait多个线程形成环形等待链A 等 BB 等 CC 又等 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成功后再尝试拿 BB 失败时必须手动释放 A成功时锁的释放顺序通常与获取顺序相反而且要用 try/finally 包裹。如果你的业务不能在超时后直接放弃就需要加入重试机制比如退回队列稍后再试绝对不能原地死循环。5.4 降低锁粒度把锁的对象变细、范围变小死锁最怕的不是锁多而是锁的范围太大、持有时间太长。锁持有时间越长两个线程在交叉路径上相遇的概率就越大。所以工作里我一直在推动一个原则能锁单个对象就不要锁整个集合能锁半个方法就不要锁整个方法。比如更新用户余额你只需要锁用户 ID 对应的UserAccount对象而不是把整个用户表锁住做库存扣减用ConcurrentHashMap加单 SKU 锁而不是一个全局锁。锁范围变小以后两个线程的临界区重叠概率会陡然下降死锁自然减少。当然锁粒度太细也有风险会出现锁顺序更难统一、代码逻辑更复杂、死锁排查更费劲的问题。所以这是个平衡题面试官有时候会追问“锁粒度小了那锁的数量变多更容易死锁怎么办”你要答出“配合锁排序和超时兜底”才算完整。6. 一次面试把并发基础全盘活写这篇文章时我一直在想为什么这道题能成为大厂面试高频题。因为一个好的死锁示例浓缩了并发编程里的核心矛盾资源竞争、锁的可重入性、线程调度、同步工具、避免策略、排查方法。面试官只要围绕这个例子连续追问五六次你到底是背过答案还是真的懂并发原理立刻分得清清楚楚。我个人强烈建议你在面试前不要只背这一段代码而是把它当成一座桥从桥走过去左手边是 JMM 和 synchronized 底层实现右手边是 JUC 框架和分布式锁。花一个晚上把手写死锁、jstack 定位、锁排序、tryLock 隔离这四个能力串起来比刷五十道并发面经都管用。最后分享一个多年踩坑得到的体会写并发代码永远先想“这段代码会不会死锁”再想“这段代码性能快不快”。线上一次死锁故障的代价足够抹掉你之前所有的性能优化收益。每一次加锁之前问问自己锁的顺序全局一致吗如果不一致我能保证超时回退吗不能保证就停下来先解决这个问题。顺序对了锁写多少都不太容易出大事顺序错了一把锁就能让你凌晨三点爬起来抓线程栈。