什么是死锁?一文讲透多线程里最易卡死的那件事

编程狮(w3cschool.cn) 2026-08-13 16:36:36 浏览数 (57)
反馈

死锁,就是两个或以上的线程各自占着对方想要的锁、又互相等待对方先放手,结果谁都走不下去,程序卡死不动。你写多线程程序时,最怕的就是本地跑得好好的,一上量就偶尔卡住,日志停在某一行再也不动,重启又能跑——这种「薛定谔的卡死」十有八九是死锁。本文基于 Java / Python 多线程实测,讲清死锁到底是什么、它的四个必要条件、一个最小复现例子、怎么避免,以及卡住了怎么排查。看完你不仅能认出死锁,还能在写代码时把它挡在门外。先给个定心丸:死锁虽然吓人,但它是「可以预防、可以排查」的,不是玄学。今天这篇文章,编程狮就把这块讲透。

一、先搞清楚:死锁到底是什么

Java 教程 的多线程章节里你会学到,锁是为了保护共享资源、防止多个线程同时改写导致数据错乱。但锁用不好,就会反噬自己。

死锁的本质,是循环等待:A 线程拿着锁 1、等锁 2;B 线程拿着锁 2、等锁 1,两人互相等,谁都释放不了。计算机科学给死锁总结了四个必要条件,四个同时成立才会发生:

  1. 互斥:资源同一时刻只能被一个线程占用;
  2. 占有且等待:线程拿着一个锁,还去等另一个锁;
  3. 不可抢占:锁不能被强制从持有者手里抢走;
  4. 循环等待:多个线程形成「你等我、我等你」的环。

只要打破其中任意一条,死锁就建不起来。后面讲避免,就是在讲怎么拆这四条。打个比方:两家餐厅各只有一把刀和一把叉,A 桌拿走了刀等叉、B 桌拿走了叉等刀,谁都没法吃饭——这就是现实里的循环等待。理解了这个画面,后面所有「避免办法」都是在想办法不让这种僵局出现。

二、一个最小复现例子

下面用 Java 写一段最经典的死锁:两个线程、两把锁,但加锁顺序相反。

Object lockA = new Object();
Object lockB = new Object();


// 线程 1:先锁 A,再锁 B
new Thread(() -> {
    synchronized (lockA) {
        synchronized (lockB) { /* 干活 */ }
    }
}).start();


// 线程 2:先锁 B,再锁 A
new Thread(() -> {
    synchronized (lockB) {
        synchronized (lockA) { /* 干活 */ }
    }
}).start();

上面这段里,线程 1 拿到 lockA 后想拿 lockB,可 lockB 正被线程 2 占着;线程 2 拿到 lockB 后想拿 lockA,又被线程 1 占着——两人僵住,程序卡死。注意死锁之所以发生,根子在于「两个线程加锁顺序相反」:只要顺序一致,环就断不了。关于多线程怎么搭起来,Java 多线程写法 讲得更细,可以对照看。

三、怎么避免死锁

既然死锁要四个条件同时成立,避免的思路就是打破其中一条,最常用的是「破坏循环等待」:

  • 固定加锁顺序:所有线程都按同一顺序申请锁(永远先 A 后 B),环就断了。把上面例子里线程 2 也改成先锁 A,死锁立刻消失。
  • 一次性申请所有资源:进临界区前把需要的锁全拿到,拿不全就全不放,避免「占着等」。
  • 加超时:用 tryLock(timeout),等不到就放弃并回退,给系统一个「松手」的机会。
  • 缩小锁范围:只在真正改共享数据时才加锁,锁住的时间越短,撞车概率越低。

Python3 教程 里用 threading 模块时同理:多个 Lock 也要约定统一获取顺序,否则 acquire 互相等待一样会卡死。把「统一顺序」落成代码,最直观的就是给锁编个号,所有地方都按编号从小到大申请:

// 约定永远先申请编号小的锁
synchronized (lockA) {       // lockA 编号小,先拿
    synchronized (lockB) {   // lockB 编号大,后拿
        /* 干活 */
    }
}

只要全项目都遵守这条规则,循环等待就无从形成,死锁在架构层面就被掐灭了。

四、卡住了怎么排查

真遇上死锁,别靠猜,按步骤来:

  1. 复现并抓线程栈:Java 用 jstack <pid> 导出线程 dump;Python 可用 faulthandler 打印栈。
  2. 找「BLOCKED / waiting to lock」:线程栈里会出现互相等待同一把锁的环,一眼就能定位。
  3. 看卡在哪一行:dump 会告诉你每个线程停在哪个方法、等哪把锁,对照代码就能找到加锁顺序不一致的源头。
  4. 加日志验证:在加锁前后打印线程名和锁名,复现时日志会停在某个锁之后不再前进。

为什么高并发场景里死锁更常见,高并发与 Java 从选型角度解释了线程密集时的风险来源,值得结合看。补充一点:生产环境抓 dump 要趁「卡住的那一刻」,程序一旦被重启,现场就没了。很多团队会配一个监控脚本,发现某线程连续几分钟处于 BLOCKED 就自动 jstack 存档,这比事后靠记忆复现靠谱得多。

五、两个常见误解

  • 误解一:单线程不会有死锁。对,死锁的前提就是多个执行流互相等,单线程不存在「互等」。
  • 误解二:加锁越多越安全。恰恰相反,锁越多、嵌套越深,越容易凑齐循环等待的四个条件。能不用锁就不用,能用局部变量就别碰共享。

死锁与饥饿、活锁的区别

死锁常和另外两个概念混为一谈,分清它们有助于排查。饥饿是指某个线程一直拿不到资源、永远在排队,但不一定互相等待,可能是优先级或调度不公平导致的;活锁是指线程们都在运行、都在互相礼让,却始终没人能往前推进,像两个人在走廊里不停给对方让路。只有死锁是全员卡死、完全停摆。排查时如果线程栈显示大家都在 waiting to lock 且形成环,那是死锁;如果只是某个线程长时间拿不到锁,更可能是饥饿;如果日志显示线程频繁重试又放弃,则偏向活锁。死锁、饥饿、活锁三者现象不同,药方也不同,把现象对号入座才能用对方法。

总结

死锁是多个线程互相占着对方要的锁、循环等待导致全员卡死。要点带走:它必须满足互斥、占有且等待、不可抢占、循环等待四个条件;最实用的避免办法是统一加锁顺序、加超时、缩小锁范围;排查靠线程栈找「waiting to lock」的环。写多线程时把加锁顺序想清楚,死锁就进不了门。下一步,把这套认知用到实际项目的并发控制里,你的程序会更稳。

延伸学习

想真正吃透并发,可以按这个顺序来:

  1. 先过一遍 Java多线程讲解,把线程、锁、同步练熟;
  2. 想看 Python 侧的并发实战,Python 并发编程实战 是边学边写的形式,适合巩固。

常见问题

Q:死锁和「活锁」是一回事吗?
A:不是。死锁是全员卡住不动;活锁是线程都在跑、都在让,却始终推进不了任务,像两个人不停互相礼让谁都过不去。

Q:加了 synchronized 就一定会死锁吗?
A:不会。死锁要多个锁形成循环等待才发生。只用一个锁、或所有地方按同一顺序加锁,通常不会死锁。

Q:线上程序怀疑死锁,但不敢随便重启怎么办?
A:先 jstack 抓线程栈定位到互等的锁和代码行,确认是死锁后再决定能否热修复;盲目重启会丢掉现场,下次还复现。

1 人点赞