☰
死锁成因、典型场景与避免手段:从全局锁序到数据库锁实战
2026/11/3 6:24:41 网站建设 项目流程

死锁这东西,没踩过的人觉得它只是教科书上的四个必要条件,踩过的人提起它就有点头皮发麻。我所在的团队维护过交易、订单、库存这几条链路,几乎每年都会遇到一两次线上卡死,排查下来一半以上跟死锁或者死锁的"近亲"——锁等待、线程饥饿——有关系。这篇文章就把死锁的成因、典型场景和避免手段一次讲透:它是什么、为什么会在你以为很安全的代码里发生、线上真卡住了怎么定位、以及从编码规范到架构层面有哪些可落地的躲坑办法。刚入行不久的同学可以把它当排查手册看,写过几年业务代码的同学可以重点看锁序设计和数据库锁那两节,那是最容易被忽略的地方。

1. 死锁到底卡在哪:四个必要条件的工程直觉

1.1 从一次线程池打满的深夜故障说起

先讲一个典型的现场。某订单服务发版后二十分钟,监控告警:接口成功率跌到 30%,线程池活跃线程数顶到 200 的满值,但 CPU 使用率只有 15%,GC 日志干净,没有任何 OOM 痕迹。这个组合非常关键——CPU 低、线程池满、日志安静,基本可以把"计算密集"和"内存问题"排除掉,剩下的可能性里,锁的问题排在第一位。

运维同学第一反应是扩容,加了两台机器,撑了十分钟又挂。真正定位问题靠的是一次jstack,输出里明晃晃一行Found one Java-level deadlock:两个线程互相持有对方需要的锁,永远在BLOCKED状态。这类线程不会消耗 CPU,也不会抛异常,就那么静静地挂着,所以从监控指标上看就是"机器很闲但服务不可用"。

我想强调的第一个经验是:死锁的表现形式不是"报错",而是"安静地不动"。很多同学排查时习惯先看异常日志,如果没有异常就怀疑是网络或者下游,方向就跑偏了。判断死锁最快的一条经验法则是:线程数打满、CPU 不高、接口超时且无堆栈异常,先抓一次线程栈,五分钟能有结论。

1.2 四个必要条件,用饭桌上的例子讲清楚

教科书上那四条——互斥、占有并等待、不可剥夺、循环等待——背起来很容易,理解起来经常是另一回事。我用一个饭桌的比喻:四个人围坐,每人面前一支筷子,规则是吃饭必须同时拿两支,而且拿到手里的筷子不能放下。这时候如果每个人都拿了自己面前那一支,再等对面的人松手,就是标准的死锁:筷子是互斥资源(一支只能一个人用),每个人都占着一支还在等另一支(占有并等待),规则不允许抢夺(不可剥夺),四个人首尾相接形成了一个等待环(循环等待)。

放到代码里,一一对应就清楚了。synchronized锁住的对象、数据库的行锁、连接池里的一个连接、信号量的一个许可,都是"筷子";线程已经拿到 A 锁还想去拿 B 锁,就是"占着一支等另一支";Java 的synchronized不支持强制释放,数据库事务不加超时也会一直等,这就是"不可剥夺";线程甲等乙、乙等甲,或者甲等乙、乙等丙、丙等甲,都是"环"。

四个条件必须同时成立才会死锁,这句话反过来用就是防御思路:只要打断其中任意一个,死锁就不会发生。工程上真正好动手的其实只有后两个,下面这张表是我自己梳理的优先级:

必要条件能否打破常用手段工程代价
互斥基本不能只有读多写少的场景能靠副本、不可变对象绕开写操作必须互斥,绕不开
占有并等待可以一次性申请全部资源、tryLock失败就全放需要能预知要哪些锁,可能降低并发
不可剥夺可以带超时的锁、事务锁等待超时、检测到环就回滚要有重试逻辑,业务得能承受回滚
循环等待最容易全局锁序、按 ID 排序加锁只是约定,需要团队共识和代码评审兜底

1.3 为什么"全局锁序"是最划算的那一刀

从上面那张表能看出来,互斥动不了,占有并等待和不可剥夺都要引入重试和回滚逻辑,业务改造成本不低。而循环等待这一条,只要定一个大家都遵守的顺序规则,环就构造不出来了——成本几乎为零,收益却覆盖了绝大多数场景。

这里有个容易误解的点:很多人以为锁序要求"所有锁都必须有一个全局编号",实际上更实用的做法是在同一个业务语义范围内定义顺序。比如账户转账,顺序就是账户 ID 升序;批量更新订单,顺序就是订单号升序;多级缓存加载,顺序就是"先本地后远端"这种固定的层级顺序。只要同一个资源集合在所有代码路径上被以相同顺序加锁,环就不可能闭合。

反过来说,死锁排查时最有价值的一条线索也是"顺序":把所有涉及同一批资源的加锁点列出来,看它们的加锁顺序是否一致,八成问题当场就现形了。

2. 死锁的成因拆解:从一行代码到一次设计妥协

2.1 加锁顺序不一致:最常见也最容易被忽视的成因

先看一段几乎每个做过支付相关业务的人都写过的代码:

// 转账服务:早期版本 public void transfer(Account from, Account to, long amount) { synchronized (from) { synchronized (to) { from.debit(amount); to.credit(amount); } } }

单线程跑一万次都不会出问题,单元测试也全绿。但当线程 A 执行transfer(账户1, 账户2)、线程 B 同时执行transfer(账户2, 账户1)时,A 拿到账户1的锁去等账户2,B 拿到账户2的锁去等账户1,环就闭合了。

这个 bug 最坑的地方在于它无法稳定复现。两个线程必须在极短的时间窗口内各自完成第一次加锁,窗口可能只有几微秒。你本地压测一小时可能一次都碰不到,上线后 QPS 上去了,概率被放大,就变成了每天几次的偶发超时。我见过有团队因为这个查了两周,最后是在一次压测中把并发从 50 调到 500 才稳定复现出来。

顺带提一句,这类问题的另一个变种是"加锁对象不一致"。比如用Integer做锁对象,值在 -128 到 127 之间会走缓存,两个逻辑上不相关的业务可能拿到同一个对象;用字符串常量做锁,不同模块拼出同样的字面量也会撞上。锁对象必须是私有的、final的、专门创建的,这一点在《Effective Java》里提过,但实际项目里踩的人不少。

2.2 持锁做慢操作:把锁窗口从微秒拉长到秒级

死锁的发生概率跟"持锁时间"强相关。同样一段有顺序隐患的代码,持锁 1 微秒时可能一年碰不到一次,持锁 300 毫秒时一天能撞好几次。所以很多死锁不是"锁用错了",而是"锁的范围画大了"。

最典型的错误写法是在锁里做 IO:

public void refreshConfig(String key) { synchronized (lock) { Config cfg = remoteClient.fetch(key); // 网络调用,P99 300ms cache.put(key, cfg); auditLog.write(key); // 写文件/发消息 } }

这段代码在正常情况下只是慢,但它把锁窗口拉长了几百倍,同时把"不可剥夺"这一条坐实了——等待方线程就在那儿干等,没有任何超时机制。一旦下游抖动,等待队列会迅速堆积,线程池被占满,表现出来就是整个服务雪崩,而根因只是某一个配置刷新接口。

我的判断标准很粗暴:锁的临界区里不允许出现网络调用、磁盘写、日志打印、随机数生成器之外的任何阻塞操作。配置拉取的正确写法是先在锁外把数据取回来,再进锁只做一次内存写;如果必须防止重复拉取,用ConcurrentHashMap.computeIfAbsent这类本身就带分段锁的容器,而不是自己拿一把大锁包住整段逻辑。

2.3 数据库锁:你以为锁了一行,其实锁了一片

数据库层面的死锁更隐蔽,因为它发生在你写的 SQL 之外。很多同学的心智模型是"我更新 id=1 和 id=2 两行,那就锁这两行",而 InnoDB 的实际行为要复杂得多。

第一个坑是无索引更新。UPDATE orders SET status = 1 WHERE user_name = 'xxx',如果user_name上没有索引,InnoDB 会扫描全表并对扫描到的所有记录加锁,等效于锁了整张表。两个这样的语句并发执行,互相等待对方释放,死锁就来了。这个场景在开发环境几乎不会暴露,因为数据量小、并发低,上线后数据量上来、并发一高,立刻炸。

第二个坑是间隙锁。在可重复读隔离级别下,范围查询会锁住记录之间的"间隙",防止幻读。两个事务分别执行SELECT ... WHERE id BETWEEN 10 AND 20 FOR UPDATE和SELECT ... WHERE id BETWEEN 15 AND 25 FOR UPDATE,锁的范围有重叠,插入操作就可能互相阻塞甚至死锁。

第三个坑是事务边界过长。一个事务里塞了十几条更新、几次远程调用、一段业务计算,持锁时间被拉到秒级,跟 2.2 里说的问题一模一样,只不过锁从 JVM 里换到了数据库里。这类问题最典型的表现是"平时没事,一到月初批量对账就死锁"。

2.4 资源池与线程池:等待关系构成的第二类环

除了锁,任何"有限的、需要归还的资源"都可能构成死锁。线程池、数据库连接池、HTTP 连接池、信号量许可,本质都一样。

经典的写法长这样:

ExecutorService pool = Executors.newFixedThreadPool(2); Future<Integer> outer = pool.submit(() -> { Future<Integer> inner = pool.submit(() -> 42); return inner.get(); // 占着工作线程等另一个任务 });

池子里只有 2 个线程,如果同时来了两个这样的外层任务,两个线程都在inner.get()上阻塞,而内层任务在队列里排着,永远等不到线程来执行。这不是传统意义上的锁死锁,但四个必要条件一条不少:线程是互斥资源,外层任务占着线程等内层,线程不能被强行剥夺,两个外层任务构成了环。

这类问题的隐蔽性在于线程栈看起来很正常——线程状态是WAITING在Future.get()上,不会出现Found one Java-level deadlock的提示,得靠人去看"谁在等谁"。我在实际排查中遇到过好几次,都是因为某个接口内部又提交了异步任务,平时 QPS 低看不出问题,促销一上来池子就打满了。

2.5 锁失效与重入:容易被误判成死锁的"伪死锁"

有两类现象经常被当成死锁报上来,其实成因不同,处理方法也不一样。

第一类是锁失效。比如用 Redis 做分布式锁,设置了 30 秒过期,但业务执行了 45 秒,锁提前释放,第二个线程进来,两个线程同时操作同一份数据,出现数据错乱或者长时间的互相等待。表现上像死锁,实际上是锁的租约比业务短。

第二类是活锁和线程饥饿。多个线程不断重试、不断失败,谁也没进展,CPU 可能还不低;或者高优先级任务持续抢占,低优先级任务永远轮不到。这类问题看线程栈会发现线程状态在RUNNABLE和TIMED_WAITING之间反复跳,而不是卡在一个固定的锁地址上。

区分方法很简单:jstack连续抓三次,间隔 5 秒。如果三次的线程栈几乎一字不差、锁地址完全对得上,是真死锁;如果栈在变、状态在跳,多半是活锁或者资源饥饿;如果栈不动但线程状态是WAITING在某个Future或CountDownLatch上,那是等待关系构成的环。

3. 典型场景实录:六种最容易踩的死锁现场

3.1 账户互转与双向关系维护

前面提过转账的双向加锁,这里是完整解法。核心思路是给资源定一个稳定顺序,让所有路径都按同一方向加锁:

public void transfer(Account from, Account to, long amount) { // 按账户 ID 升序决定加锁顺序,ID 相同时直接拒绝或走特殊分支 Account first = from.getId() < to.getId() ? from : to; Account second = from.getId() < to.getId() ? to : from; synchronized (first) { synchronized (second) { from.debit(amount); to.credit(amount); } } }

这里有个细节值得说:ID 相等的情况必须显式处理。如果from和to是同一个账户,两个分支拿到的是同一个对象,synchronized是可重入的,不会死锁,但业务上自己转给自己通常是非法的,应该提前校验。

同样的思路适用于所有"双向关系"的维护:好友关系、关注关系、图上的边。只要是两个实体之间建立关联,加锁顺序就按实体 ID 排序,这条规则一旦定下来,同类问题一劳永逸。

3.2 缓存重建:锁超时与业务时长的赛跑

缓存击穿场景下,常见写法是"缓存没命中就加锁,拿到锁的线程去回源,其他线程等待"。这段代码在小流量下很稳,流量一上来就容易出问题:

public String get(String key) { String val = cache.get(key); if (val != null) return val; synchronized (lockFor(key)) { val = cache.get(key); // 双重检查 if (val != null) return val; val = db.query(key); // 慢查询,P99 800ms cache.put(key, val, 60); return val; } }

问题有两层。第一层是lockFor(key)的实现,如果用的是固定大小的锁数组,不同 key 会映射到同一把锁,热 key 和冷 key 相互拖累,虽然不是死锁,但会造成大面积的锁竞争。第二层是如果这里换成分布式锁并且设了过期时间,而db.query偶尔超过过期时间,锁提前释放,就会出现多个线程同时回源,甚至出现"锁被别的线程删掉"的经典问题——A 的锁过期后 B 拿到锁,A 执行完把 B 的锁删了,C 又能进来。

我的处理办法是:本地锁用ConcurrentHashMap按 key 分段,配合computeIfAbsent保证同一个 key 只有一个线程回源;分布式锁的过期时间给到业务 P999 的两倍以上,并且用带唯一标识的 value,删除时校验归属。如果业务实在跑得太久,用"逻辑过期 + 异步刷新"替代"互斥重建",从根上避免长时间持锁。

3.3 批量更新:列表顺序决定生死

这个场景在数据同步、批量对账、库存扣减里极其常见:

// 危险写法:顺序由上游列表决定 for (Item item : itemList) { jdbc.update("UPDATE stock SET qty = qty - ? WHERE sku_id = ?", item.getQty(), item.getSkuId()); }

两个批次同时执行,一个按[SKU-A, SKU-B]的顺序更新,另一个按[SKU-B, SKU-A]的顺序更新,如果这两条 SQL 在同一个事务里,事务 1 锁住 A 等 B,事务 2 锁住 B 等 A,数据库直接判定死锁并回滚其中一个。

解法同样是排序,在进入事务之前把列表按主键排一遍:

List<Item> sorted = itemList.stream() .sorted(Comparator.comparing(Item::getSkuId)) .collect(Collectors.toList());

排序的成本可以忽略,但它要求团队形成共识:凡是批量写操作,进事务前先排序。我在代码评审里把这条当成硬性检查项,见到没有排序的批量更新就打回去。

顺带提一句,如果批次很大,还要考虑分批提交,每批 200 到 500 条,既能缩短事务、减少锁持有时间,也能降低死锁后的回滚代价。

3.4 线程池嵌套提交与 Future 互相依赖

2.4 里已经讲过基本形态,这里补一个更隐蔽的变种:两个异步任务互相等待对方的结果。

CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> { sleep(100); return b.join(); // 等 b }); CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> { sleep(100); return a.join(); // 等 a });

这段代码会永久挂起,因为两个任务在同一个线程池里,各自占着一个线程等对方,谁也不肯先完成。如果池子是ForkJoinPool.commonPool(),默认线程数是 CPU 核数减一,两个任务正好占满,整个 JVM 里所有用公共池的并行流都会跟着卡住——影响范围远超你的想象。

判断这类问题,看线程栈能发现两条线程都停在CompletableFuture.join或者Future.get上,而且互相引用的对象 ID 能对上。排查时我会顺手统计一下池子的任务队列长度和活跃线程数,如果活跃数等于最大线程数、队列还在增长,基本就锁定了。

3.5 生产者消费者与有界队列

有界队列本身是为了背压,但用法不对也会死锁。比如一个线程既是生产者又是消费者,在队列满的时候自己去put,就会阻塞在自己身上:

BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100); // 消费线程内部 void consume() { Task t = queue.take(); Task followUp = process(t); queue.put(followUp); // 队列满 + 自己是唯一消费者 => 永久阻塞 }

单消费者的情况下,队列满了以后这个线程put阻塞,而它又是唯一能从队列里取数据的线程,环就闭合了。多消费者时如果所有消费者都在put上阻塞,同样会全员卡死。正确做法是用offer加超时,或者把后续任务投递到另一个队列/另一个线程池,让生产者与消费者的角色彻底分离。

3.6 长事务撞上 DDL 与元数据锁

这一类在线上最吓人,因为影响面是整个表。MySQL 里执行ALTER TABLE需要元数据锁(MDL),如果此时有一个长事务正在读这张表,DDL 会等待;而 DDL 一旦进入等待状态,后续所有针对这张表的请求都会被排在 DDL 后面,因为 MDL 的请求队列是严格按顺序的。结果就是一个 KILL 不掉的雪崩:业务查询全部阻塞,CPU 不高,SHOW PROCESSLIST里一堆Waiting for table metadata lock。

这类问题从严格定义上说不算死锁,但表现和危害完全一样。避免手段就两条:一是 DDL 前先查长事务(information_schema.innodb_trx里按trx_started排序),确认没有超过阈值的事务再执行;二是给 DDL 设置lock_wait_timeout,拿不到锁就放弃,别把请求队列堵死。线上变更我一般安排在低峰期,并且提前把innodb_lock_wait_timeout调到 5 到 10 秒,宁可变更失败重来,也不要拖垮业务。

4. 死锁的避免手段:从编码规范到架构设计

4.1 全局锁序:给每把锁一个确定的位置

第一优先级永远是锁序。落地时有几个具体做法:

  • 资源 ID 排序:账户、订单、库存,凡是能拿到唯一 ID 的,一律按 ID 升序加锁。ID 类型要统一,避免Long和String混用导致比较结果不一致。
  • 对象身份排序:如果资源没有天然 ID,用System.identityHashCode()排序。注意这个方法有极小概率相等,相等时必须再退化到一个固定的兜底顺序,比如统一按对象地址或者干脆对其中一方重试。
  • 层级顺序:不同类型的锁定死层级,比如"先缓存锁、后数据库锁","先本地锁、后分布式锁"。跨层加锁时,永远从高层往低层,反向加锁一律在评审阶段打回。

这套规则单靠口头约定守不住,我在实际项目里会把它写进代码规范文档,并在锁工具类上做一层封装,强制要求传入锁对象数组,由工具类内部完成排序:

public final class OrderedLock { public static void runWith(Object[] locks, Runnable action) { Object[] sorted = locks.clone(); Arrays.sort(sorted, Comparator.comparingInt(System::identityHashCode)); acquireAll(sorted, 0, action); } private static void acquireAll(Object[] locks, int idx, Runnable action) { if (idx == locks.length) { action.run(); return; } synchronized (locks[idx]) { acquireAll(locks, idx + 1, action); } } // 注意:需要在 javadoc 里明确禁止对同一对象重复传入 }

4.2 tryLock 加超时:让"不可剥夺"这一条失效

Java 里synchronized没法设置超时,这是它跟ReentrantLock最大的差异。凡是可能产生复杂等待关系的场景,我倾向于用ReentrantLock.tryLock(timeout):

ReentrantLock l1 = locks.get(id1); ReentrantLock l2 = locks.get(id2); long deadline = System.currentTimeMillis() + 500; boolean got1 = false, got2 = false; try { got1 = l1.tryLock(500, TimeUnit.MILLISECONDS); if (!got1) return TransferResult.fail("busy"); long remain = Math.max(1, deadline - System.currentTimeMillis()); got2 = l2.tryLock(remain, TimeUnit.MILLISECONDS); if (!got2) return TransferResult.fail("busy"); doTransfer(); return TransferResult.ok(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return TransferResult.fail("interrupted"); } finally { if (got2) l2.unlock(); if (got1) l1.unlock(); }

关键点在finally的释放顺序,必须跟加锁顺序相反,并且要用标记位记录是否真的拿到了锁。这里如果写错,unlock一个没加锁的ReentrantLock会抛IllegalMonitorStateException,把原始异常盖掉,排查时会非常迷惑。

超时值怎么定?我的经验是取业务 P99 耗时的大约 2 到 3 倍,同时不能超过上游接口的超时时间。比如业务 P99 是 50 毫秒,就设 150 到 200 毫秒;如果设成 3 秒,那等于没有超时,因为上游早就超时返回了。

4.3 缩小临界区与锁分段:从源头降低碰撞概率

锁序和超时是"避免环",缩小临界区是"减少等待"。这两件事方向不同,要一起做。

缩小临界区的核心操作是把不依赖共享状态的计算挪到锁外。举个查询场景:要拿用户信息,先查缓存,没有就查库,查完写缓存。正确姿势是"查库"在锁外,"写缓存"在锁内,而不是整段包起来。这样即使查库慢,锁也只被占用几微秒。

锁分段则是把一把大锁拆成 N 把。ConcurrentHashMap早期版本就是 16 个 Segment,现在改成按桶加锁,思路一样:锁的粒度和数据的分片对齐,不同分片之间互不影响。自己实现时要注意,分段数量要跟并发量匹配,分太少没效果,分太多管理成本高,还要处理"跨分段操作"的场景——跨分段操作必须先按分片编号排序,否则又回到锁序问题上。

4.4 无锁化与串行化:绕开锁本身

有些场景根本没必要加锁,换个模型就没了。三个实用的替代方案:

第一,用 CAS 替代加锁。计数器、状态机这类操作,AtomicInteger、AtomicReference、LongAdder就够了。CAS 的 ABA 问题在大部分业务里可以通过加版本号解决,AtomicStampedReference就是干这个的。

第二,用单线程写替代并发写。所有写请求投递到一个单线程队列里,天然串行,没有锁。代价是写吞吐受限于单线程处理能力,但对大部分管理后台类业务完全够用。日志框架的AsyncAppender、很多状态机的实现都是这个思路。

第三,用消息队列做异步化。写操作投递消息,由消费端按分区顺序处理。同一订单的消息进同一分区,天然保证顺序,也不需要分布式锁。这套方案在订单状态流转、库存扣减里用得很多,代价是引入最终一致性,业务上要能接受短暂的不一致。

4.5 数据库侧的四条硬规矩

数据库死锁的避免手段比 JVM 层面更具体,我总结了四条:

规矩具体做法解决的问题
更新走索引所有UPDATE/DELETE的WHERE条件必须有索引覆盖避免全表扫描导致的行锁扩散
批量先排序事务前按主键排序避免交叉加锁形成环
事务要短单事务控制在 100 毫秒以内,禁止事务内远程调用缩短持锁窗口
隔离级别按需大多数业务可用读已提交,减少间隙锁避免范围锁互相阻塞

第四条值得展开说说。很多团队默认用可重复读,是因为历史原因或者主从复制的兼容性考虑。但可重复读下的间隙锁是死锁的常见来源,尤其是"先SELECT ... FOR UPDATE再INSERT"这种写法。如果业务能接受读已提交的语义(大部分互联网业务都能),把隔离级别调下来能消掉一大类死锁。调之前要确认两件事:主从复制格式是 ROW 还是 STATEMENT(STATEMENT 格式下读已提交会有一致性问题),以及业务代码里有没有依赖可重复读的"同事务内多次读一致"行为。

4.6 资源分级与信号量:处理非锁类资源

连接池、信号量、线程池这些资源的死锁,处理思路跟锁一样,只是手段换成"预留 + 超时"。核心原则是不要在持有一个资源的时候去申请可能被同类持有者占用的另一个资源。

具体到线程池,我会强制要求:业务线程池和异步任务线程池物理隔离,不要让一个任务在池内再提交任务到同一个池。如果业务确实需要,用ForkJoinPool的ManagedBlocker或者干脆改用响应式模型。数据库连接池则可以配connectionTimeout(HikariCP 默认 30 秒,我给生产环境设 3 到 5 秒),配合"获取连接后立即做一次isValid校验"避免拿到失效连接后长时间挂起。

5. 检测、定位与恢复:真出事了怎么收场

5.1 jstack 实战:怎么读懂那段死锁报告

排查 JVM 死锁,jstack是最快的工具。命令很简单:

jstack -l <pid> > /tmp/stack-$(date +%s).log # 连续抓三次,每次间隔 5 秒,用来区分真死锁和临时阻塞 for i in 1 2 3; do jstack -l <pid> >> /tmp/stack-all.log; sleep 5; done

报告里的关键段落长这样:

Found one Java-level deadlock: ============================= "pool-1-thread-1": waiting to lock monitor 0x00007f8a4c0b3e00 (object 0x000000076ab2c8d0, a com.example.Account), which is held by "pool-1-thread-2" "pool-1-thread-2": waiting to lock monitor 0x00007f8a4c0b3e20 (object 0x000000076ab2c8a0, a com.example.Account), which is held by "pool-1-thread-1"

读法就三步:先看waiting to lock后面的对象地址,再看which is held by后面的线程名,两条记录能首尾接上就是闭环。然后回到Java stack information部分,看这两个线程各自的调用栈,栈顶那几帧就是加锁点,直接对应到代码行号。

有个小细节:-l参数会额外打印Locked ownable synchronizers,如果用的是ReentrantLock而不是synchronized,不加这个参数看不到锁的持有关系。

5.2 代码层面主动检测

光靠被动抓栈不够,我习惯在服务里加一个自检接口,用ThreadMXBean主动探测:

public List<String> detectDeadlock() { ThreadMXBean bean = ManagementFactory.getThreadMXBean(); long[] ids = bean.findDeadlockedThreads(); if (ids == null) return Collections.emptyList(); ThreadInfo[] infos = bean.getThreadInfo(ids, true, true); return Arrays.stream(infos) .map(i -> i.getThreadName() + " @ " + i.getLockName() + " <- " + i.getLockOwnerName()) .collect(Collectors.toList()); }

把它挂到一个内部管理端点上,监控系统每 30 秒调一次,一旦非空就告警。这个手段的价值在于能在故障扩散前发现苗头——很多死锁一开始只影响个别请求,等到线程池打满已经过去十几分钟了。

顺带一提,findDeadlockedThreads只检测synchronized和ReentrantLock构成的对象监控器死锁,检测不到线程池等待、信号量、分布式锁这些。所以它是个补充手段,不能替代人工分析。

5.3 MySQL 死锁日志怎么读

数据库死锁的第一手资料是SHOW ENGINE INNODB STATUS里的LATEST DETECTED DEADLOCK段:

------------------------ LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 4213, ACTIVE 8 sec starting index read LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 120 page no 4 index PRIMARY of table `shop`.`stock` trx id 4213 lock_mode X locks rec but not gap waiting *** (2) TRANSACTION: TRANSACTION 4214, ACTIVE 6 sec starting index read *** (2) HOLDS THE LOCK(S): RECORD LOCKS index PRIMARY of table `shop`.`stock` ... lock_mode X *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS ... index `idx_sku` of table `shop`.`stock` ... waiting *** WE ROLL BACK TRANSACTION (2)

读法是找"谁等谁"。这里事务 1 在等主键索引上的排他锁,事务 2 持有该锁并去等二级索引上的锁,形成了一个交叉。翻译成业务语言就是:一个按主键更新,一个按 SKU 更新,两条路径的加锁顺序不一致。

这里有个非常实用的判断:如果日志里出现locks rec but not gap,说明是行级冲突;如果出现locks gap before rec或者insert intention,说明跟间隙锁有关,优先考虑调整隔离级别或者避免范围FOR UPDATE。另外WE ROLL BACK TRANSACTION (2)这一行告诉我们 InnoDB 选择了回滚代价小的那个事务,所以业务代码里对"被回滚"要有重试处理。

5.4 恢复策略:先止血再定位

真出了线上死锁,处理顺序千万别搞反。第一步是止血:如果是 JVM 死锁且影响面大,重启实例是最快的手段,先把流量切走再重启;如果是数据库死锁,InnoDB 会自动回滚一个事务,业务侧要有重试,但如果是 MDL 阻塞导致的全表卡死,就得先KILL掉长事务(注意是会话级的KILL,KILL QUERY只终止当前语句,事务还挂着)。

第二步才是定位。抓的证据一定要在重启前保存:线程栈、SHOW ENGINE INNODB STATUS、SHOW PROCESSLIST、慢查询日志、当时的 QPS 曲线。我有一次因为重启太快,栈没抓下来,事后只能靠代码猜测,多花了一整天。

第三步是验证。修复方案上线前,用压测工具在测试环境复现一遍,把并发调到生产的两倍,跑半小时看还有没有。死锁这种概率问题,不复现就等于没验证。

6. 常见问题速查与避坑心得

6.1 一份能直接抄的速查表

现象最可能的原因第一步排查动作
线程池满、CPU 低、无异常JVM 死锁jstack -l看Found one Java-level deadlock
线程栈停在Future.get线程池嵌套等待统计池活跃数与队列长度,看谁在等谁
数据库报Deadlock found加锁顺序不一致或间隙锁看LATEST DETECTED DEADLOCK,找交叉索引
大量Waiting for table metadata lock长事务阻塞 DDL查innodb_trx按开始时间排序,KILL 长事务
接口偶发超时、偶发数据不一致分布式锁过期核对业务耗时与锁租约,检查锁归属校验
队列类任务全卡住生产消费同线程、队列满看队列长度与消费者状态,确认是否put阻塞

6.2 几个反直觉的坑

第一个坑:以为加了超时就没事。超时机制只在"业务能重试"的前提下有效。如果超时后直接返回失败,用户看到的是报错;如果超时后重试又没做退避,可能引发重试风暴,把问题放大十倍。我现在的做法是超时 + 指数退避 + 最大重试次数,三件事一起配。

第二个坑:在synchronized里调用外部方法。哪怕这个方法是你自己写的、看着很快,只要它内部可能回调到别的业务代码,就可能绕回来拿锁。我见过一个案例:锁里调了一个监听器,监听器又触发了另一个事件的锁,两个锁顺序在不同调用链上不一致,直接死锁。规则是临界区里只做纯内存操作。

第三个坑:误以为ConcurrentHashMap的computeIfAbsent一定安全。它在同一个桶上会加锁,如果映射函数内部又去操作同一个 map,就会死锁。Java 9 之后对这种情况有检测,会抛IllegalStateException,但老版本就是直接卡住。

第四个坑:数据库死锁后不重试。InnoDB 回滚事务时抛的异常是Deadlock found when trying to get lock; try restarting transaction,字面意思就是让你重启事务。但重试必须保证幂等——如果事务里有INSERT,重试可能产生重复数据,得靠唯一索引或者业务去重兜底。

6.3 日常演练比事后排查划算

我现在的习惯是每季度做一次死锁演练:在测试环境人为构造一个双向加锁场景,让团队新人独立完成"发现告警—抓栈—定位代码—给出修复方案"这条链路。第一次做的时候平均耗时两个多小时,做上三四次能压到二十分钟以内。

演练还有一个副作用是能暴露监控盲区。比如我们之前只监控了接口错误率,死锁时错误率上涨得很慢,因为线程是慢慢被占满的;后来加了"线程池活跃率超过 80% 持续 60 秒"的告警,发现时间从十几分钟缩短到一分钟以内。

6.4 我在实际项目里踩出来的几条经验

最后说几点个人体会。

一是优先怀疑锁序,而不是锁太多。很多人一出死锁就想"是不是锁加太多了,能不能去掉一把",方向往往是错的。绝大多数死锁的根因是顺序不一致,不是数量。先把顺序梳理清楚,再考虑要不要减少锁。

二是给锁起名字。我在工具类里封装的锁对象都带一个name字段,打日志和抓栈时能直接看出是哪把业务锁,而不是一堆java.lang.Object@6ab2c8a0。这个改动成本极低,排查效率提升非常明显。

三是代码评审时专门盯三行代码:synchronized、lock()、FOR UPDATE。凡是这三类关键字出现的地方,都要问一句"这把锁的加锁顺序在全局是否唯一"。这句话我问了几年,拦下来的问题比任何静态扫描工具都多。

四是别迷信工具。静态分析能查出"同一方法内多次加锁顺序不一致",但查不出"跨方法、跨类、跨服务的顺序问题"。这类问题最终还是靠人对业务链路的理解,工具只能做辅助。

死锁这件事,本质上不是技术难题,是协作难题——它要求所有写这块代码的人对"顺序"有共同认知。把规则写下来、把工具封装好、把评审卡住,比任何单点的技术手段都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询