Skip to content

死锁定位与避免 ​

学习目标 ​

  • 区分竞态、死锁、活锁、饥饿和阻塞堆积。
  • 用固定锁顺序、超时和可中断获取降低死锁风险。
  • 使用 jstack、jcmd Thread.print 和 ThreadMXBean 取得线程转储证据。

核心知识点 ​

专业术语 ​

  • 竞态条件:结果依赖并发时序,可能错误但不一定永久阻塞。
  • 死锁(deadlock):线程互相等待资源,形成循环等待,任何线程都无法继续。
  • 活锁(livelock):线程持续改变状态但没有有效进展。
  • 饥饿(starvation):某线程长期得不到 CPU、锁或许可证。
  • 线程转储(thread dump):某个时刻所有线程栈、状态和锁等待关系的快照。

白话解释与边界 ​

经典死锁同时满足互斥、持有并等待、不可剥夺和循环等待四个条件。最有效的预防是让所有路径按同一全局顺序获得多把锁;拿不到时使用 tryLock 超时退出或回滚。线程转储用于证明现场,不应靠“某次程序卡住”猜测。

线程转储显示的是诊断时刻,不能替代业务日志、请求 ID 和锁等待指标。死锁避免还要考虑数据库锁、远端服务和文件锁等 JVM 外部资源。

常用用法 ​

固定锁顺序:按稳定标识获取多把锁 ​

无论转账方向如何都先锁 id 小的账户,破坏循环等待条件。

java
// 说明:固定锁顺序:按稳定标识获取多把锁。
public class LockOrderDemo {
    static void transfer(Account from, Account to, int amount) {
        Account first = from.id() < to.id() ? from : to;
// 初始状态:first = from.id() < to.id() ? from : to。
// 作用:Account first = from.id() < to.id() ? from : to;;按稳定标识获取多把锁。
        Account second = from.id() < to.id() ? to : from;
// 初始状态:second = from.id() < to.id() ? to : from。
// 作用:Account second = from.id() < to.id() ? to : from;;按稳定标识获取多把锁。
        synchronized (first) {
            synchronized (second) {
                from.balance -= amount;
                to.balance += amount;
            }
        }
    }

    static final class Account {
        final int id;
        int balance;

        Account(int id, int balance) {
            this.id = id;
// 初始状态:id = id。
            this.balance = balance;
// 初始状态:balance = balance。
        }

        int id() {
            return id;
        }
    }

    public static void main(String[] args) {
        var a = new Account(1, 100);
// 初始状态:a = new Account(1, 100)。
// 作用:var a = new Account(1, 100);;按稳定标识获取多把锁。
        var b = new Account(2, 50);
// 初始状态:b = new Account(2, 50)。
// 作用:var b = new Account(2, 50);;按稳定标识获取多把锁。
        transfer(a, b, 10);
        System.out.println(a.balance + "," + b.balance);
// 输出:90,60
    }
}

id 必须稳定且唯一;若无法定义全局顺序,应改用单一协调锁或 tryLock 回退。

tryLock:拿不到锁就释放并重试 ​

tryLock 让线程不会无限占有第一把锁等待第二把;失败时要释放已持有的锁并制定重试、降级或回滚策略。

java
// 说明:tryLock:拿不到锁就释放并重试。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TryLockDeadlockAvoidanceDemo {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock first = new ReentrantLock();
// 初始状态:first = new ReentrantLock()。
// 作用:ReentrantLock first = new ReentrantLock();;拿不到锁就释放并重试。
        ReentrantLock second = new ReentrantLock();
// 作用:ReentrantLock second = new ReentrantLock();;拿不到锁就释放并重试。
// 初始状态:second = new ReentrantLock()。
        first.lock();
// 作用:first.lock();;拿不到锁就释放并重试。
        try {
            if (second.tryLock(1, TimeUnit.MILLISECONDS)) {
// 作用:if (second.tryLock(1, TimeUnit.MILLISECONDS)) {;拿不到锁就释放并重试。
                try {
                    System.out.println("both acquired");
// 输出:both acquired
                } finally {
                    second.unlock();
// 作用:second.unlock();;拿不到锁就释放并重试。
                }
            } else {
                System.out.println("retry later");
// 输出:retry later(可能)
            }
        } finally {
            first.unlock();
// 作用:first.unlock();;拿不到锁就释放并重试。
        }
    }
}

超时不是业务成功,不能直接忽略。

lockInterruptibly:允许诊断或关闭打断等待 ​

在请求取消、服务关闭或锁等待可放弃的场景,使用可中断等待比永久 lock 更容易恢复。

java
// 说明:lockInterruptibly:允许诊断或关闭打断等待。
import java.util.concurrent.locks.ReentrantLock;

public class InterruptibleWaitDemo {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
// 作用:ReentrantLock lock = new ReentrantLock();;允许诊断或关闭打断等待。
// 初始状态:lock = new ReentrantLock()。
        lock.lockInterruptibly();
// 初始状态:lock.lockInterruptibly()。
// 作用:lock.lockInterruptibly();;允许诊断或关闭打断等待。
        try {
            System.out.println("lock acquired");
// 输出:lock acquired
        } finally {
            lock.unlock();
// 作用:lock.unlock();;允许诊断或关闭打断等待。
        }
    }
}

捕获中断后应结束当前工作或恢复中断标记。

取得线程转储:选择独立工具或统一诊断入口 ​

取得 PID 后可用 jcmd <pid> Thread.print -l 或 jstack -l <pid> 捕获线程转储。

java
// 说明:取得线程转储:选择独立工具或统一诊断入口。
public class ThreadDumpPidDemo {
    public static void main(String[] args) {
        System.out.println("pid=" + ProcessHandle.current().pid());
// 输出:pid=12345(进程号可能不同)
    }
}

重点查看 BLOCKED、WAITING、锁拥有者、等待对象和是否存在 Found one Java-level deadlock;PID 和线程顺序是运行时可能变化的。

ThreadMXBean:程序内检测死锁 ​

findDeadlockedThreads 可以检测对象监视器和 ownable synchronizer;它是诊断探针,不应每个请求都高频调用。

java
// 说明:ThreadMXBean:程序内检测死锁。
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;

public class DeadlockDetectorDemo {
    public static void main(String[] args) {
        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
// 初始状态:bean = ManagementFactory.getThreadMXBean()。
// 作用:ThreadMXBean bean = ManagementFactory.getThreadMXBean();;程序内检测死锁。
        long[] ids = bean.findDeadlockedThreads();
// 初始状态:ids = bean.findDeadlockedThreads()。
// 作用:long[] ids = bean.findDeadlockedThreads();;程序内检测死锁。
        System.out.println(ids == null ? "no deadlock" : "deadlock detected");
// 输出:no deadlock
    }
}

发现死锁后要记录线程栈并触发恢复或实例摘除策略。

jcmd Thread.print:低开销现场快照 ​

相比只看业务日志,线程转储能看到 JVM 线程状态、锁等待关系和调用栈;采集前应记录时间、实例、负载和连续多份快照,避免把一次短暂等待误判为死锁。

java
// 说明:jcmd Thread.print:低开销现场快照。
public class DiagnosticCommandHintDemo {
    public static void main(String[] args) {
        System.out.println("run: jcmd <pid> Thread.print -l");
// 输出:run: jcmd <pid> Thread.print -l
    }
}

不常用但需要知道 ​

锁顺序图:记录等待关系 ​

这只是把现场关系写成可读图示;真正的锁拥有者和调用栈应以 jstack/jcmd 输出为准。发现相反顺序就统一为同一排序规则或撤销其中一层锁。

java
public class WaitForGraphDemo {
    public static void main(String[] args) {
        System.out.println("A -> lock-1 -> lock-2");
// 输出:A -> lock-1 -> lock-2
        System.out.println("B -> lock-2 -> lock-1");
// 输出:B -> lock-2 -> lock-1
    }
}

解释线程状态:区分锁竞争与条件等待 ​

BLOCKED 通常表示等待进入 synchronized 监视器,WAITING 表示等待 notify、join 或其他无限等待;状态本身不能证明死锁,还要结合锁拥有者和循环关系。

java
public class ThreadStateDiagnosisDemo {
    public static void main(String[] args) {
        System.out.println(Thread.State.BLOCKED);
// 输出:BLOCKED
        System.out.println(Thread.State.WAITING);
// 输出:WAITING
    }
}

数据库与 JVM 锁的交叉死锁 ​

业务同时持有数据库行锁、JVM 锁、远端租约时也必须定义全局顺序;jstack 只能看到 JVM 一侧,数据库锁等待需要数据库自己的诊断视图。

java
public class CrossResourceRuleDemo {
    public static void main(String[] args) {
        System.out.println("acquire database lock before JVM lock, consistently");
// 输出:acquire database lock before JVM lock, consistently
    }
}

jstack -l:锁信息选项 ​

-l 请求更详细的锁信息,采集命令因平台和权限可能不同;生产环境先在低风险时间采集,必要时做连续快照对比。

java
// 说明:实际命令要把 <pid> 替换为目标 JVM 进程号;-l 让 jstack 在线程栈外附带 java.util.concurrent 锁信息。
public class JstackOptionDemo {
    public static void main(String[] args) {
        System.out.println("jstack -l <pid>");
// 输出:jstack -l <pid>
    }
}

经典死锁反例:仅用于受控诊断 ​

这个反例只用于本地学习 jstack/jcmd Thread.print 的死锁现场,不能直接放进生产代码或无超时的测试套件;生产路径应使用统一锁顺序、tryLock 超时或可中断获取。

java
import java.util.concurrent.CountDownLatch;

public class DeadlockReproductionDemo {
    public static void main(String[] args) throws InterruptedException {
        // 警告:以下 main 会故意永久等待,只能在隔离进程和超时 harness 中运行。
        Object firstLock = new Object();
// 初始状态:firstLock = new Object()。
// 作用:Object firstLock = new Object();;仅用于受控诊断。
        Object secondLock = new Object();
// 初始状态:secondLock = new Object()。
// 作用:Object secondLock = new Object();;仅用于受控诊断。
        CountDownLatch firstHeld = new CountDownLatch(1);
// 初始状态:firstHeld = new CountDownLatch(1)。
// 作用:CountDownLatch firstHeld = new CountDownLatch(1);;仅用于受控诊断。
        CountDownLatch secondHeld = new CountDownLatch(1);
// 初始状态:secondHeld = new CountDownLatch(1)。
// 作用:CountDownLatch secondHeld = new CountDownLatch(1);;仅用于受控诊断。
        Thread first = new Thread(() -> {
// 初始状态:first = new Thread(() ->。
// 作用:Thread first = new Thread(() -> {;仅用于受控诊断。
            synchronized (firstLock) {
                firstHeld.countDown();
// 初始状态:firstHeld.countDown()。
// 作用:firstHeld.countDown();;仅用于受控诊断。
                await(secondHeld);
                synchronized (secondLock) {
                    // 不会执行到这里
                }
            }
        });
        Thread second = new Thread(() -> {
// 初始状态:second = new Thread(() ->。
// 作用:Thread second = new Thread(() -> {;仅用于受控诊断。
            synchronized (secondLock) {
                secondHeld.countDown();
// 初始状态:secondHeld.countDown()。
// 作用:secondHeld.countDown();;仅用于受控诊断。
                await(firstHeld);
                synchronized (firstLock) {
                    // 不会执行到这里
                }
            }
        });
        first.start();
// 初始状态:first.start()。
// 作用:first.start();;仅用于受控诊断。
        second.start();
// 初始状态:second.start()。
// 作用:second.start();;仅用于受控诊断。
        first.join();
// 初始状态:first.join()。
// 作用:first.join();;仅用于受控诊断。
        second.join();
// 初始状态:second.join()。
// 作用:second.join();;仅用于受控诊断。
    }

    static void await(CountDownLatch latch) {
        try {
            latch.await();
// 初始状态:latch.await()。
// 作用:latch.await();;仅用于受控诊断。
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
// 初始状态:Thread.currentThread().interrupt()。
// 作用:Thread.currentThread().interrupt();;仅用于受控诊断。
        }
    }
}
// 结果:first 与 second 分别持有一把锁并等待另一把锁,两个线程都进入 BLOCKED,main 停在 join()

简单案例 ​

java
import java.util.concurrent.locks.ReentrantLock;

public class SafePairLockDemo {
    public static void main(String[] args) {
        ReentrantLock left = new ReentrantLock();
// 初始状态:left = new ReentrantLock()。
// 作用:ReentrantLock left = new ReentrantLock();;仅用于受控诊断。
        ReentrantLock right = new ReentrantLock();
// 初始状态:right = new ReentrantLock()。
// 作用:ReentrantLock right = new ReentrantLock();;仅用于受控诊断。
        left.lock();
// 初始状态:left.lock()。
// 作用:left.lock();;仅用于受控诊断。
        try {
            if (right.tryLock()) {
                try {
                    System.out.println("critical pair");
// 输出:critical pair
                } finally {
                    right.unlock();
// 初始状态:right.unlock()。
// 作用:right.unlock();;仅用于受控诊断。
                }
            }
        } finally {
            left.unlock();
// 初始状态:left.unlock()。
// 作用:left.unlock();;仅用于受控诊断。
        }
    }
}

两把锁的获取要么固定顺序,要么在第二把获取失败时尽快释放第一把;这比让线程无限等待更容易取消和恢复。

易混点 ​

  • 有 BLOCKED 线程不等于死锁,必须找到循环等待;短暂锁竞争是正常现象。
  • 线程 dump 是快照,单份输出只能说明当时状态,连续快照更有证据力。
  • tryLock 失败后若不释放已有锁,仍可能形成死锁。
  • lockInterruptibly 只能让等待可被打断,不能自动解决已经持有的外部资源。
  • JVM 诊断看不到数据库内部锁;跨资源死锁要联合数据库、连接池和应用日志。

课后小问 ​

  1. 为什么统一锁顺序能避免经典死锁? 答案:所有线程都按同一顺序获取锁,无法形成 A 等 B、B 等 A 的循环等待。 解析:锁的排序键必须稳定,新增代码路径也必须遵守,否则预防规则会被绕过。

  2. 如何证明“线程卡住”是死锁而不是慢 I/O? 答案:采集连续线程转储,检查锁等待图是否存在循环,并结合 I/O、数据库和业务耗时证据。 解析:BLOCKED/WAITING 只是现象,循环拥有者和等待者关系才是 JVM 死锁的直接证据。

  3. 为什么 jstack 不能独立诊断数据库死锁? 答案:数据库行锁不完全呈现在 JVM 线程的 Java monitor 或 ownable synchronizer 中。 解析:需要同时查看数据库锁等待视图、连接池状态和应用线程栈,按请求 ID 对齐证据。

本节小结 ​

  • 死锁是循环等待,竞态、活锁和饥饿是不同故障模式。
  • 固定锁顺序、tryLock 超时、可中断等待和缩短临界区是常见预防手段。
  • jstack、jcmd Thread.print 和 ThreadMXBean 能提供 JVM 侧的线程转储与检测证据。
  • 外部数据库/远端锁要纳入统一资源顺序,不能只看 JVM 堆栈。

快速回顾 ​

  • 能区分 BLOCKED、WAITING、死锁和普通慢任务。
  • 会用稳定排序避免多锁循环等待。
  • 会写 tryLock 失败后的释放与重试边界。
  • 知道如何用 jstack、jcmd 和 ThreadMXBean 取得证据。