死锁条件与排查
候选人小庞在面试字节 P6 时,面试官问道:
"什么是死锁?产生死锁的四个条件是什么?"
小庞说:"互斥、占有并等待..."面试官追问:"能给我举一个实际的死锁例子吗?"
小庞答不上来。面试官继续:"生产环境中怎么排查死锁?"
小庞彻底卡住了...
一、核心问题:死锁条件与排查 🔴
1.1 问题拆解
第一层:四个必要条件(是什么?)
"产生死锁的四个必要条件是什么?"
考察点:互斥、占有并等待、不可抢占、循环等待
第二层:实际案例(怎么发生?)
"能举一个实际发生的死锁例子吗?"
考察点:两个锁的循环等待、生产代码示例
第三层:排查方法(怎么找?)
"生产环境中怎么排查死锁?"
考察点:jstack、ThreadMXBean、Graph LR 算法
第四层:解决方案(怎么防?)
"怎么避免死锁?"
考察点:加锁顺序、Lock.tryLock()、死锁检测
1.2 ❌ 错误示范
候选人原话 A:"死锁就是线程卡住了,什么都不做。"
问题诊断:死锁是特定的状态——两个或多个线程互相等待对方持有的锁,无法继续执行。活锁是线程在不断尝试但仍然无法前进(如反复尝试获取锁失败)。
候选人原话 B:"只要加锁就会死锁。"
问题诊断:死锁需要四个条件同时满足。可以通过打破其中一个条件来避免死锁。
1.3 标准回答
P5 级别:四个必要条件
死锁的四个必要条件(Coffman 条件):
实际死锁例子:
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 在检测到死锁时会打印警告,但不能自动打破死锁。可以使用 ThreadMXBean 的 findDeadlockedThreads() 方法主动检测死锁。
二、生产避坑
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 三者对比
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+ 的新工具有了解。
⚠️
面试陷阱:被问到"死锁中的'占有并等待'是什么意思",很多人会说"线程持有锁"。准确答案是:线程持有已获取的锁的同时,还等待获取其他线程持有的锁。单纯的持有锁不构成问题(因为最后会释放)。