死锁条件与排查

候选人小庞在面试字节 P6 时,面试官问道:

"什么是死锁?产生死锁的四个条件是什么?"

小庞说:"互斥、占有并等待..."面试官追问:"能给我举一个实际的死锁例子吗?"

小庞答不上来。面试官继续:"生产环境中怎么排查死锁?"

小庞彻底卡住了...

一、核心问题:死锁条件与排查 🔴

1.1 问题拆解

第一层:四个必要条件(是什么?)
  "产生死锁的四个必要条件是什么?"
  考察点:互斥、占有并等待、不可抢占、循环等待

第二层:实际案例(怎么发生?)
  "能举一个实际发生的死锁例子吗?"
  考察点:两个锁的循环等待、生产代码示例

第三层:排查方法(怎么找?)
  "生产环境中怎么排查死锁?"
  考察点:jstack、ThreadMXBean、Graph LR 算法

第四层:解决方案(怎么防?)
  "怎么避免死锁?"
  考察点:加锁顺序、Lock.tryLock()、死锁检测

1.2 ❌ 错误示范

候选人原话 A:"死锁就是线程卡住了,什么都不做。"

问题诊断:死锁是特定的状态——两个或多个线程互相等待对方持有的锁,无法继续执行。活锁是线程在不断尝试但仍然无法前进(如反复尝试获取锁失败)。

候选人原话 B:"只要加锁就会死锁。"

问题诊断:死锁需要四个条件同时满足。可以通过打破其中一个条件来避免死锁。

1.3 标准回答

P5 级别:四个必要条件

死锁的四个必要条件(Coffman 条件)

条件含义如何打破
互斥条件资源在同一时刻只能被一个线程持有无法打破(锁本身要求互斥)
占有并等待线程持有资源的同时请求其他资源一次性获取所有需要的资源
不可抢占条件已持有的资源不能被强制释放使用 tryLock() 超时回退
循环等待线程集合之间形成循环等待关系按固定顺序加锁

实际死锁例子

Object lockA = new Object();
Object lockB = new Object();

// 线程 1
new Thread(() -> {
    synchronized (lockA) {
        System.out.println("Thread 1: got lock A");
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        synchronized (lockB) {
            System.out.println("Thread 1: got lock B");
        }
    }
}).start();

// 线程 2
new Thread(() -> {
    synchronized (lockB) {
        System.out.println("Thread 2: got lock B");
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        synchronized (lockA) {
            System.out.println("Thread 2: got lock A");
        }
    }
}).start();

// 结果:线程 1 持有 A 等 B,线程 2 持有 B 等 A → 死锁

P6 级别:活锁与饥饿

活锁 vs 死锁 vs 饥饿

类型状态原因
死锁线程阻塞,完全停止互相等待锁
活锁线程运行但无法前进不断重试但总是失败
饥饿线程无法获得资源高优先级线程一直占用资源

活锁例子

while (true) {
    if (lockA.tryLock()) {
        try {
            if (lockB.tryLock()) {
                try {
                    doWork();
                } finally {
                    lockB.unlock();
                }
            }
        } finally {
            lockA.unlock();
        }
    }
    // 两个线程都一直重试,但总在对方持锁时尝试
}

P7 级别:排查方法与解决方案

生产环境排查死锁

方法 1:jstack

jstack <pid> | grep -A 10 "Found 1 deadlock"
# 输出:
Found one Java-level deadlock:
============================
"Thread-1":
  waiting to lock monitor 0x00007f8a5c01e3a8 (Object@7a1),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x00007f8a5c01e4b8 (Object@7a2),
  which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================

方法 2:ThreadMXBean(JDK 5+)

ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
if (deadlockedThreads != null) {
    for (long id : deadlockedThreads) {
        ThreadInfo info = threadMXBean.getThreadInfo(id);
        System.out.println("Deadlocked: " + info.getThreadName());
    }
}

方法 3:jvisualvm

JDK 自带的可视化工具,可以图形化查看死锁线程。

解决方案

方案 1:固定加锁顺序(打破循环等待)

// 错误:不同线程以不同顺序加锁
// 线程 1: lockA → lockB
// 线程 2: lockB → lockA

// 正确:所有线程都按固定顺序加锁
if (lockA.hashCode() < lockB.hashCode()) {
    synchronized (lockA) {
        synchronized (lockB) {
            doWork();
        }
    }
} else {
    synchronized (lockB) {
        synchronized (lockA) {
            doWork();
        }
    }
}

方案 2:tryLock 超时回退(打破不可抢占)

while (true) {
    if (lockA.tryLock(1, TimeUnit.SECONDS)) {
        try {
            if (lockB.tryLock(1, TimeUnit.SECONDS)) {
                try {
                    doWork();
                    return;
                } finally {
                    lockB.unlock();
                }
            }
        } finally {
            lockA.unlock();
        }
    }
    // 等待后重试,避免活锁
    Thread.sleep(100);
}

方案 3:Lock 接口的 tryLock()(可抢占)

ReentrantLock lock = new ReentrantLock();
if (lock.tryLock()) {  // 非阻塞获取
    try {
        // 临界区
    } finally {
        lock.unlock();
    }
} else {
    // 获取失败,做其他事
}

【面试官心理】 这道题我能问到 P7 级别,是因为死锁排查需要工具和方法论。能说出 ThreadMXBean 的候选人说明他理解了 JVM 诊断接口。能设计出固定加锁顺序方案的候选人说明他有工程实践的思考。

1.4 追问升级

追问 1:银行家算法是什么?

银行家算法是操作系统中用于避免死锁的算法。核心思想是:在分配资源前,检查分配后是否会导致系统进入不安全状态(所有线程都无法完成)。如果会,则拒绝分配。但银行家算法在生产环境中很少使用,因为需要事先知道最大资源需求。

追问 2:synchronized 锁能检测死锁吗?

JDK 6+ 的 JVM 在检测到死锁时会打印警告,但不能自动打破死锁。可以使用 ThreadMXBeanfindDeadlockedThreads() 方法主动检测死锁。

二、生产避坑

2.1 数据库死锁

数据库操作中,UPDATE t1 → UPDATE t2 和 UPDATE t2 → UPDATE t1 可能导致数据库死锁:

-- 会话 1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 会话 2(同时)
BEGIN;
UPDATE accounts SET balance = balance - 200 WHERE id = 2;  -- 锁住 id=2
UPDATE accounts SET balance = balance + 200 WHERE id = 1;  -- 等待 id=1(会话1持有)
-- → 死锁!

解决:按固定顺序更新(总是先更新 id 小的表)。

2.2 Spring 事务中的死锁

在 Spring @Transactional 方法中调用多个 service,如果 service A 调用 service B,而 service B 又调用 service A,可能产生事务级死锁。

三、死锁 vs 活锁 vs 饥饿 🟡

3.1 三者对比

类型线程状态CPU 消耗能否自动恢复
死锁阻塞不能
活锁运行不能(需要外部干预)
饥饿等待可能(高优先级线程释放资源后)

3.2 避免死锁的编码规范

// 规范 1:尽量缩小 synchronized 块的范围
synchronized (lock) {
    // 只保护必要的代码,不要在 synchronized 块中做 I/O 或复杂计算
}

// 规范 2:避免在 synchronized 块中调用外部方法
synchronized (lock) {
    // 危险:外部方法可能调用同一个锁的方法
    externalService.doSomething();  // 可能在内部获取了 lock,导致重入或死锁
}

// 规范 3:使用并发工具替代 synchronized
ConcurrentHashMap<Integer, String> cache = new ConcurrentHashMap<>();
// 无需手动加锁
💡

面试加分点:能说出"JDK 9+ 的 ProcessHandle API 可以获取 JVM 进程的子进程信息,这在排查与数据库、Redis 等外部服务相关的死锁时有用",说明他对 JDK 9+ 的新工具有了解。

⚠️

面试陷阱:被问到"死锁中的'占有并等待'是什么意思",很多人会说"线程持有锁"。准确答案是:线程持有已获取的锁的同时,还等待获取其他线程持有的锁。单纯的持有锁不构成问题(因为最后会释放)。