
今天的题目来自我最近一个小伙伴去小红书面试回来的一道真题——死锁。他说面试官让他现场写一个死锁demo再讲讲怎么排查结果他只写出了demo排查环节卡住了。这道题看上去是基础题实际上面试官问的点非常细从原理到实操、从工具到预防一层套一层。这篇文章我就把这道题完整拆开讲透从代码复现到线上排查再到面试回答路径尽量按真正面试的节奏来还原。1. 面试官问死锁到底在考察什么三层能力模型死锁是并发编程里的经典问题任何一个做后端开发的人都绕不开。但面试官问死锁通常不是想听你背一遍“互斥、持有并等待、不可剥夺、循环等待”这十六个字。我复盘了这次面试的整个过程死锁这道题背后其实藏着一套能力模型大致分三层。第一层是知识点的扎实程度。死锁的四要件、死锁和活锁与饥饿的区别、JVM线程状态的变化这些属于“背了就能答”的部分。任何一个写过多线程代码的候选人都能说出个大概所以面试官不会在这一层停留太久除非你连synchronized和ReentrantLock的区别都说不清楚。第二层是动手能力。让你现场写一个能复现死锁的代码看起来很简单但很多人写不出来或者写出来了跑一遍发现根本没死锁。为什么因为写死锁demo需要对锁的获取顺序、线程调度时机有精确的控制代码写完能不能稳定复现死锁本身就是一道坎。这一层考察的是你真写过并发代码而不是只看过博客。第三层是线上问题的排查与预防能力。死锁在真实业务中不会像demo这么乖巧地跑出来它往往藏在一堆日志里表现为接口超时、线程池打满、CPU不高但服务假死。面试官问“你怎么排查”其实是在问你在线上遇到过什么、有没有用过jstack、能不能从线程转储里定位到问题。这一层才是区分普通开发者和资深开发者的关键。所以别把死锁当一道送分题。它其实是一条线把操作系统原理、JVM锁机制、工程实践串在一起。这篇文章我就按这条线往下写先讲原理再给可复现代码然后讲排查最后回到面试现场的应对思路。2. 死锁四条件没那么简单为什么缺一个都死不成四要件大多数人都能背但我想从一个不同的角度来讲为什么这四个条件缺一个就死锁不了。理解了这一点面试时再被追问才不会慌。先看这四个条件本身互斥资源同一时刻只能被一个线程持有。这是锁的基本属性不存在“共享的互斥锁”这种说法。持有并等待线程已经持有一个锁还在等待另一个锁。这一步很关键它描述的是“占着不放”的行为。不可剥夺线程持有的锁不能被别的线程强行抢走只能由持有者主动释放。synchronized就是这样JVM不会在你等锁等得不耐烦时强制把锁拿走。循环等待存在一条线程间的等待环路A等B、B等A或者更长的链A等B、B等C、C等A。用生活化的例子帮大家记忆。想象四个小朋友在饭桌上抢菜每个人手里拿了一把勺子同时还想拿对面那把勺子。每个人都不肯放下手里的勺子持有并等待勺子也不能从别人手里夺过来不可剥夺四个人的勺子刚好围成一圈互相锁定循环等待于是这顿饭永远吃不完。互斥在这里体现为一把勺子同一时刻只能被一个人握着。那为什么缺一个就不行我逐个拆假设没有互斥资源可以同时被多人使用那就不存在“等着别人释放”这回事死锁自然不成立。假设没有持有并等待每个线程在获取锁之前必须先释放手里已有的锁那最多是排队阻塞不会有“占住不放”的僵局。假设允许剥夺比如其他线程可以直接抢走你手里的锁那等待方很快能拿到资源往下走环就被打断了。假设没有循环等待那每个线程最终都能等到自己需要的资源变成单纯的串行执行而不是互相僵持。面试时如果你能把这套“反证逻辑”讲出来比单纯背定义要高一个档次。面试官再追问“那你觉得工程上破坏哪个条件最可行”注意破坏“互斥”基本不可行因为你选锁就是为了互斥破坏“持有并等待”成本很高要让每个线程在拿不到第二个锁的时候主动释放第一个锁这需要非常精细的锁管理逻辑破坏“不可剥夺”也不是JVM锁的默认行为真正被广泛采用的做法是破坏“循环等待”——让所有线程都按照同一个固定顺序获取锁。这一点后面的代码示例会用到。还有一个很容易被忽略的细节也是很多人在面试回答里暴露短板的地方死锁的四条件是必要条件不是充分条件。也就是说即使四个条件同时满足也不一定立刻死锁还需要线程调度的时机配合。比如代码逻辑有锁竞争但两个线程的大脑都按顺序往下跑可能恰好没踩到环。这也是为什么有些同学面试时写了一个逻辑上会死锁的demo但跑起来程序很快就退出了因为其中一个线程先执行完了另一个线程后来才去抢锁没形成僵持。这一点在面试里很值得主动提出来面试官会觉得你真的从原理层面理解过而不是只背过结论。3. 一个必踩的真实案例转账死锁的复现与现场分析现在动手写代码。死锁demo最经典的场景就是转账。两个账户互相转账需要同时操作两个账户的余额因此要获取两个账户的锁。如果两个线程获取锁的顺序不一致死锁就出现了。先给你一个完整的、能稳定复现死锁的Java代码这段代码我已经在JDK 8和JDK 17上都跑过行为一致public class DeadlockDemo { private final String accountId; private int balance; public DeadlockDemo(String accountId, int balance) { this.accountId accountId; this.balance balance; } // 同步方法锁是当前对象 public synchronized void transfer(DeadlockDemo target, int amount) { System.out.println(Thread.currentThread().getName() 获得 accountId 的锁尝试获取 target.accountId 的锁); target.deposit(amount); this.balance - amount; } public synchronized void deposit(int amount) { this.balance amount; } public static void main(String[] args) { DeadlockDemo accountA new DeadlockDemo(A, 1000); DeadlockDemo accountB new DeadlockDemo(B, 1000); Thread t1 new Thread(() - accountA.transfer(accountB, 100), 线程t1); Thread t2 new Thread(() - accountB.transfer(accountA, 100), 线程t2); t1.start(); t2.start(); } }运行这段代码很大概率会看到程序卡住不再打印新的信息。我用它跑了几次有一次输出是这样的线程t1 获得 A 的锁尝试获取 B 的锁 线程t2 获得 B 的锁尝试获取 A 的锁然后进程就挂在原地既不退出也不继续打印。这就是死锁的现场。为了增加复现概率我一般会在transfer方法里加一个很小的sleep模拟业务耗时public synchronized void transfer(DeadlockDemo target, int amount) { System.out.println(Thread.currentThread().getName() 获得 accountId 的锁尝试获取 target.accountId 的锁); try { Thread.sleep(100); // 放大锁竞争窗口 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } target.deposit(amount); this.balance - amount; }sleep会放大“同时持有一个锁再等另一个锁”的时间窗口让两个线程更容易在同一个时间点交叉等待。不加sleep也能跑出死锁只是概率相对低一些取决于线程的调度顺序。停下来分析一下这个例子里的死锁结构t1线程持有accountA的锁等待accountB的锁t2线程持有accountB的锁等待accountA的锁。等待资源的方向刚好形成一个环。这个环一旦成形两个线程就会永远阻塞。interrupt、notify这类手段并不会让线程从锁等待中退出因为线程阻塞在synchronized的monitor enter上时即使收到中断信号也只会设置中断标志位等拿到锁之后才能响应中断。很多人面试写到这一步就停了然后面试官问“那你用jstack看一下这段代码死锁在哪”他就懵了。下面这节就是排查的正篇。4. 死锁真正的难处在排查一次jstack实战看完整个链路线上死锁不像demo那么明显它藏在几十个线程的dump里要自己去找哪两个线程卡住了。排查死锁的第一工具是jstack。4.1 拿线程转储的三步操作第一步先找到目标进程的PID。进程可能在本机也可能在容器里。本机直接用jps即可jps -l如果输出里有多个Java进程用端口或主类名区分。比如springboot应用的进程可以配合ps -ef | grep java确认。第二步打印线程转储jstack -l PID thread_dump.txt加-l会把锁的附加信息也打出来排查死锁时很有用。如果线上容器没有jstack可以进容器执行或者用jattach这类工具。我还见过用kill -3 PID让JVM把dump打到标准输出的做法但日志文件不一定找得到推荐还是jstack。第三步打开dump文件先搜索关键词deadlock。JVM检测到死锁后会非常贴心地在dump末尾打印一段死锁汇总而且会明确指出哪些线程存在死锁。我拿上面那段转账代码实际跑了一次然后jstack关键输出长这样Found one Java-level deadlock: 线程t1: waiting to lock monitor 0x000000001a5f4d68 (object 0x00000000ec249850, a DeadlockDemo), which is held by 线程t2 线程t2: waiting to lock monitor 0x000000001a5f4d28 (object 0x00000000ec2498a0, a DeadlockDemo), which is held by 线程t1 Java stack information for the threads listed above: 线程t1: at DeadlockDemo.deposit(DeadlockDemo.java:31) - waiting to lock 0x00000000ec249850 (a DeadlockDemo) at DeadlockDemo.transfer(DeadlockDemo.java:24) - locked 0x00000000ec2498a0 (a DeadlockDemo) at DeadlockDemo.lambda$main$0(DeadlockDemo.java:38) at DeadlockDemo.main(DeadlockDemo.java:41)这一段信息量很大我来逐行解读。waiting to lock指向的是当前线程正在等待获取的锁对象也就是它卡住的地方。在这份dump里线程t1在deposit方法上等待锁B而deposit上方的栈帧显示transfer方法里它已经locked 0x00000000ec2498a0也就是持有锁A。看到这里死锁对称结构已经浮出水面t1拿A等Bt2拿B等A。环正好封口。在实际工作中dump文件里可能有几十上百个线程大部分处于WAITING或TIMED_WAITING这些是线程池里的空闲线程或者定时任务不用管。真正需要关注的是两种状态java.lang.Thread.State: BLOCKED和那些在dump末尾被标记为deadlock的线程。先看JVM给出的死锁汇总如果JVM没检测出来有些自定义ReentrantLock嵌套场景JVM也能检测到但少数场景需要人工判断那就回到所有BLOCKED线程逐个看它waiting to lock的目标锁被谁持有顺着持有链去找环。4.2 除了jstack还需要什么工具jconsole图形化界面连接本地或远程JVM进程在“线程”页签里能直接看到死锁提示特别适合本地写demo排查按钮点一下就能看到死锁检测结果。Arthas线上排查神器用thread -b可以一键找出当前阻塞住其他线程的线程用thread -n 3按CPU占用排序查看热点线程。而且它不需要重启服务attach到进程就能用。JMC (Java Mission Control)适合做历史分析但实时排查里不太常用。我的习惯是本地有图形界面用jconsole线上首选Arthas最朴素的环境就用jstackjps。这几个工具能配合着用就行面试提一两个叫得出名字、说得出核心命令的比报菜名录一堆要强。这里有个实操体会。很多人在dump里看到线程状态是BLOCKED就慌了其实BLOCKED不一定是死锁。线程池里的工作线程经常会在某个共享锁上排队比如日志异步写入锁、连接池获取连接的内部分布式锁这些只是瞬时阻塞很快就能拿到锁继续跑。真正的死锁特征是两个线程互相成为对方等待锁的持有者形成一个闭环并且这个状态持续很久不解除。配合jstack两次导出dump文件做对比如果两次之间间隔几秒这些线程的栈没有发生变化还是在同一个锁上僵持那基本能确认是死锁。5. 死锁的预防方案怎么选顺序加锁、超时锁与无锁化的取舍排查出死锁只是第一步怎么让死锁不再发生才是工程核心。前文提到最常用的是破坏“循环等待”具体落地手段就是统一锁的获取顺序。5.1 方案一按固定顺序获取锁回到转账例子。死锁发生的直接原因是线程t1执行了A.transfer(B)先锁A再锁B线程t2执行了B.transfer(A)先锁B再锁A。顺序不一致环就产生了。解决办法是让两个线程都按同一个顺序加锁比如始终先锁账户id较小的那个。public void transfer(DeadlockDemo target, int amount) { DeadlockDemo first; DeadlockDemo second; if (this.accountId.compareTo(target.accountId) 0) { first this; second target; } else { first target; second this; } synchronized (first) { synchronized (second) { this.balance - amount; target.balance amount; } } }这段代码的核心是把“先拿当前账户锁再拿目标账户锁”调整为“先拿小id账户锁再拿大id账户锁”。不管请求方向是从A到B还是从B到A锁获取的顺序都一样这样就不存在交叉等待的环。这个方案最简单、性能也最好银行转账系统里很常见。代价是要为业务对象设计一个稳定的排序字段。如果对象没有天然的唯一标识可以引入一个全局的、单调递增的id或者用对象的System.identityHashCode保底排序。identityHashCode有一定概率碰撞概率很低但非零严格系统不建议单独依赖它只是万不得已的选择。5.2 方案二tryLock配合超时如果使用ReentrantLock可以把“无限期等待锁”改成“超时放弃”。ReentrantLock lockA new ReentrantLock(); ReentrantLock lockB new ReentrantLock(); public void transferWithTimeout(Account target, int amount) { long start System.currentTimeMillis(); boolean gotA lockA.tryLock(1, TimeUnit.SECONDS); try { boolean gotB false; if (gotA) { gotB lockB.tryLock(1, TimeUnit.SECONDS); } if (gotB) { // 转账逻辑 } else { // 回滚或重试 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lockB.isHeldByCurrentThread()) lockB.unlock(); if (lockA.isHeldByCurrentThread()) lockA.unlock(); } }这个方案的优点是不会无限期卡住超时后可以释放已持有的锁再决定重试还是走降级。缺点是获取两个锁的操作不再是原子的可能出现拿到了锁A、等锁B超时、释放锁A的间隙里别的线程插队而且超时时间设置要结合实际业务设置太长等于没超时设置太短会造成大量请求失败。释放锁时的isHeldByCurrentThread()判断也很关键因为tryLock超时后可能根本没拿到锁直接unlock会抛出IllegalMonitorStateException。方案二适合业务对实时性要求高、无法忍受长时间阻塞的场景但要注意它不能百分百保证不死锁只能说降低了死锁持续时间和发生概率。5.3 方案三无锁化与只持有一把锁比调节锁顺序更彻底的思路是减少锁的数量。能不能用一次锁操作完成整个逻辑比如转账可以把账户余额设计成不可变对象每次转账都通过一个全局的转账服务串行处理或者用AtomicReference做CAS式余额变更。还有一种常见做法是引入“账本”概念不直接修改两个账户的余额而是写入一条转账记录由后续对账任务统一结算。这种无锁化或单锁化方案能从根本上消除死锁但对业务建模的改动较大不是所有场景都能这么玩。还有一类容易被忽略的锁粒度过大导致“伪死锁”。比如给整个账户表加一把锁所有账户操作串行化虽然不会死锁但性能会掉到惨不忍睹。实际系统里锁的粒度和并发度是一对矛盾单纯为了防死锁把所有操作串行化属于因噎废食。下面这个表格总结了三个方案的核心差异面试时能把这个表格说全面试官一般就会换下一个话题了方案实现难度是否完全消除死锁典型代价固定顺序获取锁低是需要业务对象提供稳定排序tryLock超时中否降低概率超时时间难定可能大量重试无锁化/单锁化高是业务模型需要重构5.4 从单机到分布式锁环的延伸现在很多系统都是微服务架构多把分布式锁叠加使用时同样会死锁。比如一个服务先拿Redis的分布式锁A再拿ZooKeeper的分布式锁B另一个服务先拿B再拿A两个服务就可能在分布式锁层面形成死锁。排查方式也类似只是把对象从JVM内存锁换成了外部锁的key抓住的本质仍然是“环”。用我自己的习惯总结一下工程上优先用固定顺序因为它简单、确定、可控只有在无法统一顺序时才退到tryLock超时无锁化是锦上添花前置条件是业务允许重新建模。面试谈到这一层基本能体现出你对并发编程的思考深度。6. 面试现场的高分回答路径从白板demo到追问应对最后把整个面试过程串起来聊聊。面试官给出“死锁问题”这个题目后不同层级的候选人表现差异非常明显。6.1 基础答题结构先定义再条件再讲打破如果面试官只是从概念切入我建议你按这个顺序回答用一句话定义死锁两个或两个以上的线程在争夺资源时因为互相等待对方持有的锁而陷入的永久阻塞状态。把四要件完整说出来。主动讲“破坏任意一个条件死锁就解不开”。结合案例讲你在项目里是怎么预防的。这四步相当于固定套路不会大错。但如果面试官要求你写代码注意别一上来就写transfer场景。可以先问清楚“您希望我写一个synchronized风格的死锁还是基于Lock接口的”这个反问本身就能体现你对两者差异的掌握。6.2 写demo时的两个加分细节写demo时记得在锁之间加一个短暂的sleep并解释为什么因为死锁需要两个线程在时间上“同时”陷入互相等待不加sleep纯靠线程调度复现率可能不到50%。加sleep相当于把竞争窗口拉大让复现概率逼近百分之百。这个解释会让面试官觉得你有真实调试经验。另一个细节是代码写完不要急着说“这就是死锁”。主动演示一遍运行结果——程序卡住不动了——然后用一句话点出“t1拿A等Bt2拿B等A形成环”。很多候选人把demo写出来就闭嘴了其实写demo只是一个引子后续的分析才是重点。6.3 常见追问及回应思路我整理了这次面试中实际被追问到的问题清单几乎覆盖了死锁的全部考点追问高分回应的关键词如何确认线上发生了死锁接口超时、线程池打满、jstack看到deadlock标记、两次dump栈不变synchronized和ReentrantLock在死锁上有什么区别synchronized不可中断、不可超时ReentrantLock支持tryLock和lockInterruptibly死锁和活锁的区别死锁是永久阻塞不动活锁是两个线程反复谦让都拿不到资源线程状态仍能变化但任务无法推进数据库层面会不会死锁会比如两个事务按不同顺序更新同一批行回滚机制和锁等待超时能缓解破坏四条件里哪个成本最低循环等待通过统一加锁顺序实现回答追问时有一个常见的坑就是“只答结论不给理由”。比如问你线上怎么排查只回答“用jstack”四个字等于没说。应该把完整链路讲出来先观察现象再用jps定位进程再导出dump最后找到deadlock标记或人工分析锁环。这一串话讲完面试官才会相信你真的操作过。6.4 如果被问到“线上已经死锁了你怎么恢复”这是一个很实战的追问不少人都栽在这里。正确答案不是“重启服务”因为重启虽然能清掉锁但代价太大。正规做法是先用jstack拿到当前线程转储确认死锁线程。如果线程里存有业务上下文信息用kill命令强制终止其中一个线程打破锁环让另一个线程继续执行。终止线程前评估业务影响——正在操作的数据是否需要补偿。恢复后立即分析锁顺序代码提交修复不给第二次机会。补充一点使用Thread.stop()这种强杀手段极度危险可能破坏数据一致性不推荐在Java 8以上的版本里使用。更温和的手段是让业务代码本身具备锁超时和重试机制这样即使死锁发生也能自愈而不是僵死。6.5 面试收尾的一个建议如果你还有余力可以在回答里带上一点自己的业务案例。比如“我们系统之前有个定时任务和用户操作同时跑出现过一次死锁后来把所有涉及多把锁的路径统一了获取顺序再没复发”。真实案例比任何理论都打动人但前提是别编编的案例很容易被追问细节问穿。最后分享一个实际项目中踩过的死锁坑我印象最深的一次死锁不是发生在经典的转账逻辑里而是出现在一个看似无关的场景订单状态更新和库存扣减两个方法各自拿着自己的锁然后互相调用。两个方法都由同一个事务管理一个在事务里先更新订单再扣库存另一个在另一条代码路径上先扣库存再更新订单。两个并发线程刚好走了不同路径锁就死在了一起。那次排查花了两个多小时最后用Arthas的thread -b一步定位到阻塞源头再翻开代码发现两个方法的加锁顺序确实是反的。从那以后我养成了一个习惯写涉及多把锁的代码之前先在一张草稿纸上画出锁的获取顺序图所有线程必须沿着同一个方向走。这个习惯帮我避开了后面好几个潜在的锁环。死锁这道题表面考的是并发原理实际上考的是你有没有一套从“写代码”到“排问题”再到“改架构”的完整闭环。你只要能把这个闭环讲通面试官大概率会对你的工程能力留下印象。希望这篇对你准备死锁相关的面试有帮助。面试前也建议自己亲手把demo写一遍jstack跑一遍Arthas命令敲一遍踩过坑的记忆比看十篇面经都管用。