欢迎光临
我们一直在努力

一次线上死锁引发的血案!万字长文讲透原因、预防与解决

        在多线程编程中,死锁是最让人头疼的问题之一 —— 它让多个线程互相等待对方释放资源,最终所有线程都陷入永久阻塞,程序卡死且无法自行恢复。小到单机应用的功能异常,大到分布式系统的服务不可用,死锁都可能引发严重问题。

        本文会从死锁的核心发生原因讲起,结合 Java 代码模拟死锁场景,用直观的表格和图文梳理死锁形成过程,再详细讲解死锁的预防策略和死锁发生后的解决办法,让你从根源理解并解决死锁问题。

一、什么是死锁?

        先给死锁一个通俗定义:多个线程在执行过程中,因争夺共享资源而造成的一种互相等待的僵局,当死锁发生时,没有任何一个线程能主动打破这种局面,必须通过外部干预才能恢复。

        举个生活中的例子:两个人过独木桥,一人从东上桥,一人从西上桥,走到桥中间时互相挡住对方。两人都要求对方先退回去,却都不肯自己后退,最终谁也走不过桥 —— 这就是典型的死锁,两人是 “线程”,独木桥是 “共享资源”。

二、死锁的发生原因

死锁的发生必须同时满足四个必要条件,缺一不可。这是理解死锁的核心,所有预防和解决死锁的方案,本质都是破坏这四个条件中的一个或多个。

2.1 四个必要条件(图文解析)

用一张图直观展示四个条件的关联,再逐一解释:

  • 互斥条件:共享资源同一时间只能被一个线程占用,其他线程想要使用该资源,必须等待占用者释放。比如 Java 中的Object锁,一个线程获取synchronized锁后,其他线程只能阻塞等待。
  • 请求与保持条件:线程已经持有了至少一个资源,又去请求新的资源,而新资源被其他线程占用,该线程不会释放自己已持有的资源,而是继续等待新资源。比如线程 A 拿着锁 1,又去抢锁 2,锁 2 被线程 B 拿着,线程 A 不释放锁 1,一直等锁 2。
  • 不剥夺条件:线程持有的资源只能由自己主动释放,其他线程无法强制剥夺该资源。还是 Java 锁的例子,线程 A 获取锁后,除非 A 执行完代码释放,或发生异常退出,否则其他线程不能抢锁。
  • 循环等待条件:多个线程之间形成一个资源请求的闭环,每个线程都在等待闭环中下一个线程持有的资源。线程 A 等线程 B 的资源,线程 B 等线程 C 的资源,线程 C 等线程 A 的资源,形成循环。
  • 2.2 Java 代码模拟死锁 + 表格梳理执行过程

    接下来用 Java 代码实现一个经典的死锁场景:两个线程,各自持有一个锁,又互相请求对方的锁,满足死锁的四个必要条件。

    2.2.1 死锁模拟代码

    /**
    * 死锁模拟:两个线程,两把锁,互相等待对方释放锁
    */
    public class DeadLockDemo {
    // 定义两把共享锁(资源)
    private static final Object LOCK1 = new Object();
    private static final Object LOCK2 = new Object();

    public static void main(String[] args) {
    // 线程1:先获取LOCK1,再尝试获取LOCK2
    Thread thread1 = new Thread(() -> {
    synchronized (LOCK1) {
    System.out.println(Thread.currentThread().getName() + " 已获取LOCK1,等待获取LOCK2");
    try {
    // 休眠100ms,确保线程2先获取LOCK2,让死锁必现
    Thread.sleep(100);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }
    // 尝试获取LOCK2,此时LOCK2已被线程2持有
    synchronized (LOCK2) {
    System.out.println(Thread.currentThread().getName() + " 已获取LOCK2,执行完成");
    }
    }
    }, "线程A");

    // 线程2:先获取LOCK2,再尝试获取LOCK1
    Thread thread2 = new Thread(() -> {
    synchronized (LOCK2) {
    System.out.println(Thread.currentThread().getName() + " 已获取LOCK2,等待获取LOCK1");
    try {
    Thread.sleep(100);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }
    // 尝试获取LOCK1,此时LOCK1已被线程1持有
    synchronized (LOCK1) {
    System.out.println(Thread.currentThread().getName() + " 已获取LOCK1,执行完成");
    }
    }
    }, "线程B");

    // 启动两个线程
    thread1.start();
    thread2.start();
    }
    }

    2.2.2 执行结果

    程序运行后会卡死,控制台输出如下,永远不会打印 “执行完成”:

    线程A 已获取LOCK1,等待获取LOCK2
    线程B 已获取LOCK2,等待获取LOCK1

    2.2.3 表格梳理死锁形成过程(横表头 = 线程,竖表头 = 时刻)

    用表格清晰展示每个时刻两个线程的资源持有和请求状态,让死锁形成过程一目了然:

    时刻线程 A线程 B资源状态
    T1 启动,尝试获取 LOCK1 启动,尝试获取 LOCK2 LOCK1、LOCK2 均未被占用
    T2 成功获取 LOCK1,执行 sleep (100) 成功获取 LOCK2,执行 sleep (100) LOCK1 被 A 持有,LOCK2 被 B 持有
    T3 休眠结束,尝试获取 LOCK2 休眠结束,尝试获取 LOCK1 LOCK1 被 A 持有,LOCK2 被 B 持有
    T4 因 LOCK2 被 B 持有,进入阻塞状态 因 LOCK1 被 A 持有,进入阻塞状态 资源持有状态不变
    T5+ 持续阻塞,等待 LOCK2 持续阻塞,等待 LOCK1 永久死锁,无任何线程释放资源

    三、死锁的预防

    死锁的预防是最理想的解决方案—— 在编码阶段就通过设计规避死锁,让死锁的四个必要条件无法同时满足。

    核心思路:破坏死锁的四个必要条件中的一个或多个(互斥条件通常无法破坏,因为多数共享资源本身就需要互斥访问,比如数据库行锁、文件写锁),以下是可落地的预防策略,结合 Java 场景讲解。

    3.1 破坏 “请求与保持” 条件

    核心方案:线程在执行前,一次性获取所有需要的资源,如果有任何一个资源获取失败,就放弃已获取的资源(若有),直到所有资源都能获取到再执行。

    Java 实现思路

    比如需要 LOCK1 和 LOCK2 的线程,必须先同时尝试获取两把锁,而不是先拿一把再拿另一把。可以用CountDownLatch或自定义逻辑实现 “一次性获取资源”:

    // 破坏请求与保持:一次性获取所有需要的锁
    public class PreventDeadLock1 {
    private static final Object LOCK1 = new Object();
    private static final Object LOCK2 = new Object();

    // 封装“一次性获取两把锁”的方法
    private static void acquireAllLocks() {
    synchronized (LOCK1) {
    synchronized (LOCK2) {
    // 执行需要两把锁的业务逻辑
    System.out.println(Thread.currentThread().getName() + " 一次性获取LOCK1和LOCK2,执行业务");
    }
    }
    }

    public static void main(String[] args) {
    new Thread(PreventDeadLock1::acquireAllLocks, "线程A").start();
    new Thread(PreventDeadLock1::acquireAllLocks, "线程B").start();
    }
    }

    效果:线程 A 先获取 LOCK1 和 LOCK2,线程 B 只能等待;线程 A 执行完释放后,线程 B 再获取,不会出现 “持一把等一把” 的情况。

    3.2 破坏 “不剥夺条件”

    核心方案:让线程在请求新资源失败时,主动释放已持有的资源,等待一段时间后重新尝试获取所有资源。

    Java 实现思路

    利用ReentrantLock(可重入锁)的可中断锁特性(lockInterruptibly()),当线程获取锁失败时,触发中断并释放已持有的锁,而不是一直阻塞:

    import java.util.concurrent.locks.ReentrantLock;

    // 破坏不剥夺条件:获取锁失败时释放已持有的锁
    public class PreventDeadLock2 {
    private static final ReentrantLock LOCK1 = new ReentrantLock();
    private static final ReentrantLock LOCK2 = new ReentrantLock();

    public static void main(String[] args) {
    new Thread(() -> {
    boolean lock1Acquired = false;
    boolean lock2Acquired = false;
    try {
    // 尝试获取LOCK1
    lock1Acquired = LOCK1.tryLock();
    if (lock1Acquired) {
    System.out.println(Thread.currentThread().getName() + " 获取LOCK1成功");
    // 尝试获取LOCK2,失败则释放LOCK1
    lock2Acquired = LOCK2.tryLock();
    if (!lock2Acquired) {
    System.out.println(Thread.currentThread().getName() + " 获取LOCK2失败,释放LOCK1");
    LOCK1.unlock();
    lock1Acquired = false;
    // 等待100ms后重新尝试
    Thread.sleep(100);
    }
    }
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    } finally {
    // 释放已持有的锁
    if (lock1Acquired) LOCK1.unlock();
    if (lock2Acquired) LOCK2.unlock();
    }
    if (lock1Acquired && lock2Acquired) {
    System.out.println(Thread.currentThread().getName() + " 执行业务完成");
    } else {
    System.out.println(Thread.currentThread().getName() + " 重新尝试获取锁");
    }
    }, "线程A").start();

    new Thread(() -> {
    // 线程B逻辑与A一致
    boolean lock1Acquired = false;
    boolean lock2Acquired = false;
    try {
    lock2Acquired = LOCK2.tryLock();
    if (lock2Acquired) {
    System.out.println(Thread.currentThread().getName() + " 获取LOCK2成功");
    lock1Acquired = LOCK1.tryLock();
    if (!lock1Acquired) {
    System.out.println(Thread.currentThread().getName() + " 获取LOCK1失败,释放LOCK2");
    LOCK2.unlock();
    lock2Acquired = false;
    Thread.sleep(100);
    }
    }
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    } finally {
    if (lock1Acquired) LOCK1.unlock();
    if (lock2Acquired) LOCK2.unlock();
    }
    }, "线程B").start();
    }
    }

    效果:线程获取资源失败时,不会持有已有资源等待,而是主动释放,破坏 “不剥夺条件”(这里是主动放弃,而非强制剥夺,是 Java 中更易实现的方式)。

    3.3 破坏 “循环等待” 条件(最常用、最易落地)

    核心方案:为所有共享资源规定统一的获取顺序,所有线程都必须按照这个顺序获取资源,这样就不会形成资源请求的闭环。

    这是实际开发中使用最多的策略,因为它实现简单,对程序性能影响最小。

    Java 实现思路

    比如给 LOCK1 和 LOCK2 编号(LOCK1 编号 1,LOCK2 编号 2),所有线程都先获取编号小的锁,再获取编号大的锁,改造之前的死锁代码:

    // 破坏循环等待:按统一顺序获取锁(最常用)
    public class PreventDeadLock3 {
    private static final Object LOCK1 = new Object(); // 编号1
    private static final Object LOCK2 = new Object(); // 编号2

    public static void main(String[] args) {
    // 线程1:按 LOCK1 -> LOCK2 顺序获取
    Thread thread1 = new Thread(() -> {
    synchronized (LOCK1) {
    System.out.println(Thread.currentThread().getName() + " 已获取LOCK1,等待获取LOCK2");
    try {
    Thread.sleep(100);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }
    synchronized (LOCK2) {
    System.out.println(Thread.currentThread().getName() + " 已获取LOCK2,执行完成");
    }
    }
    }, "线程A");

    // 线程2:同样按 LOCK1 -> LOCK2 顺序获取(不再先拿LOCK2)
    Thread thread2 = new Thread(() -> {
    synchronized (LOCK1) { // 统一先拿编号小的LOCK1
    System.out.println(Thread.currentThread().getName() + " 已获取LOCK1,等待获取LOCK2");
    try {
    Thread.sleep(100);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }
    synchronized (LOCK2) {
    System.out.println(Thread.currentThread().getName() + " 已获取LOCK2,执行完成");
    }
    }
    }, "线程B");

    thread1.start();
    thread2.start();
    }
    }

    执行结果:不会死锁,线程按顺序执行:

    线程A 已获取LOCK1,等待获取LOCK2
    线程A 已获取LOCK2,执行完成
    线程B 已获取LOCK1,等待获取LOCK2
    线程B 已获取LOCK2,执行完成

    核心逻辑:所有线程都按统一顺序获取资源,永远不会形成 “你等我、我等你” 的循环,从根源上避免了循环等待条件。

    四、死锁的解决(死锁发生后)

    尽管我们可以通过预防策略减少死锁,但在复杂的多线程 / 分布式系统中,死锁仍有可能发生(比如资源数量动态变化、获取顺序难以统一)。此时需要有效的死锁解决办法,核心思路是检测到死锁后,通过外部干预打破死锁。

    解决死锁分为两个步骤:死锁检测 → 死锁解除,以下结合 Java 应用和实际生产场景讲解。

    4.1 第一步:死锁检测

    需要先发现死锁,才能进行后续的解除操作。Java 提供了多种工具和 API 来检测死锁,从简单的命令行工具到代码级检测,满足不同场景需求。

    4.1.1 命令行工具检测(最常用:jps + jstack)

    这是排查 Java 应用死锁的首选方式,无需修改代码,操作简单,适合生产环境。操作步骤:

  • 用jps命令查看 Java 进程的 PID(进程号): jps
    # 输出示例:1234 DeadLockDemo (1234是PID,DeadLockDemo是我们的死锁程序)
  • 用jstack + PID查看线程堆栈信息,jstack 会自动检测死锁并标注: jstack 1234
  • 查看输出结果,死锁部分会明确标注: Found one Java-level deadlock:
    =============================
    "线程B":
    waiting to lock monitor 0x000000001f67f688 (object 0x000000076b601060, a java.lang.Object),
    which is held by "线程A"
    "线程A":
    waiting to lock monitor 0x000000001f681908 (object 0x000000076b601070, a java.lang.Object),
    which is held by "线程B"

    Found 1 deadlock.

  • 4.1.2 代码级检测(ThreadMXBean)

    通过 Java 的ThreadMXBeanAPI,可以在程序中实时检测死锁,适合需要动态监控的场景:

    import java.lang.management.ManagementFactory;
    import java.lang.management.ThreadMXBean;

    // 代码级检测死锁
    public class DeadLockDetector {
    public static void main(String[] args) {
    // 启动死锁程序
    new Thread(DeadLockDemo::main).start();

    // 定时检测死锁
    new Thread(() -> {
    ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
    while (true) {
    // 获取死锁的线程ID数组
    long[] deadlockThreadIds = threadMXBean.findDeadlockedThreads();
    if (deadlockThreadIds != null && deadlockThreadIds.length > 0) {
    System.out.println("检测到死锁,死锁线程数:" + deadlockThreadIds.length);
    // 可以进一步获取死锁线程的详细信息
    for (long tid : deadlockThreadIds) {
    System.out.println("死锁线程ID:" + tid);
    }
    break;
    }
    try {
    Thread.sleep(1000);
    } catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    }
    }
    }).start();
    }
    }

    4.2 第二步:死锁解除

    检测到死锁后,需要主动打破死锁,核心是让死锁的线程释放资源,常用的解除策略有以下 3 种,按生产环境推荐度从高到低排序:

    4.2.1 终止线程(最常用:终止优先级低的线程)

    核心方案:检测到死锁后,终止死锁闭环中优先级较低、业务影响较小的线程,让其释放持有的资源,打破死锁。

    • Java 实现:通过Thread.stop()(不推荐,已废弃,会强制终止线程,可能导致资源未释放)或优雅中断(结合ReentrantLock的可中断锁,让线程响应中断并释放锁)。
    • 生产场景:在分布式系统中,可通过 kill 进程、关闭服务实例、熔断降级等方式终止死锁的线程 / 服务。
    • 优点:实现简单,见效快;缺点:可能丢失线程未执行完的业务数据,需保证业务幂等。
    4.2.2 资源抢占(强制剥夺资源)

    核心方案:从死锁的线程中强制剥夺部分资源,分配给其他线程,打破循环等待。

    • Java 实现:利用ReentrantLock的tryLock(long timeout, TimeUnit unit)设置超时时间,线程获取锁超时后主动释放已持有的资源,相当于 “被剥夺”。
    • 生产场景:比如数据库中,对长时间持有锁的事务进行超时回滚,释放数据库锁。
    • 优点:无需终止线程,对业务影响较小;缺点:需要资源支持超时机制,实现稍复杂。
    4.2.3 重启进程 / 服务(终极方案)

    核心方案:如果死锁检测和解除失败,或死锁涉及多个进程 / 服务,直接重启发生死锁的进程或服务,强制释放所有资源。

    • 适用场景:生产环境中死锁无法通过上述方式解除,且服务有高可用架构(比如集群),重启单个实例不影响整体服务。
    • 优点:解决最彻底,无需复杂的解除逻辑;缺点:会导致服务短暂不可用,需做好容灾。

    4.3 死锁解决的实战建议

  • 生产环境中,优先使用 jstack/jconsole/visualvm等工具快速定位死锁,再结合业务场景选择解除方式;
  • 对核心业务线程,尽量使用可中断锁(ReentrantLock) 和超时获取锁,避免永久阻塞;
  • 分布式系统中,结合熔断、降级、限流等手段,减少死锁发生的概率,同时通过服务注册发现和健康检查,自动重启异常实例。
  • 五、死锁的相关拓展:避免与检测的区别

    很多人会混淆死锁预防和死锁避免,这里做一个简单区分,让概念更清晰:

    • 死锁预防:事前设计,破坏死锁的必要条件,让死锁根本无法发生;适合编码阶段,主动规避。
    • 死锁避免:事中判断,在线程请求资源时,通过算法(比如银行家算法)判断是否会导致死锁,若会则拒绝资源请求;适合资源数量固定的场景(如操作系统内存分配),但 Java 中较少使用,因为实现复杂。
    • 死锁解决:事后处理,检测到死锁后通过外部干预解除;适合死锁已经发生的场景。

    六、总结

    死锁是多线程编程的核心问题,但其解决思路非常清晰 ——围绕死锁的四个必要条件展开,从 “事前预防” 到 “事后解决” 形成完整的应对体系:

  • 死锁的核心:必须同时满足互斥、请求与保持、不剥夺、循环等待四个必要条件,缺一不可;
  • 死锁的预防:最理想的方案,优先破坏请求与保持或循环等待(最易落地),编码阶段统一资源获取顺序是生产环境的首选;
  • 死锁的解决:分 “检测” 和 “解除” 两步,jps+jstack 是 Java 死锁检测的标配,终止低优先级线程是最常用的解除方式,重启是终极方案;
  • 实战建议:多线程编程中,尽量使用ReentrantLock替代synchronized(支持可中断、超时获取),统一资源获取顺序,做好线程监控,从根源减少死锁发生。
  • 死锁并不可怕,只要理解其发生的本质,通过合理的设计和有效的排查手段,就能让我们的多线程程序远离死锁,保持高效稳定的运行。

    赞(0)
    未经允许不得转载:171主机测评 » 一次线上死锁引发的血案!万字长文讲透原因、预防与解决
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址