Java死锁全解析:从底层原理到jstack诊断与根治方案 📅 发布时间:2026/9/11 21:51:30 👁 浏览次数: Java 并发编程里死锁真的是个老生常谈又极其头疼的问题。平时写单线程代码根本碰不到一上多线程、一上生产环境、一上高并发它就悄无声息地冒出来轻则接口超时重则整个应用线程池被打满服务直接变僵尸。更麻烦的是死锁这种东西不像空指针或者数组越界它不报错、不抛异常甚至 CPU 占用率可能还是低的查起来全靠经验和工具。这篇文章就把死锁从定义、复现、诊断到根治一条线讲透。我会先拆解死锁产生的底层逻辑然后给出能直接跑通的死锁示例代码再分享我在生产环境里真正用过的诊断手段——jstack、jconsole、jvisualvm 我都会细讲最后落到代码层面怎么规避、怎么设计才能让死锁从根源上消失。不管你是准备 Java 面试、处理线上事故还是纯粹想把并发基础补扎实这篇都值得收藏。1. 死锁的本质教科书定义与生产现场的真意1.1 死锁的四个必要条件死锁的定义用一句话概括就是两个或多个线程互相持有对方需要的锁资源同时又在等待对方释放导致所有线程都无法继续推进。这个状态就像两个人过独木桥你挡着我、我挡着你谁都不肯退谁也都过不去。学术上死锁必须同时满足四个必要条件互斥条件、请求与保持条件、不可剥夺条件、循环等待条件。互斥条件指的是资源同一时刻只能被一个线程占用Java 里 synchronized 和 ReentrantLock 天然就满足请求与保持条件是指线程已经持有一个锁又去申请新的锁申请不到时不会释放已有的锁不可剥夺条件是说线程持有的资源只能自己释放其他线程不能强行夺走循环等待条件则是多个线程之间形成了一条环形等待链A 等 B、B 等 A或者更长的链 A 等 B、B 等 C、C 等 A。这四个条件缺一不可也就是说只要打破其中一个死锁就不会发生。这个理论不是纸上谈兵后面讲解决方案时会反复用到这个框架——你可以通过锁排序打破循环等待可以用 tryLock 打破不可剥夺可以用单一锁合并资源请求来消除请求与保持。1.2 为什么 Java 并发场景里死锁如此常见Java 死锁高发根子在于它给了开发者足够自由的加锁方式但自由过度就会失控。同一个 JVM 进程里多个线程、多个类、多个方法都可以对共享资源加锁尤其是当你引入分布式锁、数据库行锁、本地锁混合使用的时候锁的维度从 JVM 内扩展到了跨进程死锁的复杂度直接上了一个档次。更隐蔽的是现代应用大量使用线程池表面上你看不见线程的创建和销毁。线程池里的工作线程一旦死锁不会立刻导致 JVM 崩溃而是表现为线程池被占满、队列任务堆积、整个服务处理能力归零。这种故障最难定位因为它不报 OOM也不报超时错误日志里只有任务排队的时间越来越长。另外业务系统里的锁往往不只一种形态。你写了一个 synchronized 代码块里面又调用了数据库事务事务里又更新了某张表另一个方法先更新表、再进入同步块——这种跨锁类型的交互单纯通过看代码很难一眼识破必须靠工具去抓线程的状态快照。2. 亲手写一个死锁从复现到现场还原2.1 经典资源互斥死锁示例我带你写一个最小的死锁程序。这个例子非常经典也是面试里最常考的代码题建议你亲手敲一遍并在本地跑一下观察线程卡住的效果。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) { Thread thread1 new Thread(() - { synchronized (LOCK_A) { System.out.println(Thread.currentThread().getName() 持有A尝试获取B); try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (LOCK_B) { System.out.println(Thread.currentThread().getName() 成功获取B); } } }, 线程1); Thread thread2 new Thread(() - { synchronized (LOCK_B) { System.out.println(Thread.currentThread().getName() 持有B尝试获取A); try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } synchronized (LOCK_A) { System.out.println(Thread.currentThread().getName() 成功获取A); } } }, 线程2); thread1.start(); thread2.start(); } }运行这个程序你大概率会看到线程1持有A尝试获取B和线程2持有B尝试获取A两行日志然后程序既不结束也不报错就卡在那里。这里的 Thread.sleep(100) 是关键它保证了两个线程在各自拿到第一把锁之后的时序避免其中一个线程太快同时抢到两把锁而侥幸通过。这种写法的实质就是两个线程以相反的顺序加锁。线程1先 A 后 B线程2先 B 后 A这样就构成了循环等待。实际业务代码里这种模式非常常见比如转账操作中同时锁住转出账户和转入账户如果不约定统一加锁顺序AB 转账和 BA 转账并发时就会撞出死锁。2.2 容易被忽视的隐式死锁上面那种 synchronized 嵌套比较明显多盯两眼代码就看得出来。生产环境里更让人抓狂的是隐式死锁——你根本没有写嵌套锁但是通过方法调用链、回调、事件通知等机制间接形成了循环等待。举一个典型的例子一个类的方法被 synchronized 修饰它在内部调用了另一个 synchronized 方法而另一个方法又回调到第一个对象的同步方法。再比如线程池嵌套线程池父任务等待子任务结果子任务又被提交到了同一个被占满的线程池里这就形成了线程池饿死型死锁本质上也符合循环等待条件。还有一种隐藏较深的是锁和长期等待混用。一个线程持有了数据库连接池里的连接锁同时在等待远程接口返回远程接口所在的服务又需要调用当前服务的另一个接口而这个接口的线程正在等待数据库连接。这种跨服务、跨资源的等死循环单纯看单个服务日志很难发现需要结合分布式链路追踪和线程 dump 综合分析。2.3 同一个类内部的锁顺序反转还有一种很低级的死锁出现在同一个类里两个同步方法互相调用或者两个同步方法分别锁定不同的成员对象而调用方以不同顺序调用。public class AccountService { private final Account fromAccount; private final Account toAccount; public void transfer(Account from, Account to, BigDecimal amount) { synchronized (from) { synchronized (to) { from.decrease(amount); to.increase(amount); } } } }转账场景中如果线程1执行 transfer(A, B)线程2执行 transfer(B, A)锁的顺序就反了。这个场景我在实际项目中见过不止一次因为业务代码往往由不同人维护每个人写自己的转账方向合并到一起就出问题。解决这个问题的标准方案是给所有 Account 生成一个全局唯一的排序键比如账户 ID加锁前先比较两个对象的排序键始终按从小到大的顺序加锁。这样无论从哪个方向发起转账锁的获取顺序都是一致的循环等待的条件就被打破了。3. 死锁诊断三板斧jstack、jconsole 与 jvisualvm3.1 jstack 手工抓取与解读如果死锁已经发生线上第一件事就是抓线程 dump。JDK 自带的 jstack 是最快、最可靠的手段它可以直接打印出 JVM 里所有线程的栈信息并且通常能直接检测到死锁。先找到 Java 进程的 PID可以用 jps -l 命令也可以 ps -ef | grep java。然后执行jstack -l PID /tmp/thread_dump_$(date %Y%m%d_%H%M%S).txt拿到 dump 文件后如果你用的是较新的 JDK8u72 以上jstack 输出里会直接带一段 Found one Java-level deadlock 的总结信息类似下面这样Found one Java-level deadlock: 线程1: waiting to lock monitor 0x000000001e2a6d00 (object 0x000000076b5f10a8, a java.lang.Object), which is held by 线程2 线程2: waiting to lock monitor 0x000000001e2a6d00 (object 0x000000076b5f10a8, a java.lang.Object), which is held by 线程1 Java stack information for the threads listed above: 线程1: at DeadLockDemo.lambda$main$0(DeadLockDemo.java:18) - waiting to lock 0x000000076b5f2008 (a java.lang.Object) - locked 0x000000076b5f1ff8 (a java.lang.Object) at DeadLockDemo.lambda$main$0(DeadLockDemo.java:16) - locked 0x000000076b5f2008 (a java.lang.Object)这个输出已经把死锁链条完整呈现出来了。哪怕没有那句总结你也可以通过搜索 dump 文件中的 waiting to lock 和 locked 关键字人工拼出等待关系图。排查的核心思路是找到每个线程的 waiting to lock 对象再看这个对象被哪个线程 locked最后把这些关系串联起来看是否成环。我在线上还会配合抓多次 dump间隔 3 到 5 秒抓两份做对比。如果某个线程两次 dump 里栈顶都在同一个锁等待位置并且业务线程数量持续堆积基本可以确定不是瞬时假象而是真实死锁。多次 dump 这个技巧很重要因为单次 dump 只能证明此刻线程在等待不能完全证明永远等下去多次对比能提高判断的准确性。3.2 jconsole 图形化监控jconsole 适合在开发环境或者预发环境快速观察线程状态。启动 jconsole选择本地进程或远程进程切到线程页签点一下检测死锁按钮它会用可视化方式标出死锁线程节点以及它们之间的等待关系。这种方式比 jstack 更直观适合给不熟悉命令行的人展示现场。不过 jconsole 有明显的局限性。它对远程连接要求 JMX 端口开放而且在高负载生产环境里jconsole 本身比较重连上去会有性能影响。我的建议是本地复现和测试时用 jconsole 快速验证线上事故优先选择 jstack 抓 dump不要为了图方便在生产环境开图形界面。3.3 jvisualvm 与线程 Dump 分析jvisualvm 在较新版本 JDK 中已经不再默认打包但 IDEA 自带的或者独立安装的 VisualVM 依然很好用。VisualVM 能从已经运行的 JVM 里导出线程 dump也可以直接打开之前 jstack 生成的 dump 文件做离线分析。它的强大之处在于线程状态的颜色标记绿色表示 RUNNABLE、黄色表示 WAITING 或 TIMED_WAITING、红色表示 BLOCKED。当出现死锁时你会看到至少两个线程长时间处于红色 BLOCKED 状态并且互相同步等待。配合线程时间线还能观察到这些线程从什么时刻开始卡死方便与发布、变更等时间点对齐。如果你想更进一步可以用 ThreadDumpAnalyzer 这类开源工具批量分析 dump它会把线程按状态分组自动提取出疑似死锁的线程对。但在小型项目里纯手工看 jstack 输出通常已经足够没必要引入太重的分析平台。4. 解决方案从代码层面根治死锁4.1 锁排序法统一加锁顺序最直接有效的方案就是锁排序。不管业务对象是账户、订单还是资源先定义一套全局一致的排序规则比如按 ID、按 hashCode、按名称在加多把锁之前先比较排序键始终按升序获取锁。public void transfer(Account from, Account to, BigDecimal amount) { Account first from.getId() to.getId() ? from : to; Account second from.getId() to.getId() ? to : from; synchronized (first) { synchronized (second) { from.decrease(amount); to.increase(amount); } } }这个方案的优点在于从根源上消除循环等待代码改动量小也不依赖外部配置适合大多数资源锁场景。需要注意的点是排序键必须全局稳定如果两个对象 ID 相同跨系统、跨分库场景可能出现你还需要引入额外的比较维度比如对象类型名或者一个全局自增序列。4.2 tryLock 超时接管放弃等待抢占主动权如果你对系统性修复没有把握另一个自救手段是用 ReentrantLock 的 tryLock 替代 synchronized。tryLock 允许线程在指定时间内尝试获取锁如果超时还没拿到就主动放弃并释放已持有的锁把等待者变成主动退出者。ReentrantLock lockA new ReentrantLock(); ReentrantLock lockB new ReentrantLock(); public boolean transfer() { boolean lockedA lockA.tryLock(2, TimeUnit.SECONDS); if (!lockedA) { return false; } try { boolean lockedB lockB.tryLock(2, TimeUnit.SECONDS); if (!lockedB) { return false; } try { // 业务逻辑 return true; } finally { lockB.unlock(); } } finally { lockA.unlock(); } }这里有一个细节值得注意当第二个锁获取失败时务必将第一个锁在 finally 中释放否则你就从潜在死锁变成了实际持有锁不放反而更糟糕。而且超时后不能简单重试最好是做补偿处理或记录日志告警否则瞬间重试可能让 CPU 烧起来。tryLock 方案的本质是破坏不可剥夺条件——线程在超时后主动放弃资源避免了无限等待代价是需要业务方处理获取锁失败的分支逻辑代码复杂度比 synchronized 高一些。4.3 缩小锁粒度与并发数据结构很多时候死锁不是因为必须锁这么多而是因为锁得太多了。如果你把大范围的同步块拆小让每个线程持有锁的时间尽可能短不同线程之间撞锁的概率会大幅下降。比如原来在方法级别加 synchronized现在只对共享变量的读写加锁把耗时的 IO 操作挪到锁外面。更彻底的办法是用并发容器替代手动加锁。ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue 这些组件在内部实现了细粒度锁或无锁算法很多场景下你根本不需要自己写 synchronized。结合 AtomicInteger、LongAdder 等原子类可以把先读再改再写这种容易出问题的复合操作变成原子操作从源头上消灭竞争。4.4 检测机制与兜底恢复即使是资深工程师也很难保证线上代码绝对不死锁。所以一个成熟的系统必须有检测和兜底机制。JDK 提供了 ThreadMXBean你可以在应用启动时挂一个后台定时任务周期性检测死锁线程发现后输出告警甚至自动 dump 现场。ThreadMXBean threadMxBean ManagementFactory.getThreadMXBean(); long[] deadlockedThreadIds threadMxBean.findDeadlockedThreads(); if (deadlockedThreadIds ! null) { ThreadInfo[] threadInfos threadMxBean.getThreadInfo(deadlockedThreadIds, true, true); for (ThreadInfo threadInfo : threadInfos) { System.err.println(检测到死锁线程: threadInfo.getThreadName()); } }这个检测代码可以配合告警平台在死锁发生的第一时间通知值班人员。需要注意的是检测到死锁并不代表能自动解开除非你能安全地中断线程或回滚事务。自动杀线程是非常危险的操作可能导致数据不一致所以我的建议是自动检测 人工决策检测到死锁后立刻抓 dump、发告警、保留现场然后由值班同学判断是 kill 进程快速恢复还是通过流量摘除等待线程自然释放。兜底层面对重要的业务操作可以考虑引入分布式锁组件比如 Redis 锁并设置合理的过期时间一旦持有锁的线程异常退出锁也会在过期后自动释放。这实际上也是通过锁具备自动释放能力来避免永久死锁。5. 面试与线上问题排查经验谈5.1 高频面试题拆解死锁是 Java 并发面试的必考点面试官通常从定义问到解法再延伸到实际排查。常见的问法包括死锁的四个必要条件是什么如何避免死锁线程 dump 怎么看synchronized 和 ReentrantLock 在解决死锁上的区别数据库死锁和 Java 线程死锁有什么不同关于 synchronized 和 ReentrantLock 的区别除了 tryLock 之外你还要知道 ReentrantLock 支持可中断获取锁lockInterruptibly也就是说一个线程在等待锁的过程中可以被其他线程中断这为恢复死锁提供了额外手段。数据库死锁与 Java 线程死锁虽然原理都是资源竞争但形态不太一样数据库死锁通常是事务之间对行级锁、表级锁的竞争数据库引擎比如 MySQL InnoDB有死锁检测机制会自动回滚其中一个事务释放锁而 Java 线程死锁没有这种自动回滚能力只能靠线程 dump 人工介入。面试中还常考一个变种问题如果线程池中的核心线程全部死锁了这时候提交新任务会发生什么答案是任务进入阻塞队列如果队列也满了会触发拒绝策略。这个问题的深层含义是死锁不只会卡住当前线程还会通过对线程池资源的占用引发连锁反应最终拖垮整个服务。5.2 线上死锁排查时间线我把一次真实的线上死锁排查流程整理成时间线照着这个顺序做可以最大化缩短故障时间。发现阶段监控平台出现线程池活跃线程数打满告警接口 TP99 持续上升日志里出现大量任务排队超时。此时先不要急着重启服务保留现场是第一原则。抓取阶段连续执行三次 jstack间隔 5 秒左右保存 dump 文件。同时检查最近一次的发布记录、配置变更、流量突增情况这些信息会给排查提供方向。分析阶段在 dump 文件里搜索 deadlock 关键字如果有就直接定位到死锁线程如果没有就手工统计处于 BLOCKED、WAITING 状态的线程找出它们共同的锁对象画出等待链路。恢复阶段确认死锁代码后根据影响范围决定处理方式。如果只是少量线程卡死可以等待超时或 tryLock 自动退出如果线程池已满、服务整体不可用就需要重启应用节点同时把流量摘除避免新请求进入。修复阶段死锁代码修好之后必须有验证手段。建议写一个并发压测脚本人为构造竞争条件观察是否还能复现同时在 code review 规范中增加加锁顺序一致性的约束避免下次再犯。5.3 常见误区与避坑清单死锁排查与处理过程中有几个误区非常普遍。第一个误区是以为 synchronized 嵌套就一定会死锁其实只要锁顺序一致嵌套锁不会出问题第二个误区是以为用 ReentrantLock 就万事大吉如果你在 tryLock 拿不到锁的 catch 分支里忘记释放已持有的锁照样会制造新的锁问题第三个误区是看到线程 BLOCKED 就判定是死锁实际上 BLOCKED 状态也可能只是短暂的锁竞争需要结合多次 dump 和等待时间判断。我整理一份避坑清单这些全是我在实际项目里踩过的坑不要在工作线程里调用 Future.get() 等待子任务如果子任务又在同一个线程池里执行很容易发生线程池耗尽型死锁。解决方法是使用独立线程池或者设置 get 超时时间。数据库事务不要包住网络调用、远程 RPC。事务持有数据库连接锁的时间越长数据库行锁冲突的概率越大与别的线程形成循环等待的可能性越高。多把锁的释放顺序通常要和获取顺序相反虽然 for synchronized 无法显式控制但在 ReentrantLock 中要格外注意嵌套锁的释放路径建议全部在 finally 中释放。不要在持锁状态下做耗时操作比如文件读写、网络请求、大量循环。锁的粒度越大死锁窗口就越宽这个道理很简单但业务代码里最容易犯。使用定时任务检测死锁时要注意检测线程自身的开销不要每秒钟全量扫描所有线程建议频度控制在 30 秒或 1 分钟一次并做好日志采样。对于分布式环境和数据库环境混合的场景不要把本地锁、分布式锁、事务锁混为一谈排查时要从代码锁等待关系和数据库锁等待关系两个维度分别分析。以上这些经验总结起来其实就是一句话写并发代码的时候脑海里要时刻有一条锁等待链的意识每加一把锁就想一想如果别人反向拿锁会发生什么排查死锁的时候一定要靠线程 dump 和数据说话别凭感觉猜代码。做到这两点死锁这个老大难问题就没那么可怕了。