死锁解决策略

面试官问:"什么是死锁?如何解决死锁?"

小王说:"死锁就是两个线程互相等待对方释放资源...可以用银行家算法避免..."

面试官追问:"银行家算法需要什么前提?为什么实际系统很少用它?"

小王支支吾吾:"需要预先知道最大需求...因为..."

面试官又问:"那除了避免,还有什么策略?如果死锁已经发生了怎么办?"

小王彻底卡住了。

死锁是操作系统领域的经典难题,但很多人只了解皮毛。今天,我们把死锁的四大解决策略全部讲透。

一、从一个问题开始

先看一个经典的死锁场景——哲学家就餐问题:

5个哲学家围坐一圈,中间放着一盘意面,每两个哲学家之间有一根筷子。

每个哲学家要拿到左右两根筷子才能吃饭。

规则:
- 哲学家先拿起左边的筷子
- 然后拿起右边的筷子
- 吃完后放下两根筷子

问题:想象5个哲学家同时饿了,都拿起了左边的筷子...

这就是经典的死锁场景。每个哲学家都在等待右边的筷子,而右边的筷子正被左边的哲学家拿着。

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

┌─────────────────────────────────────────────────────┐
│                    死锁四条件                        │
├─────────────────────────────────────────────────────┤
│  1. 互斥条件:资源只能被一个进程使用                  │
│  2. 占有并等待:进程已持有资源,同时请求新资源         │
│  3. 不可抢占:已分配资源不能被强制夺走                │
│  4. 循环等待:进程-资源形成循环链                    │
│                                                     │
│  四个条件必须同时满足才会发生死锁!                    │
└─────────────────────────────────────────────────────┘

【直观类比】

死锁 = 十字路口堵死

想象一个没有红绿灯的十字路口:

        南北方向

     ┌─────┼─────┐
     │  A  │  B  │
东──┼─────┼─────┼──西
     │  C  │  D  │
     └─────┼─────┘

        南北方向

场景:
- A车要往东走,但被C挡住了
- C车要往南走,但被D挡住了
- D车要往西走,但被B挡住了
- B车要往北走,但被A挡住了

结果:四个方向全堵死了,谁都走不了。

四大策略 = 四种应对堵车的方法

策略堵车类比思路
预防禁止某些车辆上路破坏死锁必要条件
避免实时监控路况,提前疏导动态检查资源分配
检测堵死后叫拖车允许死锁发生,定期检测
恢复拖车强制挪车检测到后强制解除

死锁 vs 活锁 vs 饥饿

┌─────────────────────────────────────────────────────┐
│              三种状态对比                             │
├─────────────────────────────────────────────────────┤
│                                                     │
│  死锁(Deadlock):                                  │
│  - 进程都停下来,完全不动                             │
│  - 互相等待对方的资源                                 │
│  - 就像堵死一动不动的十字路口                        │
│                                                     │
│  活锁(Livelock):                                  │
│  - 进程在动,但永远无法前进                          │
│  - 不断检测冲突、不断让步                            │
│  - 就像两个人互相让路,让了一辈子也过不去            │
│                                                     │
│  饥饿(Starvation):                               │
│  - 有些进程永远得不到资源                            │
│  - 优先级太低,一直在排队                           │
│  - 就像VIP通道永远不让普通人过                      │
│                                                     │
└─────────────────────────────────────────────────────┘

二、核心原理

1. 死锁预防(Deadlock Prevention)

核心思想:破坏死锁的四个必要条件之一,从根本上杜绝死锁。

破坏互斥条件

方案:让资源可以共享

例如:
- 多个进程可以同时读取同一个文件
- 只读资源不需要互斥

问题:
- 不是所有资源都可以共享
- 打印机等硬件必须独占使用
- 这个条件很难破坏

破坏占有并等待条件

方案一:一次性申请所有资源
- 进程开始时必须申请所有需要的资源
- 要么全部申请成功,要么全部失败

问题:
- 资源利用率低(可能长期占用不需要的资源)
- 需要预先知道所有需求
- 饥饿风险

方案二:释放已占有资源后再申请
- 进程获取资源A后,如果要申请资源B
- 必须先释放A,再申请A+B

问题:
- 如果操作不是原子的,会引入新的竞态条件
- 实现复杂

破坏不可抢占条件

方案:允许强制抢占资源

当进程请求资源时:
- 如果资源可用,直接分配
- 如果资源不可用,可以强制从其他进程手中抢过来

问题:
- 适用于状态易于保存和恢复的资源(如CPU)
- 不适用于打印机等无法保存状态的资源
- 可能导致工作白费

破坏循环等待条件

方案:规定资源的获取顺序

例如:
- 给所有资源编号:R1 < R2 < R3 < ...
- 进程必须按编号顺序申请资源
- 只能申请比已持有资源编号大的资源

效果:
- 资源分配图不会形成环
- 永远不会循环等待

代码示例:
```c
// 错误写法:不同顺序加锁会导致死锁
void transfer(Account from, Account to, int amount) {
    lock(from);   // 线程A锁了from
    lock(to);     // 线程B锁了to
    // 如果线程B同时调用 transfer(to, from, ...),就死锁了
}

// 正确写法:固定加锁顺序
void transfer(Account a, Account b, int amount) {
    // 确保始终按ID顺序加锁
    Account first = a.id < b.id ? a : b;
    Account second = a.id < b.id ? b : a;
    
    lock(first);
    lock(second);
    // 安全!永远不会死锁
}

:::tip 💡
银行家算法属于"避免"策略,而破坏循环等待属于"预防"策略。两者的区别是:避免是在分配时检查是否安全,预防是从根本上破坏死锁发生的条件。
:::

### 2. 死锁避免(Deadlock Avoidance)

**核心思想**:在资源分配时动态判断分配后系统是否仍安全,如果可能不安全就拒绝分配。

#### 银行家算法

回顾一下银行家算法的核心:

┌─────────────────────────────────────────────────────┐ │ 银行家算法核心 │ ├─────────────────────────────────────────────────────┤ │ │ │ 数据结构: │ │ - Available[j]:资源j的可用数量 │ │ - Max[i][j]:进程i对资源j的最大需求 │ │ - Allocation[i][j]:已分配给进程i的资源j数量 │ │ - Need[i][j]:进程i还需要的资源j数量 │ │ │ │ 验证公式: │ │ Need[i][j] = Max[i][j] - Allocation[i][j] │ │ │ │ 安全性检查: │ │ 1. 找 Need <= Available 的进程 │ │ 2. 假设该进程完成,释放其资源 │ │ 3. 重复直到所有进程都能完成 │ │ │ └─────────────────────────────────────────────────────┘


**算法步骤**:

```c
// 资源请求算法
bool requestResource(int process, int request[], ...) {
    // 1. 检查请求是否超过需求
    for (int j = 0; j < m; j++) {
        if (request[j] > need[process][j]) {
            return false;  // 错误:超过最大需求
        }
    }
    
    // 2. 检查请求是否超过可用
    for (int j = 0; j < m; j++) {
        if (request[j] > available[j]) {
            return false;  // 等待:有资源但不够
        }
    }
    
    // 3. 尝试分配
    for (int j = 0; j < m; j++) {
        available[j] -= request[j];
        allocation[process][j] += request[j];
        need[process][j] -= request[j];
    }
    
    // 4. 检查分配后是否安全
    if (safetyCheck(available, allocation, need)) {
        return true;  // 安全,分配成功
    } else {
        // 不安全,回滚
        for (int j = 0; j < m; j++) {
            available[j] += request[j];
            allocation[process][j] -= request[j];
            need[process][j] += request[j];
        }
        return false;  // 分配失败
    }
}

银行家算法的局限性

┌─────────────────────────────────────────────────────┐
│                银行家算法的缺点                        │
├─────────────────────────────────────────────────────┤
│                                                     │
│  1. 需要预先知道最大需求                             │
│     → 实际系统中难以准确预估                         │
│                                                     │
│  2. 算法开销大                                      │
│     → 每次分配都要遍历检查 O(m × n²)                │
│     → 高并发场景性能瓶颈                             │
│                                                     │
│  3. 资源利用率低                                    │
│     → 为了安全可能拒绝合理的分配请求                  │
│                                                     │
│  4. 不适用于动态变化的系统                           │
│     → 进程可能中途改变需求                           │
│                                                     │
└─────────────────────────────────────────────────────┘

资源分配图算法(简化版)

对于单实例资源类型:

资源分配图:
- 进程用圆圈表示:P1, P2, P3...
- 资源用方框表示:R1, R2...
- 方框内的黑点表示资源实例数量
- 箭头:
  - 进程 → 资源:请求边
  - 资源 → 进程:分配边

死锁检测:
- 如果资源分配图中存在环,就存在死锁
- 环:P1 → R1 → P2 → R2 → P1

检测算法:
- 使用深度优先搜索检测环路
- 如果找到环,说明对应进程陷入死锁

3. 死锁检测(Deadlock Detection)

核心思想:允许死锁发生,但系统会定期检测是否发生了死锁。如果检测到死锁,就采取措施恢复。

检测时机

┌─────────────────────────────────────────────────────┐
│              何时检测死锁?                           │
├─────────────────────────────────────────────────────┤
│                                                     │
│  方案一:每当进程请求资源时检测                       │
│  - 优点:尽早发现死锁                                │
│  - 缺点:开销大,每次请求都要检测                    │
│                                                     │
│  方案二:定期检测(如每分钟、每小时)                 │
│  - 优点:开销可控                                    │
│  - 缺点:死锁可能持续一段时间                        │
│                                                     │
│  方案三:当CPU利用率降到某个阈值时检测                │
│  - 优点:只在系统空闲时检测                          │
│  - 缺点:可能错过最佳检测时机                        │
│                                                     │
└─────────────────────────────────────────────────────┘

检测算法

// 死锁检测算法
bool detectDeadlock(int available[], int allocation[][], int request[][]) {
    int n = PROCESS_COUNT;  // 进程数
    int m = RESOURCE_COUNT; // 资源类型数
    
    int work[m];
    bool finish[n];
    
    // 1. 初始化
    for (int i = 0; i < m; i++) {
        work[i] = available[i];
    }
    for (int i = 0; i < n; i++) {
        // 如果没有分配资源,进程可以正常结束
        finish[i] = (allocation[i]全部为0);
    }
    
    // 2. 找可以完成的进程
    while (true) {
        bool found = false;
        for (int i = 0; i < n; i++) {
            if (!finish[i] && canSatisfy(request[i], work)) {
                // 进程i可以完成,释放其资源
                for (int j = 0; j < m; j++) {
                    work[j] += allocation[i][j];
                }
                finish[i] = true;
                found = true;
            }
        }
        if (!found) break;  // 没有进程能完成
    }
    
    // 3. 检查是否还有未完成的进程
    for (int i = 0; i < n; i++) {
        if (!finish[i]) {
            // 进程i陷入死锁
            return true;
        }
    }
    return false;  // 没有死锁
}

4. 死锁恢复(Deadlock Recovery)

核心思想:检测到死锁后,采取措施解除死锁。

方法一:终止进程

┌─────────────────────────────────────────────────────┐
│              终止进程策略                             │
├─────────────────────────────────────────────────────┤
│                                                     │
│  全部终止:                                         │
│  - 终止所有死锁进程                                  │
│  - 简单粗暴,但代价大                                │
│  - 已完成的工作可能全部丢失                          │
│                                                     │
│  部分终止:                                         │
│  - 按某种策略选择一个"牺牲者"                       │
│  - 终止最少进程数来解除死锁                          │
│                                                     │
│  选择牺牲者的因素:                                  │
│  1. 进程优先级(最低优先终止)                       │
│  2. 已运行时间(运行越久终止代价越大)                │
│  3. 已占用资源数量(占用越少终止代价越小)             │
│  4. 进程类型(用户进程优先于系统进程)                │
│  5. 还需要多久能完成(快要完成的优先保留)            │
│                                                     │
└─────────────────────────────────────────────────────┘

方法二:资源抢占

┌─────────────────────────────────────────────────────┐
│              资源抢占策略                             │
├─────────────────────────────────────────────────────┤
│                                                     │
│  概念:从死锁进程中强制夺取资源分配给其他进程         │
│                                                     │
│  抢占步骤:                                          │
│  1. 选择被抢占的进程和资源                           │
│  2. 将资源标记为"已抢占"状态                        │
│  3. 将资源分配给请求它的进程                        │
│  4. 被抢占的进程回滚到某个检查点                     │
│                                                     │
│  关键问题:                                          │
│  1. 选择牺牲哪个进程?                               │
│  2. 抢占哪个资源?                                   │
│  3. 如何回滚?需要保存检查点                         │
│  4. 防止饥饿:同一进程不能一直被抢占                  │
│                                                     │
│  回滚方式:                                          │
│  - 保存进程的检查点(Checkpoint)                    │
│  - 回滚到最近的检查点                                │
│  - 重新执行                                           │
│                                                     │
└─────────────────────────────────────────────────────┘

三、边界与特例

1. 实际系统中的死锁处理

┌─────────────────────────────────────────────────────┐
│              各操作系统的选择                          │
├─────────────────────────────────────────────────────┤
│                                                     │
│  Windows:                                          │
│  - 使用对象句柄和等待链检测死锁                      │
│  - 超时机制:等待超过一定时间就认为可能死锁           │
│  - 最终可能蓝屏...                                   │
│                                                     │
│  Linux:                                            │
│  - 使用超时检测锁等待                                │
│  - OOM Killer:当内存耗尽时,选择牺牲某些进程         │
│  - 内核参数调优:调整等待超时时间                     │
│                                                     │
│  数据库系统:                                        │
│  - 死锁检测 + 回滚                                  │
│  - 设置锁等待超时                                    │
│  - 回滚代价最小的事务                                │
│                                                     │
│  Java应用:                                         │
│  - ThreadMXBean提供死锁检测                         │
│  - JConsole可以看到死锁线程                         │
│  - 代码规范:固定加锁顺序、使用tryLock超时            │
│                                                     │
└─────────────────────────────────────────────────────┘

2. 分布式死锁

单机死锁和分布式死锁有很大区别:

┌─────────────────────────────────────────────────────┐
│              分布式死锁的特点                         │
├─────────────────────────────────────────────────────┤
│                                                     │
│  与单机死锁的区别:                                  │
│  1. 涉及多个节点,检测更复杂                        │
│  2. 网络延迟、时钟漂移增加检测难度                   │
│  3. 没有全局视图,需要协调算法                       │
│                                                     │
│  检测方法:                                          │
│  1. 集中式检测:全局协调器收集资源状态               │
│  2. 分布式检测:Chandy-Misra-Haas算法               │
│                                                     │
│  预防方法:                                        │
│  1. 资源ordering:全局统一的资源编号顺序             │
│  2. 超时机制:等待超过时间就释放资源                 │
│  3. 两阶段锁定:先检查再锁定                         │
│                                                     │
└─────────────────────────────────────────────────────┘

3. 锁粒度与死锁风险

┌─────────────────────────────────────────────────────┐
│              锁粒度的影响                             │
├─────────────────────────────────────────────────────┤
│                                                     │
│  粗粒度锁:                                         │
│  - 整个数据库/整个表加锁                             │
│  - 不容易死锁,但并发性能差                          │
│                                                     │
│  细粒度锁:                                         │
│  - 行级锁、页级锁                                   │
│  - 并发性能好,但死锁风险高                          │
│                                                     │
│  最佳实践:                                          │
│  - 业务允许的情况下,用粗粒度锁                       │
│  - 必须用细粒度锁时,严格控制加锁顺序                 │
│  - 使用超时机制防止永久等待                         │
│                                                     │
└─────────────────────────────────────────────────────┘

四、常见误区

❌ 误区一:死锁只会在代码里出现

死锁可以在多个层面发生:

系统层面:
- 进程间的文件锁
- 设备驱动程序中的中断处理
- 内核数据结构

数据库层面:
- 多个事务互相持有锁等待
- 表级锁与行级锁的组合
- 分布式数据库中的跨节点锁

应用层面:
- 线程间的synchronized/Lock
- 连接池耗尽
- 分布式服务调用超时

硬件层面:
- 内存分配
- 磁盘I/O调度

❌ 误区二:避免比预防好

维度预防避免
实现复杂度简单复杂(需要银行家算法)
系统开销高(每次分配都要检查)
资源利用率
前提条件需要知道最大需求需要知道最大需求
适用场景资源分配不频繁资源充足

❌ 误区三:只要加锁就一定会死锁

死锁需要四个条件同时满足:

反例1:单线程永远不会死锁
- 只有一个执行流
- 不存在资源竞争

反例2:锁保护的是完全独立的资源
- 两个锁保护的是互不相关的对象
- 没有依赖关系

反例3:按固定顺序加锁
- 始终先A后B
- 不会形成循环等待

反例4:使用超时机制
- lock.tryLock(timeout)
- 不会永久等待

❌ 误区四:死锁检测可以完全解决问题

检测只是发现问题,不解决问题:

检测的局限性:
1. 检测本身有开销
2. 检测到死锁后需要决定终止哪些进程
3. 终止进程可能导致数据不一致
4. 真正的死锁原因可能不是表面看到的

最佳实践:
- 预防为主
- 避免为辅
- 检测+恢复作为兜底

五、记忆技巧

一句话总结

死锁四条件要记牢:互斥、占有、不可抢、循环等。 预防破坏条件,避免动态检查,检测靠算法,恢复靠终止或抢占。

对比速记表

策略核心思想实现难度资源利用率系统开销
预防破坏条件
避免动态检查
检测允许+检测
恢复检测+解除

口诀

"死锁四个条件,缺一不可才能死" "预防破坏是根本,避免要算安全序" "检测周期要合理,恢复可杀可抢占" "加锁顺序要统一,超时机制是保险"

场景速查表

场景推荐策略
嵌入式实时系统预防(静态分析)
数据库系统检测+恢复、避免
Java并发应用预防(规范)+ 超时机制
分布式系统避免(资源ordering)

六、实战检验

自检题目

题目1:哲学家就餐问题有哪些解决方案?

点击查看答案
  1. 最多允许N-1个哲学家同时入座

    • 破坏循环等待条件
    • 5根筷子最多4个人同时吃
  2. 奇数号先拿左边,偶数号先拿右边

    • 破坏循环等待条件
    • 不会有环等待
  3. 拿不到右边就放下左边

    • 破坏占有并等待条件
    • 引入活锁风险(需要随机等待)
  4. 服务员协调(引入一个中心协调者)

    • 相当于银行家算法
    • 服务员判断能否让哲学家吃饭
  5. 检测+恢复

    • 检测到死锁后,让某个哲学家放下筷子

题目2:为什么实际系统中更常用死锁检测而不是避免?

点击查看答案
  1. 避免需要预先知道最大需求

    • 实际系统很难准确预估
    • 动态变化的场景无法使用银行家算法
  2. 避免开销大

    • 每次资源分配都要做安全性检查
    • O(m × n²) 的时间复杂度
  3. 检测可以定期进行

    • 不影响正常资源分配
    • 在系统空闲时再检测
  4. 检测+恢复更灵活

    • 可以选择代价最小的恢复方案
    • 根据实际情况决定是否值得回滚

题目3:如何在代码中预防死锁?

点击查看答案
  1. 固定加锁顺序
// 错误
lock(account1);
lock(account2);

// 正确
Account first = account1.id < account2.id ? account1 : account2;
Account second = account1.id < account2.id ? account2 : account1;
lock(first);
lock(second);
  1. 使用超时机制
if (lock.tryLock(5, TimeUnit.SECONDS)) {
    try {
        // 业务逻辑
    } finally {
        lock.unlock();
    }
} else {
    // 处理获取锁失败的情况
}
  1. 减少锁的持有时间
  • 只在必要时加锁
  • 锁外的操作尽量多
  1. 避免在持有锁时调用外部方法
  • 外部方法可能获取其他锁
  • 外部方法可能阻塞
  1. 使用Lock的高级特性
  • ReentrantReadWriteLock 读写分离
  • StampedLock 乐观读

面试追问预测

问题考察点进阶追问
死锁四个必要条件基础概念破坏哪个条件最容易实现
银行家算法复杂度算法分析为什么开销大
哲学家就餐问题综合应用你的项目里遇到过死锁吗
分布式死锁检测扩展知识Chandy-Misra-Haas算法原理

七、生产避坑指南

Java并发中的死锁检测

// 使用ThreadMXBean检测死锁
import java.lang.management.*;

ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadLockedThreads = threadMXBean.findDeadlockedThreads();

if (deadLockedThreads != null) {
    ThreadInfo[] threadInfos = threadMXBean.getThreadInfo(deadLockedThreads);
    for (ThreadInfo info : threadInfos) {
        System.out.println("死锁线程: " + info.getThreadName());
        System.out.println("等待锁: " + info.getLockName());
        System.out.println("持有锁: " + Arrays.toString(info.getLockedSynchronizers()));
    }
}

数据库死锁处理

MySQL中的死锁:
- InnoDB会自动检测死锁并回滚代价最小的事务
- 可以通过 SHOW ENGINE INNODB STATUS 查看死锁信息
- 建议:
  - 按固定顺序访问表/行
  - 使用低隔离级别
  - 减少事务持有时间
  - 批量操作拆分成小事务
⚠️

死锁恢复时,如果选择不当的事务回滚,可能导致业务数据不一致。建议在事务设计时就考虑好回滚的代价和影响。

生产环境监控建议

┌─────────────────────────────────────────────────────┐
│              死锁监控清单                             │
├─────────────────────────────────────────────────────┤
│                                                     │
│  1. JVM层面:                                        │
│     - 使用ThreadMXBean定期检测死锁                   │
│     - 记录死锁发生时的线程栈                          │
│                                                     │
│  2. 数据库层面:                                     │
│     - 开启死锁日志                                   │
│     - 监控锁等待超时                                 │
│     - 定期分析慢查询中的锁相关问题                    │
│                                                     │
│  3. 应用层面:                                       │
│     - 记录lock等待时间超标的日志                      │
│     - 使用 APM 工具追踪跨线程的锁依赖                 │
│                                                     │
│  4. 告警配置:                                       │
│     - 死锁发生告警                                   │
│     - 锁等待时间超过阈值告警                         │
│                                                     │
└─────────────────────────────────────────────────────┘