线程 A 已经拿到了资源 1,线程 B 也拿到了资源 2。接下来,线程 A 想继续往下走,需要资源 2;线程 B 想继续往下走,又需要资源 1。

两个线程都没抛异常,也不是 CPU 把机器打满了。线上看到的现象可能只是几个请求一直不返回,线程池里的工作线程慢慢被占住。

这类问题麻烦就麻烦在这里:程序没有“算错”,而是卡在一条自己解不开的等待链上。

线程死锁说的就是这种情况:一组线程互相等对方释放资源,等待关系绕成闭环,参与其中的线程都没办法靠自己继续执行。

如果这些线程正好承载订单、支付、库存这类关键流程,外部看到的就不只是某个线程 BLOCKED 了,而是接口超时、队列堆积,甚至进程迟迟退不干净。

BLOCKED

死锁场景示意图:线程 A 持有 resource1 并等待 resource2,线程 B 持有 resource2 并等待 resource1,等待链形成闭环

把范围放大一点,死锁不只属于 Java 线程。进程、数据库事务、分布式任务,只要互相占着资源再继续等待,都可能卡成同样的形状。这里的资源也不一定是操作系统教材里的打印机、磁带机,它可以是 Java 对象监视器、ReentrantLock、数据库行锁、分布式锁、连接池里的连接、线程池里的工作线程,甚至是管道缓冲区。

ReentrantLock

后面会用 Java 代码演示,是因为这类例子最容易复现。但要记住,死锁不是 Java 专属的问题。只要系统里同时出现独占资源、持有后继续等待、资源不能被强制剥夺、等待关系成环,线程、进程和事务都会踩进去。

如果你想先把 mutex、semaphore、condition variable、futex 这些同步原语的职责边界理清楚,可以先看:操作系统锁与同步机制详解:mutex、semaphore、condition variable、spinlock 与 futex。这篇死锁专题会把重点放在等待关系怎么成环,以及线上怎么排查和恢复。

操作系统锁与同步机制详解:mutex、semaphore、condition variable、spinlock 与 futex

这篇文章按排查时更常用的顺序来讲:先看等待环怎么形成,再看四个必要条件、Java 复现代码、资源分配图、处理策略,最后落到线上该怎么抓线程栈、怎么看数据库锁。

死锁是怎么卡住的?

死锁是怎么卡住的?

先看并发编程里最常见的场景。系统里有两个线程和两份资源:

线程 A 先拿到资源 1,再去申请资源 2。

线程 B 先拿到资源 2,再去申请资源 1。

如果两个线程刚好交错执行,就会出现下面这个状态:

线程 A 持有资源 1,等待资源 2。

线程 B 持有资源 2,等待资源 1。

线程 A 想继续运行,必须等线程 B 释放资源 2;线程 B 想继续运行,又必须等线程 A 释放资源 1。两边都在等对方先动,但谁都没有继续执行到释放资源的机会。

所以,死锁和“等得久”不是一回事。普通阻塞还有自然恢复的机会:别的线程释放锁,事务提交,网络 I/O 返回,后面的线程就能继续跑。死锁里多了一个环,环上的每个参与者都在等别人先释放资源。

还有一个排查时很容易误判的点:死锁不一定伴随高 CPU。很多时候线程只是安静地停在 BLOCKED 或 WAITING,CPU 反而很低。接口超时、线程池占满、数据库连接耗尽时,CPU 只能作为线索之一,不能当成唯一判断依据。

BLOCKED
WAITING

死锁的四个必要条件

死锁的四个必要条件

操作系统教材通常会讲 Coffman 条件。死锁要发生,下面 4 个条件必须同时成立:

条件含义对应到 Java 或数据库互斥某个资源同一时刻只能被一个执行单元占用synchronized 锁对象、行级排他锁、独占文件锁请求与保持(占有并等待)已经拿着一部分资源,同时继续等待其他资源线程持有 resource1 时继续申请 resource2非抢占资源不能被外部强行拿走,只能由持有者释放Java 内置锁不能被另一个线程直接剥夺循环等待等待关系形成闭环线程 1 等线程 2,线程 2 又等线程 1

条件含义对应到 Java 或数据库

条件

含义

对应到 Java 或数据库

互斥某个资源同一时刻只能被一个执行单元占用synchronized 锁对象、行级排他锁、独占文件锁

互斥

某个资源同一时刻只能被一个执行单元占用

synchronized 锁对象、行级排他锁、独占文件锁

synchronized

请求与保持(占有并等待)已经拿着一部分资源,同时继续等待其他资源线程持有 resource1 时继续申请 resource2

请求与保持(占有并等待)

已经拿着一部分资源,同时继续等待其他资源

线程持有 resource1 时继续申请 resource2

resource1
resource2

非抢占资源不能被外部强行拿走,只能由持有者释放Java 内置锁不能被另一个线程直接剥夺

非抢占

资源不能被外部强行拿走,只能由持有者释放

Java 内置锁不能被另一个线程直接剥夺

循环等待等待关系形成闭环线程 1 等线程 2,线程 2 又等线程 1

循环等待

等待关系形成闭环

线程 1 等线程 2,线程 2 又等线程 1

死锁四个必要条件示意图:互斥、请求与保持、非抢占、循环等待同时成立才会形成死锁

这张表不要当成“满足其中一条就死锁”的清单。它真正表达的是:四项同时出现,死锁才具备发生条件;少掉任意一项,等待环就很难闭合。

写业务代码时,最容易下手的是第 2 条和第 4 条。

互斥通常绕不开。同一行库存、同一个账户余额、同一段共享内存,本来就不能让多个线程同时乱写。非抢占也不好硬改,锁保护的往往是一段尚未完成的状态,粗暴抢走可能留下半成品。相比之下,让线程一次拿齐资源,或者规定所有入口都按同一顺序拿锁,更容易变成团队能执行的代码规范。

用 Java 复现一个死锁

用 Java 复现一个死锁

下面这段代码就是把图 1 写成 Java。两个 Object 分别充当资源 1 和资源 2,两个线程按相反顺序进入 synchronized。

Object
synchronized

Thread.sleep(1000) 不是死锁的原因,它只是把两个线程交错执行的窗口拉大,让问题更容易复现。

Thread.sleep(1000)
public class DeadLockDemo {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}
public class DeadLockDemo {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}

比较典型的一次输出是:

Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

程序到这里就停住了。sleep() 早晚会结束,真正卡住的是第二层 synchronized:线程 1 进不去 resource2,线程 2 进不去 resource1。

sleep()
synchronized
resource2
resource1

把现场对回前面的四个条件:

互斥:resource1 和 resource2 同一时间只能被一个线程持有。

resource1
resource2

请求与保持:线程 1 拿着 resource1 等 resource2,线程 2 拿着 resource2 等 resource1。

resource1
resource2
resource2
resource1

非抢占:Java 不会强制把 resource1 从线程 1 手里拿走。

resource1

循环等待:线程 1 等线程 2,线程 2 又等线程 1。

四个条件都对上了,剩下的就是调度时序。也正因为触发依赖时序,有些死锁在线上不是每次都能复现,压测跑十次可能只有一两次卡住。

怎么改这段代码才能没有死锁问题?

最直接的修法是把加锁顺序固定下来。所有线程都先拿 resource1,再拿 resource2,等待链就没有机会绕回起点。

resource1
resource2
public class OrderedLockDemo {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Runnable task = () -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");

                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        };

        new Thread(task, "线程 1").start();
        new Thread(task, "线程 2").start();
    }
}
public class OrderedLockDemo {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Runnable task = () -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");

                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        };

        new Thread(task, "线程 1").start();
        new Thread(task, "线程 2").start();
    }
}

这个改法破坏的是“循环等待”。只要所有代码路径都遵守同一个顺序,就不会出现 A 等 B、B 又等 A 的闭环。

难点在“所有代码路径”。小例子里只有两个锁,一眼就能看完;业务系统里,锁可能散在订单、库存、支付几个模块里。A 链路先拿订单锁再拿库存锁,B 链路先拿库存锁再拿订单锁,单独看每个方法都像是合理的,组合起来才出问题。

实际项目里,我更建议把下面几条写进并发代码的检查清单:

资源要有稳定顺序。可以按业务 ID、数据库主键、账户号这类不会变的值排序,别依赖对象哈希这种不适合表达业务顺序的东西。

锁内只做必要的状态修改。RPC、慢 SQL、文件 I/O 这类操作尽量放到锁外,否则一次慢调用就会把等待链拉长。

拿不齐资源就退出来。已经拿到 A、拿不到 B 时,释放 A 后重试,比拿着 A 一直等 B 更安全。

业务允许失败时,用 tryLock(timeout, unit) 给等待加上上限,别让线程无限挂住。

tryLock(timeout, unit)

如果两把锁总是一起出现,考虑合成一把更粗的锁。并发度会下降,但换来的是更容易证明的正确性。

最后这一条看起来有点“退步”,但工程里经常有用。锁拆得太细不一定更高级;如果几份状态天然强相关,拆开反而会给死锁留出空间。

资源分配图和等待图

资源分配图和等待图

操作系统教材里常用资源分配图来画死锁。图里其实就两类东西:

进程或线程节点。

资源节点。

箭头也分两种:

从线程指向资源,表示线程正在等待这个资源。

从资源指向线程,表示资源已经分配给这个线程。

先看一个最有用的结论:图里没有环,就没有死锁。

图里有环时,不能立刻一刀切,还要看资源实例数:

每类资源只有 1 个实例时,有环就代表死锁。

每类资源有多个实例时,有环只说明可能死锁,还要继续判断是否存在某个线程能先完成并释放资源。

拿数据库行锁举个例子。事务 T1 已经锁住订单 id=1,接着要更新 id=2;事务 T2 先锁住了 id=2,又回头更新 id=1。这时可以把资源节点先拿掉,只看事务之间的等待关系:

id=1
id=2
id=2
id=1
T1 -> T2
T2 -> T1
T1 -> T2
T2 -> T1

这种只保留“谁等谁”的图叫等待图(Wait-for Graph),可以看成资源分配图的简化版。Java 线程死锁、数据库死锁检测、Linux lockdep 都会用到类似的图思维,只是使用时机不一样:数据库通常等事务真的阻塞后再检查等待环;lockdep 更像是记录锁获取顺序,提前发现某些顺序组合可能绕成环。

资源分配图与等待图示意图:资源分配图包含线程和资源节点,等待图只保留线程之间的等待关系

预防、避免、检测、恢复

预防、避免、检测、恢复

讲死锁处理时,经常会看到 4 个词:预防、避免、检测、恢复。名字很像,但它们介入的时间点不同。

业务代码里最常见的是预防,比如统一加锁顺序、缩短持锁时间;数据库更习惯检测和恢复,因为事务可以回滚;银行家算法属于“避免”,很适合理解安全状态,但普通后端服务很少真的照着实现一套。

方法做法代价工程常见程度预防破坏死锁四条件之一,让死锁结构上不成立可能降低并发度或增加编码约束很常见避免分配资源前判断这次分配会不会把系统推向危险状态需要提前知道资源需求,检查成本高教材常讲,通用系统少见检测允许死锁发生,定期或按需检查等待环检测本身有成本数据库、JVM 工具、内核调试常见恢复检测到死锁后终止、回滚或抢占资源可能丢弃已完成工作数据库事务里比较自然

方法做法代价工程常见程度

方法

做法

代价

工程常见程度

预防破坏死锁四条件之一,让死锁结构上不成立可能降低并发度或增加编码约束很常见

预防

破坏死锁四条件之一,让死锁结构上不成立

可能降低并发度或增加编码约束

很常见

避免分配资源前判断这次分配会不会把系统推向危险状态需要提前知道资源需求,检查成本高教材常讲,通用系统少见

避免

分配资源前判断这次分配会不会把系统推向危险状态

需要提前知道资源需求,检查成本高

教材常讲,通用系统少见

检测允许死锁发生,定期或按需检查等待环检测本身有成本数据库、JVM 工具、内核调试常见

检测

允许死锁发生,定期或按需检查等待环

检测本身有成本

数据库、JVM 工具、内核调试常见

恢复检测到死锁后终止、回滚或抢占资源可能丢弃已完成工作数据库事务里比较自然

恢复

检测到死锁后终止、回滚或抢占资源

可能丢弃已完成工作

数据库事务里比较自然

死锁处理策略图:预防、避免、检测、恢复四类方法的作用位置和工程常见程度

死锁预防

死锁预防

预防做的事很直接:别让四个必要条件同时凑齐。

破坏互斥:把资源改成可共享。只读数据、不可变对象、无锁数据结构、追加写日志,都能减少互斥需求。但这条路经常走不通,比如同一行库存扣减、同一个文件写入位置、同一个用户余额,本来就不能让多个执行单元随便同时改。

破坏占有并等待:要么一次拿齐资源,要么一个都不拿。这样就不会出现“手里攥着 A,又一直等 B”的状态。代价也清楚:资源利用率可能下降,调用方还得提前知道自己到底要哪些资源。

破坏非抢占:拿不到新资源时,主动释放已经拿到的资源,稍后再试。Java 内置锁不支持超时获取,也不能让别的线程强制撤销;Lock 接口提供的 tryLock(),可以把“等不到就退出来”写进代码里。

Lock
tryLock()
boolean gotA = false;
boolean gotB = false;

try {
    gotA = lockA.tryLock(100, TimeUnit.MILLISECONDS);
    if (!gotA) {
        return;
    }

    gotB = lockB.tryLock(100, TimeUnit.MILLISECONDS);
    if (!gotB) {
        return;
    }

    // 同时拿到两把锁后再处理共享状态
    updateSharedState();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    // 不要吞掉中断信号,具体是返回还是抛异常由业务决定。
} finally {
    if (gotB) {
        lockB.unlock();
    }
    if (gotA) {
        lockA.unlock();
    }
}
boolean gotA = false;
boolean gotB = false;

try {
    gotA = lockA.tryLock(100, TimeUnit.MILLISECONDS);
    if (!gotA) {
        return;
    }

    gotB = lockB.tryLock(100, TimeUnit.MILLISECONDS);
    if (!gotB) {
        return;
    }

    // 同时拿到两把锁后再处理共享状态
    updateSharedState();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    // 不要吞掉中断信号,具体是返回还是抛异常由业务决定。
} finally {
    if (gotB) {
        lockB.unlock();
    }
    if (gotA) {
        lockA.unlock();
    }
}

这段代码的好处是不会无限等下去。坏处也要看清:它只是把等待变成了失败返回,后面怎么重试、是否允许重试、有没有幂等键,都得由业务自己处理。否则死锁没了,频繁失败或活锁又来了。

破坏循环等待:给资源排一个稳定顺序,所有线程只能按这个顺序申请。后端业务里最常见的例子是批量更新数据库行时先按主键排序,再逐条更新。

死锁避免

死锁避免

死锁避免不直接拆四个条件,而是在资源分配前先问一句:这次分配出去之后,系统还找不找得到一条“大家都能陆续完成”的顺序?

教材里最典型的是银行家算法。它要求每个进程提前声明最大资源需求,系统每次准备分配资源前,都要做一次安全性检查:

如果分配后仍然存在一个安全序列,就允许分配。

如果分配后找不到安全序列,就先让申请方等待。

安全状态不会走到死锁;不安全状态也不是已经死锁,只是后面可能走进死锁。

银行家算法适合用来理解“安全状态”,但普通业务服务很少直接用它。原因并不玄乎:大多数程序很难提前说清最大资源需求,请求顺序也会随业务分支变化;每次分配前都做全局检查,成本还不低。

死锁检测

死锁检测

检测的思路换了一个方向:系统先正常运行,等线程或事务真的互相等住了,再去找等待环。

数据库很适合这么做。事务本来就有回滚边界,发现死锁后选一个事务回滚,另一个事务就能继续执行。应用侧要做的是识别这类错误,并决定是否重试整个事务。

Java 进程里也能做诊断。JDK 提供了 ThreadMXBean:

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

public class DeadlockDetector {
    public static void printDeadlocks() {
        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        long[] threadIds = bean.findDeadlockedThreads();

        if (threadIds == null || threadIds.length == 0) {
            System.out.println("No deadlock found");
            return;
        }

        ThreadInfo[] threadInfos = bean.getThreadInfo(threadIds, true, true);
        for (ThreadInfo threadInfo : threadInfos) {
            System.out.println(threadInfo);
        }
    }
}
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;

public class DeadlockDetector {
    public static void printDeadlocks() {
        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        long[] threadIds = bean.findDeadlockedThreads();

        if (threadIds == null || threadIds.length == 0) {
            System.out.println("No deadlock found");
            return;
        }

        ThreadInfo[] threadInfos = bean.getThreadInfo(threadIds, true, true);
        for (ThreadInfo threadInfo : threadInfos) {
            System.out.println(threadInfo);
        }
    }
}

findDeadlockedThreads() 可以检查对象监视器,也能覆盖 java.util.concurrent 里的 ownable synchronizer。它更适合放在诊断工具或临时排障脚本里,不适合高频塞进业务主流程;这类检查本身也会有开销。

findDeadlockedThreads()
java.util.concurrent

它的边界也要说清楚:它只能看到 JVM 内部可见的 monitor 和 ownable synchronizer。线程 A 拿着 Java 锁去等数据库行锁,线程 B 拿着数据库行锁又卡在另一个应用动作上,这种跨系统等待链,单靠 ThreadMXBean 看不完整,还得把线程栈、数据库锁视图和业务日志放在一起对。

ThreadMXBean

线上更常见的是直接打线程栈:

jcmd <pid> Thread.print -l
jstack -l <pid>
jcmd <pid> Thread.print -l
jstack -l <pid>

如果输出里出现 Found one Java-level deadlock、waiting to lock、which is held by,通常就可以顺着等待链反查到业务代码。这里建议带上 -l,因为很多项目里用的是 ReentrantLock、ReentrantReadWriteLock 这类 JUC 锁;少了 -l,ownable synchronizer 的信息可能不完整。

Found one Java-level deadlock
waiting to lock
which is held by
-l
ReentrantLock
ReentrantReadWriteLock
-l

本地复现时,JConsole、VisualVM 这类图形化工具也很好用。以 JConsole 为例,先找到 JDK 的 bin 目录并打开 jconsole。

bin
jconsole

jconsole

连接目标 Java 进程后,进入“线程”页面,点击“检测死锁”。

jconsole 检测死锁

如果目标进程里存在 Java 线程死锁,JConsole 会把相关线程单独列出来。

jconsole 检测到死锁

线上环境一般还是优先用 jcmd、jstack。它们可以通过 SSH 执行,输出也容易留档。JConsole 更适合本地复现、教学演示,或者测试环境里快速看线程状态。生产环境远程连 JConsole 要额外考虑权限、网络暴露和运行开销,很多团队会选择先导出线程栈,再离线分析。

jcmd
jstack

如果应用大量使用 Java 21+ 虚拟线程,还要多留一个心眼。虚拟线程不是长期绑定在某个 OS 线程上,传统 jstack 或 Thread.print 看到的信息可能不如平台线程直观。可以用下面的命令导出虚拟线程 dump:

jstack
Thread.print
jcmd <pid> Thread.dump_to_file -format=text thread-dump.txt
jcmd <pid> Thread.dump_to_file -format=json thread-dump.json
jcmd <pid> Thread.dump_to_file -format=text thread-dump.txt
jcmd <pid> Thread.dump_to_file -format=json thread-dump.json

虚拟线程 dump 和传统线程 dump 的字段并不完全一样;对象地址、锁、JNI 统计、堆统计等传统线程 dump 里常见的信息未必都会包含。排查时别只看一份 dump,业务日志、JFR、数据库和外部依赖状态都要一起看。

死锁恢复

死锁恢复

恢复比检测更棘手,因为系统得决定“牺牲谁”。

常见手段有 3 类:

终止所有参与死锁的执行单元。

一次终止一个,检测死锁是否解除。

抢占某些资源,回滚到可继续执行的状态。

数据库事务适合恢复,因为事务边界清楚,回滚以后可以重新执行。普通 Java 线程就麻烦得多:一个线程持锁时可能已经改了一半内存状态、写了一半文件、发了一半远程请求,直接杀掉通常不可取。Java 早就不推荐使用 Thread.stop(),也是这个原因。

Thread.stop()

应用代码更应该做的是避免把自己写进绝路:一旦卡住,只能靠杀进程恢复。

数据库里的死锁

数据库里的死锁

死锁在数据库里很常见,尤其是多事务更新多行数据时。

假设有一张订单表:

CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  status VARCHAR(32) NOT NULL
);
CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  status VARCHAR(32) NOT NULL
);

两个事务这样执行:

-- 事务 T1
BEGIN;
UPDATE orders SET status = 'PAID' WHERE id = 1;
UPDATE orders SET status = 'PAID' WHERE id = 2;
COMMIT;
-- 事务 T1
BEGIN;
UPDATE orders SET status = 'PAID' WHERE id = 1;
UPDATE orders SET status = 'PAID' WHERE id = 2;
COMMIT;
-- 事务 T2
BEGIN;
UPDATE orders SET status = 'CANCELLED' WHERE id = 2;
UPDATE orders SET status = 'CANCELLED' WHERE id = 1;
COMMIT;
-- 事务 T2
BEGIN;
UPDATE orders SET status = 'CANCELLED' WHERE id = 2;
UPDATE orders SET status = 'CANCELLED' WHERE id = 1;
COMMIT;

如果 T1 先锁住 id=1,T2 先锁住 id=2,后面就会互相等待。

id=1
id=2

数据库一般不会让这两个事务一直挂着。PostgreSQL 有 deadlock_timeout 参数,默认是 1s;事务等锁超过这个时间后,数据库才开始检查死锁,因为构造和扫描等待图也要成本。MySQL InnoDB 默认开启死锁检测,发现等待环后会回滚一个事务来解开局面,通常倾向选择修改行数更少的事务。

deadlock_timeout
1s

应用层要配合两件事。

第一,事务失败后要能重跑。PostgreSQL 的死锁错误码是 SQLSTATE 40P01;MySQL InnoDB 遇到死锁时会回滚整个事务。应用收到这类错误后,应该重新执行整个事务,而不是只补最后一条 SQL。

40P01

第二,加锁顺序要稳定。批量更新多行时,先按主键或唯一业务键排序,所有入口都按同一个顺序更新。这个习惯很普通,但能减少大量交叉等待。

重试前还要确认业务具备幂等能力。比较常见的做法是使用唯一请求号、业务流水号或状态机校验。否则数据库已经把死锁处理掉了,应用层却可能因为重试引入重复扣款、重复发货。

减少和排查数据库死锁时,可以先看这几类信息:

事务尽量短,别在事务里等用户输入、调用慢接口、处理大文件。

索引要正确,否则更新一行可能扫描并锁住更多记录。

少用不必要的 SELECT ... FOR UPDATE。

SELECT ... FOR UPDATE

MySQL 可以用 SHOW ENGINE INNODB STATUS 看最近一次 InnoDB 死锁信息;如果死锁很频繁,可以考虑开启 innodb_print_all_deadlocks 把所有死锁信息写入错误日志。

SHOW ENGINE INNODB STATUS
innodb_print_all_deadlocks

PostgreSQL 可以结合错误日志、pg_locks、pg_stat_activity 查阻塞关系。

pg_locks
pg_stat_activity

PostgreSQL 里可以先用下面这个查询看当前哪些会话正在等锁,以及它们被哪些 pid 阻塞:

SELECT
    a.pid,
    a.usename,
    a.state,
    a.wait_event_type,
    a.wait_event,
    pg_blocking_pids(a.pid) AS blocking_pids,
    a.query
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock';
SELECT
    a.pid,
    a.usename,
    a.state,
    a.wait_event_type,
    a.wait_event,
    pg_blocking_pids(a.pid) AS blocking_pids,
    a.query
FROM pg_stat_activity a
WHERE a.wait_event_type = 'Lock';

这条 SQL 只能看当前等待关系。分析一次已经发生的死锁,还要回到数据库错误日志里的死锁详情,找到应用日志里的 traceId/requestId,再还原两个事务各自执行 SQL 的顺序。

数据库死锁不是“数据库坏了”。更多时候,它是在提醒你:应用层访问同一批资源的顺序不够稳定。

死锁、饥饿和活锁有什么区别?

死锁、饥饿和活锁有什么区别?

这几个概念都会表现成“程序没按预期往前走”,但现场差别很大。

问题表现典型原因死锁多个执行单元互相等待,形成闭环反向加锁、事务交叉更新饥饿某个执行单元长期拿不到资源,但系统整体仍在推进优先级过低、非公平锁竞争活锁执行单元一直在动作,但总是互相让路,没人完成失败后同时重试、退避策略太同步

问题表现典型原因

问题

表现

典型原因

死锁多个执行单元互相等待,形成闭环反向加锁、事务交叉更新

死锁

多个执行单元互相等待,形成闭环

反向加锁、事务交叉更新

饥饿某个执行单元长期拿不到资源,但系统整体仍在推进优先级过低、非公平锁竞争

饥饿

某个执行单元长期拿不到资源,但系统整体仍在推进

优先级过低、非公平锁竞争

活锁执行单元一直在动作,但总是互相让路,没人完成失败后同时重试、退避策略太同步

活锁

执行单元一直在动作,但总是互相让路,没人完成

失败后同时重试、退避策略太同步

死锁、饥饿和活锁对比图:死锁表现为等待环,饥饿表现为长期拿不到资源,活锁表现为持续动作但没有进展

可以用三个画面记:死锁像两辆车在窄桥中间顶住,谁都不倒车;饥饿像队伍里一直有人插队,队尾那个人始终轮不到;活锁像两个人迎面走来,每次都同时往同一边让,结果一直错不开。

排查时别只看“卡住”这一个现象。死锁要找等待环,饥饿要看调度或锁竞争是否长期偏向,活锁要看重试逻辑是不是把所有参与者绑在同一个节奏上。

哪些卡住不一定是死锁?

哪些卡住不一定是死锁?

线上有不少“卡住”看起来像死锁,最后查下来并没有等待环。常见的有这些:

线程池耗尽:所有工作线程都在跑慢任务,新请求只能排队。

连接池耗尽:线程都在等数据库连接,但没有形成互相等待的闭环。

慢 SQL:线程停在 JDBC 调用里,数据库还在执行。

外部服务超时:线程卡在 HTTP/RPC 调用上,等待对方响应。

GC 或 safepoint 停顿:所有 Java 线程短时间暂停。

饥饿:某些线程长期抢不到资源,但系统整体仍在推进。

判断死锁,关键证据不是“慢”或“卡”,而是能不能找到稳定的等待环。

线上怎么排查 Java 死锁?

线上怎么排查 Java 死锁?

如果线上接口卡住,先别急着重启。只要进程还活着,就尽量先留下线程栈和现场指标。

1. 先确认是不是“全挂”

1. 先确认是不是“全挂”

先看现象是不是集中在“线程不释放资源”上:

某些接口一直超时,但进程还活着。

CPU 不高,线程数、连接数、请求队列持续堆积。

线程池活跃线程长期占满,队列不下降。

数据库连接池连接被占住不释放。

这些现象只能说明服务在等,不足以证明死锁。慢 SQL、外部依赖卡住、线程池配置不合理,也会制造类似现场。

2. 连续抓 2 到 3 次线程栈

2. 连续抓 2 到 3 次线程栈

线程栈建议连续抓几次,间隔 10 到 30 秒。只抓一次,很容易把瞬时阻塞误判成死锁:

jcmd <pid> Thread.print -l > thread-1.log
sleep 10
jcmd <pid> Thread.print -l > thread-2.log
sleep 10
jcmd <pid> Thread.print -l > thread-3.log
jcmd <pid> Thread.print -l > thread-1.log
sleep 10
jcmd <pid> Thread.print -l > thread-2.log
sleep 10
jcmd <pid> Thread.print -l > thread-3.log

多次栈的价值在于对比。如果三次都停在同一把锁、同一个连接池、同一段业务代码上,判断会比单次栈可靠得多。

Java 能识别的线程死锁,线程栈通常会直接打印死锁信息。没有直接打印时,也可以观察大量线程是不是长期停在同一批锁、同一个连接池获取逻辑或同一段业务方法上。

3. 顺着 waiting to lock 找持有者

3. 顺着 waiting to lock 找持有者

waiting to lock

读线程栈时,先抓这几类信息:

线程名和线程状态,例如 BLOCKED、WAITING。

BLOCKED
WAITING

正在等待的锁对象。

当前已经持有的锁。

栈顶业务方法。

parking to wait for 对应的 Lock 或条件队列。

parking to wait for
Lock

如果能看到 A 等 B 持有的锁,B 又等 A 持有的锁,等待环基本就浮出来了。

synchronized 相关死锁通常会看到 waiting to lock <...> 和 locked <...>;ReentrantLock 这类 JUC 锁通常会看到 parking to wait for <...>,并且需要关注 Locked ownable synchronizers。因此抓栈时建议带上 -l。

synchronized
waiting to lock <...>
locked <...>
ReentrantLock
parking to wait for <...>
Locked ownable synchronizers
-l

4. 回到代码看锁顺序

4. 回到代码看锁顺序

定位到栈里的业务方法后,再回代码里查这些点:

是否存在多个入口反向获取同一组锁。

是否在持锁期间调用外部服务或数据库。

是否锁住了范围过大的对象,例如全局 Map、单例对象、Class 对象。

Map
Class

是否混用了 Java 锁和数据库事务锁,导致链路更长。

是否用了非公平锁、无限等待、无超时获取。

很多死锁不是某一行代码单独造成的,而是两个调用链组合以后才出现。单看 A 链路、B 链路都说得过去,放在一起才绕成环。

写代码时怎么减少死锁?

写代码时怎么减少死锁?

下面几条更像代码评审时的检查项,尤其适合多锁、多事务、多资源更新的场景。

固定加锁顺序

固定加锁顺序

同时操作多个用户、订单或账户时,先排序再加锁。下面这个转账例子假设账户 ID 全局唯一,并且创建后不再变化。

public void transfer(Account from, Account to, long amount) {
    if (from == to) {
        return;
    }

    Account first;
    Account second;
    int compare = Long.compare(from.id(), to.id());
    if (compare < 0) {
        first = from;
        second = to;
    } else if (compare > 0) {
        first = to;
        second = from;
    } else {
        throw new IllegalStateException("Account id must be unique");
    }

    synchronized (first) {
        synchronized (second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}
public void transfer(Account from, Account to, long amount) {
    if (from == to) {
        return;
    }

    Account first;
    Account second;
    int compare = Long.compare(from.id(), to.id());
    if (compare < 0) {
        first = from;
        second = to;
    } else if (compare > 0) {
        first = to;
        second = from;
    } else {
        throw new IllegalStateException("Account id must be unique");
    }

    synchronized (first) {
        synchronized (second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}

这个例子里,不管是 A 给 B 转账,还是 B 给 A 转账,都会先锁 ID 小的账户,再锁 ID 大的账户。顺序固定后,循环等待就少了一条边。

避免持锁做慢操作

避免持锁做慢操作

持锁期间尽量别做下面这些事:

RPC 或 HTTP 请求。

慢 SQL 或大事务。

文件上传下载。

等待消息队列返回。

调用不清楚内部会不会加锁的第三方代码。

锁应该保护共享状态,而不是把整段业务流程都包进去。能提前算好的数据放在锁外算,锁内只留下最短的状态切换。

使用超时和失败策略

使用超时和失败策略

内置锁 synchronized 没有超时获取能力。业务允许失败或重试时,可以考虑 ReentrantLock.tryLock():

synchronized
ReentrantLock.tryLock()
try {
    if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
        throw new IllegalStateException("系统繁忙,请稍后重试");
    }

    try {
        updateSharedState();
    } finally {
        lock.unlock();
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new IllegalStateException("获取锁时被中断", e);
}
try {
    if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
        throw new IllegalStateException("系统繁忙,请稍后重试");
    }

    try {
        updateSharedState();
    } finally {
        lock.unlock();
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new IllegalStateException("获取锁时被中断", e);
}

超时只能限制等待时间,不能自动保证业务正确。拿不到锁以后要不要重试、最多重试几次、会不会重复提交、是否需要幂等键,这些问题都要提前设计好。否则,超时只是让错误更快暴露出来。

少混用多套锁体系

少混用多套锁体系

比较难排的是跨层死锁,比如:

Java 线程持有 JVM 锁,同时等待数据库行锁。

另一个请求持有数据库行锁,回调到应用逻辑里等待 JVM 锁。

这种等待链会同时出现在 JVM 线程栈和数据库日志里,单看一边都不完整。能把锁控制在同一层,就别让等待关系穿透太多组件;必须跨层时,至少要有超时、日志和统一顺序。

给锁命名,给线程命名

给锁命名,给线程命名

线上排查时,最怕看到的就是“Thread-17 等待 Object@4afcd809”这种信息。线程池自定义线程名、锁对象绑定业务 ID、日志里打印关键资源顺序,平时多写几行,出问题时能省很多时间。

比如线程名里带上业务池:

private static final AtomicInteger THREAD_INDEX = new AtomicInteger();

ThreadFactory factory = runnable -> {
    Thread thread = new Thread(runnable);
    thread.setName("order-worker-" + THREAD_INDEX.incrementAndGet());
    return thread;
};
private static final AtomicInteger THREAD_INDEX = new AtomicInteger();

ThreadFactory factory = runnable -> {
    Thread thread = new Thread(runnable);
    thread.setName("order-worker-" + THREAD_INDEX.incrementAndGet());
    return thread;
};

AtomicInteger 来自 java.util.concurrent.atomic。自己编号比直接依赖线程 ID 更稳,也兼容 Java 8/11 这类仍然很常见的运行环境。

AtomicInteger
java.util.concurrent.atomic

命名本身不能防死锁,但能让你更快知道是哪一类业务线程卡住了。

面试怎么回答死锁?

面试怎么回答死锁?

面试里问死锁,不用一上来背很长的定义。可以先用一个两锁互等的例子把场景讲清楚:

死锁是多个线程或进程互相等待对方释放资源,导致所有参与者都无法继续执行的状态。典型例子是线程 A 拿着锁 1 等锁 2,线程 B 拿着锁 2 等锁 1。

死锁是多个线程或进程互相等待对方释放资源,导致所有参与者都无法继续执行的状态。典型例子是线程 A 拿着锁 1 等锁 2,线程 B 拿着锁 2 等锁 1。

然后补四个必要条件:

死锁要同时满足互斥、占有并等待、非抢占、循环等待这 4 个条件。只要能破坏其中一个条件,就可以从结构上避免死锁。

死锁要同时满足互斥、占有并等待、非抢占、循环等待这 4 个条件。只要能破坏其中一个条件,就可以从结构上避免死锁。

再说处理方法:

工程里最常用的是预防,比如统一加锁顺序、缩小锁范围、一次申请完整资源、使用超时锁。操作系统教材还会讲银行家算法,它属于死锁避免,需要提前知道最大资源需求。数据库一般采用检测和恢复,发现等待环后回滚一个事务,应用层再重试。

工程里最常用的是预防,比如统一加锁顺序、缩小锁范围、一次申请完整资源、使用超时锁。操作系统教材还会讲银行家算法,它属于死锁避免,需要提前知道最大资源需求。数据库一般采用检测和恢复,发现等待环后回滚一个事务,应用层再重试。

如果继续追问 Java 排查:

Java 可以用 jcmd <pid> Thread.print -l 或 jstack -l <pid> 打线程栈,也可以用 ThreadMXBean.findDeadlockedThreads() 在程序里做诊断。排查时看线程状态、正在等待的锁、已经持有的锁,再回到代码里确认是否存在反向加锁或持锁慢操作。

Java 可以用 jcmd <pid> Thread.print -l 或 jstack -l <pid> 打线程栈,也可以用 ThreadMXBean.findDeadlockedThreads() 在程序里做诊断。排查时看线程状态、正在等待的锁、已经持有的锁,再回到代码里确认是否存在反向加锁或持锁慢操作。

jcmd <pid> Thread.print -l
jstack -l <pid>
ThreadMXBean.findDeadlockedThreads()

这样答能覆盖概念、条件、方案和排查,比只背四个条件更完整。

总结

总结

死锁最该记住的不是术语,而是等待关系。

只要代码里存在“已经持有一部分资源,又继续等待另一部分资源”的路径,就要多问一句:这些等待关系有没有可能绕成环?如果有,要么固定顺序,要么缩短持有时间,要么允许超时撤退,要么交给数据库事务这类能检测、能回滚的系统处理。

有些地方没法保证永远不死锁,比如复杂数据库事务、高并发批量更新、跨服务资源编排。更现实的目标是把概率降下来,把现场留住,把失败做成可以安全重试,而不是一路拖到只能重启进程。

参考资料

参考资料

JavaGuide:操作系统常见面试题总结(上)

JavaGuide:操作系统常见面试题总结(上)

用个通俗的例子讲一讲死锁 - 知乎专栏

用个通俗的例子讲一讲死锁 - 知乎专栏

Yale CS:Deadlock

Yale CS:Deadlock

University of Wisconsin CS 537 Notes:Deadlock

University of Wisconsin CS 537 Notes:Deadlock

Oracle Java Tutorials:Deadlock

Oracle Java Tutorials:Deadlock

Oracle JDK API:ReentrantLock

Oracle JDK API:ReentrantLock

Oracle JDK API:ThreadMXBean

Oracle JDK API:ThreadMXBean

Oracle Troubleshooting Guide:The jstack Utility

Oracle Troubleshooting Guide:The jstack Utility

Oracle Java Documentation:Virtual Threads

Oracle Java Documentation:Virtual Threads

Linux Kernel Documentation:Runtime locking correctness validator

Linux Kernel Documentation:Runtime locking correctness validator

PostgreSQL Documentation:Lock Management

PostgreSQL Documentation:Lock Management

PostgreSQL Documentation:Error Codes

PostgreSQL Documentation:Error Codes

PostgreSQL Documentation:pg_locks

PostgreSQL Documentation:pg_locks

MySQL 8.4 Reference Manual:Deadlock Detection

MySQL 8.4 Reference Manual:Deadlock Detection

MySQL 8.4 Reference Manual:InnoDB Error Handling

MySQL 8.4 Reference Manual:InnoDB Error Handling