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 与 jvisualvm
3.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 文件后,如果你用的是较新的 JDK(8u72 以上),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 和数据说话,别凭感觉猜代码。做到这两点,死锁这个老大难问题,就没那么可怕了。