ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

多线程死锁实战:从四个必要条件到jstack排查与工程化预防

多线程死锁实战:从四个必要条件到jstack排查与工程化预防 上周又有人在群里发了一张线程转储截图问“线程状态全是WAITING服务也不报错业务就是不动是不是死锁”我一看那图上两条线程互等对方释放锁就知道又是老场面了。多线程死锁这东西面试题里年年考理论背得滚瓜烂熟可真到线上出问题时能一眼认出来并快速处理的人其实不多。这篇文章就围绕多线程死锁展开先说清楚它的本质和四个必要条件然后用一个账户转账的真实案例一步步复现死锁现场再讲线上排查的完整链路jstack、JConsole、数据库锁日志这些工具怎么配合用最后给出修复方案和工程化的预防手段。适合正在学并发编程的初学者也适合写过多线程代码、但还没被死锁“教育”过的开发者。1. 死锁是并发编程里最安静的故障1.1 线上死锁长什么样死锁在线上有个很坑的特点它不会让进程崩溃不会抛出异常不会像OOM那样直接留下日志。你只会发现某个接口突然超时然后超时的请求越来越多紧接着线程池被占满健康检查开始失败最后整台机器被流量调度系统摘掉。我见过最典型的案例是这样的一个支付回调服务白天业务量平稳还好一到晚上账务批处理任务跑起来就会有几个交易卡住。重启服务就好了但过了几个小时又出现。刚开始大家以为是数据库慢查询后来抓线程栈才看见账务批处理线程持有一把锁在处理回调的线程等待而回调线程也持有一把锁批处理线程正在等它。两边谁也不让谁业务就停了下来。这类问题的可怕之处在于它在测试环境很难复现。单元测试里两个线程按顺序跑根本不会出现竞态即使偶发一次重启就好报错日志为零很容易被当成“偶发网络抖动”略过。1.2 死锁和普通性能问题有什么不同普通性能问题比如慢SQL、Full GC、CPU飙高通常都有明显的“症状指标”要么响应时间上去要么CPU利用率异常要么内存曲线陡增。而死锁的症状非常隐蔽CPU利用率可能很低内存也正常线程池里的线程却全部卡在BLOCKED或WAITING状态活活把一个可以3秒内完成的请求拖到超时。用个生活类比死锁就像是两辆车在窄桥上迎面相遇彼此都占着自己这边的车道同时坚持让对方让路。没有一辆车坏掉也没有人违规可交通就是永远瘫痪了。排查这类问题不能靠看CPU和日志必须直接看线程在等什么。我平时排查的第一步永远是找到那些长期处于BLOCKED或WAITING状态的线程打印它们的堆栈看它们分别持有什么锁、正在请求什么锁。只要看到一条“A持有X等YB持有Y等X”的循环链基本就可以锁定死锁。2. 四个必要条件差一个都锁不死想理解死锁为什么发生先要记住一个经典结论死锁发生产生的核心原因可以归纳为四个条件它们必须同时满足缺一不可。这也是为什么所有修复方案本质都是在“破坏其中一个条件”。2.1 互斥没法改的前提第一个条件是互斥同一时刻一个共享资源只能被一个线程占用。锁的语义天然就是互斥的读写锁里写锁是互斥的数据库的行锁也是互斥的分布式锁更是专门做互斥的。这个条件在绝大多数场景下不能也无需被破坏。我们加锁的目的正是为了保证互斥访问如果去掉互斥那并发安全就无从谈起。所以修复死锁时不要去动这个条件。2.2 持有并等待嵌套加锁的元凶第二个条件叫持有并等待线程已经持有一把锁同时还想去获取另一把锁在等待第二把锁的过程中它不释放第一把锁。这是我自己写代码时最常踩的坑。比如一个账户转账功能转账操作需要同时锁定转出账户和转入账户防止余额被并发修改。如果先锁from账户再去锁to账户而另一个线程正好反过来先锁了to账户再去锁from账户持有并等待的条件就成立了。记住一句话每出现一次“持有一把锁再去拿另一把锁”就是往死锁的方向迈了一步。不是每次都会死锁但一旦时机配合死锁就是必然。2.3 不可剥夺锁释放点决定了死局第三个条件是不可剥夺线程已经获得的锁在自己主动释放之前不能被其他线程强行夺走。Java的synchronized和ReentrantLock在默认语义下都是不可剥夺的synchronized连中断都不响应ReentrantLock还得靠lockInterruptibly才能响应中断。数据库的行锁也是这样事务没提交之前其他事务只能等着。这个条件告诉我们锁的释放时机完全掌握在持有者手里。如果持有者因为网络、等待、外部调用阻塞住了那所有等待这把锁的线程都会被拖下水。2.4 循环等待最后一个条件第四个条件是循环等待存在一组线程它们互相等待对方持有的资源形成一个首尾相接的等待环。比如线程A持有锁1等待锁2线程B持有锁2等待锁1这就是最简单的两节点环。3. 用账户转账复现一次标准的死锁过程3.1 从业务逻辑到代码模型理论讲多了容易飘直接看一段能跑的死锁代码。假设有个最简单的银行账户系统Account类维护余额和一把自己的锁class Account { private final int id; private int balance; private final ReentrantLock lock new ReentrantLock(); public Account(int id, int balance) { this.id id; this.balance balance; } public void deposit(int amount) { balance amount; } public void withdraw(int amount) { balance - amount; } public void lock() { lock.lock(); } public void unlock() { lock.unlock(); } }转账方法天然需要锁定两个账户防止转账过程中余额被并发修改static void transfer(Account from, Account to, int amount) { from.lock(); try { to.lock(); try { from.withdraw(amount); to.deposit(amount); } finally { to.unlock(); } } finally { from.unlock(); } }这段代码从功能上完全“正确”既不丢钱也不重复扣钱可它隐含了一个致命问题锁的顺序不一致。只要调用方向相反就会埋下死锁的种子。3.2 完整复现代码与运行结果下面用两个线程让死锁必然发生。设想账户A余额1000账户B余额1000。线程1执行“从A转100到B”线程2同时执行“从B转200到A”public class DeadlockDemo { public static void main(String[] args) throws Exception { Account a new Account(1, 1000); Account b new Account(2, 1000); Thread t1 new Thread(() - transfer(a, b, 100), T1); Thread t2 new Thread(() - transfer(b, a, 200), T2); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(transfer done); } }如果运气好跑一两次可能不会死锁因为线程启动顺序和调度时机决定了是否真的形成交叉等待。为了稳定复现可以在线程1锁定from之后加一个小小的停顿让两个线程能卡在对方节点上Thread t1 new Thread(() - { a.lock(); try { Thread.sleep(100); b.lock(); try { a.withdraw(100); b.deposit(100); } finally { b.unlock(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { a.unlock(); } }, T1);这样跑程序大概率会直接卡住永远打印不出“transfer done”。两个线程都持有一把锁同时等待对方的那把锁恰好满足前面说的全部四个条件。3.3 数据库行锁同样会死锁眼光放大一点多线程死锁不止发生在Java内存锁上数据库表里同样有死锁。两个事务同时更新两条记录但更新顺序相反就会触发数据库行锁死锁。比如事务A先更新user_id1的行再更新user_id2的行事务B先更新user_id2的行再更新user_id1的行。由于行锁互斥两个事务互相等待对方提交数据库死锁检测器介入后会回滚其中一个事务让另一个继续执行。这给业务代码的启示是即使服务端没有任何锁只要SQL事务里涉及多条记录的更新就存在数据库层面的行锁死锁风险。修复思路其实一样让事务内的多条SQL语句总是按同一顺序更新数据即可。4. 线上排查死锁的三板斧与完整链路4.1 先确认“卡住”是不是死锁线上遇到接口超时、线程池耗尽第一步不是找死锁而是先确认现象。看线程是不是大面积长时间处于BLOCKED或WAITING状态以及有没有一条清晰的等待链。常见误区是只要看到WAITING就觉得是死锁。实际上线程在Object.wait()或LockSupport.park()的时候也是WAITING状态很多是正常的等待唤醒机制。只有“等待的锁正被另一个线程持有而那个线程也在等锁”才是死锁。所以排查一定要拿线程栈逐线程分析不能光看状态名。4.2 jstack找Java级死锁jstack是排查Java死锁最趁手的工具用法很简单jstack pid thread_dump.txt如果JVM检测到Java级别的死锁会在线程栈顶部明确输出“Found one Java-level deadlock”字样并列出死锁线程的等待关系格式大致如下Found one Java-level deadlock: T2: waiting to lock monitor 0x00007f (object 0x0000000012345678, a Account), which is held by T1 T1: waiting to lock monitor 0x00007f (object 0x00000000abcdef01, a Account), which is held by T2 Java stack information for the threads listed above: ...看到这个头问题就基本定性了。建议连续抓两三次线程栈间隔几秒确认等待链稳定存在避免抓到半路正在释放锁的瞬时状态。不过要注意一点jstack能自动检测的是synchronized和ReentrantLock等Java管程对象上的死锁。如果死锁发生在AQS内部的显式条件队列之间或者锁是通过Unsafe这类底层机制实现的jstack不一定能自动给出“Found deadlock”的结论这时候就要手动解析线程栈里的“waiting to lock”和“locked”信息。4.3 JConsole与ThreadMXBean兜底生产环境如果不好执行命令行可以用JConsole连上进程在线程页签里看线程状态和堆栈界面很直观双击任何一条线程就能看到它的调用栈和锁持有情况。如果想把死锁检测做成自动化Java标准库提供了ThreadMXBeanThreadMXBean tmb ManagementFactory.getThreadMXBean(); long[] deadlockedIds tmb.findDeadlockedThreads(); if (deadlockedIds ! null) { for (long id : deadlockedIds) { ThreadInfo info tmb.getThreadInfo(id); System.out.println(deadlocked thread: info.getThreadName()); } }注意两个方法的区别findDeadlockedThreads能检测到包括java.util.concurrent锁在内的死锁findMonitorDeadlockedThreads只能检测到monitor锁也就是synchronized相关的死锁。建议用前者。在自动化回归测试里可以在所有并发用例跑完后调用一次findDeadlockedThreads一旦返回非空就直接让测试失败。这是成本最低的死锁防线比靠人工review代码靠谱得多。4.4 数据库死锁的排查路径数据库死锁的排查链路不同重点看两个地方一是数据库自己的死锁日志MySQL 5.7以上默认开启innodb_print_all_deadlocks时可以打印到错误日志或者用SHOW ENGINE INNODB STATUS查看最近一次死锁的事务详情二是应用日志里是否出现错误码1213(MySQL死锁)或类似“Deadlock found”的报错。拿到数据库死锁日志后重点看“WAITING FOR THIS LOCK TO BE GRANTED”和“HOLDS THE LOCK”这两段就能还原出两个事务分别持有哪一行锁、等待哪一行锁。修复手段通常是让事务内的SQL执行顺序统一或者在业务层面上按某个唯一键排序后再执行批量更新。5. 修复死锁的几种思路以及它们的代价5.1 全局排序用规则破坏循环等待最经典的修复死锁方案就是打破循环等待这个条件给所有需要加锁的资源定义一个全局顺序所有线程一律按照这个顺序加锁不允许跳过或倒序。账户转账的例子可以改成按账户ID排序后加锁static void transfer(Account from, Account to, int amount) { Account first from.id to.id ? from : to; Account second from.id to.id ? to : from; first.lock(); try { second.lock(); try { from.withdraw(amount); to.deposit(amount); } finally { second.unlock(); } } finally { first.unlock(); } }这样无论外部传入的转账方向如何内部都是先锁ID小的账户、再锁ID大的账户。两个线程不可能出现“你等我我等你”的环形等待。这个方案看起来简单实际落地有几个坑。最典型的是如果资源本身没有天然的ID顺序比如一组对象池里的连接对象可以退一步用System.identityHashCode排序但哈希值可能会冲突冲突时要再准备一个“打破平局”的锁或比较规则否则排序就失效了。我一般会直接在业务对象里加一个自增ID尽量不依赖哈希。5.2 有界等待用超时打破僵局如果说全局排序是“从设计上让死锁不可能发生”那有界等待就是在“赌死锁就算发生也最多等一个超时时间”。Java的ReentrantLock提供了tryLock方法可以指定最长等待时间if (first.lock().tryLock(1, TimeUnit.SECONDS)) { try { if (second.lock().tryLock(1, TimeUnit.SECONDS)) { try { // 执行转账 } finally { second.unlock(); } } else { // 获取第二把锁超时放弃本次操作可记录日志后重试 } } finally { first.unlock(); } }这里的关键细节是第二把锁获取失败时一定要先释放已经获取到的第一把锁再做补偿。否则虽然避免了循环等待却可能演变成“单个线程长时间占着一把锁不放”一样把系统拖垮。tryLock超时时长的设置也很有讲究。太短比如50毫秒在高峰期可能因为锁竞争稍微激烈就频繁失败太长比如10秒请求堆积起来还是会把线程池耗尽。我一般先设1秒再根据业务尖峰的实测情况调整。5.3 减少锁嵌套消灭持锁等锁的根源全局排序和超时都是控制手段真正治本的是减少嵌套加锁。如果一段逻辑根本不需要同时持有两把锁那就没有持锁等锁的机会死锁自然无从谈起。账户转账其实有别的实现思路比如把账户余额改成AtomicLong用CAS自旋把先扣款再入账的过程做对或者用一个单一的重入锁保护整个转账模块避免锁了两个对象还可以借助数据库事务把多账户扣减放在同一个事务里用数据库的行锁去保证一致性应用层就不需要自己加锁了。这些方案各有取舍。全局单一锁实现简单但会牺牲并发度数据库事务可靠但会拉长连接占用时间。我之前在一个订单系统里见过把“锁两个对象”改成“锁一个账户服务对象”的做法并发量上来后吞吐直接掉了一半后来改成按账户纬度分桶加锁才在并发和一致性之间找到平衡。5.4 四种方案怎么选方案破坏的条件适用场景主要代价全局锁排序循环等待资源有天然ID或有明确顺序需要侵入调用方约束tryLock有界等待持有并等待/不可剥夺无法控制锁顺序且允许失败重试超时设置难可能频繁失败减少嵌套/单锁持有并等待业务能接受锁粒度粗或可无锁化并发度降低或改造成本高锁超时放弃重试持有并等待分布式系统锁竞争不极端重试风暴风险需要兜底自动检测死锁恢复人力资源干预已有监控体系允许自动回滚需要额外监控组件依赖JVM/DB检测实际项目里几乎没有一场死锁修复只靠单一方案。我的建议是能用全局排序就用全局排序这是成本最低、最不容易出错的同时给所有Lock加超时作为最后一道保险再把监控和自动检测接上保证出了问题能在几分钟内发现。6. 预防死锁的工程化做法和我在代码评审里常说的几句话6.1 加锁规范必须写进团队约定死锁问题的修复成本远大于预防成本所以工程上的预防比互联网上的“事后方案”更重要。团队里应该有一个明确的加锁规范内容不用多三条就够第一禁止嵌套加锁。如果一个方法已经持有一把锁内部不要再去获取另一把锁。特殊情况下必须嵌套要在代码注释里写清楚锁顺序比如“先锁orderLock再锁userLock任何地方都不许反着来”。第二禁止在持锁状态下做耗时操作。不要在锁内调用外部RPC、执行慢SQL、做大量IO或一切可能长时间阻塞的动作。这条不仅防死锁还防“隐式死锁”一个线程持锁后卡在网络请求上其他线程全部等着看起来像死锁其实是锁被长时间占而不放。第三统一使用带超时的锁。除非是极端短临界区且明确不会嵌套否则尽量不要用无条件的lock()能用tryLock就tryLock。这不是怂是给系统留退路。6.2 代码评审时盯哪几行我review代码时遇到并发相关改动会特别盯几个点有没有出现两层以上的锁嵌套嵌套加锁时锁的获取顺序是否全局一致持有锁的临界区内有没有调用外部方法以及线程池任务里是否获取了锁却不交还。有一个很容易忽略的场景是在回调、监听器或异步线程里获取锁然后和主流程的锁形成交叉。这种代码最容易隐藏循环等待因为调用链跨了线程只有完整的线程栈才能暴露出来。所以每次上线前组织一次并发代码走查把涉及锁的调用链画出来比出事了再抓dump省很多时间。6.3 压测和自动检测怎么配合压测阶段建议做两件事。一是写一个专门的并发回归用例用CountDownLatch让多个线程同时起跑反复执行高竞争的转账、更新操作连续跑几千次。二是给测试用例挂上ThreadMXBean检测在用例结束后自动查一次findDeadlockedThreads发现有死锁立即输出失败标志。这样只要死锁出现过一次就能被当作测试失败抓到而不是等到下一次线上事故。上线之后运维监控里要有一条针对线程状态的指标某个线程池的线程数量达到上限或者大量线程持续BLOCKED超过一定时间就应该触发告警。这些信号加上定期抓取的线程栈快照基本就构成了一个“死锁雷达”。6.4 我踩过的几个坑希望你别踩第一个坑是复现困难导致误判。线上线程卡住第一反应是死锁但抓了线程栈发现都在等同一个分布式锁而分布式锁因为持有者进程崩溃没有释放这叫“锁泄漏”不是死锁。处理方式完全不同一个是找循环等待一个是给分布式锁加过期时间。第二个坑是修完A转账死锁过了几周B模块又出现类似问题。这是因为只修了具体场景没有把锁顺序规范推广到全局。后来我把所有需要锁多个资源的入口都强制走同一个“锁排序工具类”才把这个口子堵上。第三个坑是tryLock获取成功之后忘了在finally里释放。出现死锁时通过超时自愈了但因为少写一个unlock导致锁一直被持有系统反而慢慢没并发度了。这个错误比死锁更难排查因为线程栈显示的是“持锁”不是“等待”痛过一次之后就长记性了。多线程死锁不会因为你背熟了四个必要条件就远离你的代码。真正让它远离的是把锁顺序写进规范、把超时机制当成习惯、把检测手段接进回归流程。希望这些实战经验对你有所帮助下次你看到线程栈上的“Found one Java-level deadlock”时能少走几步弯路。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表