1. 死锁现象的本质与面试考察点
多线程编程中,死锁就像两个固执的人互相挡着对方的路,谁都不肯退让一步。面试官要求手写必然死锁的例子,实际上是在考察候选人对线程同步机制的深入理解。我见过太多工程师能说出死锁的四个必要条件(互斥、占有且等待、非抢占、循环等待),但被要求现场写一个时却卡壳。
死锁产生的根本原因在于多个线程对共享资源的竞争访问,以及不合理的加锁顺序。当两个或多个线程互相持有对方需要的锁,并且都在等待对方释放时,系统就会陷入永久阻塞。这种问题在大厂分布式系统中尤为致命——我曾参与排查过一个线上死锁故障,导致整个支付系统瘫痪37分钟。
2. 经典双锁死锁实现方案
2.1 基础版死锁代码实现
java复制public class CertainDeadlock {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread1 got lockA");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (lockB) {
System.out.println("Thread1 got both locks");
}
}
}).start();
new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread2 got lockB");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (lockA) {
System.out.println("Thread2 got both locks");
}
}
}).start();
}
}
这个例子中,两个线程以相反的顺序获取锁:
- Thread1先拿lockA再尝试获取lockB
- Thread2先拿lockB再尝试获取lockA
当两个线程同时执行到第一个synchronized块时,就会形成循环等待条件。
2.2 为什么这个例子必然死锁
- 互斥条件:synchronized保证同一时刻只有一个线程能持有锁
- 占有且等待:Thread1持有lockA的同时等待lockB,Thread2同理
- 非抢占:Java的synchronized锁不能被强制抢占
- 循环等待:Thread1等待Thread2释放lockB,Thread2等待Thread1释放lockA
关键技巧:中间的Thread.sleep(100)不是必须的,但能显著提高死锁发生的概率。在实际面试手写时建议保留这个"保险"。
3. 死锁的变体与高级实现
3.1 资源链式死锁
java复制public class ChainDeadlock {
private static final int THREAD_COUNT = 5;
private static final Object[] locks = new Object[THREAD_COUNT];
static {
for (int i = 0; i < locks.length; i++) {
locks[i] = new Object();
}
}
public static void main(String[] args) {
for (int i = 0; i < THREAD_COUNT; i++) {
final int threadId = i;
new Thread(() -> {
synchronized (locks[threadId]) {
System.out.println("Thread"+threadId+" got lock"+threadId);
synchronized (locks[(threadId + 1) % THREAD_COUNT]) {
System.out.println("Thread"+threadId+" got next lock");
}
}
}).start();
}
}
}
这个例子创建了一个闭环的锁等待链,比双锁死锁更具隐蔽性。当线程数量≥2时都可能发生死锁,且线程越多死锁概率越高。
3.2 数据库事务死锁模拟
java复制public class DbDeadlockSimulator {
public static void transfer(Connection conn,
String from,
String to,
int amount) throws SQLException {
conn.setAutoCommit(false);
// 故意制造错误的更新顺序
if (from.compareTo(to) > 0) {
updateBalance(conn, to, -amount); // 先更新收款方
updateBalance(conn, from, amount); // 再更新付款方
} else {
updateBalance(conn, from, amount);
updateBalance(conn, to, -amount);
}
conn.commit();
}
private static void updateBalance(Connection conn,
String account,
int delta) throws SQLException {
// 实际SQL执行逻辑
}
}
这个模式模拟了金融系统中典型的转账死锁场景。当两个转账事务以相反顺序更新账户时(如A→B和B→A同时发生),数据库就会检测到死锁。
4. 死锁的预防与诊断实践
4.1 编码阶段的预防措施
-
全局锁顺序协议:
- 为所有锁分配全局唯一的编号
- 强制要求线程必须按照编号升序获取锁
- 示例:将前文的lockA和lockB定义顺序,要求所有线程必须先获取编号小的锁
-
锁超时机制:
java复制if (lock.tryLock(1, TimeUnit.SECONDS)) { try { // 临界区 } finally { lock.unlock(); } } else { // 处理获取锁失败 } -
开放调用:避免在持有锁的情况下调用外部方法
4.2 线上死锁诊断技巧
-
jstack命令分析:
bash复制
jstack <pid> | grep -A10 deadlock -
ThreadMXBean检测:
java复制ThreadMXBean bean = ManagementFactory.getThreadMXBean(); long[] threadIds = bean.findDeadlockedThreads(); if (threadIds != null) { ThreadInfo[] infos = bean.getThreadInfo(threadIds); // 打印死锁详情 } -
可视化工具:
- JConsole的"检测死锁"功能
- VisualVM的线程分析选项卡
- Arthas的thread -b命令
5. 大厂面试的深度考察方向
5.1 可能追问的问题清单
- 如何修改前面的例子使其不再死锁?
- 除了synchronized,还有哪些方式会导致死锁?
- 比如:ReentrantLock未正确释放、CountDownLatch误用
- 分布式系统下的死锁与单机有何不同?
- 数据库是如何检测和处理死锁的?
- 如何设计一个死锁自动恢复机制?
5.2 回答技巧与避坑指南
- 避免理论堆砌:结合代码示例解释概念
- 展示排查能力:可以现场演示用jstack分析死锁
- 区分死锁与活锁:活锁是线程在不断重试但无法进展
- 注意锁粒度:粗粒度锁容易引发死锁,但细粒度锁管理复杂
我在实际系统设计中总结的死锁处理原则:
- 能用无锁数据结构就尽量不用锁
- 必须用锁时,确保超时和重试机制
- 对核心业务路径的锁获取顺序进行严格评审
- 在测试环境故意制造高并发场景验证死锁防护