资讯动态

从线上故障到原理剖析:线程死锁的四大条件、排查与预防实战

发布时间:2026/8/11 6:16:24 来源:尧图企业网站定制
1. 项目概述从一次线上服务“卡死”说起那天下午监控系统突然告警一个核心的订单处理服务响应时间飙升最终完全无响应。登录服务器一看CPU占用率极低内存也正常但服务就是“卡”住了新的请求进不来旧的请求也处理不完。这场景但凡做过几年后端开发的朋友估计都心头一紧——大概率是遇到线程死锁了。果然通过jstack命令导出线程堆栈信息后在一堆BLOCKED状态的线程中清晰地看到了两个线程在互相等待对方持有的锁形成了一个经典的“抱死”局面。这次故障让我损失了几个小时的排查时间和一部分用户口碑但也让我彻底把“线程死锁”这个老生常谈却又极易疏忽的问题掰开揉碎地研究了一遍。线程死锁简单说就是两个或更多的线程在执行过程中因争夺资源而陷入的一种互相等待的僵局若无外力干涉它们都将无法向前推进。理解死锁不仅仅是背下那四个必要条件更重要的是能在复杂的业务逻辑和并发场景中识别出潜藏的死锁风险并掌握一套行之有效的预防、检测和排查方法。无论是使用 Java、C、Python 还是 C#无论是处理数据库事务还是设计异步任务死锁都是高并发编程路上必须跨过去的一道坎。接下来我就结合那次故障的复盘和多年踩坑经验把死锁产生的条件、背后的原理、实战排查手段以及如何从设计上规避给你一次讲透。2. 死锁产生的四大必要条件缺一不可的“完美”僵局死锁的发生不是偶然它需要四个条件同时满足就像凑齐四把钥匙才能打开一扇麻烦之门。理解这四个条件是诊断和预防死锁的理论基石。2.1 互斥条件资源本身的排他性这是最基础的条件。所谓互斥指的是一个资源如一个锁对象、一个数据库连接、一个文件句柄在任意时刻只能被一个线程持有和使用。如果资源可以同时共享那就不会有争夺自然也不会死锁。生活类比这就像公司里唯一的一台彩色打印机。同一时间只能有一个同事在打印他的报告其他想用的人必须等待。技术体现在编程中synchronized关键字修饰的代码块或方法、ReentrantLock、数据库的行锁/表锁等都创造了互斥条件。例如Java 中的每一个对象都有一个内置锁监视器锁这就是一种互斥资源。2.2 请求与保持条件吃着碗里的看着锅里的一个线程在持有至少一个资源的情况下又提出了对新的资源的请求而该新资源恰好被其他线程持有。此时该线程不会释放自己已持有的资源而是阻塞等待。生活类比同事A正在使用彩色打印机持有资源R1同时他又申请使用唯一的扫描仪请求资源R2。而扫描仪正被同事B使用着。同事A不会放下手中的打印任务去干别的而是拿着打印好的部分站在原地等扫描仪。技术体现在代码中这通常表现为嵌套的锁获取。例如线程1先获取了锁A然后在持有锁A的情况下尝试去获取锁B。如果此时锁B被线程2持有线程1就会阻塞但它不会释放锁A。// 线程1的代码逻辑 synchronized(lockA) { // 获取并持有锁A // ... 一些操作 ... synchronized(lockB) { // 尝试获取锁B可能阻塞 // ... 操作需要同时持有A和B ... } }2.3 不剥夺条件资源只能自愿释放线程已获得的资源在未使用完之前不能被其他线程强行抢占或剥夺只能由该线程自己主动释放。生活类比打印机和扫描仪的使用权不能由经理强行从正在使用的同事手中夺走。必须等同事自己用完、放回原位后其他人才能取用。技术体现大多数锁机制都遵循这个原则。例如synchronized锁需要执行完同步块才能释放ReentrantLock需要显式调用unlock()。操作系统级别的资源调度可能会更复杂但在应用层编程的锁语义下不剥夺是常态。2.4 循环等待条件形成一个等待环存在一个线程-资源的循环等待链。比如线程T1持有资源R1并等待资源R2线程T2持有资源R2并等待资源R1。这就形成了一个闭环。生活类比同事A拿着打印机等扫描仪同事B拿着扫描仪等打印机。两个人互相看着对方手里的设备谁也不肯先放手工作就卡住了。技术体现这是死锁最直观的表现形式。在堆栈信息中你会看到类似这样的依赖关系。它不一定只是两个线程可能是多个线程形成一个复杂的等待环。Thread-1 #12 prio5 os_prio0 tid0x00007f... nid0x6d4 waiting for monitor entry [0x00007f...] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadLockClass.methodB(DeadLockClass.java:30) - waiting to lock 0x000000076bf0c4a0 (a java.lang.Object) // 等待锁B - locked 0x000000076bf0c490 (a java.lang.Object) // 持有锁A Thread-2 #13 prio5 os_prio0 tid0x00007f... nid0x6d5 waiting for monitor entry [0x00007f...] java.lang.Thread.State: BLOCKED (on object monitor) at com.example.DeadLockClass.methodA(DeadLockClass.java:15) - waiting to lock 0x000000076bf0c490 (a java.lang.Object) // 等待锁A - locked 0x000000076bf0c4a0 (a java.lang.Object) // 持有锁B核心要点这四个条件必须同时成立死锁才会发生。因此我们预防死锁的思路就是想办法破坏其中至少一个条件。通常互斥条件1是资源本身的特性很难破坏不剥夺条件3在应用层编程中也一般遵循。所以主攻方向就落在了破坏“请求与保持”2和“循环等待”4上。3. 死锁场景深度解析与代码还原理论懂了还得看实战。死锁往往隐藏在复杂的业务逻辑和看似无害的代码改动中。下面我通过几个典型场景带你看看死锁是怎么“炼”成的。3.1 经典场景顺序不一致的锁获取这是教科书般的死锁案例也是我线上故障的直接原因。场景描述有两个共享资源或锁对象ResourceA和ResourceB。两个业务操作Operation1和Operation2都需要同时访问这两个资源但它们获取锁的顺序是相反的。代码示例 (Java)public class ClassicDeadLock { private final Object lockA new Object(); private final Object lockB new Object(); public void operation1() { synchronized (lockA) { // 先获取lockA System.out.println(Thread.currentThread().getName() 持有 lockA尝试获取 lockB); try { Thread.sleep(50); } catch (InterruptedException e) {} // 模拟业务操作增加死锁概率 synchronized (lockB) { // 再尝试获取lockB System.out.println(Thread.currentThread().getName() 成功获取 lockA 和 lockB); } } } public void operation2() { synchronized (lockB) { // 先获取lockB System.out.println(Thread.currentThread().getName() 持有 lockB尝试获取 lockA); try { Thread.sleep(50); } catch (InterruptedException e) {} synchronized (lockA) { // 再尝试获取lockA System.out.println(Thread.currentThread().getName() 成功获取 lockB 和 lockA); } } } public static void main(String[] args) { ClassicDeadLock demo new ClassicDeadLock(); new Thread(demo::operation1, Thread-1).start(); new Thread(demo::operation2, Thread-2).start(); } }运行结果大概率两个线程都会打印出“持有...尝试获取...”的信息然后程序就卡住不动了永远不会打印“成功获取...”的那一行。原因分析Thread-1执行operation1先拿到lockA。Thread-2执行operation2先拿到lockB。Thread-1尝试获取lockB发现已被Thread-2持有于是阻塞等待lockB。Thread-2尝试获取lockA发现已被Thread-1持有于是阻塞等待lockA。循环等待形成死锁发生。实操心得这种死锁在代码审查时很容易被忽略尤其是当operation1和operation2分布在不同的服务、不同的类甚至不同的模块中时。一个有效的预防方法是在团队内建立“锁顺序约定”比如全局规定所有需要锁住User对象和Order对象的操作都必须先锁User再锁Order。3.2 隐蔽场景嵌套调用与动态锁这种死锁更隐蔽因为它可能发生在单一线程的调用链中涉及了“可重入锁”的特性。场景描述一个类的方法syncMethodA是同步的持有锁this其内部调用了另一个同步方法syncMethodB。同时存在一个外部入口直接调用syncMethodB。代码示例 (Java)public class NestedSyncDeadLock { public synchronized void syncMethodA() { System.out.println(进入 syncMethodA, 持有锁: Thread.currentThread().getName()); syncMethodB(); // 可重入没问题 System.out.println(离开 syncMethodA); } public synchronized void syncMethodB() { System.out.println(进入 syncMethodB, 持有锁: Thread.currentThread().getName()); // 执行一些操作 System.out.println(离开 syncMethodB); } public static void main(String[] args) throws InterruptedException { NestedSyncDeadLock obj new NestedSyncDeadLock(); // 线程1通过A调用B new Thread(obj::syncMethodA, Thread-A-Caller).start(); Thread.sleep(10); // 确保线程1先进入A // 线程2直接调用B new Thread(obj::syncMethodB, Thread-B-Direct).start(); } }这个例子本身在Java中不会死锁因为synchronized是可重入的Thread-A-Caller在syncMethodA里调用syncMethodB时可以再次获得this锁。Thread-B-Direct会阻塞直到Thread-A-Caller完全释放锁。真正的风险点在于如果syncMethodB内部需要获取另一个锁比如一个静态锁或另一个对象的锁而那个锁正被其他线程持有那么Thread-A-Caller就会在持有this锁的情况下去等待另一个锁这就满足了“请求与保持”条件。如果其他线程正持有那个锁并且也在等待this锁死锁就形成了。这种跨方法、跨对象的锁依赖关系在大型项目中很难梳理。3.3 数据库死锁并发事务的杀手数据库死锁是另一个重灾区原理和线程死锁相通但表现和排查工具不同。场景描述两个并发事务以相反的顺序更新同一批数据行。SQL模拟-- 事务1 BEGIN; UPDATE accounts SET balance balance - 100 WHERE user_id 1; -- 锁住 user_id1 的行 -- 此时事务1暂停事务2开始 -- 事务2 BEGIN; UPDATE accounts SET balance balance 50 WHERE user_id 2; -- 锁住 user_id2 的行 UPDATE accounts SET balance balance 50 WHERE user_id 1; -- 尝试锁 user_id1等待事务1释放 -- 回到事务1 UPDATE accounts SET balance balance 100 WHERE user_id 2; -- 尝试锁 user_id2等待事务2释放 COMMIT; -- 永远等不到结果数据库引擎如InnoDB会检测到死锁并自动回滚其中一个代价较小的事务通常是通过SHOW ENGINE INNODB STATUS命令查看LATEST DETECTED DEADLOCK部分让另一个事务完成。对应用来说会收到一个死锁错误如MySQL的ERROR 1213 (40001): Deadlock found。数据库死锁的特别之处锁粒度更细可能是行锁、间隙锁、Next-Key锁等。自动检测与处理现代关系数据库通常有死锁检测和回滚机制。索引的影响巨大不当的索引会导致锁升级行锁变表锁大幅增加死锁概率。例如在没有索引的字段上做条件更新数据库可能直接锁表。注意事项处理数据库死锁首要任务是优化事务尽量缩短事务时间避免在事务内进行远程调用或复杂计算保持一致的访问顺序合理设计索引让查询尽量使用索引减少锁冲突范围。其次才是重试机制对于因死锁回滚的事务可以进行有限次数的重试。4. 死锁的排查、诊断与取证实战当系统出现“卡死”、吞吐量骤降但CPU/内存不高时就要高度怀疑死锁。下面是一套从线上应急到深度排查的实战流程。4.1 初步判断与信息收集观察系统指标CPU使用率低、线程数正常或偏高、GC正常但请求完全超时或无响应。使用基础命令Linux/Mac:top -H查看进程的线程状态。但更常用的是jstack(Java)。Java应用:jps -l先找到应用的PID然后jstack -l PID thread_dump.txt导出线程堆栈。这是最关键的证据。.NET应用: 可以使用Process Explorer或通过dotnet-dump工具收集和分析转储文件。Python应用: 可以用faulthandler模块或gdb来获取线程信息。4.2 分析线程堆栈以Java为例打开thread_dump.txt搜索关键字deadlockJDK的堆栈转储如果检测到死锁会在开头或结尾明确标出Found one Java-level deadlock:并列出死锁线程。BLOCKED重点关注状态为BLOCKED的线程。waiting to lock和locked这是死锁线程的典型特征。仔细对比画出线程和锁的等待关系图。分析示例 假设堆栈中有如下片段Thread-1 #12 prio5 ... BLOCKED at com.xxx.Service.methodA(Service.java:100) - waiting to lock 0x00000000f8d1c1b0 (a java.lang.Object) // 它在等这个对象 - locked 0x00000000f8d1c1a0 (a java.lang.Object) // 它已经锁了这个对象 Thread-2 #13 prio5 ... BLOCKED at com.xxx.Service.methodB(Service.java:200) - waiting to lock 0x00000000f8d1c1a0 (a java.lang.Object) // 它在等这个对象 - locked 0x00000000f8d1c1b0 (a java.lang.Object) // 它已经锁了这个对象一目了然Thread-1锁了0x...a0等0x...b0Thread-2锁了0x...b0等0x...a0。循环等待死锁实锤。4.3 利用可视化工具辅助排查对于复杂的分布式系统或海量线程堆栈人工分析效率低。可以借助工具在线分析网站将jstack输出粘贴到一些在线分析网站如 fastthread.io它能自动解析出死锁、锁竞争热点等信息。APM工具如 SkyWalking、Pinpoint 等可以监控到慢请求和线程池状态有时能间接反映问题。Grafana Prometheus结合jmx_exporter暴露的JVM指标如jvm_threads_state可以在Grafana上绘制线程状态仪表盘。当BLOCKED状态的线程数持续增长或长期不为零时就是一个强烈的警告信号。虽然它不能直接定位死锁代码但能提供关键的监控预警。4.4 数据库死锁排查对于数据库死锁以MySQL InnoDB为例查看最近死锁信息执行SHOW ENGINE INNODB STATUS\G查看输出中LATEST DETECTED DEADLOCK这一节。它会详细记录导致死锁的两个事务的最后一条SQL、各自持有的锁和等待的锁。开启慢查询日志死锁往往伴随低效SQL。分析慢日志优化相关查询。使用information_schema查询INNODB_LOCKS和INNODB_LOCK_WAITS表在MySQL 8.0中已被performance_schema中的相关表替代可以查看当前的锁等待关系。5. 死锁的预防、规避与最佳实践知道了怎么死更重要的是知道怎么活。预防死锁远比事后排查成本低。5.1 破坏循环等待条件强制统一的锁顺序这是最有效、最常用的方法。为系统中所有可能被竞争的资源定义一个全局的、严格的获取顺序。如何实施资源排序给每个需要加锁的资源一个唯一的、可比较的ID例如对象的hashCode、数据库记录的主键、按资源类型和名称生成的字符串等。按序获取在任何需要获取多个锁的地方都按照这个ID的固定顺序如升序来获取锁。释放顺序释放锁的顺序则无所谓但通常建议按相反顺序释放代码更清晰。代码改进修复3.1的经典死锁public class OrderedLockSolution { private final Object lockA new Object(); private final Object lockB new Object(); // 定义一个获取锁的顺序。这里假设 lockA 的“顺序值”小于 lockB private void acquireLocksInOrder(Object firstLock, Object secondLock) { // 确定哪个锁应该先获取 Object lock1 firstLock; Object lock2 secondLock; // 这里需要一个比较规则。简单示例通过对象的identityHashCode排序注意hashCode可能重复生产环境需更稳定规则 if (System.identityHashCode(firstLock) System.identityHashCode(secondLock)) { lock1 secondLock; lock2 firstLock; } synchronized (lock1) { synchronized (lock2) { // 执行需要两个锁的业务逻辑 doBusiness(); } } } public void operation1() { acquireLocksInOrder(lockA, lockB); // 无论传入顺序内部都会排序 } public void operation2() { acquireLocksInOrder(lockB, lockA); // 传入顺序相反但内部排序后获取顺序和operation1一致 } }通过一个辅助方法统一排序确保了无论业务逻辑的调用入口如何锁的获取顺序都是一致的从根本上杜绝了循环等待。5.2 破坏请求与保持条件一次性申请所有资源如果可能让线程在开始执行前就申请它所需要的全部资源如果申请不到就释放所有已申请资源等待。优点简单粗暴有效。缺点资源利用率可能降低因为线程可能在很长时间内占着资源但不使用在获取所有资源前无法开工。也可能导致“饥饿”某个线程需要的资源总是被其他线程组合占用。实现方式可以设计一个“锁管理器”或使用java.util.concurrent包中的Lock接口的tryLock()方法尝试获取所有锁如果失败则立即释放已获得的锁。ReentrantLock lockA new ReentrantLock(); ReentrantLock lockB new ReentrantLock(); public void safeOperation() { while (true) { if (lockA.tryLock()) { try { if (lockB.tryLock()) { try { // 成功获取所有锁执行业务 doBusiness(); return; // 业务完成退出循环 } finally { lockB.unlock(); } } } finally { lockA.unlock(); // 如果没拿到B释放A } } // 获取失败随机休眠一会儿避免活锁然后重试 try { Thread.sleep((long) (Math.random() * 10)); } catch (InterruptedException e) { break; } } }5.3 使用更高级的并发工具很多时候死锁源于我们过度使用底层的锁synchronized,ReentrantLock。现代并发库提供了更安全、更高级的抽象。并发集合优先使用java.util.concurrent.ConcurrentHashMap、CopyOnWriteArrayList等它们内部实现了高效的线程安全机制大多数情况下无需外部加锁。原子变量对于简单的计数器、状态标志使用AtomicInteger、AtomicReference等避免锁。同步器使用CountDownLatch、CyclicBarrier、Semaphore、Exchanger等工具来协调线程它们封装了复杂的同步逻辑更不易出错。线程池合理配置和使用线程池如ThreadPoolExecutor可以控制并发度减少资源竞争的概率。但要注意线程池任务本身如果设计不当仍会发生死锁。5.4 超时与中断机制给锁的获取设置一个超时时间。如果在规定时间内无法获得所有需要的锁就主动放弃、回滚已做的工作、记录日志并抛出异常。这至少保证了系统不会永久挂起具备了自我恢复的能力。ReentrantLock lockA new ReentrantLock(); ReentrantLock lockB new ReentrantLock(); public void timeoutOperation() throws InterruptedException { long timeout 3000; // 3秒超时 long startTime System.currentTimeMillis(); if (lockA.tryLock(timeout, TimeUnit.MILLISECONDS)) { try { long remainingTime timeout - (System.currentTimeMillis() - startTime); if (lockB.tryLock(remainingTime, TimeUnit.MILLISECONDS)) { try { doBusiness(); } finally { lockB.unlock(); } } else { // 获取B锁超时 throw new RuntimeException(Acquire lockB timeout, possible deadlock risk.); } } finally { lockA.unlock(); } } else { // 获取A锁超时 throw new RuntimeException(Acquire lockA timeout, possible deadlock risk.); } }5.5 设计层面的规避降低锁粒度尽量缩小同步代码块的范围只锁住真正共享的资源。避免在方法上直接加synchronized特别是耗时较长的方法。避免嵌套锁在设计和代码审查时警惕一个同步方法/块内调用另一个同步方法/块。如果不可避免要非常清晰地画出锁的依赖图。使用无锁编程对于高性能场景研究CASCompare-And-Swap操作、不可变对象、Actor模型等无锁或锁分离的技术。静态代码分析工具集成像FindBugs、PMD、SpotBugs或SonarQube到CI/CD流程中它们有一些规则可以检测出潜在的嵌套锁和锁顺序问题。6. 进阶话题活锁、饥饿与死锁的“亲戚”除了死锁并发编程中还有两个“近亲”问题需要注意。6.1 活锁线程没有被阻塞它们一直在运行通常是重复执行相同的操作但任务却无法向前推进因为线程间在相互“礼让”导致状态无法更新。典型场景两个线程在走廊相遇都向右让结果又面对面然后又同时向左让又面对面……如此循环。代码场景两个线程都采用“获取锁失败后释放所有锁并重试”的策略如5.2中的tryLock循环如果它们的重试节奏完全同步就可能永远在重复“获取-释放”的循环中。解决方法在重试时引入随机退避时间Thread.sleep(randomDelay)打破同步节奏。6.2 饥饿某个或某些线程因为优先级太低或锁竞争策略不合理长期甚至永远无法获得执行所需的资源通常是CPU时间片或锁导致任务无法完成。原因线程优先级设置不当高优先级线程长期霸占CPU。锁不公平某些锁的实现如synchronized不保证公平性可能导致某些线程一直抢不到锁。存在“贪婪”线程某个线程持有锁的时间过长。解决方法使用公平锁如new ReentrantLock(true)、合理设置线程优先级、确保锁的持有时间尽可能短。7. 总结与个人体会死锁问题就像程序世界里的“慢性病”平时不发作一旦发作就要命。通过这次线上故障的复盘我最大的体会是防范死锁意识比工具更重要。在写每一行涉及共享资源的代码时心里都要绷紧一根弦这里会不会形成循环等待锁的顺序是否一致对于团队而言建立代码规范如锁顺序约定、进行有效的代码审查、在关键服务中加入线程堆栈定期输出和监控比如每分钟自动jstack一次并分析BLOCKED线程数都是必不可少的工程实践。对于个人开发者熟练掌握jstack等排查工具理解死锁产生的四个条件并能在设计阶段就运用“按序加锁”、“尝试锁-超时”等技巧是成长为高级工程师的必经之路。最后再分享一个小心得在复杂的业务系统中如果锁的粒度很难细化或者锁的顺序很难统一不妨换个思路考虑能否用队列如BlockingQueue将并发请求串行化处理。虽然牺牲了一点绝对的并发度但换来了代码的简单性和极高的稳定性在很多业务场景下这往往是更优的选择。并发编程没有银弹在性能和正确性之间在复杂度和可维护性之间做出适合你当前业务阶段的选择才是真正的智慧。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价