ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

多线程卡死怎么办?死锁排查、线程池调优与并发调试实战

多线程卡死怎么办?死锁排查、线程池调优与并发调试实战 1. 多线程卡死到底卡在哪先搞懂问题本质多线程卡死这个事我踩过的坑比大多数人吃过的饭还多。刚工作那会儿写 Java 多线程程序跑着跑着就一动不动日志停在某个位置不再输出CPU 占用率要么飙到 100% 要么低到 0%用 jstack 打线程栈一看几个线程互相抱着对方需要的锁不撒手。后来做 Python 爬虫、写 C 服务、调 Flutter 的 isolate几乎每种语言都遇到过类似场景。多线程卡死不是某一种语言的专利它是并发编程里最典型、最难缠的一类问题表现形式千奇百怪但底层原因就那么几种。很多人一遇到卡死就重启服务这是最糟糕的处理方式。重启能解决当下问题但根因没找到下次流量一上来照样卡。我见过一个线上服务平均每天卡死两三次运维写了个定时重启脚本凑合了半年直到某次大促前彻底崩盘才有人认真排查结果发现就是一个再简单不过的锁顺序问题。所以搞清楚卡死到底是怎么产生的比背几招应急手段重要得多。从工程角度看多线程卡死可以分成三大类死锁、活锁和线程饥饿。死锁是两个或多个线程互相持有对方需要的资源且都不释放形成循环等待活锁是线程没被阻塞一直在重试某个操作但始终无法推进看起来在忙其实没进展线程饥饿则是某些线程长期拿不到 CPU 时间片或锁资源被其他线程“饿死”。这三类的排查思路和解决手段完全不同混为一谈只会浪费时间。提示判断卡死类型有个快办法——看 CPU 占用。死锁通常表现为 CPU 骤降线程都阻塞了活锁和饥饿往往 CPU 居高不下线程在空转。这是初步定位的第一步。这篇文章面向的是所有写过并发代码的人不管你是写 Java 后端、Python 脚本、C 底层还是 Flutter 移动端只要你的程序里出现了多线程就有必要把这套排查和解决的方法论装进脑子里。接下来我会把四类核心解决手段拆开讲透每一招都配上我实际项目中遇到过的例子包括参数怎么定、代码怎么写、坑在哪尽量让你看完就能直接用到自己的代码里。1.1 为什么多线程程序特别容易“卡”住单线程程序出问题基本就是逻辑写错了报错、返回异常值、崩溃这些都有明确线索可循。多线程完全不一样它的执行顺序是不确定的线程调度由操作系统决定同一份代码在不同机器、不同负载下跑出来的结果可能完全不同。这就是并发问题最坑的地方——你以为测试环境跑通了就万事大吉结果上线就出事。线程之间要靠共享内存通信共享就意味着竞争。为了保证数据一致性你得上锁上了锁就有持有和等待有了等待就有顺序问题顺序一旦错乱就可能死锁。这整条链条像多米诺骨牌任何一环设计不当最终都可能以“程序卡死”的形式爆发出来。而且卡死的现场往往很难复现因为它依赖于特定的执行时序有时候加个日志就能让问题消失这就是典型的“海森堡 bug”。另一个容易忽视的点是多线程卡死不一定是你的代码直接写错了也可能是依赖库的问题。比如某些数据库连接池配置不当连接耗尽后所有线程都在等连接表现上跟死锁一模一样。再比如日志框架在高并发下内部锁竞争激烈也可能拖慢整个服务。排查时一定要把视野从自己写的代码扩展到整个调用链。1.2 卡死、死锁、活锁、饥饿别傻傻分不清楚把这几个概念掰扯清楚很有必要因为面试里常考实际排查时也用得上。我整理了一张对照表把几种状态的核心特征列出来方便你快速对号入座。状态类型线程行为CPU 表现能否自行恢复典型诱因死锁线程阻塞互相等待极低或骤降基本不能锁顺序不一致、嵌套锁活锁线程运行但无法推进偏高理论上可能重试策略不当线程饥饿部分线程长期得不到执行因情况而异视调度策略优先级设置不合理资源耗尽线程等待外部资源偏低资源释放后可恢复连接池、线程池配置过小无限循环线程死循环满载某核不能边界条件写错这张表是我排查问题时脑子里过一遍的清单。拿到一个卡死现场先看 CPU再看线程栈然后往这几类里套命中率很高。比如 CPU 满但没进展八成是活锁或死循环CPU 空但线程全在 waiting那就是死锁或者资源等待。2. 第一招锁顺序统一从根上掐死死锁死锁是所有卡死问题里最常见也最经典的一种。它产生的四个必要条件分别是互斥、占有且等待、不可抢占、循环等待。这四个条件缺一不可所以破解死锁的思路也很直接——破坏其中任意一个即可。实战中最容易操作、副作用最小的就是破坏“循环等待”也就是让所有线程按统一的顺序去获取锁。2.1 死锁是怎么一步步形成的举个我自己项目里的真实例子。有个订单处理系统两个线程一个负责从账户 A 转账到账户 B另一个负责从 B 转到 A。为了线程安全转账前要锁住转出账户再锁住转入账户。问题就出在这线程 1 锁了 A 等 B线程 2 锁了 B 等 A两边都不撒手程序就卡住了。代码大概长这样// 有死锁风险的写法 public void transfer(Account from, Account to, double amount) { synchronized (from) { synchronized (to) { from.debit(amount); to.credit(amount); } } }这个写法在单线程测试下永远不会出问题因为一次只有一个线程跑。但一旦并发上来A 转 B 和 B 转 A 同时发生死锁概率就上来了。关键在于两个线程获取锁的顺序相反形成了循环等待。2.2 统一锁顺序的具体做法解决办法就是给每个账户一个唯一 ID转账前先比较两个账户的 ID永远先锁 ID 小的再锁 ID 大的。这样无论转账方向如何获取锁的顺序都是一致的循环等待就被破坏了。public void transfer(Account from, Account to, double amount) { 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、哈希值、内存地址只要全局一致就行。注意统一锁顺序的前提是你知道所有需要同时持有的锁。如果锁是动态获取的、数量不固定这个办法就不灵了得换招。2.3 用超时机制做兜底统一锁顺序能解决大部分死锁但不是万能的。有些第三方库内部的锁你控制不了或者业务逻辑太复杂没法保证顺序。这时候可以用尝试获取锁加超时的方式做兜底。public boolean transferWithTimeout(Account from, Account to, double amount, long timeoutMs) { long deadline System.currentTimeMillis() timeoutMs; while (System.currentTimeMillis() deadline) { if (from.getLock().tryLock()) { try { if (to.getLock().tryLock()) { try { from.debit(amount); to.credit(amount); return true; } finally { to.getLock().unlock(); } } } finally { from.getLock().unlock(); } } // 没拿到锁随机退避一下再重试避免活锁 try { Thread.sleep(ThreadLocalRandom.current().nextInt(10, 50)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } return false; }这段代码有几个细节值得说。第一tryLock带超时拿不到就直接返回不会无限等下去死锁的必要条件“不可抢占”被破坏了。第二失败后睡眠一小段随机时间再重试这个随机很重要如果所有线程都固定睡眠同样时长容易形成活锁——大家同时醒来又同时抢锁反复循环。第三外层还套了一个总超时防止重试次数过多拖垮调用方。这里的随机退避时间我一般定在 10 到 50 毫秒之间。太短了竞争激烈太长了响应迟钝。实际项目中可以根据锁的粒度和业务 QPS 调整高并发场景可以缩到 1 到 10 毫秒。2.4 死锁排查的实战手法就算预防做得再好老代码里还是可能藏着死锁。线上发现服务卡死第一时间要抓现场。Java 用jstack pid打出线程栈查找输出里的 “Found one Java-level deadlock” 字样它会直接把死锁的线程和锁对象列出来一目了然。Python 可以用py-spy dump --pid pidC 用gdb附加后thread apply all bt看所有线程栈Flutter 的 isolate 之间如果卡住可以用 Observatory 或者 DevTools 看 isolate 状态。排查时有个技巧重点看处于 BLOCKED 或 WAITING 状态的线程在等什么。顺着等待链一路追下去如果追回起点形成了环那就是死锁无疑。另外jstack建议连续打三次间隔几秒三次的等待链如果完全不变基本可以确认是死锁而不是暂时性阻塞。3. 第二招线程池参数调优别让资源等待拖垮服务线程池是好东西用错了就是灾难。我见过太多线上故障是线程池配置不当引起的表现同样是“卡死”但根因是线程饥饿或者资源耗尽跟死锁完全是两码事。排查时如果一上来就往锁上找方向就错了。3.1 线程池参数到底该怎么算线程池的核心参数有四个核心线程数、最大线程数、队列容量、拒绝策略。很多人配置的时候凭感觉核心线程数写个 10队列写个 1000跑起来发现要么 CPU 没吃满要么请求全堵在队列里。我来给一个可落地的计算方法。先确定任务的类型。CPU 密集型任务大量计算、加密解密、序列化线程数一般设为 CPU 核数加一。为什么加一因为偶尔会有线程因缺页中断等原因短暂停顿多一个线程能顶上保持 CPU 利用率。IO 密集型任务数据库查询、网络调用、文件读写线程数可以设得大一些因为线程大部分时间在等待 IO计算资源是空闲的。经验公式是线程数 CPU 核数 × (1 平均等待时间 / 平均计算时间)举个实际的数假设一个任务平均计算 20 毫秒等待 IO 80 毫秒CPU 是 8 核那么线程数 8 × (1 80/20) 40。这个值比很多人拍脑袋定的 8 或 16 大不少但确实更贴合 IO 密集场景。队列容量的设置也很关键。队列太小线程忙不过来时任务直接进拒绝策略用户体验差队列太大任务在队列里排队太久响应时间飙升极端情况下内存还被撑爆。我的经验是队列容量不要超过线程数的两倍宁可让任务快速失败并触发扩容也不要让它们在队列里慢慢排队。3.2 一个真实的线程池踩坑案例之前有个服务核心线程数 8最大线程数 8队列容量 2000用的CallerRunsPolicy。平时 QPS 低看不出问题某次上游推送流量翻了三倍问题来了所有线程都在处理任务新任务全往队列里塞队列迅速堆到 2000然后开始触发CallerRunsPolicy把任务回调给调用线程执行。调用线程是 Tomcat 的工作线程被这么一占用连接受理能力急剧下降整个服务对外表现就是响应越来越慢直到超时。排查的时候先看线程池监控发现活跃线程数一直顶在 8队列长度接近满值。再看 Tomcat 线程数也快满了。问题定位得很清楚线程池容量不足加拒绝策略选择不当。后来把核心线程数调到 32队列缩到 64拒绝策略换成AbortPolicy配合上层重试服务就稳了。这个案例说明一个道理线程池的“卡死”往往不是真的死而是资源被耗尽后的排队等待。表现上跟死锁很像但根因和解决手段完全不同。3.3 动态调整与监控的重要性线程池参数不是配一次就一劳永逸的流量会变业务会变参数也得跟着调。Java 里可以用ThreadPoolExecutor提供的setCorePoolSize和setMaximumPoolSize在运行时动态调整配合配置中心做到不重启就能改参数。这是我强烈推荐的做法尤其线上服务。监控方面必须盯住这几个指标活跃线程数、队列长度、完成任务数、拒绝任务数。活跃线程数长期处于高位说明容量可能不足队列长度持续增长说明处理速度跟不上拒绝任务数非零说明策略已经触发要立刻关注。这些指标接到监控面板上出问题能第一时间发现不至于等用户投诉才知道。提示线程池的拒绝策略在生产环境慎用CallerRunsPolicy它会把压力传导到调用方线程容易引发连锁反应。AbortPolicy配合上游重试通常是更可控的选择。线程池这块还有个容易忽略的点就是线程泄漏。如果任务里用到了阻塞操作又没有正确处理中断线程可能永远卡在某个调用上导致池子里的线程越来越少最终无线程可用。排查方法是看线程池的实际线程数和各线程的状态如果有大量线程处于 WAITING 且栈顶是同一个阻塞方法基本都是这个原因。4. 第三招用好调试工具让卡死现场无处遁形工具是排查卡死的眼睛和耳朵。凭直觉猜问题所在效率极低还容易误判。我这些年用下来几套工具组合拳非常管用不同语言各有一套但思路是相通的抓现场、看栈、找等待环。4.1 Java 生态的排查工具链Java 的排查工具最成熟。jstack是基础款打线程栈适合初步定位。jconsole和jvisualvm是图形化工具能看线程状态分布、CPU 占用曲线直观但连接线上环境有时不方便。Arthas是近几年我最常用的功能强大能在线诊断不用改代码不用重启。用 Arthas 排查卡死我一般会走这么几步。先thread命令看一下所有线程的状态概览它会按 CPU 占用排序同时列出线程数统计。如果发现 BLOCKED 状态的线程特别多用thread -b直接找出阻塞其他线程的元凶它会提示哪个线程持有锁并阻塞了多少个线程。这一步往往就能锁定问题线程。然后对有嫌疑的线程用thread id打栈看它卡在哪行代码。# 启动 Arthas 附加到目标进程 java -jar arthas-boot.jar pid # 查看线程状态统计和 CPU 占用最高的线程 thread # 找出阻塞其他线程的“罪魁祸首” thread -b # 查看指定线程的调用栈 thread 23再配合jstat看 GC 情况jmap看堆内存基本上 Java 服务的卡死问题都能定位。有个细节要注意jstack打线程栈本身会暂停应用一小会儿STW高频调用有风险线上排查时别打太多次控制在三次以内。4.2 Python 与 C 的调试思路Python 因为 GIL 的存在多线程主要用于 IO 密集场景卡死排查相对简单。py-spy是神器不用改代码就能 dump 出所有线程的调用栈而且对目标进程几乎无影响。用法很简单py-spy dump --pid pid一条命令搞定。如果是进程级别的卡死配合strace看系统调用能快速判断是卡在锁上还是卡在 IO 上。# dump 指定进程的所有线程栈 py-spy dump --pid 12345 # 跟踪进程的系统调用看卡在哪个 syscall strace -p 12345 -fC 的排查环境最硬核gdb是主力。附加到进程后用info threads列出所有线程thread apply all bt打出所有线程的调用栈然后人工分析哪些线程在等锁、哪些在正常运行。如果程序编译时带了调试符号栈信息会非常清晰。C 还有个pstack命令作用和gdb类似但更轻量适合快速抓栈。生产环境如果没有调试符号栈信息会比较难看建议发布时保留符号表。# gdb 附加到进程 gdb -p 12345 # 列出所有线程 info threads # 打印所有线程的调用栈 thread apply all bt4.3 Flutter 与移动端的卡死定位Flutter 的并发模型比较特殊用的是 isolateisolate 之间不共享内存通过消息通信。所以传统意义上的锁死锁在 Dart 层面很少见但 isolate 之间如果互相等待消息同样会卡死。排查时用 DevTools 的 Performance 面板看各 isolate 的运行状态或者用 Observatory 的_getIsolate之类接口看 isolate 消息队列。另外主 isolate 如果被大量计算阻塞UI 会直接卡住这种要重点看是不是把耗时任务放在了主 isolate 里。移动端另一个常见卡死是主线程做了网络请求或文件读写导致 UI 无响应。这个不是多线程死锁但现象上一样是“卡死”。解决办法就是把耗时操作挪到后台 isolate 或线程主线程只负责 UI 渲染。4.4 工具选型的对比思路语言/平台首选工具适用场景侵入性JavaArthas在线诊断、动态排查极低Javajstack快速抓栈低有短暂 STWPythonpy-spy无侵入 dump极低Cgdb / pstack底层排查低FlutterDevToolsisolate 状态低工具再多核心逻辑就一条把不可见的线程状态变成可见的栈信息然后顺着等待关系找环。掌握这个思路换任何语言都不慌。5. 第四招从架构层面降低并发复杂度前面三招都是在“发生问题后解决或预防”这第四招是从源头减少多线程卡死概率的思路。很多并发问题根本不需要那么复杂的锁是架构设计得过于共享了。把共享减少把耦合降低卡死的土壤就少了。5.1 无锁数据结构的适用场景锁是并发问题的根源那能不能不用锁能。无锁lock-free数据结构通过原子操作CAS来保证线程安全避免了锁的持有和等待从原理上就杜绝了死锁。Java 的ConcurrentHashMap、AtomicInteger、LongAdderC 的std::atomic都是无锁或乐观锁的实现。但无锁不等于无脑用。CAS 会带来 ABA 问题和自旋开销高竞争下自旋会白白浪费 CPU。我一般这么选读多写少的共享状态用 CAS 类原子结构写多且竞争激烈的场景老老实实用锁。比如计数器、状态标志这种用原子变量非常合适而复杂对象的复合操作还是锁更省心。5.2 消息队列与异步化拆解并发最高的境界是不并发用消息队列把同步调用变成异步处理天然避免了多线程共享状态。比如订单创建的流程原来是在一个方法里同步调支付、扣库存、发通知多个线程同时进来就得处处加锁。改成把订单事件丢进消息队列各下游消费者各自处理各自的共享状态大幅减少锁的需求也就下降了。异步化还有一个好处是削峰。突发流量先堆到队列里后端按自己的节奏消费不会因为瞬间并发过高导致线程池打满、资源耗尽。这也是为什么大型系统里消息队列几乎是标配。5.3 不可变对象与线程封闭不可变对象Immutable Object天生线程安全因为它创建后状态就不变了多个线程随便读都没问题根本不需要同步。Java 的String、Integer这些包装类就是不可变的。设计时尽量让共享的数据不可变能省掉大量锁。线程封闭Thread Confinement是另一个思路把数据限制在单个线程内访问不让它外泄。最典型的就是ThreadLocal每个线程有自己的副本互不干扰。数据库连接、用户上下文这些经常用这种方式管理。不过要注意ThreadLocal用完必须清理否则在线程池场景下会内存泄漏这也是个常见的坑。// 线程封闭的典型用法注意 finally 里一定要清理 private static final ThreadLocalUserContext CONTEXT new ThreadLocal(); public void handleRequest(Request req) { try { CONTEXT.set(loadContext(req)); doBusiness(); } finally { CONTEXT.remove(); // 线程池复用线程不清理就会串数据 } }5.4 架构层面的取舍与成本架构优化效果最好但成本也最高改动大、周期长、风险高。我的建议是分层次推进新项目一开始就按低共享的思路设计老项目则在局部高并发模块逐步重构。不要为了消除一个偶发死锁就把整个系统推翻重来投入产出比算不过来。也不是所有场景都能异步化有些业务必须同步返回结果这时候消息队列就用不上还得靠锁。所以这四招不是互相替代而是组合使用架构层面降低复杂度代码层面统一锁顺序资源层面配好线程池出问题时用工具快速定位。四招配合多线程卡死基本就没有立足之地了。注意异步化不是银弹。引入消息队列后系统的调试复杂度和数据一致性难度会上升需要考虑消息丢失、重复消费、顺序性等问题。选型时要权衡。6. 常见问题与排查速查手册前面讲的是方法和思路这一节把实战中最高频的问题整理成速查表都是我被问过无数次、自己也踩过坑的问题。遇到卡死先对着表过一遍能省大量时间。6.1 卡死问题的现场判断清单现象可能原因第一步动作线程全 WAITINGCPU 低死锁或资源等待打线程栈找等待环CPU 满但无进展活锁或死循环看热点方法调用栈部分请求超时线程池打满查线程池和队列指标服务逐渐变慢线程泄漏或内存泄漏看线程数和内存曲线无响应但进程存活主线程阻塞查主线程栈这张表是我出问题时脑子里过一遍的流程配合前面的工具使用定位速度能快很多。关键是要形成条件反射看到现象立刻能想到往哪个方向查而不是漫无目的地试。6.2 那些年踩过的坑第一个坑是在锁内做耗时操作。有次我在同步块里调了个远程接口正常情况下几十毫秒返回但对方服务偶尔抽风要几秒结果持锁线程一卡其他等锁的线程全跟着卡。后来把远程调用移出同步块先查数据再进锁更新问题解决。记住一个原则锁的范围越小越好锁内只做必要的内存操作。第二个坑是锁的粒度过大。曾经有个服务整个方法都加了个大锁并发一上来全在排队。后来把锁细化只锁真正共享的那一小段数据吞吐量翻了好几倍。锁粒度这个事宁可细不可粗细了最坏是多几次竞争粗了直接串行化。第三个坑是忘了处理中断。线程在wait、sleep、join时被中断会抛InterruptedException如果不处理而是直接吞掉线程可能一直卡在那。正确做法是捕获后恢复中断状态Thread.currentThread().interrupt()或者向上抛让调用方决定怎么处理。第四个坑是嵌套锁没留意。一个方法拿了锁 A调用了另一个拿锁 B 的方法如果别处有相反顺序的调用死锁就来了。这种隐蔽的死锁最难查因为看单个方法都没问题。建议用静态分析工具扫一遍能发现不少这类隐患。6.3 预防卡死的编码习惯与其出了问题再排查不如从编码习惯上预防。我总结了几条自己一直坚持的获取多把锁时永远按统一顺序最好写个工具方法封装起来。锁内不做 IO、不调外部服务、不等待其他线程。优先用现成的高层并发工具ConcurrentHashMap、BlockingQueue、线程池少手写wait/notify。ThreadLocal、锁、连接这些资源用完必须释放放在finally里。关键并发代码写单元测试虽然很难覆盖所有时序但能挡住明显的错误。线上服务加上线程池、连接池、锁等待时间的监控问题早发现。这些习惯看着简单坚持下来能避免绝大多数多线程卡死。并发编程的难度不在于写出能跑的代码而在于写出在各种极端时序下都不会卡住的代码靠的就是这些看似啰嗦的纪律。6.4 面试常考的多线程卡死问题多线程卡死在面试里出现的频率很高尤其是 Java 岗位。常见问法有“死锁的四个必要条件是什么怎么破坏”“怎么用 jstack 排查死锁”“线程池的核心参数怎么设置”“CAS 有什么问题”这些问题背后考察的都是对并发本质的理解不是背答案能应付的。我的建议是把死锁四个条件、线程池参数计算、几种工具的使用方法这几个点吃透然后在脑子里准备一两个自己实际处理过的案例面试时讲出来会很有说服力。案例不用复杂像我前面说的转账死锁、线程池拒绝策略踩坑都是很好的素材。面试官更想听你怎么想、怎么查、怎么解决而不是标准答案。7. 我个人在实际操作中的几点体会写了这么多年并发代码最大的体会是多线程卡死这事的难点从来不在技术本身而在于你能不能快速定位。四招里前面三招是术最后一招是道。术能解决眼下问题道能减少问题发生。真正的高手不是卡死后能快速救火而是设计时就让卡死难以发生。另外一点工具一定要提前熟悉。不要等线上出事了才现学 Arthas、现查 py-spy 用法那时候分秒必争手忙脚乱容易出错。平时在测试环境多练几遍把常用命令做成脚本或者备忘真出事时能省下宝贵的几分钟。我自己的习惯是每个负责的服务都提前准备好一份排查手册写好进程 ID 怎么找、用哪些命令、看哪些指标关键时刻直接照着做。最后一个真实经验多线程问题千万别靠“感觉修好了”。改完代码一定要有明确的验证手段能复现的写测试复现不能复现的至少加监控观察一段时间。我见过太多“改了好像好了”实际上只是问题隐藏起来的情况过段时间换个姿势又冒出来。定位到根因、改对地方、验证到位这三步缺一不可。如果后面还要继续深挖我建议往两个方向走一是研究 JMMJava 内存模型或者各语言的内存可见性规则把并发底层原理吃透二是学一点分布式并发的知识单机多线程的那套东西到了分布式场景会演化出新的问题。但那是另一个话题了先把单机多线程这四招用熟足够应付绝大多数日常工作了。
返回列表