
我记得最清楚的一次线上故障不是 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(targetworker_first, nameworker-first) t2 threading.Thread(targetworker_second, nameworker-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, repeatTrue)或者用外部工具在进程外抓避免侵入代码。不管用哪种方式读法是一样的找到所有停在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 我一律不用宁可多写一行参数。理由是无限等待意味着一个局部故障能升级成全站故障而超时能把故障限制在可控范围内。第四条死锁探测常态化。关键服务内置探测线程发现问题第一时间告警而不是等用户投诉。这个投入很小收益是把“事后抢救”变成“事中发现”。最后分享一个排查时的小技巧拿到线程栈之后别急着看业务代码先在纸上把“持有 → 等待”的关系画成箭头图。人眼在一堆栈帧里找环很容易漏但画成箭头之后环几乎一眼就能看出来。我第一次独立定位死锁就是靠这个方法在一百多个线程里揪出了那两根互指的箭头。后来发现同样是这张图用来分析数据库事务的锁等待也完全适用——工具在变看问题的方式没变过。