什么是线程死锁,如何排查?如何解决?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

面试官提出这个问题,通常想考察以下几个层面:

  1. 基础概念理解:你是否能清晰、准确地定义线程死锁,并理解其发生的本质原因。
  2. 理论掌握深度:你是否掌握导致死锁发生的 四个必要条件,这是分析和预防死锁的理论基石。
  3. 实战排查能力:当线上系统出现疑似死锁时,你是否知道如何利用工具(如 jstack)进行诊断和定位,这是高级工程师必备的故障排查技能。
  4. 解决方案与设计思维:面试官不仅仅想知道如何“解决”一个已发生的死锁,更想考察你如何在系统设计阶段就预防死锁的发生,这体现了你的并发编程功底和系统设计意识。

核心答案

线程死锁 是指两个或两个以上的线程在执行过程中,因争夺共享资源而造成的一种互相等待的现象。若无外力干涉,这些线程都将无法向前推进。

排查死锁 主要依赖 JVM 提供的命令行工具:

  1. 使用 jstack -l <pid> 命令 dump 出线程栈信息。jstack 会自动检测并报告发现的死锁,明确指出哪些线程、在等待哪个锁、当前持有哪个锁。
  2. 使用 JConsole 或 VisualVM 这类图形化工具,它们提供了“检测死锁”的按钮,可以直观地查看死锁线程的依赖关系图。

解决和预防死锁 的核心在于破坏其产生的必要条件

  1. 避免嵌套锁:尽量只获取一个锁。如果必须使用多个锁,则强制规定所有线程以相同的全局顺序获取锁(例如,按锁对象的 HashCode 排序)。
  2. 使用带超时的锁:如 Lock.tryLock(long, TimeUnit),获取锁失败时进行回退、重试或记录告警,而不是无限等待。
  3. 死锁检测与恢复:对于已发生的死锁,在检测到后,可以中断(Thread.interrupt())或终止其中一个线程,强制释放资源。

深度解析

原理/机制:四个必要条件

死锁的发生必须同时满足以下四个条件,缺一不可:

  • 互斥条件:资源是独占的,一次只能被一个线程持有。
  • 占有且等待:线程已经持有了至少一个资源,并在等待获取其他线程持有的资源时,不会释放自己已占有的资源。
  • 不可剥夺条件:线程已获得的资源在未使用完之前,不能被其他线程强行抢占。
  • 循环等待条件:存在一个线程-资源的环形等待链。例如,线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1。

“哲学家就餐问题” 是诠释这四个条件的经典模型。

代码示例

下面是一个必然会发生死锁的典型代码:

public class DeadLockDemo {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lockA) { // 线程1获取lockA
                System.out.println("Thread-1: Holding lock A...");
                try { Thread.sleep(10); } catch (InterruptedException e) {}
                System.out.println("Thread-1: Waiting for lock B...");
                synchronized (lockB) { // 线程1尝试获取lockB,但此时它被线程2持有
                    System.out.println("Thread-1: Acquired both locks!");
                }
            }
        }).start();

        new Thread(() -> {
            synchronized (lockB) { // 线程2获取lockB
                System.out.println("Thread-2: Holding lock B...");
                try { Thread.sleep(10); } catch (InterruptedException e) {}
                System.out.println("Thread-2: Waiting for lock A...");
                synchronized (lockA) { // 线程2尝试获取lockA,但此时它被线程1持有
                    System.out.println("Thread-2: Acquired both locks!");
                }
            }
        }).start();
    }
}

运行此程序,两个线程将卡住,使用 jstack 查看会看到清晰的死锁报告。

最佳实践与解决方案

  1. 固定锁顺序(破坏循环等待):这是最常用且有效的预防策略。为所有需要竞争的锁定义一个全局的获取顺序(例如,按 System.identityHashCode 排序,但需处理 Hash 冲突),所有线程都必须遵守这个顺序。
    // 示例:按对象HashCode决定获取顺序
    void transfer(Account from, Account to, int amount) {
        Object firstLock = from.hashCode() < to.hashCode() ? from : to;
        Object secondLock = firstLock == from ? to : from;
    
        synchronized (firstLock) {
            synchronized (secondLock) {
                // ... 转账操作
            }
        }
    }
    
  2. 使用尝试锁(破坏占有且等待):通过 ReentrantLocktryLock 方法。
    if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) {
        try {
            if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) {
                try {
                    // 成功获取两把锁,执行业务
                } finally { lockB.unlock(); }
            }
        } finally { lockA.unlock(); }
    } else {
        // 获取锁失败,进行回退、重试或记录日志
        log.warn("Failed to acquire locks, rollback or retry.");
    }
    
  3. 开放调用:在调用外部方法(尤其是可能获取其他锁的方法)前,释放自己持有的锁。这需要仔细设计,可能降低并发性。

常见误区

  • 误区一:“synchronized 不会产生死锁”。错,synchronized 正是因其 “占有且等待” 和 “不可剥夺” 的特性,很容易导致死锁。
  • 误区二:“只要不用嵌套锁就安全”。错,在复杂的调用链路中(如 A->B->C),可能间接形成锁的嵌套,同样危险。
  • 误区三:“用 jstack 看到线程 BLOCKED 就是死锁”。不一定,BLOCKED 状态只说明线程在等待进入同步块,可能是正常的锁竞争。死锁的关键特征是 jstack 输出中明确指出的 “Found 1 deadlock” 及后续的循环等待详情。

总结

线程死锁是并发编程中的经典难题,理解其 四个必要条件 是预防的根本,熟练使用 jstack 等工具 是线上排查的利剑,而在设计时遵循 固定锁顺序、使用尝试锁 等最佳实践,则是从根本上规避风险的关键。