直接动手写一个死锁示例,这几乎是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等待B,T2持有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输出,让面试官看到你完整的排障链路。希望能对你应对这场面试有所帮助,也祝你下次遇到这题时能游刃有余地把它拿下。