线程问题排查实战:死锁、线程池与数据竞争的定位与修复

发布时间:2026/9/16 23:56:18
线程问题排查实战:死锁、线程池与数据竞争的定位与修复 1. 先建立排查线程问题的整体框架别急着翻代码线程问题大概是并发编程里最让人头秃的一类bug。普通的逻辑错误断点一打、日志一加基本都能揪出来。但线程问题不一样它像幽灵一样偶发、随机、换台机器就不复现、加了日志反而消失了。我见过不少同学第一次遇到这种问题时第一反应是盯着代码逐行看看半天也看不出所以然最后在群里问了一圈别人的回复是你这个问题我需要现场才能定位。1.1 线程问题为什么难查三个不线程问题难查归根结底是三个不不可控线程的执行顺序不受你控制操作系统调度器决定哪个线程什么时候跑、跑多久、什么时候被换下去。你今天跑一万次没问题明天第一次就跑挂了。不可复现很多线程bug依赖精确的时序窗户比如线程A刚读完共享变量、还没来得及写回去线程B就插进来改了值。这个窗口可能只有几微秒靠手工测试基本碰不到。不可直观线程问题不像数组越界那样有明确的崩溃点它的表现往往是结果偶发不对程序卡住不动CPU忽高忽低表象和根因之间隔了好几层。恰恰是这三个不决定了线程问题排查必须走方法论而不是凭感觉。正确做法是先收集现场再缩小范围最后才去读代码。1.2 一套可复用的排查流程我自己在实战中沉淀了一套流程每次遇到线程问题都按这个顺序走确认现象搞清楚到底是卡死、崩溃、还是结果错误。现象不同排查方向天差地别——卡死优先查锁崩溃优先查内存结果错误优先查数据竞争。抓现场第一时间保留现场证据。Java用jstack抓线程转储C/C用gdb attach或者生成core dump别急着重启服务。很多线程问题一重启就再也抓不到了。看资源用top -H看线程级别的CPU占用用jstat看GC情况确认是不是资源耗尽导致的连锁反应。分析转储把线程转储拿下来看线程处在什么状态、等哪把锁、栈顶在哪个方法。还原代码路径带着证据去看代码确定是哪几条执行路径交织产生了问题。修复并验证改完之后用压力测试、并发测试工具反复验证确认修复有效。这套流程看起来简单但很多人就是跳过了第2步直接去读代码——这是最大的坑。1.3 线程排查工具箱不同语言、不同场景怎么选我先把常用的工具整理成一张表后面每个场景再展开讲场景工具核心作用Java线程转储jstack pid看线程状态、锁等待关系、死锁检测Java堆内存jmap -dump排查因内存问题引发的线程异常Java GC情况jstat -gcutil pid确认GC是否频繁导致线程停顿Linux线程级CPUtop -H -p pid定位哪个线程占CPU高系统调用跟踪strace -f -p pid看线程卡在哪个系统调用上C/C调试gdb attach pid附加到进程查看线程栈C/C线程竞争检测ThreadSanitizer编译期插桩检测数据竞争通用pstack pid打印所有线程的调用栈嵌入式RTOSvTaskList()/uxTaskGetStackHighWaterMark()FreeRTOS查看任务状态与栈余量工具不在多关键是要知道每个工具解决哪类问题。下面我按最常见的几类线程问题逐个讲实战。2. 死锁排查的完整链路从程序卡死到锁依赖定位死锁是线程问题里最经典、也最容易复现的一类。它的典型表现就是程序卡住不动不报错、不崩溃、CPU占用率可能还是0%。很多第一次遇到死锁的人完全懵了因为从日志上什么都看不出来。2.1 死锁的四个必要条件不是玄学是数学死锁不是随机发生的它必须同时满足四个条件互斥资源一次只能被一个线程占用。这个条件在现实中基本无法避免锁本身就是为了互斥。持有并等待线程已经持有一把锁又在等待另一把锁。不可剥夺线程持有的锁不能被其他线程强行抢走只能自己释放。循环等待存在一个线程间的等待环A等B、B等C、C等A。这四个条件缺一不可。所以死锁的破解思路也很清晰破坏任何一个条件死锁就解开了。最常见的做法是破坏循环等待——规定所有线程必须按相同顺序获取锁这个后面细说。2.2 一个典型的死锁案例两把锁、两个线程、一次交叉我第一次在实战中定位死锁是一个典型的双锁交叉public class DeadlockDemo { private final Object lockA new Object(); private final Object lockB new Object(); public void method1() { synchronized (lockA) { // 模拟业务处理 sleep(100); synchronized (lockB) { // do something } } } public void method2() { synchronized (lockB) { sleep(100); synchronized (lockA) { // do something } } } }这个例子中的问题一望即知method1先拿A再拿Bmethod2先拿B再拿A。当两个线程同时分别执行这两个方法时线程1持有A等B线程2持有B等A谁都不会放手程序就永久卡死。但实际工作中的代码远没有这么直白可能分布在十几个类、七八个方法里锁是哪个、在哪儿获取的根本一眼看不出来。这时候就需要工具了。2.3 定位死锁的关键步骤jstack输出怎么读遇到程序卡死的现场第一步是拿到线程转储# 找到Java进程PID jps -l # 抓线程转储输出到文件 jstack pid thread_dump_$(date %Y%m%d_%H%M%S).txt抓下来之后不要被几百行的输出吓到核心就找两个东西第一找Found one Java-level deadlock字样。JVM自带的死锁检测会在转储文件里直接标出来Found one Java-level deadlock: Thread-1: waiting to lock monitor 0x00007f8b8c004b28 (object 0x000000076b5e6d30, a java.lang.Object), which is held by Thread-0 Thread-0: waiting to lock monitor 0x00007f8b8c004b58 (object 0x000000076b5e6d70, a java.lang.Object), which is held by Thread-1 Java stack information for the threads listed above: Thread-1: at com.example.DeadlockDemo.method2(DeadlockDemo.java:24) - waiting to lock 0x000000076b5e6d30 (a java.lang.Object) at com.example.DeadlockDemo.method2(DeadlockDemo.java:22) - locked 0x000000076b5e6d70 (a java.lang.Object) ...这一段信息量非常大哪个线程在等哪把锁、那把锁被谁持有、线程的栈顶在哪个类的哪个方法。顺着这个输出你就能画出锁依赖图。第二看线程状态分布。没有死锁但程序也卡住的情况可以看线程转储里大量线程处于什么状态BLOCKED在等锁看它在等哪把锁、谁持有。WAITING/TIMED_WAITING在等wait()/notify()或者join()要检查等待条件是否永远不会满足。RUNNABLE不一定真的在跑可能是正在执行IO或者持有锁。2.4 死锁修复与预防不只改代码还要加防线把死锁定位出来后修复方案要根据现场情况选方案一统一锁顺序。所有线程都先拿A再拿B循环等待就没了。这是最彻底的办法但代码复杂时很难保证所有路径一致。方案二用tryLock加超时。拿不到锁就放弃不让线程无限期等下去ReentrantLock lockA new ReentrantLock(); ReentrantLock lockB new ReentrantLock(); public void method1() { if (lockA.tryLock(3, TimeUnit.SECONDS)) { try { if (lockB.tryLock(3, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lockB.unlock(); } } } finally { lockA.unlock(); } } }超时后可以记录日志、重试或者抛异常至少不会让整个服务静默卡死。方案三加监控。线上环境用ThreadMXBean定期检查死锁发现后自动告警或者尝试恢复。Java本身提供了这个能力ThreadMXBean tmx ManagementFactory.getThreadMXBean(); long[] ids tmx.findDeadlockedThreads(); if (ids ! null) { ThreadInfo[] infos tmx.getThreadInfo(ids, true, true); // 打印死锁线程的栈信息发告警 }这里我想强调一个实战经验修复死锁只是第一步更重要的是让死锁不再静默。很多死锁修复完之后下次还会以新的形态出现。所以死锁检测代码和告警一定要留着让它成为系统的基本能力。3. 线程池问题参数配置、拒绝策略与线程数异常增长如果说死锁是线程问题的显性炸弹那线程池问题就是慢性病。它的隐蔽性极强——服务不会马上挂但性能一点一点下滑最终在某个流量高峰突然雪崩。3.1 线程池为什么是问题高发地线程池的核心价值是复用线程、控制并发度。但它也是最容易被误用的组件我见过的问题基本集中在三类参数拍脑袋配置核心线程数、最大线程数、队列长度完全凭感觉填没有根据业务量估算。任务里嵌套线程池调用线程池里的任务又去调另一个线程池形成依赖链某个环节队列满了就倒灌。任务执行时间远超预期线程池能创建的线程数是有限的任务执行时间长就等于吞吐量上不去队列越积越长。3.2 核心参数推导怎么算出一个合理的线程池先说结论线程池参数没有标准答案但有一套可以复用的推演方法。先看核心线程数的计算公式业界最常参考的是Brian Goetz在《Java Concurrency in Practice》里给出的思路线程数 CPU核心数 * (1 等待时间 / 计算时间)这个公式的核心是线程在执行任务时一部分时间在计算占用CPU一部分时间在等待IO、网络、锁。等待占比越高越需要用更多线程来填满CPU的空闲时间。如果是纯计算任务等待时间接近0线程数等于CPU核心数就差不多了。再结合实际场景来算一个例子。假设一台4核机器业务是调用外部HTTP接口平均耗时200ms其中网络等待180ms本地计算20ms线程数 4 * (1 180 / 20) 4 * 10 40这个40就是理论上的最优线程数。如果参数拍成200线程多了也不会提升吞吐反而会增加上下文切换开销。队列长度的选择也经常被忽视。队列太长会导致任务积压严重业务响应时间飙升队列太短又会让拒绝策略频繁触发。我的习惯是核心线程满后队列长度按峰值QPS × 可接受排队时间来估算。比如峰值QPS是200可接受排队1秒队列就设200左右。最大线程数也不宜和核心线程数差太多。很多人把核心线程设4、最大线程设200这个跨度太大了会导致队列一满就疯狂创建线程资源瞬间被打满。3.3 线程数暴涨的排查思路从线程转储到线程工厂有次线上告警说某服务的线程数从200涨到了3000多我第一反应不是看代码而是先抓现场# 确认进程PID jps # 抓线程转储 jstack pid dump.txt # 统计线程名分布 grep ^ dump.txt | awk -F {print $2} | sort | uniq -c | sort -rn统计完发现大量线程名是pool-17-thread-*说明是线程池创建的线程。接着在转储里单独看这些线程的栈grep -A 20 pool-17-thread-1 dump.txt栈信息直接指到了某个HTTP调用上说明这个线程池里跑的任务在等待外部接口响应而外部接口响应又依赖这个服务的某个接口——典型的线程池互相等待、互相拖垮。最后我们通过调整阻塞超时和重构调用链才把问题解决。这里要提醒一句给线程池起一个有意义的名字。用默认的pool-17-thread-1排查时要先搞清楚17是哪个池子如果线程池名字是order-submit-thread-一眼就能看出是订单提交的池子。用ThreadFactory自定义线程名是最值得做的基础建设ThreadFactory namedFactory r - { Thread t new Thread(r, order-submit-thread); t.setDaemon(true); return t; };3.4 拒绝策略队列满的那一刻发生了什么线程池的拒绝策略是最后一个防线。四种内置策略各自适用的场景差别很大策略行为适用场景AbortPolicy默认抛RejectedExecutionException能接受任务失败且调用方有兜底逻辑CallerRunsPolicy调用者线程自己执行任务不想丢任务且可以接受调用线程被阻塞DiscardPolicy静默丢弃任务允许丢任务如打点、日志DiscardOldestPolicy丢弃队列最老的任务只需要最新数据如实时行情我自己的经验是核心业务场景不适合用默认的AbortPolicy因为它直接抛异常如果调用方没有try-catch请求就断了也不建议用DiscardPolicy因为静默丢任务会让问题更难发现。折中方案是自定义拒绝策略在拒绝时记录日志、做限流降级、或者将任务写入持久化队列等补偿机制。而且我想提醒的是拒绝策略触发本身就是一种信号——说明线程池容量和流量不匹配是容量规划出了问题。只看拒绝策略怎么选不解决容量问题等于治标不治本。3.5 C和Python的线程池注意点C用自定义线程池时一个高频错误是任务队列的size()和empty()没有在锁保护下调用。多个线程同时读写队列导致队列判断为空但实际有任务线程直接退出了。 用std::condition_variable时的经典假唤醒问题也是重灾区等待条件必须放在while循环里std::unique_lockstd::mutex lock(mtx); cond.wait(lock, [this] { return !tasks.empty() || stop; });Python的concurrent.futures.ThreadPoolExecutor相对省心但要特别注意线程池里的任务如果抛异常future.result()才会抛出异常不调result()的话异常会被静默吞掉。很多人写完submit不拿结果异常全被吞了排查半天找不到原因。所以Python线程池的任务函数内部一定要自己捕获异常、记录日志。4. 数据竞争的定位手段从偶发脏读到抓到现场数据竞争是最容易让人崩溃的线程问题——它不崩溃、不死锁就是结果偶尔不对而且没有规律。这类问题我在Java和C项目里都遇到过定位手段有共通之处。4.1 三个核心概念可见性、原子性、有序性要理解数据竞争必须先分清楚三个概念它们是并发正确的基石原子性操作不可被中断。比如i在CPU层面是读、加、写三步线程A做到一半线程B插进来结果就不对了。可见性一个线程对共享变量的修改另一个线程能不能立刻看到。由于CPU缓存的存在线程A改了变量线程B可能还在用旧值。有序性编译器、CPU可能为了优化重排指令。比如线程A先给flag赋值再修数据线程B看到flag变了、以为数据也准备好了实际上数据修改可能还没执行。这三条正好对应Java内存模型JMM里的三大特性。理解了它们再看数据竞争本质就是多个线程同时读写同一段内存且缺少同步机制来保证这三个性质。4.2 Java中的排查方法从jstat到加锁验证在Java中遇到偶发的数据错误我一般按这个顺序排查第一步检查共享变量有没有安全发布。看标的对象是不是被多个线程直接修改。很多问题其实不是并发工具没用对而是根本没有意识到某个对象被共享了。第二步用jstat看GC和线程状态排除内存问题导致的奇怪现象jstat -gcutil pid 1000第三步加日志确认时序。在读写共享变量的关键位置加上线程名和值跑一段时间看日志里读写的交错顺序。这一步往往能直接暴露问题。第四步加锁验证。如果怀疑某段代码有数据竞争就在那段代码上加synchronized或ReentrantLock看问题是否消失。如果加了锁就好、不加锁就坏那就是竞争。这是一个非常有效的二分定位法。4.3 C/C的利器ThreadSanitizer和gdbC/C的数据竞争比Java更难排查因为根本没有语言层面的内存模型保护。好在有两大杀器ThreadSanitizerTSan和gdb。TSan需要在编译期开启插桩g -fsanitizethread -g -O1 -o app app.cpp ./app它会在运行时报出具体的数据竞争点包括两个线程的完整调用栈和共享变量地址。我在一次双线程分别读写大数组的问题中就是用TSan直接定位到了未加锁共享索引。不过TSan对运行环境要求比较高需要shadow memory支持有时编译不过或者跑不起来。这时就退回gdb手动排查# 附加到进程 gdb -p pid # 查看所有线程 info threads # 切换线程查看调用栈 thread 2 btgdb排查数据竞争比较费劲但因为能直接看到每个线程的局部变量和内存地址在理解问题成因上比工具更有帮助。两个方法配合使用TSan负责报位置gdb负责还原现场。4.4 一个实战案例双线程读写大数组的索引竞争前面热搜词里有C两个线程分别读写一个大数组这个场景太典型了。我遇到的具体问题是两个线程一个负责生产数据写入数组一个负责读取数组做后续处理单独跑都正常同时跑时出现部分数据丢失。最开始我怀疑是数组越界加了一堆边界检查什么问题都没发现。后来想到两个线程之间需要一个数据就绪的信号。写线程写完某个位置要通知读线程但如果没有同步读线程可能读到半截数据或者读的数据是旧数据。最终定位到的原因是共享的位置索引变量没有做原子更新。线程A和线程B同时更新索引互相覆盖了对方的进度。修复也简单把索引换成std::atomicint或者给索引更新加锁。这种问题如果不借助TSan或者加日志看时序真的很难靠肉眼看出来。4.5 线程安全容器的选择别用自己的双手实现同步数据竞争的另一个高发场景是容器。很多人习惯用普通的ArrayList、HashMap然后手动加synchronized。我不推荐这种做法原因有二一是容易漏加二是性能不一定好。语言安全容器适用场景JavaConcurrentHashMap高并发读多写少JavaCopyOnWriteArrayList读极多、写极少JavaConcurrentLinkedQueue无界队列生产者消费者Cstd::mutexstd::queue自己封装注意锁粒度Cfolly::ConcurrentQueue高吞吐场景第三方库Pythonqueue.Queue自带锁最稳的选择特别是在Java里ConcurrentHashMap在JDK 8之后的分段锁和CAS优化做得相当成熟绝大多数场景比自己加锁要安全且性能更好。5. 线程与内存的纠缠栈大小、线程泄漏和栈溢出线程问题里有一类很容易被忽略就是线程本身就是内存消耗大户。尤其在高并发服务里线程数量乘以线程栈大小会吃掉大量内存。5.1 线程栈大小别用默认值也别拍脑袋每个线程都有自己的调用栈默认大小随平台和JVM版本变化。Linux x86-64下JVM默认线程栈通常是1MBC/C pthread默认是8MB。这个默认值对很多场景来说太浪费了。一个只处理简单任务的线程池如果有500个线程光栈内存就是500MB。所以线程栈大小要按需调整# Java调整线程栈大小 java -Xss256k -jar app.jar # C pthread设置栈大小 pthread_attr_t attr; pthread_attr_init(attr); pthread_attr_setstacksize(attr, 256 * 1024); pthread_create(thread, attr, thread_func, arg);但栈也不是越小越好。栈太小会导致StackOverflowError尤其在递归调用深的场景。我的建议是先压测观察线上最大栈深度然后给个1.5~2倍的余量。5.2 FreeRTOS中检查线程内存使用两个关键API热搜词里有FreeRTOS中检查线程中内存使用大小的接口这个在嵌入式开发里太重要了。单片机的RAM就那么多任务栈分配多了浪费分配少了直接硬件异常。FreeRTOS提供了两个核心API// 返回任务的历史最小剩余栈空间单位字 UBaseType_t uxTaskGetStackHighWaterMark(TaskHandle_t xTask); // 打印所有任务的状态信息包括栈高水位 void vTaskList(char *pcWriteBuffer);uxTaskGetStackHighWaterMark的用法是任务运行一段时间后调用该函数看高水位线任务曾用到的最大栈深度离栈顶还有多远。如果这个值接近0说明栈快爆了。vTaskList更直观它能输出一张表列出每个任务的状态、优先级、栈高水位等。需要在FreeRTOSConfig.h中开启#define configUSE_TRACE_FACILITY 1 #define configUSE_STATS_FORMATTING_FUNCTIONS 1我踩过的一个坑是某个任务在压力测试时偶尔崩溃查了很久才发现是任务栈分配太小某次更大的数据包进来后栈溢出把相邻内存踩了。后来用uxTaskGetStackHighWaterMark跑压测发现水位线只剩几十字节把栈加大后才稳定。嵌入式开发里任务栈大小不是靠看的是靠测的。5.3 线程泄漏创建后没有join资源无法回收线程泄漏是个隐蔽的问题。C里创建线程后忘记join()或detach()线程对象析构时会直接调用std::terminate()把整个进程干掉。更隐蔽的是循环里创建线程既没join也没detach线程资源越来越多最后进程OOM或者线程数超限。排查线程泄漏Linux下可以直接数线程# 查看进程有多少线程 ls /proc/pid/task | wc -l # 或 ps -L -p pid如果线程数持续增长且不回落基本可以确认有线程泄漏。修复思路通常是确认每个线程都有明确的归途——要么join回收要么detach让他自己结束。5.4 Java的 unable to create new native thread 问题Java中还有一种经典错误java.lang.OutOfMemoryError: unable to create new native thread。这个错误其实不是真的堆内存不够而是进程级线程数到了上限无法再创建原生线程。可用线程数由多个因素共同限制操作系统最大进程数限制ulimit -u系统内存每个线程要分配栈内存JVM参数-Xss线程栈大小、-Xmx堆大小内核参数threads-max、pid_max等排查步骤# 当前进程线程数 ls /proc/pid/task | wc -l # 确认系统限制 ulimit -u cat /proc/sys/kernel/threads-max cat /proc/sys/vm/max_map_count然后对照检查是不是Java堆配了太大内存留给线程的内存不够了是不是线程池参数配置不合理任务堆积导致疯狂创建线程。这个问题的通用解法优先级是优先排查线程池配置和线程泄漏这是根因其次才是调大系统限制这是兜底。别把兜底当正解否则下次流量一高还是会爆。6. 排查线程问题时顺手好用的几个命令组合最后分享一些我实际排查线程问题时常用的命令组合。这些命令单个拿出来都很基础但组合起来能覆盖大多数线程问题场景。6.1 CPU高时定位热点线程线上最常遇到的是CPU高。先看线程级CPU占用再看线程栈# 1. 找到进程 top -p pid # 2. 看进程内哪些线程CPU高 top -H -p pid # 3. 把线程号转成16进制因为线程栈里显示的是16进制nid printf %x\n 线程号 # 4. jstack看栈 jstack pid | grep -A 30 nid0x16进制线程号这一套下来CPU高是哪条线程、跑在哪个方法的哪一行直接锁定。有一次我定位一个C服务的CPU飙升问题就是用top -H看到某个线程CPU占了90%gdb attach后看栈发现一个自旋锁在忙等一查是锁的owner线程挂掉了没有人释放锁。6.2 线程卡住了pstack/strace看阻塞点线程卡住的排查除了看锁还要注意系统调用层。有时候线程不是在等JVM锁而是卡在某个系统调用上——比如网络IO、文件锁、共享内存等待。# 打印所有线程栈 pstack pid # 跟踪系统调用观察线程阻塞在哪个调用上 strace -f -p pid -e tracenetwork,file,ipcstrace输出的是系统调用级别的信息能看到线程在read、write、futex等调用上的状态。futex等待是c线程阻塞的常见信号说明线程确实在等待锁。6.3 几条个人经验送给大家到这儿线程排查的框架、死锁、线程池、数据竞争、内存纠缠这几块都讲完了。最后分享几个我自己的体会第一线程问题的排查七分靠现场三分靠分析。我见过太多人遇到线程问题第一反应是重启一下看看结果重启完什么证据都没了下次出现又抓瞎。正确做法是先保留现场再动手处理。线上服务如果必须重启至少先jstack和jmap把现场留档。第二给代码加基础设施比临时排查更值钱。线程名用有含义的名字、关键路径加日志、核心服务加死锁检测和线程数监控这些小投入能让下次排查快十倍。平时把基础打好出了事才有据可查。第三别迷信工具工具最终要配合理解。死锁检测报告告诉你锁在哪但为什么会有这种锁顺序、怎么从设计上避免还是要回到代码层面思考。工具是照亮现场的灯路还是要自己走。我后来带团队时定了一条规矩任何一次线程问题的排查结束之后必须留下一份排查文档包括现象、采集的数据、分析路径、根因、修复方案、预防措施。这份文档的价值远远大于问题本身——它会慢慢沉淀成一个团队的知识库下次再遇到类似问题不用从零开始。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询