首页 / 资讯中心 / 文章详情

Java并发面试:手写必然死锁示例与排查技巧

Java并发面试:手写必然死锁示例与排查技巧 ★ FEATURED ARTICLE
直接动手写一个死锁示例这几乎是Java并发面试里的保留节目。很多候选人能背出“互斥、占有且等待、不可抢占、循环等待”四句话但真让他当场手写一个必然死锁的代码反而卡壳要么写出的demo根本不产生死锁要么依赖random休眠碰运气。如果你正在准备大厂面试或者带新人想考察他的并发功底这篇文章值得看完。我会从最朴素的死锁案例开始逐步分析为什么它“必然”死锁再给出扩展场景、排查手段和面试时的表达框架。1. 死锁的前置认知面试官到底在考什么1.1 死锁的定义与听课时的“必要条件”死锁是指两个或多个线程互相持有对方需要的资源同时又都在等待对方释放资源导致所有线程都无法继续推进。教科书上给出的四个必要条件任何一个被打破死锁就不可能成立互斥资源同一时刻只能被一个线程占用。占有且等待线程已经持有一个资源又在等待另一个资源。不可抢占线程持有的资源不能被其他线程强行抢走只能由持有者主动释放。循环等待存在一个线程等待环比如T1等T2的资源T2等T1的资源。面试时最容易出问题的地方是很多人把“必要条件”背成了“充分条件”。死锁发生需要四个条件同时存在但四个条件存在并不表示死锁必然发生因为还有“碰巧同一时刻产生等待”的运气成分。而面试官要求的是“必然死锁”这意味着你需要通过同步机制把那个碰巧变成百分之百的确定性事件做到不管跑多少次不管什么调度顺序程序最终一定卡死。1.2 手写题背后的能力考察点让候选人手写必然死锁表面考察编码实则考察三件事。第一是否真的理解锁的获取与释放过程而不是只会用synchronized修饰方法。第二能否意识到“必然”意味着需要主动控制执行顺序而不是依赖线程调度接口。第三是否具备排查与规避死锁的实战意识因为死锁一旦发生在生产环境往往伴随着线程池耗尽、接口超时、服务不可用等连锁故障。明白这三点之后再动手写代码思路就会清晰很多我们要用某种栅栏或协调机制让线程A先拿到锁1再让线程B先拿到锁2最后让A去等锁2、B去等锁1。只要执行顺序被严格控制死锁就是必然事件。2. 手写必然死锁从初级版到高区分度版本2.1 经典双线程双锁版本为什么它能“必然”死锁先看面试中最常见、也最稳妥的写法。两个共享对象作为两把锁两个线程按相反顺序加锁同时用CountDownLatch或CyclicBarrier确保线程间的启动顺序。public class DeadlockDemo { private static final Object LOCK_A new Object(); private static final Object LOCK_B new Object(); public static void main(String[] args) throws InterruptedException { CountDownLatch startGate new CountDownLatch(1); Thread thread1 new Thread(() - { try { startGate.await(); synchronized (LOCK_A) { System.out.println(Thread.currentThread().getName() 持有LOCK_A等待LOCK_B); // 模拟持有锁期间做点事情 Thread.sleep(50); synchronized (LOCK_B) { System.out.println(Thread.currentThread().getName() 获取到LOCK_B); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, T1); Thread thread2 new Thread(() - { try { startGate.await(); synchronized (LOCK_B) { System.out.println(Thread.currentThread().getName() 持有LOCK_B等待LOCK_A); // 模拟持有锁期间做点事情 Thread.sleep(50); synchronized (LOCK_A) { System.out.println(Thread.currentThread().getName() 获取到LOCK_A); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, T2); thread1.start(); thread2.start(); startGate.countDown(); thread1.join(); thread2.join(); System.out.println(主线程结束); } }运行这段代码程序永远停在“持有LOCK_A等待LOCK_B”和“持有LOCK_B等待LOCK_A”两行日志主线程的join()一直阻塞最终整个进程无法结束。为什么说它是必然死锁关键在于startGate.await()。CountDownLatch在这里充当了起跑线两个线程内部都等主线程的startGate.countDown()主线程先启动两个线程再放行这就保证了两个线程都不会出现某个线程抢先运行完毕释放锁的情况。即使Thread.sleep(50)被去掉只要T1拿住A之后T2拿住B紧接着就是T1等B、T2等A不需要任何运气成分。事实上去掉sleep之后死锁概率依然是100%因为两个线程同时被放行调度器无论先执行谁最终都会因交叉等待而卡住。如果不用闸门直接thread1.start(); thread2.start();理论上偶尔会出现T1先跑完再跑T2的极端调度那种情况下死锁不成立——当然概率极低但它不够“必然”。写这个示例时注意锁对象必须是稳定的引用不能用new Object()作为局部锁变量否则两个线程拿到的可能不是同一把锁。另外synchronized (LOCK_A)块内不要加异常捕获导致提前退出否则锁会被释放死锁就被破坏了。2.2 升级版线程池中的资源竞争死锁双线程版本虽然能跑通但面试官如果追问“生产上有类似场景吗”你可以引出线程池死锁。这是区分度很高的知识点。import java.util.concurrent.*; public class ThreadPoolDeadlock { private static final ExecutorService pool Executors.newFixedThreadPool(1); public static void main(String[] args) { pool.submit(() - { System.out.println(外部任务开始执行); // 在单线程线程池中再提交一个任务这个任务永远无法执行 Future? future pool.submit(() - System.out.println(内部任务执行)); try { future.get(); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } System.out.println(外部任务结束); }); } }固定大小为1的线程池中工作线程正在执行外部任务外部任务内部又提交了一个内部任务并调用future.get()等待结果。内部任务排在任务队列里但唯一的工作线程被外部任务持有外部任务又等待内部任务完成于是两者互相等待。这种死锁没有锁对象但本质同样是循环等待而且future.get()是无期限阻塞的生产环境很容易造成线程池队列堆积最终触发拒绝策略。再往外延伸一点可以提到数据库连接池和线程池嵌套导致的死锁。比如一个业务方法持有数据库连接再去调用另一个依赖连接池的方法而连接池已满新请求都在等待归还连接持有连接的方法又在等待新请求的结果这就会拖垮整个服务。这种场景虽然不能靠区区几行代码完整复现但思路完全一致。2.3 高区分度版本锁顺序不一致引发的死锁还有一类写法在实战中更容易出现即多个线程执行同一个方法但由于入参顺序不同导致加锁顺序不同。面试官看到这种写法至少会认为你有真实项目经验。public class TransferDeadlock { private static class Account { final int id; int balance; Account(int id, int balance) { this.id id; this.balance balance; } } private static void transfer(Account from, Account to, int amount) { synchronized (from) { System.out.printf(%s 锁定账户 %d等待账户 %d%n, Thread.currentThread().getName(), from.id, to.id); synchronized (to) { from.balance - amount; to.balance amount; System.out.printf(%s 转账完成%d - %d%n, Thread.currentThread().getName(), from.id, to.id); } } } public static void main(String[] args) { Account a new Account(1, 100); Account b new Account(2, 100); Thread t1 new Thread(() - transfer(a, b, 10), 转账线程1); Thread t2 new Thread(() - transfer(b, a, 20), 转账线程2); t1.start(); t2.start(); } }线程1执行transfer(a, b)先锁a再锁b线程2执行transfer(b, a)先锁b再锁a。二者的锁获取顺序恰好相反。只要两个线程同时在转账函数里交错执行就会产生死锁。这种写法在企业转账、订单处理等业务代码里很常见尤其是两个服务互相调用对方接口时更容易踩中类似的问题。面试时把这段代码和真实业务场景挂钩比单纯背示例更有说服力。3. 深挖死锁的触发机制与排查手段3.1 为什么“必然”死锁与调度无关很多初学者有个误区认为加Thread.sleep()就会死锁不加可能不死锁。实际恰恰相反不加sleep也照样死锁关键在于“交错”的必然性。当两个线程被同时启动并各持有一把锁后再去获取对方持有的锁只要两把锁都没有被释放系统就陷入僵局。调度器能改变谁先获得CPU的执行权但改变不了“T1持有A等待BT2持有B等待A”的资源占用关系。Thread.sleep()在这里只是放大了排查窗口让日志输出更明显让观察者更容易看到中间状态。如果完全不加sleep两个线程可能瞬间完成加锁、解锁最终日志只显示成功完毕反而看不出问题。但这并不代表死锁不存在只是碰巧这次执行没有发生交错。面试官要求“必然”最简单的实现方式就是确保两个线程先各自持有一把锁再发起第二次加锁。CountDownLatch在双线程版本里起的作用就是让两个线程的“先持有”阶段交错从而制造出僵局。3.2 使用jstack定位死锁的完整流程如果不运行程序只看代码也能分析出死锁是否可能发生。但在面试现场如果你的例子写完后还能顺手演示怎么用命令排查那绝对是加分项。实际工作中排查死锁的第一利器就是jstack。操作流程非常简单。首先在程序死锁状态下用jps -l找到Java进程号。然后执行jstack -l pid把线程快照导出。如果存在死锁jstack会在快照末尾明确输出Found one Java-level deadlock:并附上线程名、锁对象地址以及等待关系。还有一种方法是用jconsole连接本地进程线程面板里直接显示死锁检测结果适合桌面环境。jps -l # 输出示例23654 com.example.DeadlockDemo jstack -l 23654快照里类似这样Found one Java-level deadlock: T1: waiting to lock monitor 0x000000001c5a3c30 (object 0x00000000d5b0e0f8, a java.lang.Object), which is held by T2 T2: waiting to lock monitor 0x000000001c5a3c30 (object 0x00000000d5b0e0f0, a java.lang.Object), which is held by T1这里能直截了当地看到T1持有哪个锁、等待哪个锁T2又持有哪个锁、等待哪个锁整个循环依赖一目了然。有经验的工程师拿到这份快照后第一反应不是看业务代码里谁对谁错而是看加锁的顺序是否一致以及锁范围是否过大。3.3 死锁定位的常见误判与踩坑经验我在实际排查中发现几个容易踩坑的细节特意列出来供大家参考。第一jstack必须抓两次甚至多次对比线程栈变化。如果线程栈始终停在同一个位置且出现两个以上的线程互相等待基本可以确认死锁。如果只抓一次某些瞬间等待可能被误判为死锁尤其是数据库连接等待、网络IO等待区分起来需要经验。第二synchronized与ReentrantLock在jstack中的显示方式不同。synchronized显示为monitor而ReentrantLock则显示为parking to wait for或locked如果有条件锁获取tryLock可能显示为waiting on condition。不要因为看到waiting on condition就以为是死锁那可能只是正常的条件等待。第三线程池死锁往往不会被jstack直接标记为deadlock。比如之前提到的单线程线程池嵌套提交任务jstack只会看到工作线程在future.get()处park任务队列里有一个待执行任务但不会明确打出“Java-level deadlock”字样。这种情况下需要结合代码逻辑判断。4. 生产环境中如何有效避免死锁4.1 锁顺序一致性从源头解决问题避免死锁的黄金法则就是全局排序。假如系统中有多把锁必须给它们定义一个唯一的顺序所有线程都按照同样的顺序加锁破坏循环等待条件。还是拿账户转账举例。可以规定账户ID小的先锁或者大的先锁只要全系统统一就不会出现交叉等待。private static void transferWithOrder(Account from, Account to, int amount) { Account first; Account second; if (from.id to.id) { first from; second to; } else { first to; second from; } synchronized (first) { synchronized (second) { from.balance - amount; to.balance amount; } } }这段代码的关键改动在于无论外部调用是transfer(a, b)还是transfer(b, a)内部加锁顺序都严格按id从小到大。于是两个线程最多出现“都先锁小id账户”的竞争而不会出现一个锁a等b、另一个锁b等a的情况。这个技巧不仅适用于股票交易、支付系统也适用于分布式事务协调、多节点数据更新等场景。4.2 使用超时机制替代无限期阻塞ReentrantLock提供了tryLock(timeout, unit)可以在指定时间内获取不到锁时自动放弃避免永久等待。ReentrantLock lockA new ReentrantLock(); ReentrantLock lockB new ReentrantLock(); public void doTask() { try { if (lockA.tryLock(1, TimeUnit.SECONDS)) { try { if (lockB.tryLock(1, TimeUnit.SECONDS)) { try { // 业务处理 } finally { lockB.unlock(); } } else { System.out.println(获取lockB超时执行补偿逻辑); } } finally { lockA.unlock(); } } else { System.out.println(获取lockA超时执行补偿逻辑); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }注意释放锁的规则如果内层锁获取失败必须释放已经持有的外层锁否则即使没死锁也会因“持有A不释放”而长时间占用资源。很多新手在写tryLock时只顾着处理超时异常忘记在else分支释放已有锁结果从一个死锁变成另一个资源泄露问题。4.3 用并发工具简化锁的协作与其手写复杂的加锁顺序不如借助现成的并发工具。ConcurrentHashMap、CopyOnWriteArrayList、LinkedBlockingQueue等线程安全容器已经封装了底层锁细节内部通常已经避免了死锁陷阱。事务性更强的场景可以用StampedLock、ReadWriteLock读写分离还能提升并发度。如果业务需要同时更新多个资源优先考虑不可变对象或原子变量。能合并成一次原子操作的就不要拆成多把锁。数据库层面则可以利用行锁的顺序配合SELECT ... FOR UPDATE时按固定条件排序降低死锁概率。总之锁的粒度越细锁持有时间越短死锁的风险就越低。5. 面试官追问从手写死锁到系统性解决问题的思路5.1 追问一如何快速判断一个锁等待是不是死锁面试官经常会拿一个线程转储文件让你判断。我的经验是先看结果输出末尾再看线程栈的具体状态。若两个线程都被BLOCKED或WAITING且锁持有关系构成环基本可以锁定死锁。如果某些线程阻塞在IO或条件变量上则可能是性能问题或资源竞争问题不能一概而论。更严谨的办法是观察线程的ThreadMXBean。JDK内置了findDeadlockedThreads()方法它可以返回当前处于死锁状态的线程ID数组在线运维工具、监控系统里可以直接用它做自动检测。import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; ThreadMXBean tmx ManagementFactory.getThreadMXBean(); long[] deadlockedIds tmx.findDeadlockedThreads(); if (deadlockedIds ! null) { for (long id : deadlockedIds) { System.out.println(死锁线程ID id); } }这个方法不仅能检测synchronized死锁还能检测ReentrantLock等显式锁导致的死锁比肉眼分析转储文件更可靠。遇到线上故障时写个临时接口调用它能秒级确认是否为死锁问题。5.2 追问二写一个必然死锁的要点如何向面试官表达面试回答问题逻辑比代码本身更重要。我建议按以下顺序展开先说明死锁的四个必要条件。再用“两把锁、两个线程、交叉加锁”作为核心思路。最后强调必然性的来源通过同步工具确保加锁顺序的确定性而不是依赖运气。如果面试官让你进一步优化就按前面提到的全局排序、超时锁、无锁结构三个方面延伸。语言上可以这样组织“我先把四要素说清楚然后写一个两个线程分别持有锁A、锁B再互相申请对方锁的经典场景。为了确保必然死锁我用CountDownLatch做栅栏让两个线程先同时启动各自持有第一把锁之后再申请第二把锁这样无论调度器如何调度最终都会出现循环等待。”这种表达既展示了原理又体现了对“必然性”的把握面试官很难挑出毛病。5.3 追问三死锁和活锁、饥饿的区别偶尔会遇到面试官把概念混在一起问差异化回答能体现功底。死锁是线程互相等锁谁也无法推进。活锁是线程不断尝试重新获取资源但始终无法成功比如两个线程检测到对方占用资源后都主动退让然后又同时重新尝试如此反复类似两个人在狭窄走廊里互相让路结果还是堵住。饥饿则是某个线程始终得不到所需的资源比如优先级调度导致低优先级线程长期被冷落。三者的共性是任务无法按期完成但处理手段不同。死锁靠打破循环等待活锁可以引入随机退避或调整重试策略饥饿则要保证调度的公平性比如使用公平锁new ReentrantLock(true)。6. 经验总结与进一步的自检清单6.1 我踩过的一些死锁相关的坑也就是前两天排查问题一同事在代码里用了两个嵌套的synchronized块第二个锁是有条件获取的结果一旦条件不满足内层锁直接return外层锁虽然是在finally里释放的但中间那段把数据库连接一直握在手心后面所有获取连接的请求全部排队。这虽然没有形成严格互等的死锁但效果和死锁几乎一样接口超时、线程池排队、监控告警一套连锁反应全来了。用ReentrantLock的那段代码也得特别注意锁的释放顺序。如果外层锁和内层锁都用了lock()释放时顺序必须与获取顺序相反否则虽然不会立刻死锁却容易造成后续线程无法获得锁的诡异现象。出现这种问题不妨借助try-with-resources风格的自定义锁封装类用AutoCloseable保证有序释放。还有一个容易忽略的点Thread.sleep()放在持有锁的代码块内会让锁被长时间占用加剧死锁窗口也让排查过程更难复现。虽然例子中加sleep是为了放大问题生产环境的高并发场景如果这样写等于给死锁创造更优质的条件。6.2 自检清单写死锁示例前过一遍这五条一是锁对象必须稳定不能每次new。二是获取锁的顺序必须明确且清晰打印日志。三是同步工具的配合要保证必然性尽量用CountDownLatch、CyclicBarrier这种确定性协调手段。四是生产代码不能直接照搬此例只能用于演示和教学。五是如果demo跑不出死锁先检查是否缺少某一道闸门控制而不是怀疑机器性能。写示例的最终目的不是炫技而是让你能够准确识别代码中的交叉依赖关系养成在写并发代码之前先画资源依赖图的习惯。拿到任何一段并发代码先问自己哪些资源会被持有多久多个线程的获取顺序是否一致有没有可能形成环如果这三个问题都能给出明确答案死锁问题基本就消失了。最后再分享一个小技巧面试时手写代码字迹和缩进没那么要紧但一定要用标准类名、标准关键字别为了省事写拼音或无线程名的空实现。把锁对象命名成LOCK_A / LOCK_B或者ACCOUNT_A / ACCOUNT_B面试官一眼就能看清你的意图。配合适当的输出日志整个演示过程会更加完整甚至直接贴出jstack输出让面试官看到你完整的排障链路。希望能对你应对这场面试有所帮助也祝你下次遇到这题时能游刃有余地把它拿下。
阅读完成 · 觉得有帮助?
咨询建站