ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【面朝大厂】面试官:手写一个必然死锁的例子 2 万字详解

【面朝大厂】面试官:手写一个必然死锁的例子 2 万字详解 一、面试开场一道看似简单却暗藏玄机的题在多线程并发编程的面试中有一道题几乎年年出现它看起来只需要写几十行代码却能一路追问到操作系统原理、JVM 锁实现、线上故障排查甚至分布式系统设计。这道题就是请你手写一个一定会发生死锁的例子。很多候选人一听到这个题目第一反应是「这不难」于是飞快地写下两个线程、两把锁、互相等待的代码。但真正能把这几十行代码背后的因果链条讲清楚的人并不多。面试官想考察的绝不只是你能不能背出那段代码而是下面这一整串问题你写出来的代码为什么「必然」死锁而不是「偶尔」死锁死锁发生的四个必要条件是什么缺一个还会死锁吗这段代码在 JVM 层面发生了什么synchronized 的锁到底存在哪里如果线上真的出现死锁你该怎么定位用什么命令、看什么输出除了 synchronized用 ReentrantLock 写死锁有什么不同如果你是架构师如何从设计层面避免死锁本文就以这道面试题为切入点从手写一个必然死锁的代码开始逐步深入到死锁的本质、排查手段和规避方案。全文会保持「用 Java 讲清楚原理」的路线代码可以直接复制运行适合正在准备校招、社招以及希望系统补齐并发基础的同学阅读。二、先搞清楚什么是死锁在动手写代码之前我们必须先把「死锁」这个概念钉死。死锁的定义在操作系统的经典教材里是这样的死锁是指两个或两个以上的进程在 Java 中可以理解为线程在执行过程中因争夺资源而造成的一种互相等待的现象。若无外力干预这些进程将永远无法继续向前推进。这句话有四个关键词两个或以上一个线程自己是无法形成死锁的死锁本质上是一种「环形等待」关系。争夺资源资源可以是锁、文件句柄、数据库连接、打印机等一切需要互斥占用的东西。互相等待线程 A 等线程 B 手里的资源线程 B 又在等线程 A 手里的资源谁都不放手谁也等不到。无法推进没有外力比如 kill 进程、超时释放、强制重启的话这个状态会永远持续下去。用一句更接地气的话来类比就是两个人面对面走到一条只能通过一个人的小桥上甲说「你先让」乙说「不你先让」结果两个人僵在桥中央后面谁也别想过去。这里的「桥」就是被互斥占用的资源「谁先让」就是释放锁的条件而僵持不动就是死锁。死锁之所以可怕不在于它一定会让程序崩溃而在于它通常「不报错、不退出、不释放」。服务看起来还活着JVM 进程还在CPU 占用可能不高但相关的业务请求却永远拿不到响应。这类问题是典型的「沉默故障」排查成本远高于空指针、数组越界这类一眼就能看出堆栈的异常。在 Java 中死锁主要发生在下面几种资源的竞争上对象监视器锁也就是 synchronized 关键字背后依赖的锁。JUC 显式锁ReentrantLock、ReentrantReadWriteLock 等 AQS 体系下的锁。数据库行锁、表锁多个事务按不同顺序更新相同数据。连接池资源两个线程各占一部分连接又同时申请对方占用的连接。IO、文件锁等其他互斥资源。理解了定义之后下一个必须掌握的知识点就是死锁产生的充要条件。三、死锁产生的四个必要条件这是面试的第一道必问扩展开也是所有死锁解法的最底层理论依据。要想让死锁发生下面四个条件必须同时成立反过来只要我们破坏其中任意一个死锁就不可能发生。3.1 互斥条件所谓互斥指的是某个资源在任一时刻只能被一个进程线程占用。其他线程想要使用这个资源只能等待当前占用者主动释放。在 Java 里synchronized 锁住的锁对象就是互斥资源同一时刻只能有一个线程持有某把锁。互斥是资源本身的性质。需要注意的是互斥条件往往是我们「无法破坏」的因为很多资源的独占性是由业务语义或硬件特性决定的。比如一笔钱的扣减必须串行一张数据库记录的行锁天然就是互斥的。我们无法为了让程序不死锁而放弃资源的互斥性否则数据就会乱套。因此实际工程中很少从互斥条件入手去解决死锁更多的是从后面三个条件找突破口。3.2 请求与保持条件也叫「占有且等待」一个线程在已经持有了至少一个资源的情况下又去申请新的资源如果新的资源暂时拿不到它就「保持」已经到手的资源不放同时「等待」新的资源。回到我们的桥的例子甲已经走到了桥中央占住了桥的一半还想继续往前走申请另一半但乙也占住了另一半不放。甲既不肯退回去又过不去于是僵住。这就是占有且等待。破坏这个条件的经典思路是「一次性申请所有资源」要么一次性把需要的锁全部拿到要么一个都别拿拿了之后发现拿不全就整体释放避免「拿一半等另一半」的尴尬局面。3.3 不可剥夺条件线程已经获得的资源在它自己主动释放之前不能被其他线程强行抢走。在 Java 的 synchronized 机制里锁一旦被某个线程持有其他线程只能等不能把锁从持有者手里「抢」过来。这一点正是死锁难以自愈的根本原因。破坏不可剥夺条件的思路是允许在等待超时或收到中断时主动放弃已经持有的资源。JUC 中的 Lock 接口提供了 lockInterruptibly 和 tryLock(timeout, unit) 方法本质上就是在工具层面提供了「可剥夺」的可能等不到就撤把已经拿到的也还回去。3.4 循环等待条件存在一个线程-资源的环形等待链线程 T1 等待 T2 持有的资源T2 等待 T3 持有的资源以此类推最后 Tn 又在等待 T1 持有的资源形成一个闭环。这是死锁四个条件里「最有操作价值」的一个因为它是唯一一个可以通过「约定加锁顺序」来系统性规避的条件。只要所有线程都按照完全一致的固定顺序申请资源循环等待的环就会被打破。这个思路我们后面会专门用一整节展开。3.5 一张表记住四条件条件名称通俗理解Java 中的体现常见破坏手段互斥资源只能一个人用锁同一时刻只能被一个线程持有一般无法破坏也不应破坏请求与保持拿着不放还要新资源持锁后继续 synchronized 其他锁一次性申请所有资源不可剥夺拿到手了别人抢不走synchronized 无法被其他线程抢占tryLock 超时主动释放循环等待你等我、我等你绕成圈不同线程以相反顺序持锁固定全局一致的加锁顺序记住这张表面试时无论面试官从哪个角度追问你都能快速映射到对应的条件并给出相应对策。四、手写一个必然死锁的经典例子下面给出的是最经典、最直观也是在面试中被要求手写频率最高的一个版本。它只用 synchronized、两个锁对象和两个线程就能稳定复现死锁。所谓「必然」是指在这段代码的逻辑约束下死锁发生的概率无限接近百分之百而不是依赖线程调度的运气。javapublic class DeadLockDemo { // 两个静态锁对象作为互斥资源 private static final Object lockA new Object(); private static final Object lockB new Object(); public static void main(String[] args) { // 线程1先拿锁A再拿锁B Thread thread1 new Thread(() - { synchronized (lockA) { System.out.println(Thread.currentThread().getName() 成功获取锁A); try { // 睡一小会儿放大竞争窗口让线程2有机会拿到锁B Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() 尝试获取锁B...); synchronized (lockB) { System.out.println(Thread.currentThread().getName() 成功获取锁B); } } }, 线程1); // 线程2先拿锁B再拿锁A与线程1的加锁顺序恰好相反 Thread thread2 new Thread(() - { synchronized (lockB) { System.out.println(Thread.currentThread().getName() 成功获取锁B); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() 尝试获取锁A...); synchronized (lockA) { System.out.println(Thread.currentThread().getName() 成功获取锁A); } } }, 线程2); thread1.start(); thread2.start(); } }这段代码的关键点有三个两把锁和两个线程lockA 和 lockB 是两个独立的锁对象线程1 和线程2 分别持有其中一把并试图申请另一把。加锁顺序相反线程1 的顺序是「先 A 后 B」线程2 的顺序是「先 B 后 A」。这是制造循环等待的核心。中间的 sleepThread.sleep(100) 的目的不是制造死锁而是「放大死锁发生的窗口」。如果没有这个小停顿在多核机器上某个线程可能快得在另一个线程启动之前就把两把锁都拿完释放了导致偶尔观察不到死锁。加了 sleep 之后两个线程几乎必然会各自拿到第一把锁然后同时卡在第二把锁上。运行这段代码控制台通常只会输出下面四行text线程1 成功获取锁A 线程2 成功获取锁B 线程1 尝试获取锁B... 线程2 尝试获取锁A...之后程序就「卡死」了再也不会打印「成功获取锁B」或「成功获取锁A」。主线程虽然已经执行完 start 方法但 JVM 并不会退出因为还有两个非守护线程处于存活状态。此时进程就像被按下了暂停键。如果你把这段代码原样复制到 IDE 里跑一次会直观地感受到程序没有任何异常抛出也没有错误日志就是安静地停在那里。这正是死锁最典型的现场。五、逐行拆解两个线程的相爱相杀为了把这段代码讲透我们把时间线一点点铺开。假设线程1 先启动并获得 CPU 时间片。5.1 第一步线程1 顺利拿到锁 A线程1 进入 synchronized(lockA) 代码块。由于此时没有任何线程持有 lockAJVM 把 lockA 的对象头中的锁记录指向线程1线程1 成功进入临界区打印text线程1 成功获取锁A接下来它执行 Thread.sleep(100)。注意sleep 不会释放已经持有的锁。很多初学者会误以为 sleep 期间锁会被让出来这是错误的。sleep 只是让线程放弃 CPU 执行权锁仍然牢牢握在手里。这个细节稍后会在面试追问里详细展开。5.2 第二步线程2 顺利拿到锁 B在线程1 睡觉的时候操作系统调度线程2 运行。线程2 进入 synchronized(lockB)此时 lockB 空闲于是线程2 成功拿到锁 B打印text线程2 成功获取锁B随后线程2 也开始 sleep。此时场上的资源分布是线程1 持有 A线程2 持有 B两道门都上了锁但分别被不同的人占着。5.3 第三步线程1 撞上锁 B100 毫秒后线程1 醒过来继续向下执行到 synchronized(lockB) 这一行。它试图获取锁 B但锁 B 已经被线程2 持有。于是线程1 进入 BLOCKED 状态在锁 B 的等待队列里排队同时它手里依然死死攥着锁 A。5.4 第四步线程2 撞上锁 A紧接着线程2 也醒过来向下执行到 synchronized(lockA)。它试图获取锁 A而锁 A 正被线程1 握着。于是线程2 也进入 BLOCKED 状态在锁 A 的等待队列里排队同时继续持有锁 B。至此死锁闭环形成线程1 持有 A等待 B。线程2 持有 B等待 A。A 的释放依赖线程1 继续执行但线程1 继续执行又依赖拿到 B。B 的释放依赖线程2 继续执行但线程2 继续执行又依赖拿到 A。这是一个完美的资源分配图环路。谁先松手都不可能因为松手的条件正是对方先松手。于是两个线程永久阻塞程序静默挂起。5.5 为什么说它是「必然」死锁有人会问如果线程1 跑得足够快先拿 A 再拿 B 然后全部释放在线程2 启动之前就结束了那不是就不会死锁了吗确实严格从并发理论上讲没有绝对的「必然」因为线程调度存在不确定性。但在我们的代码里两个线程几乎同时 start中间又有 100 毫秒的 sleep 同步点。sleep 的目的就是主动拖慢节奏让两个线程「步调一致」地各自拿到第一把锁。因此在这个特定代码下死锁的触发概率非常非常高实际运行中几乎每次都会复现。从面试表达的角度说「这是必然死锁」指的不是概率学上的绝对必然而是「在可预期的执行时序下它稳定地满足死锁的四个必要条件从而必然发生」。如果面试官较真追问你可以这样回答严格来说如果某一个线程抢在另一个线程拿到第一把锁之前就完成全部加解锁操作死锁可能暂时观察不到但这段代码通过 sleep 制造了确定性时序窗口使两个线程都会先持有一把锁再去竞争另一把因此按当前实现执行死锁是必然发生的。如果要构造「逻辑上绝对无法避免」的死锁可以借助 CyclicBarrier 让两个线程在同一时刻分别获取第一把锁后再启动第二段竞争进一步消除调度偶然性。这个回答既体现了严谨性又展示了你可以用同步工具把「概率死锁」升级为「精确死锁」的能力是一个非常加分的表达。六、运行结果为什么程序卡住了很多刚接触并发的同学第一次运行这段代码时最大的困惑是程序既没有异常也没有退出控制台就停在那几行输出上不动了。这时候需要理解 Java 的线程模型和 JVM 的退出机制。JVM 的退出规则是当所有「非守护线程」都执行结束后虚拟机才会终止。main 方法本身运行在主线程里主线程执行完 thread1.start() 和 thread2.start() 之后main 方法结束主线程退出但线程1 和线程2 都是通过 Thread 构造的普通线程默认不是守护线程。它们在 synchronized 上永远阻塞代码永远走不到 return因此 JVM 认为还有非守护线程存活进程就不会退出。这个现象可以从两个角度理解正确性层面两个线程都没有完成自己的任务程序没有「跑完」所以不退出是合理的。故障层面真实业务里这往往意味着两个请求或两个任务互相卡住各自占用的数据库连接、线程或锁都无法归还。时间一长连接池被打满、线程池被耗尽、接口大面积超时整体服务被「沉默地」拖垮。死锁并没有让 JVM 立刻崩溃而是让系统逐渐失去响应能力这正是它比普通异常更危险的地方。七、线程状态与锁等待BLOCKED 到底意味着什么在第五章拆解执行时序时我们反复提到线程进入 BLOCKED 状态。很多同学能背出「死锁的两个线程都会阻塞」却说不清 BLOCKED 在 Java 线程状态机里到底处于什么位置。这一节就把线程状态和锁等待的关系彻底讲清楚。Java 线程共定义了 6 个状态它们定义在 Thread.State 枚举中NEW线程刚被创建还没调用 start尚未开始执行。RUNNABLE线程正在 JVM 中运行或者在等待 CPU 调度。注意Java 的 RUNNABLE 把操作系统层面的就绪态和运行态合并到了一起。BLOCKED线程正在等待进入 synchronized 方法或 synchronized 代码块所需的监视器锁。WAITING线程因调用 Object.wait、Thread.join、LockSupport.park 等方法而无限期等待必须被其他线程显式唤醒。TIMED_WAITING带超时时间的等待例如 Thread.sleep、Object.wait(timeout)、Lock.tryLock(timeout) 中的等待。TERMINATED线程的 run 方法已经执行结束。死锁样例中的两个线程最终都停在哪里答案是BLOCKED。线程1 在 synchronized(lockB) 处等待 lockB 的监视器锁线程2 在 synchronized(lockA) 处等待 lockA 的监视器锁。它们都不是 WAITING因为 synchronized 拿不到锁时走的是监视器锁等待路径而不是 wait、sleep 或 park。这里有一个关键区别面试中经常被追问BLOCKED 和 WAITING 有什么不同简单说BLOCKED 是「排队等一把 synchronized 锁」锁一旦被释放线程会被 JVM 自动唤醒并重新竞争而 WAITING 是「主动进入等待状态」必须由其他线程调用 notify、notifyAll 或 unpark 才能唤醒。举个例子Object.wait 会先释放锁再进入 WAITING而 synchronized 竞争不到锁时不会释放任何东西只是进入 BLOCKED。下面这个程序可以直接验证死锁线程的状态javapublic class ThreadStateDemo { public static void main(String[] args) throws Exception { Object lockA new Object(); Object lockB new Object(); Thread t1 new Thread(() - { synchronized (lockA) { sleep(100); synchronized (lockB) { System.out.println(T1 拿到全部锁); } } }, T1); Thread t2 new Thread(() - { synchronized (lockB) { sleep(100); synchronized (lockA) { System.out.println(T2 拿到全部锁); } } }, T2); t1.start(); t2.start(); // 等待两个线程都进入锁竞争 Thread.sleep(500); System.out.println(T1 当前状态 t1.getState()); System.out.println(T2 当前状态 t2.getState()); } private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }运行后最后两行输出通常是textT1 当前状态BLOCKED T2 当前状态BLOCKED看到这个输出就能非常直观地确认死锁不是「线程消失了」而是两个线程都卡在监视器锁的等待队列里。理解了这一点下一节我们就能深入 synchronized 的底层实现看看这个「监视器锁」究竟存在哪里。八、synchronized 的锁到底存在哪里从对象头到锁升级文章开头我埋过一个面试题这段代码在 JVM 层面发生了什么synchronized 的锁到底存在哪里现在来系统回答。synchronized 的锁并不是一个独立的全局对象而是存放在「被锁对象」的 Java 对象头里。Java 对象在堆内存中大致分为三部分对象头、实例数据和对齐填充。对象头中最重要的部分是Mark Word它记录了这个对象当前是偏向锁、轻量级锁还是重量级锁以及对应的线程 ID 或指向监视器的指针。从 JVM 的角度看synchronized 经历了从「偏向锁」到「轻量级锁」再到「重量级锁」的升级过程偏向锁当只有一个线程反复进入同一把锁时JVM 会把 Mark Word 标记为偏向锁并直接记录线程 ID。线程再次进入时检查线程 ID 是否是自己如果是就直接进入不需要任何系统级同步。偏向锁解决的是「只有一个线程执行但每次都要 CAS 检查」的开销。轻量级锁当第二个线程参与竞争但竞争不激烈时JVM 把锁升级为轻量级锁。此时线程会在栈帧中创建 Lock Record尝试用 CAS 把 Mark Word 指向自己的 Lock Record。自旋失败到一定次数后才会继续升级。重量级锁当竞争激烈、自旋长时间拿不到锁时JVM 把锁升级为重量级锁依赖操作系统级别的互斥量实现。重量级锁与系统调度绑定拿不到锁的线程会真正进入阻塞状态也就是我们前面看到的 BLOCKED。在死锁样例里两个线程都长时间卡在锁上锁已经被 JVM 膨胀为重量级锁。此时 Mark Word 中保存的是指向 ObjectMonitor 的指针ObjectMonitor 是 HotSpot 中真正负责维护等待队列的数据结构。线程进入和退出 synchronized 的背后就是对 ObjectMonitor 的争用和调度。把锁跟线程状态串起来可以提炼成一条清晰链路两个线程先各自拿到第一把锁在第二把锁上发生竞争随后自旋失败升级为重量级锁两个线程都进入 ObjectMonitor 的等待队列状态显示为 BLOCKED。到这里代码、线程状态和 JVM 锁实现三者就完全对应起来了。九、换一种写法ReentrantLock 也能写出死锁面试官经常在 synchronized 死锁之后追问ReentrantLock 会不会死锁它和 synchronized 有什么不同答案是会死锁而且写法稍有不同。下面这段代码就是 ReentrantLock 版本的死锁。核心思想没有变依然是一正一反的加锁顺序javaimport java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockDeadLock { private static final Lock lockA new ReentrantLock(); private static final Lock lockB new ReentrantLock(); private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public static void main(String[] args) { new Thread(() - { lockA.lock(); try { sleep(100); lockB.lock(); try { System.out.println(T1 同时拿到 A 和 B); } finally { lockB.unlock(); } } finally { lockA.unlock(); } }, T1).start(); new Thread(() - { lockB.lock(); try { sleep(100); lockA.lock(); try { System.out.println(T2 同时拿到 B 和 A); } finally { lockA.unlock(); } } finally { lockB.unlock(); } }, T2).start(); } }这段代码运行后一样会卡死。与 synchronized 相同的地方在于死锁的根本原因都是「相反顺序加锁形成循环等待」不同的地方在于ReentrantLock 在 API 层面提供了更多打破死锁的武器。ReentrantLock 和 synchronized 的常见区别可以归纳为下面几点锁的释放时机synchronized 由 JVM 自动加锁、自动释放ReentrantLock 需要手动 lock 和 unlock通常要放在 finally 中保证解锁。是否可中断synchronized 等待锁时基本不可中断ReentrantLock 提供 lockInterruptibly 方法允许线程在等待锁时响应中断。是否可超时synchronized 拿不到锁只能一直等ReentrantLock 提供 tryLock(timeout, unit)等不到就返回 false线程可以主动放弃。公平性synchronized 是非公平锁ReentrantLock 可以指定公平或非公平策略。条件队列ReentrantLock 可以配合多个 Condition 实现更细粒度的等待、唤醒。其中最重要的是 tryLock 和 lockInterruptibly因为它们直接破坏了死锁四条件里的「不可剥夺」线程等不到锁时不再一味傻等而是可以放弃已经持有的锁从而打破僵局。下面给出一个用 tryLock 规避死锁的示例javaimport java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class TryLockAvoidDeadLock { private static final Lock lockA new ReentrantLock(); private static final Lock lockB new ReentrantLock(); private static void requireBoth(Lock first, Lock second) { while (true) { boolean gotFirst false; boolean gotSecond false; try { gotFirst first.tryLock(200, TimeUnit.MILLISECONDS); if (!gotFirst) { continue; } gotSecond second.tryLock(200, TimeUnit.MILLISECONDS); if (!gotSecond) { continue; } System.out.println(Thread.currentThread().getName() 成功拿到两把锁); break; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } finally { if (gotSecond) { second.unlock(); } if (gotFirst) { first.unlock(); } } } } public static void main(String[] args) { new Thread(() - requireBoth(lockA, lockB), T1).start(); new Thread(() - requireBoth(lockB, lockA), T2).start(); } }这段代码的思路是在限定时间内拿不到第二把锁就把已经拿到的那把也释放掉然后重新尝试。注意这只是演示「可超时、可放弃」的核心思想真实工程中还需要考虑重试次数、退避策略否则可能出现两个线程不断互相放弃又互相重试的「活锁」问题。这个细节我们放到规避方案一节继续深入。十、线上死锁怎么排查jstack、jconsole 与 arthas死锁最让人头疼的是线上没有异常日志。既然程序不报错我们就必须借助 JVM 自带的工具或诊断中间件主动发现它。10.1 jps 找到进程jstack 查看线程排查死锁的第一条命令是 jps用来查看 Java 进程编号bashjps -l假设输出中包含我们的 DeadLockDemo 进程及其 PID接着执行bashjstack -l pidjstack 会把所有线程的堆栈打印出来。如果进程里发生死锁输出末尾会有一段非常明确的提示textFound one Java-level deadlock: 线程1: waiting to lock monitor 0x000000010d3a6b20 (object 0x000000078b516e40, a java.lang.Object), which is held by 线程2 线程2: waiting to lock monitor 0x000000010d3a6970 (object 0x000000078b516e50, a java.lang.Object), which is held by 线程1 Java stack information for the threads listed above: 看到Found one Java-level deadlock这句话就可以确定发生了死锁。下面的两段信息会直接告诉你线程1 在等哪把锁这把锁正被线程2 持有线程2 又在等哪把锁而这把锁正被线程1 持有。环闭合了证据链一目了然。10.2 jconsole 可视化检测如果希望更直观可以使用 JDK 自带的 jconsole。启动后连接对应进程进入「线程」标签页底部有「检测死锁」按钮。点击后jconsole 会列出发生死锁的线程并展示它们之间的等待关系。10.3 arthas 快速定位阻塞阿里巴巴开源的 arthas 也是线上排查利器。连接目标进程后直接执行bashthread -bthread -b会找出当前被阻塞的线程以及阻塞它们的源头。在死锁场景下它能很快把互相等待的线程链展示出来。arthas 的 dashboard、thread 命令还能帮助观察线程状态、CPU 占用和锁竞争情况适合在生产环境做低侵入诊断。排查死锁的关键不止是工具而是先要意识到「程序没有报错却在慢慢变慢或卡住」可能是死锁。建立起这个嗅觉之后jstack、jconsole、arthas 才能真正发挥作用。十一、一个更隐蔽的死锁案例连接池耗尽很多同学以为死锁只发生在两把对象锁之间。实际上数据库连接池、线程池、HTTP 连接池等「有限资源池」同样是死锁高发地。考虑这样一个场景一个服务同时执行两个事务事务一先拿到连接1再申请连接2事务二先拿到连接2再申请连接1。当两个事务并发执行、连接池又恰好只剩下这两个连接时事务一占着连接1 等连接2事务二占着连接2 等连接1连接池被占满谁也等不到谁。这和两把锁的循环等待本质完全一致只是资源从锁对象换成了连接。下面用简化代码表示这种连接池死锁javapublic class ConnectionPoolDeadLock { // 简化表示用两个对象代表池中仅有的两个连接 private static final Object conn1 new Object(); private static final Object conn2 new Object(); private static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public static void main(String[] args) { // 事务一先拿连接1再拿连接2 new Thread(() - { synchronized (conn1) { sleep(100); synchronized (conn2) { System.out.println(事务一提交); } } }, 事务一).start(); // 事务二先拿连接2再拿连接1顺序反了 new Thread(() - { synchronized (conn2) { sleep(100); synchronized (conn1) { System.out.println(事务二提交); } } }, 事务二).start(); } }真实系统中这种死锁往往更隐蔽两个事务可能分散在两个不同的服务、两个不同的方法里代码本身看起来都没有问题。只有把它们放到同一时刻的资源视图中才能看到那个闭环。因此排查连接池类死锁不仅要看线程堆栈还要结合连接池监控观察是否有连接长期被占用却不归还。十二、架构层面如何系统性规避死锁死锁的根因是四个必要条件同时成立所以规避死锁的总体思路也很清晰破坏其中至少一个条件。工程中最常用的是破坏「请求与保持」和「循环等待」因为「互斥」往往无法破坏「不可剥夺」在 synchronized 下也较难实现。下面六条是实战中最有效的规避手段第一固定全局加锁顺序。这是最简单也最有效的办法。只要所有线程都按同一个顺序申请锁循环等待就不可能形成。例如所有涉及账户转账的业务都约定「先按账户 ID 排序再依次加锁」就能从根源上杜绝循环等待。java// 正例按账户 ID 排序后依次加锁 public void transfer(Account from, Account to, long amount) { Account first from.getId() to.getId() ? from : to; Account second from.getId() to.getId() ? to : from; synchronized (first) { synchronized (second) { // 转账逻辑 } } }第二使用 tryLock 带超时。用 ReentrantLock 的 tryLock(timeout, unit) 替代无超时的 lock。拿不到锁就主动放弃已持有的锁破坏「不可剥夺」和「请求与保持」。第三缩小锁的粒度与持有时间。尽量避免在持锁期间调用外部接口、执行耗时操作、等待网络 IO。锁只保护必要的临界区其余逻辑移出锁外。第四避免嵌套锁。如果确实需要多个资源尽量合并为一个锁或用一次性申请的方式避免「持一把等另一把」的局面。第五使用更高层的并发工具。例如用 ConcurrentHashMap 替代「HashMap 同步」用 BlockingQueue 替代「手写锁 队列」用 CompletableFuture 替代手工线程编排减少显式锁的使用。第六加监控和超时兜底。即使设计上已经尽量规避生产环境仍要监控线程状态、锁等待时间、连接池使用率。一旦发现长时间阻塞能第一时间告警并介入。十三、面试高频追问与答题模板把前面的内容压缩成几条面试现场可以直接使用的表达问题一死锁的四个必要条件是什么回答要点互斥、请求与保持、不可剥夺、循环等待。四个条件必须同时满足才会死锁破坏其中任意一个即可避免死锁。其中循环等待最容易通过固定加锁顺序来破坏。问题二synchronized 的锁存在哪里回答要点锁信息保存在对象的对象头 Mark Word 中。JVM 会根据竞争情况把锁从偏向锁升级为轻量级锁再升级为重量级锁。重量级锁依赖 ObjectMonitor线程竞争失败会进入 BLOCKED 状态。问题三BLOCKED 和 WAITING 有什么区别回答要点BLOCKED 是等待进入 synchronized 的监视器锁锁释放后线程会被 JVM 自动唤醒WAITING 是调用 wait、join、park 后主动进入等待必须由其他线程显式唤醒。问题四线上出现死锁怎么排查回答要点先用 jps 找到进程再用 jstack -l 打印线程堆栈看到 Found one Java-level deadlock 就确认了。也可以用 jconsole 的可视化检测或 arthas 的 thread -b 命令。问题五如何从设计上避免死锁回答要点固定全局加锁顺序、使用 tryLock 超时、缩小锁粒度、避免嵌套锁、使用更高层的并发工具、加监控和超时兜底。核心思路是破坏死锁四条件中的至少一个。问题六ReentrantLock 和 synchronized 有什么区别回答要点ReentrantLock 支持可中断、可超时、公平锁、多条件队列但需要手动 unlock。synchronized 由 JVM 自动管理写法更简单。避免死锁时ReentrantLock 的 tryLock 和 lockInterruptibly 是更实用的武器。十四、总结手写一个必然死锁的例子看起来只有几十行代码但它背后串起了操作系统、JVM、并发编程和线上排查的完整知识链。回顾全文可以把核心结论压缩成下面几句话死锁的本质是环形等待两个或以上的线程互相持有对方需要的资源谁都不放手形成闭环。四个必要条件缺一不可互斥、请求与保持、不可剥夺、循环等待。破坏任意一个即可避免死锁。synchronized 死锁靠相反加锁顺序制造两个线程分别先拿不同的锁再互相申请对方的锁加上 sleep 放大竞争窗口就能稳定复现。死锁线程状态是 BLOCKED它们卡在监视器锁的等待队列上而不是 WAITING。锁信息存在对象头 Mark WordJVM 会根据竞争情况升级锁最终膨胀为重量级锁。ReentrantLock 也能死锁但提供了逃生通道tryLock 超时和 lockInterruptibly 可以打破「不可剥夺」。线上排查靠 jstack、jconsole、arthas看到 Found one Java-level deadlock 就能确认。规避死锁的关键是破坏循环等待固定全局加锁顺序是最简单有效的手段。连接池、线程池同样是死锁高发区只要存在有限资源和相反顺序的申请就可能形成循环等待。架构层面要多管齐下固定顺序、超时放弃、缩小粒度、避免嵌套、使用高层工具、加监控兜底。
返回列表