#死锁故障实战复盘
2021年某银行的转账系统在高峰期突然完全卡死,所有请求超时。
监控显示:JVM进程还在运行,CPU使用率正常,但所有线程全部阻塞。
技术团队紧急排查后发现:两个转账任务交叉持有锁,一个线程持有A锁等待B锁,另一个线程持有B锁等待A锁,形成了经典的死锁。
更可怕的是:这套系统已经运行了3年,从来没出现过这个问题,直到某次代码重构改了一个加锁顺序。
这次故障导致银行系统瘫痪3小时,影响了约10万笔转账交易。
【面试官手记】
死锁是生产环境最隐蔽的故障之一。我面试过的候选人里,能说清楚"死锁四要素"的有50%,能说清楚"排查方法"的有30%,能说清楚"预防方案"的有20%。死锁的关键词是预防优于排查。
#一、死锁的四个必要条件 🔴
#1.1 四要素详解
死锁四要素(必须同时满足):
1. 互斥条件
- 资源只能被一个线程持有
- 典型场景:锁、数据库行锁
2. 持有并等待
- 线程持有资源的同时请求其他资源
- 典型场景:持有锁A,等待锁B
3. 不可抢占
- 已持有的资源不能被强制释放
- 典型场景:synchronized锁不能被抢
4. 循环等待
- 形成资源等待环路
- 典型场景:T1等T2,T2等T1
【面试官手记】
只要破坏其中一个条件,死锁就不会发生。
实际工作中,最常用的是破坏循环等待条件:统一加锁顺序。#1.2 死锁示例
// 死锁示例:转账场景
@Service
public class TransferService {
private final Object lockA = new Object();
private final Object lockB = new Object();
/**
* 转账A→B
*/
public void transferAtoB(BigDecimal amount) {
synchronized (lockA) { // T1: 持有lockA,等待lockB
try { Thread.sleep(10); } catch (InterruptedException ignored) {}
synchronized (lockB) {
// 执行转账
}
}
}
/**
* 转账B→A
*/
public void transferBtoA(BigDecimal amount) {
synchronized (lockB) { // T2: 持有lockB,等待lockA
try { Thread.sleep(10); } catch (InterruptedException ignored) {}
synchronized (lockA) {
// 执行转账
}
}
}
}
// 死锁发生过程:
// T1调用transferAtoB,持有lockA
// T2调用transferBtoA,持有lockB
// T1尝试获取lockB,等待T2释放
// T2尝试获取lockA,等待T1释放
// 死锁形成!#1.3 数据库死锁
-- 数据库死锁示例
-- 会话1:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 锁定user_id=1
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 尝试锁定user_id=2
-- 会话2(并发执行):
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 2; -- 锁定user_id=2
UPDATE accounts SET balance = balance + 100 WHERE user_id = 1; -- 尝试锁定user_id=1
-- 死锁!MySQL会自动选择一个事务回滚#二、死锁排查实战 🔴
#2.1 jstack排查
# 1. 查找Java进程ID
jps -l | grep transfer-service
# 输出:12345 com.example.TransferServiceApplication
# 2. 导出线程栈
jstack 12345 > thread.log
# 3. 分析死锁
jstack -l 12345 > deadlock.log
# -l选项会打印锁的详细信息# thread.log关键内容:
Found one Java-level deadlock:
=============================
"pool-1-thread-1":
waiting for ownable synchronizer:
(a java.util.concurrent.locks.ReentrantLock$NonfairSync),
which is held by "pool-1-thread-2"
"pool-1-thread-2":
waiting for ownable synchronizer:
(a java.util.concurrent.locks.ReentrantLock$NonfairSync),
which is held by "pool-1-thread-1"
Java stack information for the threads listed above:
===================================================
"pool-1-thread-1":
at sun.misc.Unsafe.park(Native Method)
- waiting to lock <0x0000000717a8e4a0> (a java.lang.Object)
- locked <0x0000000717a8e4b0> (a java.lang.Object)
...
"pool-1-thread-2":
at sun.misc.Unsafe.park(Native Method)
- waiting to lock <0x0000000717a8e4b0> (a java.lang.Object)
- locked <0x0000000717a8e4a0> (a java.lang.Object)
...#2.2 Arthas在线排查
# 使用Arthas排查死锁
# 1. 启动Arthas
java -jar arthas-boot.jar 12345
# 2. 查看死锁
thread -b
# 输出:
# Found 1 deadlock.
# "pool-1-thread-1" id=10 group=main waiting for lock on
# 0x0000000717a8e4b0@0x000000070e90f748,
# held by "pool-1-thread-2"
# ...
# 3. 查看指定线程
thread 10
# 查看线程10的完整堆栈
# 4. 监控锁的竞争情况
jvm | grep Lock
# Monitor:
# Monitor deflation 0 times
# Maximum monitor occupancy age 3,645 ms#2.3 数据库死锁排查
-- 查看死锁日志
SHOW ENGINE INNODB STATUS;
-- 输出:
---TRANSACTION 12345678, ACTIVE 10 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136
MySQL thread id 23456, OS thread handle 1234, query id 78901
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1
*** WE ROLL BACK TRANSACTION(12345678)
-- 分析:哪个事务在等锁
SELECT * FROM information_schema.INNODB_LOCK_WAITS;
-- 分析:哪个事务持锁
SELECT * FROM information_schema.INNODB_LOCKS;#三、死锁预防方案 🟡
#3.1 统一加锁顺序
// 预防死锁方案1:统一加锁顺序
@Service
public class SafeTransferService {
private final ConcurrentHashMap<Long, Object> accountLocks =
new ConcurrentHashMap<>();
/**
* 获取账户锁,按ID排序保证顺序一致
*/
private Object getLock(Long accountId) {
return accountLocks.computeIfAbsent(accountId, k -> new Object());
}
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 按ID排序,确保加锁顺序一致
Long firstId = fromId.compareTo(toId) < 0 ? fromId : toId;
Long secondId = fromId.compareTo(toId) < 0 ? toId : fromId;
Object firstLock = getLock(firstId);
Object secondLock = getLock(secondId);
synchronized (firstLock) {
synchronized (secondLock) {
// 执行转账
Account from = accountDao.selectById(fromId);
Account to = accountDao.selectById(toId);
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
accountDao.update(from);
accountDao.update(to);
}
}
}
}#3.2 使用显式锁
// 预防死锁方案2:使用ReentrantLock + tryLock
@Service
public class LockBasedTransferService {
private final ConcurrentHashMap<Long, ReentrantLock> locks =
new ConcurrentHashMap<>();
private static final long LOCK_TIMEOUT = 3; // 3秒超时
public boolean transfer(Long fromId, Long toId, BigDecimal amount) {
// 按ID排序
Long firstId = fromId.compareTo(toId) < 0 ? fromId : toId;
Long secondId = fromId.compareTo(toId) < 0 ? toId : fromId;
ReentrantLock firstLock = locks.computeIfAbsent(firstId, k -> new ReentrantLock());
ReentrantLock secondLock = locks.computeIfAbsent(secondId, k -> new ReentrantLock());
boolean firstAcquired = firstLock.tryLock(LOCK_TIMEOUT, TimeUnit.SECONDS);
if (!firstAcquired) {
log.warn("获取锁[{}]超时,转账失败", firstId);
return false;
}
try {
boolean secondAcquired = secondLock.tryLock(LOCK_TIMEOUT, TimeUnit.SECONDS);
if (!secondAcquired) {
log.warn("获取锁[{}]超时,转账失败", secondId);
return false;
}
try {
// 执行转账
doTransfer(fromId, toId, amount);
return true;
} finally {
secondLock.unlock();
}
} finally {
firstLock.unlock();
}
}
}#3.3 数据库死锁预防
-- 预防数据库死锁:按主键顺序操作
-- 方案1:按主键排序
BEGIN;
-- 转账金额小:先锁ID小的
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 总是先锁1
SELECT * FROM accounts WHERE id = 2 FOR UPDATE; -- 再锁2
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 方案2:缩小锁范围
-- 错误:SELECT * 会锁定整行
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 只锁定需要的列
-- 业务计算
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;
-- 方案3:减少事务时长
-- 错误:在事务中做远程调用
BEGIN;
UPDATE accounts SET balance = ... WHERE id = 1;
-- 远程调用耗时1秒
callRemoteService(); -- 事务持有锁1秒!
UPDATE accounts SET ... WHERE id = 2;
COMMIT;
-- 正确:减少事务内操作
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 先做远程调用
callRemoteService();
// 再开始事务
transactionTemplate.execute(status -> {
accountDao.decreaseBalance(fromId, amount);
accountDao.increaseBalance(toId, amount);
return null;
});
}#四、活锁问题 🟡
#4.1 活锁示例
// 活锁示例:两个线程不断重试但都无法完成
public class LivelockExample {
private final AtomicBoolean aliceTurn = new AtomicBoolean(true);
public void aliceAction() {
while (true) {
if (aliceTurn.get()) {
System.out.println("Alice: 等待Bob...");
aliceTurn.set(false); // 切换给Bob
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
} else {
// Alice执行
System.out.println("Alice: 执行中...");
aliceTurn.set(true);
break;
}
}
}
}
// 活锁特点:
// - 线程不会阻塞
// - 但也无法完成任务
// - 两个线程不断切换,谁都无法完成#4.2 解决活锁
// 解决活锁:添加随机退避
@Service
public class SafeLockService {
private final Random random = new Random();
public void transfer(Long fromId, Long toId, BigDecimal amount) {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
doTransfer(fromId, toId, amount);
return;
} catch (Exception e) {
log.warn("转账失败,重试第{}次", i + 1);
// 随机退避,避免活锁
try {
Thread.sleep(random.nextInt(100));
} catch (InterruptedException ignored) {}
}
}
throw new BizException("转账失败,已达到最大重试次数");
}
}#五、生产避坑 🟡
#5.1 死锁的五大坑
坑1:加锁顺序不一致
问题:不同代码路径加锁顺序不同
场景:转账A→B和B→A
解决方案:
- 统一加锁顺序,按ID排序
- 使用ReentrantLock + tryLock坑2:事务中做远程调用
问题:事务持有锁时间过长
场景:在锁内调用远程服务
解决方案:
- 远程调用放在事务外
- 缩小锁范围坑3:锁粒度过大
问题:锁粒度过大,持有时间过长
场景:锁住整个方法
解决方案:
- 只锁必要的代码块
- 使用读写锁坑4:嵌套锁
问题:方法A加锁调用方法B加锁
场景:serviceA.method1() → serviceB.method2()
解决方案:
- 避免嵌套锁
- 提取公共加锁方法坑5:数据库锁未及时释放
问题:长事务持有行锁
场景:大事务内包含多次查询
解决方案:
- 缩小事务范围
- 减少锁等待时间#5.2 死锁检查清单
代码规范:
- [ ] 多个锁必须按固定顺序加锁
- [ ] 锁的粒度尽量小
- [ ] 事务中不做远程调用
- [ ] 事务尽量短
监控规范:
- [ ] 监控死锁发生次数
- [ ] 监控锁等待时间
- [ ] 监控长事务
- [ ] 定期检查死锁日志
测试规范:
- [ ] 压力测试触发死锁
- [ ] 代码Review检查加锁顺序#六、真实面试回放 🟡
面试官:死锁的必要条件是什么?
候选人(小张):四个条件:
一是互斥,资源只能被一个线程持有。
二是持有并等待,持有资源的同时请求其他资源。
三是不可抢占,已持有的资源不能被强制释放。
四是循环等待,形成环路。
面试官:怎么预防死锁?
小张:破坏循环等待条件就行。
多个锁按固定顺序加锁,比如按账户ID排序。
T1先加锁1再锁2,T2也是先锁1再锁2,就不会循环等待了。
面试官:怎么排查死锁?
小张:用jstack -l导出线程栈,找到deadlock关键字。
或者用Arthas的thread -b命令。
数据库死锁看SHOW ENGINE INNODB STATUS。
【面试官手记】
小张这场面试的亮点:
知道死锁四要素
知道统一加锁顺序的预防方法
知道jstack和Arthas排查方法
死锁是P6工程师必备知识点,能完整回答的候选人,说明有生产排障经验。
死锁的核心是预防优于排查。记住三个要点:
- 破坏循环等待:多个锁按固定顺序加锁
- 缩小锁范围:事务尽量短,锁粒度尽量小
- 使用tryLock:带超时的tryLock可以打破死锁
死锁是最隐蔽的并发问题,宁可代码多写几行,也不要埋下死锁的雷。