所属模块:模块一:并发编程进阶
死锁排查实录:jstack定位死锁的完整流程
真实场景
一个转账业务,两个并发的转账请求分别以不同的顺序锁定了两个账户的记录行(A锁账户1再锁账户2,B锁账户2再锁账户1),线上偶发出现请求卡死不返回,只能重启服务恢复,一直没有根治。
原理拆解
死锁产生需要同时满足四个条件:互斥条件 (资源同一时刻只能被一个线程占用)、请求与保持条件 (线程持有资源的同时还在请求其他资源)、不可剥夺条件 (已分配的资源不能被强制剥夺)、循环等待条件 (多个线程之间形成资源等待的环形链)。破坏其中任意一个条件都能避免死锁,实践中最常用的手段是破坏循环等待------统一所有线程的加锁顺序。
死锁不仅发生在JVM层面的synchronized/Lock之间,数据库层面的行锁同样会因加锁顺序不一致产生死锁,而且这种情况在业务代码里更隐蔽,因为代码本身"看起来"没有显式加锁,只是SQL执行时数据库引擎内部产生了锁等待。
JVM层面还有一个容易被忽视的细分场景:ReentrantLock导致的死锁,普通jstack默认输出可能看不出来。 synchronized是JVM内置的监视器锁,jstack天然能识别并在末尾给出"Found one Java-level deadlock"的明确提示;但ReentrantLock这类基于AQS实现的显式锁,如果只用默认参数执行jstack <pid>,有些JDK版本下不会在结果末尾自动标注出死锁,需要加上-l参数(打印锁的附加信息,包括java.util.concurrent相关的锁)才能看到完整的持有关系,或者直接用jconsole/VisualVM这类带界面的工具,它们通常会更主动地检测并高亮标注出ReentrantLock导致的死锁。
排查工具/关键命令
JVM层面死锁,直接用jstack <pid>,输出末尾会有明确的死锁提示:
csharp
Found one Java-level deadlock:
=============================
"Thread-A":
waiting to lock monitor 0x00007f... (object 0x000000076... a AccountLock),
which is held by "Thread-B"
"Thread-B":
waiting to lock monitor 0x00007f... (object 0x000000076... a AccountLock),
which is held by "Thread-A"
这段信息直接告诉你哪两个线程互相等待、等待的是哪个锁对象,定位非常明确。数据库层面的死锁,MySQL可以用SHOW ENGINE INNODB STATUS查看LATEST DETECTED DEADLOCK部分,里面会记录死锁涉及的具体SQL语句和锁信息:
perl
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-08-20 15:12:03
*** (1) TRANSACTION:
TRANSACTION 421889, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 88, OS thread handle 0x..., query id 92341 localhost root updating
UPDATE account SET balance = balance - 100 WHERE id = 2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 45 page no 4 n bits 80 index PRIMARY of table `bank`.`account`
trx id 421889 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 421890, ACTIVE 2 sec starting index read
UPDATE account SET balance = balance + 100 WHERE id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 45 page no 4 n bits 80 index PRIMARY of table `bank`.`account`
trx id 421890 lock_mode X locks rec but not gap
*** WE ROLL BACK TRANSACTION (1)
这段输出的关键信息:两个事务分别持有和等待account表不同记录的行锁,MySQL引擎自己检测到了循环等待,并主动选择了代价较小的事务(通常是持有锁较少、回滚成本较低的一方)执行回滚来打破死锁------WE ROLL BACK TRANSACTION (1)这一行说明数据库自身已经有了死锁自动检测和恢复机制,不需要人工干预就能让服务恢复,但回滚会导致这次转账请求失败,业务层面依然需要处理这次失败(比如重试或者提示用户) ,这也是为什么即使数据库能自愈,依然要从根源上解决加锁顺序问题。
代码示例:复现与修复
复现代码(两个线程以不同顺序加锁):
scss
Object lockA = new Object();
Object lockB = new Object();
// 线程1
new Thread(() -> {
synchronized (lockA) {
sleep(100);
synchronized (lockB) { /* ... */ }
}
}).start();
// 线程2:加锁顺序相反,构成循环等待
new Thread(() -> {
synchronized (lockB) {
sleep(100);
synchronized (lockA) { /* ... */ }
}
}).start();
修复方式:统一加锁顺序(比如按对象的某个唯一ID大小排序后加锁):
ini
Object first = idA < idB ? lockA : lockB;
Object second = idA < idB ? lockB : lockA;
synchronized (first) {
synchronized (second) { /* ... */ }
}
对应到转账业务,数据库层面的修复思路同样是统一按账户ID大小顺序加锁,而不是按转账的"转出方/转入方"这种业务顺序加锁。
另一种规避方式:用带超时的锁,把"死锁"转化成"可感知、可重试的失败" 。相比于synchronized发生死锁后线程会永久阻塞,ReentrantLock提供的tryLock(timeout)能够在获取不到锁时主动放弃并返回,从根本上避免了"卡死不返回"这种最坏情况:
java
ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();
boolean success = false;
try {
if (lockA.tryLock(200, TimeUnit.MILLISECONDS)) { // 最多等200ms
try {
if (lockB.tryLock(200, TimeUnit.MILLISECONDS)) {
try {
// 执行转账逻辑
success = true;
} finally {
lockB.unlock();
}
}
} finally {
lockA.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (!success) {
throw new BizException("系统繁忙,请重试"); // 获取锁失败,快速返回而不是无限等待
}
这种方式不能从根本上"预防"死锁的发生(两个线程依然可能同时持有对方需要的锁),但把"永久卡死"转化成了"短暂等待后失败,由业务层决定是否重试",大幅降低了故障的严重程度------统一加锁顺序是从根源上消除死锁,tryLock超时是死锁万一发生时的兜底手段,两者可以配合使用。
常见误区
只关注JVM线程死锁,排查线上"卡死不返回"问题时习惯性地先看jstack,却忽视了数据库连接池里的连接可能正阻塞在等待数据库锁上------这种情况jstack只会显示线程在等待数据库驱动返回结果,看不出数据库内部的死锁详情,必须结合数据库自身的死锁日志才能定位根因。
另一个容易和死锁混淆的概念是"活锁"(Livelock) 。死锁的线程是完全阻塞、静止不动的;活锁则相反------线程一直在运行,却因为不断"谦让"对方而始终无法真正推进业务逻辑,表现为CPU可能有一定占用、线程状态是RUNNABLE而不是BLOCKED/WAITING,但业务结果就是迟迟出不来。典型场景是两个线程都尝试用"失败后退避重试"的策略避让对方,但退避的时机恰好总是同步的,导致双方反复地"同时后退、同时重试、同时又冲突":
scss
// 简化示意:两个线程都采用"礼让"策略,可能陷入活锁
while (!tryAcquireBoth(lockA, lockB)) {
releaseAny(); // 获取不全就都释放,让给对方
Thread.sleep(100); // 如果两个线程的休眠时机恰好总是同步,会反复冲突,谁都无法真正拿到锁
}
排查活锁问题,jstack看到的线程状态是正常的RUNNABLE(不像死锁会显示BLOCKED),这也是活锁比死锁更难通过简单的堆栈分析定位的原因------通常需要结合业务日志观察"这个操作是不是在反复重试却一直不成功",而不能只靠线程状态判断。解决活锁的常见思路是在退避策略里引入随机化的等待时间(类似网络协议里的指数退避+随机抖动),打破多个线程重试节奏的同步性。