死锁故障实战复盘

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。

【面试官手记】

小张这场面试的亮点:

  1. 知道死锁四要素

  2. 知道统一加锁顺序的预防方法

  3. 知道jstack和Arthas排查方法

死锁是P6工程师必备知识点,能完整回答的候选人,说明有生产排障经验。

死锁的核心是预防优于排查。记住三个要点:

  1. 破坏循环等待:多个锁按固定顺序加锁
  2. 缩小锁范围:事务尽量短,锁粒度尽量小
  3. 使用tryLock:带超时的tryLock可以打破死锁

死锁是最隐蔽的并发问题,宁可代码多写几行,也不要埋下死锁的雷。