ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

并发编程实战:多线程死锁的成因、排查与修复

并发编程实战:多线程死锁的成因、排查与修复

1. 项目缘起:一次让我失眠的生产事故

先讲个真实经历。几年前我在某交易系统做开发,凌晨两点被电话叫醒——线上服务大面积超时,接口响应从几十毫秒飙升到十几秒,数据库连接池被打满,用户操作一批批失败。我下意识看了一眼监控,CPU和内存都还算正常,但线程池指标异常:大量线程卡在同一个状态,既不干活也不退出。

我当机立断打了一份线程快照,一眼就看到了那个经典的循环等待结构。没错,就是死锁。四个线程两两持锁互相等待,谁也不肯先放手。

这件事之后,我下定决心把并发这块基础彻底啃透,并且在团队里定了一条规矩:并发相关的代码,必须走死锁排查演练。今天这篇“从零起步学习并发编程”第二章,就结合项目里真实遇到的场景,把多线程和死锁这层窗户纸捅破。这篇内容适合刚接触并发编程的同学,也适合写过一些线程代码但总在线上出问题的朋友——你会发现,死锁离你并不远。

2. 从多线程到死锁:先搞清楚我们在解决什么问题

2.1 多线程不是“开线程就完事”

很多新手对多线程的理解停留在“开几个线程跑任务,速度就快了”。实际上并发编程的核心不是线程本身,而是线程之间的协作与资源管理。

我们项目里一个典型的场景:批量处理订单状态流转。原来单线程处理一批订单,每单要查库存、扣减、记流水、推送消息,一套流程下来单个订单要十几毫秒,一千个订单就是十几秒,体验很糟糕。

改成多线程之后,把订单列表分片,每片交个一个线程去处理。理想情况下,四核机器开四个线程,理论上能接近四倍吞吐。但问题在于:这些线程要访问共享数据——订单表、库存表、消息队列。一旦多个线程同时读写同一块数据,各种竞争问题就来了。

这时候你就面临两个层次的问题:

  • 并发正确性:数据不错、不重复、不丢失
  • 并发效率:锁用得好不好,线程是不是都在等

而死锁,恰恰是这两者交汇处最常见的坑——它不破坏数据,但让系统直接“冻住”。

2.2 死锁到底长什么样:一个生活类比

死锁的原理说复杂也复杂,说简单也简单。想象一座独木桥,两个人分别从桥的两端走来,各不相让。甲占着桥头A,非要等乙退回去;乙占着桥头B,非等甲退回去。两个人谁都不肯先退,结果就是谁也别想过桥。

把“桥”换成“锁”,把“人”换成“线程”,这就是死锁。技术上讲,死锁需要四个条件同时成立:

  1. 互斥:资源一次只能被一个线程占用
  2. 持有并等待:线程已经持有一个资源,还在等另一个资源
  3. 不可剥夺:资源不能被其他线程强行抢走,只能由持有者主动释放
  4. 循环等待:线程A等B持有的资源,B等A持有的资源,形成环路

这四个条件在项目代码里往往不是一眼能看出来的。尤其是大型系统里,锁散落在不同类、不同方法里,从代码审查的角度很难直接发现隐患。下面我用一个简化但非常典型的项目场景来讲解。

3. 核心场景:账户转账中的多线程与死锁

3.1 业务背景与初版实现

我在某支付项目中做过一个账户间转账的功能。需求很简单:两个账户之间互相转账,要保证金额不会出错,同时性能要能扛住并发请求。

初版代码大概是这样的。定义一个账户类,持有余额和一把锁。

public class Account { private final long accountId; private int balance; private final Lock lock = new ReentrantLock(); public Account(long accountId, int balance) { this.accountId = accountId; this.balance = balance; } public void debit(int amount) { // 扣款 balance -= amount; } public void credit(int amount) { // 入账 balance += amount; } public Lock getLock() { return lock; } }

转账逻辑写成这样:

public class TransferService { public void transfer(Account from, Account to, int amount) { // 加锁顺序:先锁转出账户 synchronized (from) { // 模拟一些业务处理耗时 sleepQuietly(50); synchronized (to) { if (from.getBalance() >= amount) { from.debit(amount); to.credit(amount); } } } } }

测试的时候单线程跑一千万次,数据全对,一点问题没有。当时我还觉得这段代码挺简单的——直到压测环境把并发拉起来,问题就爆了。

3.2 并发压测时的死锁现场

压测脚本模拟多线程同时执行两类操作:A账户向B账户转账,同时B账户向A账户转账,两边并发量都很大。

跑了一分钟,接口开始大量超时。我去抓线程快照,看到了这样的信息:

Found one Java-level deadlock: "pool-1-thread-3": waiting to lock monitor 0x0000000002a5f9b8 (object 0x00000000b1a4e018, a TransferService$Account), which is held by "pool-1-thread-1" "pool-1-thread-1": waiting to lock monitor 0x0000000002a5f9b7 (object 0x00000000b1a4e018, a TransferService$Account), which is held by "pool-1-thread-3"

翻译成人话就是:线程3等线程1释放锁,线程1等线程3释放锁,两个互不相让,彻底死锁。

我仔细分析了这段代码的加锁顺序。

线程1执行“A转B”时,先锁A账户,再尝试锁B账户。

线程3执行“B转A”时,先锁B账户,再尝试锁A账户。

当线程1拿到A账户的锁,线程3拿到B账户的锁之后,两个线程各自握着一个锁,同时等对方释放另一个锁——这不就是循环等待吗?

发现自己写出了这么经典的死锁代码,说实话挺丢人的。但这也是我踩过的最值得的一坑,因为它让我彻底理解了加锁顺序对并发系统的重要性。

3.3 为什么资源竞争没让数据出错,反而让系统死了

有个细节让我印象很深:死锁发生的时候,数据其实是没有错的——因为竞争失败的那些操作根本没执行成功,锁没拿到,自然不会产生脏数据。

但坏就坏在“锁没拿到”这个状态一直持续。线程占着连接池的连接、占用着CPU调度片、占着内存资源,却不干活。如果说并发竞争是“大家一起挤,但总能挤过去”,那死锁就是“路彻底堵死了,所有车都停在那里”。

项目里那次的后果很直接:

  • 接口响应时间疯狂飙高
  • 数据库连接池被占满,后续请求连数据库都连不上
  • 依赖该服务的其他系统跟着连环超时
  • 最后只能重启服务,把死锁线程全部清掉才恢复

所以这里我要特别强调一句:死锁的可怕之处不在于它破坏数据,而在于它让系统慢慢窒息。排查和防范死锁,是并发场景下必过的一关。

4. 死锁排查的实操手法:抓线程快照是救命技能

4.1 线上问题第一反应:jstack 用起来

死锁的排查,最重要也是最基础的手段就是线程转储(Thread Dump)。它是某一时刻所有线程状态的“快照”,记录了每个线程在干啥、等什么、持有哪些锁。

Linux环境下,用jstack命令直接打印:

jstack -l <pid> > thread_dump.txt

拿到线程号的方式也有很多种,常用的有这些:

# 方式1:使用jps查看Java进程 jps -l # 方式2:使用ps命令 ps -ef | grep java # 方式3:通过端口号反查进程 netstat -tlnp | grep <port>

如果是容器环境,先拿到容器的PID,再进容器执行jstack。部分场景下可能没有jstack工具,可以退而求其次使用kill -3 <pid>,这会把线程转储打到应用的标准输出日志里。

4.2 五分钟快速定位死锁线程的方法

拿到线程转储文件之后,别急着从头看到尾——文件通常几百上千行,直接看会崩溃。我的经验是分三步走。

第一步,搜索死锁关键词。如果JVM自己检测到了死锁,会在转储文件开头直接输出:

Found one Java-level deadlock:

你顺着这个关键词往下看,系统会把死锁的线程、持有的锁、等待的锁直接列出来,这是最快的方式。

第二步,如果JVM没有检测到死锁,就用-l参数获取的锁信息来排查。搜索waiting to lock,能看到线程在等哪个锁;再搜索locked <0x...>,能看到该线程持有哪些锁。把这两类信息拼凑起来,形成一张“谁拿着哪把锁,谁在等哪把锁”的图,循环等待的环路就浮出水面了。

第三步,确认线程池和业务线程对应关系。线程名如果带业务标识,比如transfer-service-1,那直接顺着名字定位代码;如果线程名是默认的pool-1-thread-3,就需要结合日志上下文来看具体卡在哪个业务环节。

4.3 线程转储中如何判断锁竞争和死锁的区别

有一个常见误区:看到很多线程BLOCKED就以为死锁了。实际上大量线程卡在BLOCKED状态,可能只是普通的锁竞争——一个线程持锁时间太长,其他线程排队等着,系统看起来卡住了,但持有锁的线程其实还在跑。

死锁和锁竞争的区别在于有没有进展。锁竞争时,持锁线程总会把锁释放掉,排队线程轮着拿到锁;死锁时,所有线程都卡在等锁状态,谁也没法继续。

线程转储里有个小技巧:看等锁的锁地址有没有形成闭环。如果A等B持的锁,B等A持的锁,这就形成闭合环路了,必然是死锁。如果只是一排线程等同一个锁,那是竞争,不是死锁——优先排查持锁线程为什么执行那么慢。

我在项目里就用这个方法定位过不止一次问题。有一次也是大量线程BLOCKED,很多同事一口咬定是死锁,结果线程转储一分析,发现所有线程都在等同一个数据库连接池的锁,而持锁线程卡在一个慢SQL上——这就是竞争而非死锁,解法完全不一样。

5. 项目中的死锁修复方案:四种常用策略的对比与选型

5.1 策略一:规范加锁顺序(最推荐,成本最低)

第一次修复转账死锁,我采用了最经典的解决方案——锁顺序法。给所有账户对象加一个全局唯一的ID,加锁时先比较ID大小,永远先锁ID小的账户,再锁大的账户。

public void transfer(Account from, Account to, int amount) { // 按账户ID排序,保证所有线程加锁顺序一致 Account first = from.getAccountId() < to.getAccountId() ? from : to; Account second = from.getAccountId() < to.getAccountId() ? to : from; synchronized (first) { sleepQuietly(50); synchronized (second) { if (from.getBalance() >= amount) { from.debit(amount); to.credit(amount); } } } }

这样无论实际是A转B还是B转A,加锁的顺序都是“先小后大”。两个线程再也不会分别握着两把锁去等对方——因为大家都按同样的顺序拿锁,先拿第一把锁的人才有资格拿第二把,不存在“你拿我的终点锁、我拿你的终点锁”的交叉局面。

这个策略的优点是显而易见的:改动小、不依赖超时机制、不需要额外配置。缺点是锁顺序需要全局约定,如果系统里有多个模块、多套资源,每一处加锁都要遵守同一套顺序规则。而且有些场景下资源没有天然的顺序,比如锁的是两个不同的缓存对象,就不好排序了。

5.2 策略二:tryLock 加超时兜底

锁顺序法虽然有,但有些情况下业务代码很复杂,锁不是只锁两个资源,可能是三个、四个,排序就会变得很难维护。这时可以考虑第二种方案——使用带超时机制的tryLock。

public boolean transferWithTimeout(Account from, Account to, int amount, long timeout, TimeUnit unit) { boolean acquiredFrom = false; boolean acquiredTo = false; try { acquiredFrom = from.getLock().tryLock(timeout, unit); if (!acquiredFrom) { return false; } acquiredTo = to.getLock().tryLock(timeout, unit); if (!acquiredTo) { return false; } // 业务操作 return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { if (acquiredTo) { to.getLock().unlock(); } if (acquiredFrom) { from.getLock().unlock(); } } }

关键点在tryLock的返回值——如果等待超时还拿不到锁,它不会继续死等下去,而是返回false,调用方可以选择重试、标记失败或走降级逻辑。

这个方案的好处是即使线上真的出现了死锁,也不会永久卡住,最多等满超时时间就会自行解开。但圈子里对它有争议:超时时间设多长是门学问。设短了,高并发下正常的锁等待也会超时,很多请求明明没死锁也会被放弃;设长了,真死锁的时候系统要卡很久才有反应。

我在项目里的经验是:超时时间=业务单次操作耗时的最坏情况的2-3倍。比如正常单次转账50毫秒,超时设成150-200毫秒相对合理,既不会误杀正常请求,也能快速让死锁现象暴露出来——注意只是暴露,真正解决还得靠下面的方案。

5.3 策略三:缩小锁粒度,单锁换多锁

第三种方案其实是在设计层面规避死锁:尽量减少同时持有的锁数量。

比如转账操作实际上真正需要保证原子性的,只是在扣减和入账这两个瞬间。那么可以把锁粒度从“账户级”缩小到“流水记录级”——先插入转出流水,再插入转入流水,由数据库的唯一约束来保证一致性,而不是让Java代码锁住两个账户对象。

举个例子,把转账改成异步化处理:

  1. 请求先把“转账指令”写入本地消息表,状态为待处理
  2. 后台单线程池消费指令,依次处理每一笔转账
  3. 每笔转账内部用数据库事务保证扣减和入账原子性

这样锁的粒度从“两个账户”变成了“一条指令”,线程之间不再争夺账户锁,自然就没有死锁条件了。

这个方案的思路是对的:能用数据约束解决就不要用代码锁,能保持简单就别引入复杂锁结构。但改动量通常不小,业务逻辑可能要重构,不是所有场景都适用。如果你在项目里能推动这种设计,长期来看收益最大。

5.4 策略四:读写分离与无锁化设计

再往前走一步,有些场景本身就不需要锁。比如订单状态查询、账户余额查询这类操作,读多写少,完全可以用ReadWriteLock或CopyOnWriteArrayList这类并发容器来处理。

public class AccountBalanceCache { private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Map<Long, Integer> balanceMap = new HashMap<>(); public int getBalance(long accountId) { rwLock.readLock().lock(); try { return balanceMap.getOrDefault(accountId, 0); } finally { rwLock.readLock().unlock(); } } public void updateBalance(long accountId, int newBalance) { rwLock.writeLock().lock(); try { balanceMap.put(accountId, newBalance); } finally { rwLock.writeLock().unlock(); } } }

读写锁允许多个读者同时进入,但写者独占。多数情况下读锁不会相互阻塞,只有写锁会和读写锁互相排斥,并发度比单一互斥锁高了不少。不过这也不是万能解药——如果业务里写操作很频繁,写锁过多抢占,读线程依然会大量等待,性能提升有限。

而真正无锁化的设计,是使用ConcurrentHashMap、AtomicLong这类基于 CAS 的原子类。它们通过CPU底层的比较交换指令实现线程安全,不依赖阻塞锁,从根本上消除死锁。我一直跟大家说:能用并发容器和原子类,就优先用它们,代码更简单、性能更稳、还天然免疫死锁——代价是容器的API不如普通集合丰富,功能上有边界。

5.5 四种策略的选用对照

方案实现成本代码侵入适用场景风险点
规范加锁顺序低小锁数量少、资源可排序新代码不遵守约定会破功
tryLock加超时中中锁数量多,顺序难统一超时参数需要调优
缩小锁粒度高大可重构、能拆分业务开发周期长
CAS/并发容器低中覆盖容器能力的场景功能受限,复杂逻辑难覆盖

我实际项目里用的是“规范加锁顺序+tryLock兜底”的组合:主路径通过顺序锁保证不产生死锁,异常路径通过超时机制避免意外卡死。双保险的效果比较理想。

细心的朋友可能已经发现,synchronized和ReentrantLock在这个组合里可以分别印证两种方案——synchronized只能顺序等待,没法抢超时;ReentrantLock的tryLock才是实现超时兜底的关键。所以选锁之前先想清楚你是要简单还是要灵活。

6. 多线程设计中的扩展知识点:从死锁走向高并发设计

6.1 活锁与饥饿:比死锁更隐蔽的两个问题

死锁讲多了,很多同学容易把并发问题都往死锁上套。实际上除了死锁,还有两种并发故障需要了解——活锁和饥饿。

活锁的场景很有意思:两个线程都在尝试避让对方,却导致双方反复尝试却毫无进展。比如 A 和 B 同时占着一个锁,检测到冲突后都主动释放锁并尝试重新获取,结果又同时获取、又同时释放,如此循环——看起来线程“活”着,但任务永远推不动。排查活锁比死锁更难,因为线程快照里看不到循环等待,看到的全是RUNNABLE状态。

饥饿是另一种情况:某个线程因为优先级始终较低,或者某个锁总是被其他线程抢到,导致它永远等不到执行机会。虽然系统整体在跑,但那个线程被“饿死”了。排查饥饿通常要关注线程调度日志和线程优先级配置。

这两类问题虽然没有循环等待那么直观,但在高并发系统里也会真实发生。设计时可以通过公平锁、随机退避、等待-通知机制来控制。

6.2 等待-通知机制的正确打开方式

多线程之间除了竞争同一把锁,还有协作场景——比如一个线程负责往队列里放数据,另一个线程负责取数据。队列为空的时候取数据的线程不该反复空转,而是应该等待;队列有数据的时候,放数据的线程该通知它去取。

这就是wait/notify机制的用武之地。

public class TaskQueue { private final Queue<Runnable> queue = new LinkedList<>(); public synchronized void addTask(Runnable task) { queue.offer(task); notifyAll(); } public synchronized Runnable getTask() throws InterruptedException { while (queue.isEmpty()) { wait(); } return queue.poll(); } }

有几个关键点必须注意:

  1. wait()必须在synchronized方法或同步块中调用
  2. wait()会释放当前持有的锁,这是和其他阻塞操作的本质区别
  3. while循环判断条件而不是if——因为有虚假唤醒,线程可能在没有被notify的情况下醒来
  4. notifyAll()比notify()更保险,notify()只唤醒一个线程,容易导致某些线程永远等不到

我早期写过一次if判断条件,在高并发下偶现“个别线程被唤醒后发现队列还是空”的诡异现象,后来查了资料才知道是虚假唤醒,换成while循环就彻底解决了。

6.3 线程池的正确姿势

项目里我几乎不会手动new Thread去开线程,都是走线程池。线程池除了复用线程、减少创建销毁开销外,还有一个隐藏优势——它天然限制了并发度,避免无限制的线程竞争导致死锁概率激增。

一个最常见的误区是:线程池大小设得越大越好。实际上当任务之间存在锁依赖时,线程池过大会引发另一种死锁——任务A持锁等任务B的任务完成,但任务B排在线程池队列里迟迟得不到执行线程,整个池子就卡死了。

所以在设计线程池时,我会同步考虑:

  • 线程池的队列是有界还是无界
  • 拒接策略是丢弃还是调用方自己跑
  • 任务之间会不会嵌套提交子任务

比如下面这个设计:

ExecutorService executor = new ThreadPoolExecutor( 4, // 核心线程数 8, // 最大线程数 60L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue<>(100), // 有界队列 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝时调用线程自己跑 );

CallerRunsPolicy很关键,它让提交任务的线程在拒接时自己执行任务,避免任务被无声遗弃。我用这种方式处理过一次生产事故,系统高负载下不再丢任务,也不会无限堆积内存。

6.4 并发测试:不能只在代码里找自信

写完了多线程代码,不做并发测试就等于没验证。我吃过的亏告诉我,单线程测试过不了并发场景的门槛。

至少要掌握这几种并发测试手段:

  • 压测工具:并发请求模拟,确认吞吐量和响应时间达标
  • 竞态检测:用-Xint或专用工具(如JUnit并发测试框架、Thread Sanitizer等思路)对关键方法反复并发调用
  • 线程转储分析:压测过程中手动抓几次线程堆栈,确认没有BLOCKED聚集
  • 故障注入:让人为构造的锁超时、线程池饱和场景发生,看系统是否会死锁或崩溃

项目里我压测转账服务的时候,会专门写一个并发测试用例,让两个线程组分别执行A转B和B转A,循环几千次。如果修改后的代码在这个用例下运行三分钟不出问题,才能算基本放心。

7. 避免死锁的工程规范与团队实践

7.1 代码审查中的锁定标准

开发团队里光靠个人自觉是不够的。我在团队推了一套死锁防范规范,几个核心要点列在这里:

  1. 禁止在持锁状态下调用外部接口——网络耗时不可控,容易无限期持有锁
  2. 加锁必须按统一顺序——所有锁操作列出全局排序,新模块写代码前先查排序表
  3. 锁的粒度要明确注释——每个同步块注明保护的是什么数据
  4. 持锁时间要短——锁内只做必要的状态变更,复杂计算放到锁外
  5. 能不用锁就不用锁——优先考虑 CAS、并发容器、消息队列等技术

审查代码时遇到synchronized嵌套、多个锁在一个方法里交叉获取的情况,我会要求开发者画出锁的获取顺序图——如果画不清楚顺序,那就说明这段代码有隐患,必须重构。

7.2 生产环境死锁预防的三大防线

真正的高可用系统,不能只靠代码写得对,还得靠运行时的防护。我总结为三条防线:

第一道防线是代码规范,从源头减少死锁的引入;

第二道防线是压测演练,上线前各种并发场景、边界条件都跑一边,模拟最坏情况下的并发压力;

第三道防线是监控告警,核心线程池指标打点上报,一旦出现大量线程BLOCKED或者线程池队列积压,立刻触发告警。我们在某个项目里做过一个动态检测:后台每30秒抓一次线程快照,如果连续三次监测到“一个线程等锁超过5秒”,就自动标记疑似死锁并拉出上下文信息存下来——这套机制帮我提前发现了好几次隐患。

这三道防线一层套一层,死锁能造成的破坏就会降到最低。我在项目里还吃过一次亏,压测没跑充分就上了线,结果一个低概率的并发交错在凌晨触发死锁——自那以后,任何并发改动我都会要求团队至少花一整晚跑压测,同时监控指标全程盯守。

7.3 一次完整的死锁故障复盘流程

最后还是想给大家一套复盘模板,遇到死锁故障可以照着走,别慌。

第一步,保护现场。立刻抓线程转储,同时把堆内存、GC日志、压测期间的监控数据都保存下来——这是事后分析最重要的原料。

第二步,快速恢复。优先重启服务,让业务尽快回到正常状态。别急着破案,线上恢复是第一优先级。

第三步,离线分析。把线程转储打开,找死锁环路、分析锁获取顺序、核对接线时间点。

第四步,识根因。确认是代码顺序问题、资源竞争过度还是第三方组件超时引发的连锁反应。

第五步,验证修复。修复代码必须跑并发压测,最好加个压力比原问题场景更恶劣的回归用例。

第六步,复盘沉淀。把这次的教训写成规范或checklist,下次代码审查直接引用。

这套流程我在团队里跑了三年,效果良好。你不需要一次性做得很完美,但每次故障后能沉淀一点经验,并发能力就会逐步积累起来。我自己最深的一次体会是:并发问题不像业务逻辑那样容易在代码评审阶段被发现,它更像是概率事件——测试环境跑一万次不出事,不代表生产上十万次不出事。唯一的出路就是理解底层原理、严格执行规范、再加上足够的演练。这套组合拳打下来,死锁就不是魔鬼了,它只是一个可以预判、可以控制、可以修复的常规问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进