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

#Java#面试八股

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

结论先行:死锁(deadlock)是一组线程各自持有对方需要的锁,又都在等对方释放,导致全部永久阻塞。它必须同时满足四个条件:互斥、持有并等待、不可剥夺、循环等待——破坏任意一个就能避免。

1. 四个必要条件

条件含义破坏手段
互斥资源同一时刻只能被一个线程持有用无锁结构、只读副本、ThreadLocal
持有并等待拿着 A 的同时去申请 B一次性申请所有资源,或申请失败就释放已持有的
不可剥夺别人持有的锁不能强抢tryLock(timeout) 超时后放弃并释放
循环等待形成 A→B→A 的等待环统一加锁顺序(按 id/哈希排序)

2. 死锁长什么样

Thread-1: 持有 lockA ──等待──▶ lockB
                    ▲            │
                    │            ▼
Thread-2: 持有 lockB ──等待──▶ lockA
           (互相等对方手里的锁,环闭死)
public class DeadlockDemo {
    private static final Object A = new Object();
    private static final Object B = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (A) {
                sleep(100);                 // 给另一个线程抢 B 的机会
                synchronized (B) { System.out.println("t1"); }
            }
        }, "Thread-1").start();

        new Thread(() -> {
            synchronized (B) {
                sleep(100);
                synchronized (A) { System.out.println("t2"); }
            }
        }, "Thread-2").start();
    }
}

3. 排查三步

第一步:确认是不是死锁。死锁的典型特征是 CPU 不高、线程数稳定、接口全部超时——因为线程都在 BLOCKED 状态睡觉,不消耗 CPU。

# 1. 找到 Java 进程
jps -l
# 2. 导出线程栈,jstack 会自动检测死锁
jstack -l 12345 > /tmp/stack.txt
# 3. 直接搜关键字
grep -A 20 "Found one Java-level deadlock" /tmp/stack.txt

输出大致是:

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

"Thread-2":
        at com.demo.DeadlockDemo.lambda$main$1(DeadlockDemo.java:19)
        - waiting to lock <0x000000076ab622a8> (a java.lang.Object)
        - locked <0x000000076ab622b8> (a java.lang.Object)

waiting to lock 加 locked 两行合起来就是"拿着 X 等 Y",环一目了然。

第二步:用代码在线检测。JDK 自带 ThreadMXBean,可以做成定时巡检:

ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] deadlocked = bean.findDeadlockedThreads();   // 内部做环检测
if (deadlocked != null) {
    for (ThreadInfo info : bean.getThreadInfo(deadlocked, true, true)) {
        log.error("死锁线程: {}\n{}", info.getThreadName(), info);
    }
}

第三步:定位到业务代码。看两个栈帧共同持有的锁对象,回溯是谁按什么顺序加的锁——通常是一个方法按 A→B、另一个方法按 B→A。

4. 四种修复手段

// 1. 统一加锁顺序:所有地方都按 id 从小到大加锁
public void transfer(Account from, Account to, long amount) {
    Account first  = from.id < to.id ? from : to;
    Account second = from.id < to.id ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.balance -= amount;
            to.balance += amount;
        }
    }
}

// 2. tryLock + 超时:拿不到就放弃并回滚,避免无限等待
if (lockA.tryLock(1, TimeUnit.SECONDS)) {
    try {
        if (lockB.tryLock(1, TimeUnit.SECONDS)) {
            try { doWork(); } finally { lockB.unlock(); }
        } else {
            log.warn("获取 lockB 超时,回滚");
        }
    } finally { lockA.unlock(); }
}

// 3. 把两把锁合并成一把(减少锁数量),或用单一锁保护整块状态
// 4. 用无锁结构:AtomicReference + CAS 整体替换状态对象

5. 常见追问

追问一:死锁、活锁、饥饿的区别? 死锁是"都停住不动";活锁(livelock)是"都在动但谁也推进不了"(比如两个线程互相礼让重试);饥饿是"有人一直抢不到"(非公平锁下的长尾线程)。三者排查手段不同:死锁看 jstack,活锁看 CPU 高但业务无进展,饥饿看等待时间分布。

追问二:数据库死锁和 JVM 死锁是一回事吗? 机制相同(都是循环等待),但处理方式不同:数据库(InnoDB)有死锁检测,会主动回滚代价小的事务;JVM 不会自动解开死锁,只能重启或靠代码避免。所以数据库死锁要"重试事务",JVM 死锁只能"预防"。

追问三:为什么线上死锁往往在重启后才暴露? 因为死锁需要特定的交错时序,平时流量下两个线程恰好错开就不会触发。大促、批量任务、定时任务并发时才会撞上——这类问题要靠统一加锁顺序从设计上消灭,而不是靠测试覆盖。

小结

排查死锁记住"CPU 不高但全卡住 → jstack 找 Found one Java-level deadlock → 看 waiting to lock / locked 配对 → 回溯加锁顺序"。解决死锁只有两条正路:统一顺序,或者超时放弃。

笔记加载中…