我记得最清楚的一次线上故障,不是 CPU 打满,也不是内存溢出,而是监控大盘上一片安静——CPU 12%,内存平稳,GC 日志干干净净,可业务接口全线超时,线程池队列堆到上限,最后连健康检查都返回不了。值班的同事第一反应是下游依赖挂了,挨个去问,全是好的。真正的原因藏在一次线程栈 dump 里:两个线程,一个持着 A 锁在等 B 锁,另一个持着 B 锁在等 A 锁,谁也不肯松手,就这么僵在那儿,一直到进程被重启。这就是死锁(Deadlock),多线程编程里最“安静”的一类故障——它不烧 CPU,不涨内存,只是让参与其中的线程无限期地互相等待,然后把整条调用链路一起拖住。
这篇内容想讲清楚三件事:死锁在真实系统里长什么样、怎么用几十行代码稳定复现它、以及怎么在设计阶段就把它的发生条件拆掉。顺带会把排查死锁的完整链路走一遍,包括怎么看线程栈、怎么在代码里做常态化的死锁探测,还会聊到数据库死锁和分布式锁死锁——这两类经常被和多线程死锁混为一谈,但成因和处理方式差别不小。适合已经写过并发代码、但还没被死锁真正咬过的人,也适合被咬过但当时只知道“重启一下”的人。
先把概念框住,不然后面全是空谈。死锁指的是一组线程(或进程)各自持有资源、又都在等待组内其他成员释放资源,形成一个闭环的等待关系,任何一方都无法推进。注意这里是“一组”,单个线程自己等自己不算死锁,那叫重复加锁或自旋,属于另一类问题,后面会提到。死锁的核心特征是不可自愈——不会因为时间流逝而缓解,只能靠外力(超时、中断、重启)打破。
1. 线程栈里那两行 waiting to lock:死锁长什么样
1.1 一次典型的僵死现场还原
回到开头那个场景,把当时的情况掰开看。系统是一个典型的生产者-消费者模型:一个线程从队列取任务,加锁更新库存,然后调用下游;另一个线程做定时对账,加锁更新同一批库存记录,顺序反过来。两把锁分别是“库存锁”和“对账锁”,单看代码谁都没错,问题出在加锁顺序不一致。A 线程先拿库存锁再拿对账锁,B 线程先拿对账锁再拿库存锁,只要两边几乎同时进入临界区,就必然有一对线程互相卡住。
这类故障最迷惑人的地方在于表象和根因完全对不上。你看到的是“某个接口超时”,实际是“两个线程互锁”;你怀疑的是数据库慢查询,实际数据库连接池里全是健康的;你去看 CPU,它甚至比平时还低——因为真正的计算线程全被阻塞了,没活干。所以排查死锁时,第一件要建立的直觉是:CPU 不高、内存不涨、GC 正常,但吞吐量突然归零,这组特征本身就高度指向阻塞类问题,而死锁是其中最常见的一种。
另外要区分一个常见误判:不是所有“卡住”都是死锁。线程池队列满、下游响应慢、连接池借不到连接、大对象序列化、日志同步刷盘,这些都会让接口变慢,但它们通常表现为“慢”而不是“停”。死锁的典型特征是突变——前一个时间窗口还正常,下一个窗口直接掉到接近零,且持续时间和你重启的时间点严丝合缝。
1.2 死锁、活锁、饥饿、锁死,别混为一谈
这四个词经常被互换使用,但在排查时它们的处理方式完全不同,分清楚能省下大量时间。
| 状态 | 线程在做什么 | CPU 表现 | 能否自愈 | 典型成因 |
|---|---|---|---|---|
| 死锁 | 阻塞,互相等待 | 低 | 不能 | 循环等待锁 |
| 活锁 | 一直在重试但始终失败 | 高 | 可能 | 重试策略冲突、冲突后回退再一起重试 |
| 饥饿 | 一直抢不到资源 | 不定 | 可能 | 优先级不均、锁被长期占用 |
| 锁死(livelock 的极端) | 无限重试且必然冲突 | 高 | 不能 | 双方同时退让再同时抢占 |
活锁比死锁更隐蔽。举个直观的例子:两个线程都想拿两把锁,都采用“拿不到就释放已有锁、随机退避后重试”的策略。如果退避时间设成同一个固定值,它们会永远在同一时刻一起重试、一起冲突、一起释放,形成一种“看起来很忙但永远完不成”的状态。线程栈里能看到它们一直在跑,CPU 也不低,很容易被误判成性能问题,实际上是策略设计出了岔子。解决办法通常是把固定退避改成带抖动的随机退避,打破同步节奏。
饥饿则更常见于读写场景:读多写少时,如果读锁不断涌入,写线程可能长期拿不到写锁。某些实现里这是允许的(读优先),但如果写操作是业务关键路径,就会表现为“写接口偶发超时,读接口一直很快”。这类问题不需要死锁检测工具,靠监控读写锁的等待时长就能发现。
2. 四个必要条件不是背出来的,是四条能拆掉的腿
教科书上那条“互斥、请求与保持、不可剥夺、循环等待”的结论,很多人能背但不会用。它的实际价值在于:这四个条件必须同时成立才会死锁,你只要拆掉任意一条,死锁就不可能出现。所以它不是考点,是一张排查清单——遇到一个疑似死锁的设计,挨个问“这条我能不能拆”。
2.1 互斥:能从根上绕开就别用锁
互斥指的是资源同一时刻只能被一个线程持有。这条最难拆,因为很多资源的本质就是排他的,比如写文件、更新同一条数据库记录。但“难拆”不等于“不能绕”,常见的绕法有这么几种。
第一种是把共享状态改成线程私有。统计计数用LongAdder或者线程本地累加、最后汇总,就比一把大锁保护一个long变量要划算得多。第二种是用不可变对象,状态一变就产生新对象,读线程永远读到完整的旧版本,天然无锁——函数式编程里这套思路用得很多。第三种是用消息传递替代共享内存,把并发问题转换成队列问题,Actor 模型和多线程消费队列都属于这一类,每个线程只管自己的邮箱,不碰别人的数据。
第四种是 CAS 类的原子操作。它本质上是用硬件指令保证单个变量的读-改-写原子性,没有锁的阻塞语义,但要注意它只解决“单变量原子性”,一旦涉及多个变量的不变式,还是得回到锁上。我见过不少代码为了“无锁”强行把本该一起更新的两个字段拆成两次 CAS,结果出现中间态,问题比死锁还难查。
2.2 请求与保持:要么一次拿全,要么一个都不拿
“请求与保持”指的是线程已经持有一个资源,又去申请另一个资源,在等待期间不释放已持有的。这条最容易拆,也最符合直觉:不要“拿一个再要一个”,改成“一次性申请全部”。
具体做法是在进入临界区之前,把所有需要的锁按顺序全部申请下来,申请失败就把已经拿到的全部释放,退回去重试。这在资源申请成本低的场景很有效,比如内存里的几把对象锁。但如果其中一把是数据库连接或者网络资源,一次性申请的成本就太高了,这时候更合适的是“全部释放后重试”策略,典型代表就是数据库事务遇到死锁后整体回滚重来。
这里有个很实际的坑:一次性申请看似简单,但如果申请清单是动态计算出来的(比如按业务 ID 批量处理),很容易漏算。我的习惯是让申请清单来自一个统一的方法,而不是在各个调用点手工拼——清单逻辑集中在一处,加锁顺序才能保证一致。
2.3 不可剥夺:给等待加上一个硬性上限
“不可剥夺”指的是资源只能由持有者主动释放,别人抢不走。这条在 Java 的synchronized上完全拆不掉——它没有超时机制,一旦进入阻塞就只能等。但换成ReentrantLock的tryLock(timeout, unit),这条就拆掉了:等不到就放弃,释放自己持有的,走降级路径。
这是我认为最值得推广的一条工程实践。凡是跨模块、跨服务边界、可能引入外部依赖的锁,一律用带超时的版本,超时时间按业务 P99 的 2 到 3 倍设置,宁可让这一次请求失败或者走兜底逻辑,也不要让它无限期挂着把线程池吃干净。带超时的锁虽然不能“预防死锁”,但能把“无限期阻塞”降级成“有限失败”,对整体可用性的意义非常大。
要注意tryLock的写法很容易出错,后面第 6 节会单独讲。
2.4 循环等待:给所有锁排一个全局序号
这是四条里最容易在工程上落地的一条,也是我处理业务代码死锁时的第一选择。核心思路很简单:给每一把锁分配一个全局唯一的编号,任何线程都必须按编号从小到大的顺序申请锁。
拿前面的库存-对账场景举例,假设库存锁编号 10、对账锁编号 20。那么无论哪个线程,都必须先拿库存锁再拿对账锁。这样一来,原本的 AB / BA 环就被压成了单向的 A→B,循环等待在数学上就不可能成立。
难点在于“全局编号”怎么维护。我的做法是建一个常量类,把所有跨模块共享的锁对象集中声明并注释编号,禁止在业务类里随手new Object()当锁用。听起来有点笨,但真的能挡住绝大部分顺序不一致的问题。更彻底一点,可以用“锁层级”注解配合静态检查,在编译期就拦住违反顺序的代码——不过对小团队来说,一个集中声明加代码评审就够了。
3. 用二十行代码把死锁稳定复现出来
理论讲完,得能复现。不能稳定复现的问题,你永远学不会排查。下面这两段代码是我自己练手时用的最小样例,跑起来几秒内必现死锁,非常适合拿来练习读线程栈。
3.1 Java 版本:锁顺序反转
public class DeadlockDemo { private static final Object LOCK_A = new Object(); private static final Object LOCK_B = new Object(); public static void main(String[] args) { Thread t1 = new Thread(() -> { synchronized (LOCK_A) { sleepQuietly(100); synchronized (LOCK_B) { System.out.println("t1 done"); } } }, "worker-1"); Thread t2 = new Thread(() -> { synchronized (LOCK_B) { sleepQuietly(100); synchronized (LOCK_A) { System.out.println("t2 done"); } } }, "worker-2"); t1.start(); t2.start(); } private static void sleepQuietly(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }那个sleep(100)是关键,不是可有可无的。它的作用是制造一个“窗口期”:让 t1 拿到 A 之后先睡一会,确保 t2 有足够时间拿到 B,两边再同时去抢对方手里的锁。如果没有这个延迟,很可能 t1 一口气把 A、B 都拿完了,程序正常结束,你却以为“死锁复现不了”。
这个细节值得多说一句。很多人在本地复现并发问题时反复失败,原因就是在自己机器上 CPU 太快、线程调度太顺,竞争窗口被压到几乎为零。我的做法是人为放大窗口:加延迟、加循环、把线程数从 2 提到 20。并发问题的复现,本质上是把概率事件变成必然事件,手段就是扩大竞争窗口。
3.2 Python 版本与几种常见变体
import threading import time lock_a = threading.Lock() lock_b = threading.Lock() def worker_first(): with lock_a: time.sleep(0.1) with lock_b: print("worker_first done") def worker_second(): with lock_b: time.sleep(0.1) with lock_a: print("worker_second done") t1 = threading.Thread(target=worker_first, name="worker-first") t2 = threading.Thread(target=worker_second, name="worker-second") t1.start() t2.start() t1.join() t2.join()除了最经典的“两把锁顺序反转”,还有几种变体值得单独练一遍,因为它们在实际项目里出现频率更高,也更难一眼看出来。
第一种是嵌套锁被抽象成了方法调用。比如transfer(from, to, amount)里先锁 from 再锁 to,两个线程同时做 A→B 和 B→A 的转账,瞬间死锁。这个场景的经典修法是给账户 ID 排序,永远先锁小 ID。第二种是锁在父类和子类里各拿一次,子类方法调父类方法时重复加锁,如果用的是不可重入的实现(比如某些语言的普通互斥量),直接自锁。第三种是回调里加锁,A 持锁调用回调,回调里又要拿 A 锁,重入锁能扛住,非重入锁直接卡死,而且线程栈上看不出“两个线程互等”,只有一个线程孤零零地在等自己——这种更容易被误判成普通卡顿。
4. 定位死锁的完整链路:从告警到那两行栈帧
4.1 先分清是卡住还是算得慢
拿到告警后不要急着 dump,先做三个观察,能省掉一半时间。
第一个观察是看曲线的形状。突变的断崖式下跌,多半是阻塞;缓慢的爬升式劣化,多半是资源泄漏或者慢查询堆积。第二个观察是看 CPU 和线程数的关系。线程数很多但 CPU 很低,是典型的阻塞特征;线程数不多但 CPU 打满,那是真的在算。第三个观察是看能不能自愈。重启后立刻恢复、过一段时间又复发,说明是代码里的确定性缺陷(死锁属于这类);重启后恢复但几小时才复发,可能是资源累积。
这三个观察做完,如果高度指向阻塞,就可以进入下一步抓线程栈了。
4.2 抓线程栈,以及怎么读它
Java 环境下最直接的方式是jstack,把进程号带上:
jstack -l 12345 > /tmp/stack-$(date +%s).txt-l参数很重要,它会额外输出锁的附加信息(比如ReentrantLock的持有者)。抓到之后先别急着通读,直接搜关键字:
grep -A 30 "Found one Java-level deadlock" /tmp/stack-*.txtJVM 自己在 dump 时会做一次死锁检测,如果发现了,会直接打印一段“Found one Java-level deadlock”,里面清楚写着哪个线程持有什么、在等什么。这是最省事的路径。
如果没搜到,说明可能不是synchronized层面的死锁(比如用的是Lock且 JVM 没识别出来,或者是线程池/信号量层面的等待),这时候就要手工读栈了。读法是这样:先搜BLOCKED,找到所有阻塞在对象监视器上的线程,然后看每个线程下面的两行:
java.lang.Thread.State: BLOCKED (on object monitor) at com.example.Demo.lambda$main$1(Demo.java:20) - waiting to lock <0x000000076ab5b8f8> (a java.lang.Object) - locked <0x000000076ab5b8e8> (a java.lang.Object)waiting to lock后面那个十六进制地址是它在等的锁,locked后面那个是它已经持有的锁。把这两行做成一张“线程 → 持有 → 等待”的表,然后在表里找环。有环就是死锁,没环但有人一直在等,那就是有人持锁不放(可能是在做耗时操作),属于锁粒度过大的问题。
Python 环境稍麻烦一些,faulthandler可以定时 dump 所有线程栈:
import faulthandler faulthandler.dump_traceback_later(30, repeat=True)或者用外部工具在进程外抓,避免侵入代码。不管用哪种方式,读法是一样的:找到所有停在acquire上的线程,看它们各自被谁挡着。
4.3 把检测做成常态,而不是等出事再抓
我现在的习惯是在关键服务里内置一个死锁探测线程,每隔一段时间主动查一次,发现了就打日志、发告警,而不是等接口全挂了才去 dump。Java 里借助ThreadMXBean几行就能做:
ThreadMXBean bean = ManagementFactory.getThreadMXBean(); ScheduledExecutorService probe = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "deadlock-probe"); t.setDaemon(true); return t; }); probe.scheduleWithFixedDelay(() -> { long[] ids = bean.findDeadlockedThreads(); if (ids != null && ids.length > 0) { ThreadInfo[] infos = bean.getThreadInfo(ids, true, true); for (ThreadInfo info : infos) { System.out.println(info.getThreadName() + " -> " + info.getLockName()); } } }, 30, 30, TimeUnit.SECONDS);这里有个细节:探测线程一定要设成守护线程,否则它会阻止 JVM 正常退出。另外采样间隔不要太短,30 到 60 秒足够,太频繁会有轻微的锁竞争开销。这个方法只能检测对象监视器层面的死锁,Lock接口和信号量不在检测范围内,但能覆盖绝大部分常见情况。
5. 数据库里的死锁是另一套逻辑
5.1 为什么明明能检测到死锁还要报错
聊完应用层,必须说说数据库。很多人在代码里锁用得小心翼翼,结果一跑批量更新就报Deadlock found when trying to get lock; try restarting transaction,一脸茫然——明明代码里一把锁都没加。
原因是数据库有自己的锁体系,而且是自动加的。InnoDB 在执行UPDATE、DELETE、SELECT ... FOR UPDATE时会自动对涉及的行加行锁,在可重复读隔离级别下还会加间隙锁和临键锁。两个事务按不同顺序更新同一批行,天然就构成了循环等待。
有意思的是,InnoDB 默认开启死锁检测(innodb_deadlock_detect),它在发现环之后会立刻回滚“代价较小”的那个事务,让另一个继续跑。所以你收到的那个报错,其实是数据库在帮你解死锁——它不是故障,而是保护机制。真正需要处理的是应用层:捕获这个异常后重试,通常重试一次就成功了。很多人不知道这一点,直接把这个异常往上抛成 500,用户看到的就是“随机失败”。
排查数据库死锁的工具是:
SHOW ENGINE INNODB STATUS\G输出里有一段LATEST DETECTED DEADLOCK,会清楚列出两个事务分别持有什么锁、在等什么锁、执行了什么 SQL、回滚了谁。这段信息非常详细,比应用层线程栈好读得多,我每次遇到必看。
5.2 几个把死锁概率放大十倍的习惯
死锁概率不是固定的,它和代码写法强相关。下面这几个习惯,每一个都能显著提高死锁率。
第一个是批量更新顺序不一致。两个事务,一个按 id 1,2,3 更新,另一个按 3,2,1 更新,交错执行必死锁。修法很简单:批量操作前把 ID 排序,统一升序处理。这一条能消掉我见过的大半数据库死锁。
第二个是大事务。事务越大,持有锁的时间越长,和别人冲突的窗口就越宽。有些业务为了“保证一致性”,把几个完全不相关的更新塞进一个事务,锁的持有时间从几毫秒变成几百毫秒,死锁率直接上升两个数量级。该拆就拆。
第三个是在事务里调用外部服务。这是最要命的。事务开启 → 更新数据 → 调下游接口(耗时 200ms)→ 提交。这 200ms 里锁一直不释放,等于把冲突窗口放大了几十倍。而且如果下游超时重试,整个事务可能挂几秒钟。外部调用必须挪到事务外面,这是硬规矩。
第四个是非唯一索引上的等值查询配加锁读。这种组合在可重复读级别下会加间隙锁,两个事务先查后插很容易互相卡住。这类问题的解决要么改隔离级别,要么把“先查后插”改成“直接插、靠唯一约束兜底”,后者在高并发下更可靠。
6. 那些看起来没问题、实际上会咬人的写法
6.1 线程池里的父子任务互等
这个坑我踩过一次,印象很深。场景是主任务提交到线程池,然后future.get()等子任务结果,子任务又提交到同一个线程池。看起来完全正常,但当线程池满了之后,所有线程都在等子任务,而子任务排不到线程,整个池子僵住。这不是传统意义上的锁死锁,但本质一样:资源(线程)被等待者占着,而被等待的东西又需要这个资源才能运行。
这类问题在线程栈上的特征很好认:一堆线程停在Future.get或者CountDownLatch.await,而线程池的活跃线程数等于最大线程数、队列长度在涨。修法有三种:父子任务用不同的线程池(推荐,隔离性好)、用CompletableFuture的回调式编排避免阻塞等待、或者给等待加超时。我个人首选第一种,职责清晰,出问题也好定位。
6.2 读写锁升级、tryLock 漏 unlock 这类硬伤
ReentrantReadWriteLock有一个很容易忽略的限制:不支持锁升级。也就是说,线程持有读锁时再去申请写锁,会永久阻塞。因为写锁需要等所有读锁释放,而它自己正持有读锁,等于等自己。代码写起来还很自然:
// 错误写法:读锁未释放就去拿写锁 readLock.lock(); try { if (needUpdate()) { writeLock.lock(); // 这里会永久阻塞 try { doUpdate(); } finally { writeLock.unlock(); } } } finally { readLock.unlock(); }正确做法是先释放读锁,再申请写锁,拿到之后重新校验一次条件(因为中间可能有别人改过),这套“释放-重拿-复查”的模式是标准写法。锁降级则是允许的:持有写锁时可以拿读锁,然后释放写锁,这个方向没问题。
tryLock的写法错误也很常见,主要有两类。一类是忘了判断返回值:
lock.tryLock(); // 没判断,可能压根没拿到锁就进临界区了 doSomething(); lock.unlock(); // 抛 IllegalMonitorStateException 或者放错锁另一类是拿到之后异常路径漏了 unlock。正确写法一定是“判断返回值 + try/finally 包裹”:
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { try { doSomething(); } finally { lock.unlock(); } } else { // 明确的降级路径,不要静默吞掉 fallback(); }那个else分支很重要。我见过太多代码tryLock失败之后什么都不做,日志里也看不出来,结果表现为“偶发丢数据”,查起来比死锁还费劲。
6.3 分布式锁的“死锁”和本地锁不是一回事
本地锁的死锁,锁对象在同一个 JVM 内存里,进程一挂锁自然就没了。分布式锁不一样,锁的状态存在外部,如果持有者进程崩溃而没有释放锁,这把锁会一直挂着——这才是真正意义上的“锁死”,只能靠过期时间来兜底。
所以用分布式锁时有两条必须遵守:第一,加锁和设置过期时间必须是原子的,不能先加锁再单独设过期,中间挂掉就永远不过期;第二,过期时间要大于业务执行时间,同时要做好“业务没跑完锁先过期”的防护,通常是给锁加一个唯一标识,释放时校验是不是自己的锁,避免误删别人的。
还有一种更隐蔽的情况:业务执行时间不确定,可能几毫秒也可能几秒。固定过期时间要么太短导致误释放,要么太长导致故障时锁迟迟不释放。比较稳妥的方案是加一个续期机制,在业务执行期间定期延长锁的有效期,业务结束后主动释放。这套机制的实现细节要考虑续期线程本身挂掉的情况,不能无限续下去。
7. 我在工程上坚持的几条规矩
写到这里,把前面散落的经验收一收,说几条我自己一直在执行的规矩,都是踩过坑之后形成的条件反射。
第一条,锁对象集中管理。所有跨模块共享的锁统一声明在一个类里,带编号和用途注释,业务代码里禁止new Object()当锁。这一条执行下来,锁顺序问题能消掉八成。
第二条,临界区里不碰 IO。日志、RPC、数据库、文件,一律挪到锁外面。写完锁的代码之后回头扫一眼synchronized块内部,只要有耗时操作就重构。这条规矩的收益远超预期,很多时候死锁没发生,只是因为临界区太小、窗口太窄。
第三条,跨边界一律带超时。不管是tryLock、Future.get、信号量的tryAcquire,还是数据库的连接获取,全部显式指定超时时间。默认无限等待的 API 我一律不用,宁可多写一行参数。理由是:无限等待意味着一个局部故障能升级成全站故障,而超时能把故障限制在可控范围内。
第四条,死锁探测常态化。关键服务内置探测线程,发现问题第一时间告警,而不是等用户投诉。这个投入很小,收益是把“事后抢救”变成“事中发现”。
最后分享一个排查时的小技巧:拿到线程栈之后,别急着看业务代码,先在纸上把“持有 → 等待”的关系画成箭头图。人眼在一堆栈帧里找环很容易漏,但画成箭头之后,环几乎一眼就能看出来。我第一次独立定位死锁,就是靠这个方法在一百多个线程里揪出了那两根互指的箭头。后来发现,同样是这张图,用来分析数据库事务的锁等待也完全适用——工具在变,看问题的方式没变过。