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

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

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 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. 排查能力:线上出了死锁你怎么定位?能不能熟练使用 jstackjconsoleArthas 这些工具?这块区分度很大,实际排查过死锁的候选人回答起来明显更有底气。

  3. 预防意识:考察你是否掌握了死锁的预防策略,以及在日常编码中是否有意识地去避免死锁。能主动提及锁排序、超时机制这些方案的候选人,面试官会给加分。

核心答案

死锁 是指两个或多个线程互相持有对方需要的锁,又都不肯释放自己手中的锁,导致所有线程永远阻塞的现象。

先来看一个最经典的死锁代码:

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

    public static void main(String[] args) {
        // 线程 1:先拿 lockA,再请求 lockB
        new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread-1 持有 lockA,等待 lockB");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockB) {
                    System.out.println("Thread-1 获得锁");
                }
            }
        }, "Thread-1").start();

        // 线程 2:先拿 lockB,再请求 lockA
        new Thread(() -> {
            synchronized (lockB) {
                System.out.println("Thread-2 持有 lockB,等待 lockA");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockA) {
                    System.out.println("Thread-2 获得锁");
                }
            }
        }, "Thread-2").start();
    }
}

运行后程序永远不会结束——Thread-1 拿着 lockA 等 lockB,Thread-2 拿着 lockB 等 lockA,谁也动不了。

深度解析

一、死锁产生的四个必要条件

死锁不是随便就能发生的,必须 同时满足 以下四个条件:

上图展示了死锁产生的四个必要条件。逐个解释:

  • ① 互斥条件:锁本身就是互斥的,同一时刻只能被一个线程持有。这个条件是锁的基本特性,基本没法破坏(除非不用锁)。

  • ② 请求与保持:线程已经持有至少一把锁,还在请求另一把锁,且不释放已经持有的锁。上面的代码就是典型的 "请求与保持"——Thread-1 拿着 lockA 不放,还去请求 lockB。

  • ③ 不剥夺条件:线程已经获得的锁不能被其他线程强制抢占,只能由持有者自己释放。synchronized 就是这样,拿到锁后别人没法让你交出来。

  • ④ 循环等待:多个线程之间形成环形等待链——A 等 B 的锁,B 等 A 的锁。这是死锁最直观的表现。

关键点:四个条件必须同时成立才会死锁,只要破坏其中任意一个,死锁就能被打破。实际开发中我们主要破坏 ② 和 ④。

二、如何排查死锁?

线上系统出现死锁,通常的表现是:某些接口响应极慢甚至完全卡死,CPU 使用率不高(因为线程都阻塞了),线程 dump 中能看到大量的 BLOCKED 状态线程。

方法一:jstack 命令(最常用)

# 1. 先找到 Java 进程 PID
jps -l
# 输出:12345 DeadlockDemo

# 2. 用 jstack 打印线程堆栈
jstack 12345

输出中会明确告诉你发现死锁:

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x0000... (object 0x0000..., a java.lang.Object),
  which is held by "Thread-2"
"Thread-2":
  waiting to lock monitor 0x0000... (object 0x0000..., a java.lang.Object),
  which is held by "Thread-1"

Java stack information for the threads listed above:
===================================================
"Thread-1":
        at DeadlockDemo.lambda$main$0(DeadlockDemo.java:12)
        - waiting to lock <0x0000...> (a java.lang.Object)
        - locked <0x0000...> (a java.lang.Object)
"Thread-2":
        at DeadlockDemo.lambda$main$1(DeadlockDemo.java:22)
        - waiting to lock <0x0000...> (a java.lang.Object)
        - locked <0x0000...> (a java.lang.Object)

jstack 会直接帮你找到死锁的线程和具体的代码行号,非常直观。

方法二:jconsole / VisualVM(图形化)

如果你能连到服务器(或者本地开发环境),用 jconsoleVisualVM 打开,在 "线程" 面板点击 "检测死锁" 按钮,就能看到哪些线程发生了死锁。图形化界面比命令行更直观,适合本地调试。

方法三:Arthas(线上诊断利器)

# 安装并启动 Arthas
java -jar arthas-boot.jar

# 查看线程死锁
thread -b

thread -b 会直接打印阻塞其他线程的线程信息,包括持有哪些锁、阻塞了谁。阿里开源的 Arthas 在线上问题排查中真的太好用了,强烈推荐掌握。

方法四:代码层面检测

// ThreadMXBean 可以编程式检测死锁
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
if (deadlockedThreads != null) {
    ThreadInfo[] threadInfos = threadMXBean.getThreadInfo(deadlockedThreads);
    // 记录日志、告警
}

可以在监控系统中定时检测,发现死锁自动告警。

三、如何解决和预防死锁?

上面说了,破坏四个必要条件中的任意一个就能打破死锁。实际开发中有以下几种常用策略:

策略一:固定加锁顺序(破坏循环等待)

这是最简单也最有效的方式——所有线程都按 相同的顺序 获取锁。

// 修复后的代码:两个线程都先 lockA 再 lockB
public static void main(String[] args) {
    // 线程 1:先 lockA,再 lockB
    new Thread(() -> {
        synchronized (lockA) {
            synchronized (lockB) {
                // 业务逻辑
            }
        }
    }).start();

    // 线程 2:也是先 lockA,再 lockB(顺序一致)
    new Thread(() -> {
        synchronized (lockA) {
            synchronized (lockB) {
                // 业务逻辑
            }
        }
    }).start();
}

两个线程都先请求 lockA 再请求 lockB,不会出现 "一个先 A 后 B、另一个先 B 后 A" 的交叉等待,循环等待条件被打破。实际开发中,可以按锁对象的 hashCode 值大小来确定加锁顺序。

策略二:使用 tryLock 超时机制(破坏不剥夺条件)

ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();

try {
    // 尝试获取 lockA,最多等 3 秒
    if (lockA.tryLock(3, TimeUnit.SECONDS)) {
        try {
            // 获取到 lockA 后,尝试获取 lockB
            if (lockB.tryLock(3, TimeUnit.SECONDS)) {
                try {
                    // 两把锁都拿到了,执行业务逻辑
                } finally {
                    lockB.unlock();
                }
            } else {
                // 获取 lockB 超时,主动放弃 lockA
                System.out.println("获取 lockB 超时,放弃操作");
            }
        } finally {
            lockA.unlock();
        }
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

tryLock 在指定时间内获取不到锁就返回 false,主动放弃已经持有的锁。这就打破了 "不剥夺条件"——锁虽然没有被外部强制抢占,但线程自己超时放弃了,效果一样。synchronized 做不到这一点,这也是 ReentrantLock 的一大优势。

策略三:减小锁粒度,缩小同步范围

锁的代码块越大,持有锁的时间越长,死锁概率就越高。尽量把不需要同步的操作移到同步块外面:

// 不好:整个方法都加锁,持有锁时间太长
synchronized (lock) {
    prepareData();    // 不需要锁
    updateShared();   // 需要锁
    sendNotify();     // 不需要锁
}

// 好:只锁必要的部分
prepareData();
synchronized (lock) {
    updateShared();
}
sendNotify();

策略四:避免嵌套锁

能不用嵌套锁就不用。如果必须在一个锁里请求另一个锁,说明设计可能有问题,考虑重构一下逻辑,把嵌套锁拆成独立的锁操作。

四、四种策略对比

策略破坏哪个条件实现难度推荐程度
固定加锁顺序循环等待简单首选
tryLock 超时不剥夺条件中等✅ 推荐
减小锁粒度请求与保持简单✅ 好习惯
避免嵌套锁请求与保持需设计✅ 最佳实践

生产环境最常用的就是 固定加锁顺序 + tryLock 超时 的组合拳,基本上能把死锁风险降到很低。

面试高频追问

  1. 死锁和活锁有什么区别?

    • 死锁是线程阻塞不动了;活锁是线程没阻塞但在不停地重试失败(比如两个线程互相让步,都拿不到锁),本质上是 "忙但没进展"。
  2. 数据库也会有死锁吗?怎么处理?

    • 会。数据库的死锁检测更成熟——MySQL InnoDB 会自动检测死锁并回滚其中一个事务。应用层也可以通过 tryLock 超时来避免。
  3. synchronized 能检测死锁吗?

    • synchronized 本身不能,但可以通过 jstackThreadMXBean 等外部工具来检测。ReentrantLocktryLock 则可以从编码层面主动避免死锁。

常见面试变体

  • "写一个死锁的代码示例"
  • "线上出了死锁你怎么排查?"
  • "如何预防死锁?"
  • "synchronizedReentrantLock 哪个更容易出现死锁?"

记忆口诀

死锁四条件:互斥、请求保持、不剥夺、循环等待。破坏任意一个,死锁不成立。

预防三板斧:锁排序(固定顺序)、锁超时(tryLock)、锁最小化(缩小范围)。

总结

面试答死锁,三层递进:先说清楚什么是死锁和四个必要条件,再讲排查手段(jstack、Arthas),最后重点展开预防策略(固定加锁顺序 + tryLock 超时)。如果能在回答中穿插一段自己线上排查死锁的经历,面试官基本就稳了。