早上十点刚过,群里就开始有人喊“服务卡死了”,我打开监控一看,CPU 才用到 30%,内存也没满,但接口平均响应时间从 30ms 一路涨到 3 秒,还在继续往上爬。这个现象很有代表性:资源没耗尽,服务却像被什么东西堵住了。干过几年后端的人应该都能猜到,大概率是线程出问题了——要么是死锁,要么是线程池里的线程全卡在某个地方等一个永远等不到的锁。
这类问题最麻烦的地方在于:它不像内存溢出那样会直接报错,也不像 CPU 飙高那样一眼就能看出是哪个进程在作妖,它藏得很深,经常是“系统半死不活,日志一片祥和”。当年我刚接触多线程编程时,遇到这种问题就是一个头两个大,后来踩的坑多了,才慢慢总结出一套比较完整的排查思路。这篇文章我就把平时排查线程问题的流程、工具和实战案例整理一遍,主要针对 Java 生态,但很多思路放到 C++、Python、嵌入式开发里同样通用。
不管你是写业务代码时被线程池坑过,还是自己写多线程程序时不时遇到数据错乱,或者纯粹是想建立起一套排查线程问题的知识体系,这篇内容应该都能帮上忙。
1. 先搞清楚:线程问题排查的整体思路
很多人一上来就拿着 jstack 狂刷日志,刷了半天也没看出个所以然,原因就是脑子里没有一张地图。线程问题排查跟侦探破案一个道理,先得有线索,再有推理,最后才轮到证据链闭环。
1.1 区分四类典型线程问题
我习惯把线程问题归类成四种,对应着完全不同的现象和排查路径:
第一类是阻塞与等待。线程没死,但就是卡在某个地方不往前走。常见原因包括拿锁等太久、IO 调用没有超时、CountDownLatch 或 FutureTask 一直在等一个永远不会完成的任务。这类问题的特征是:线程数慢慢堆积,请求越积越多,系统吞吐量断崖式下跌,但 CPU 往往不高。
第二类是死锁。两个或多个线程互相持有对方想要的锁,谁都不让谁,直接进入永久等待。经典现象就是 jstack 日志里能看到明显的 "deadlock" 字样,或者线程状态清一色是 BLOCKED,且长期不变化。
第三类是线程池资源耗尽。线程池的线程全被占满,新任务只能往阻塞队列里堆,队列满了之后触发拒绝策略,表现就是接口大量报错或者请求排队时间越来越长。有时你通过 jstack 会看到一堆线程处于 WAITING,都在队列里等着拿 worker。
第四类是数据竞争和线程安全问题。这类最隐蔽,代码不一定会卡住,但数据会错乱。比如两个线程同时读写同一个 HashMap,轻则丢数据,重则 CPU 直接飙到 100%,因为 HashMap 在扩容时发生了死循环。这类问题通常在并发量上来之后才爆发,而且不是每次都能复现。
1.2 建立“看现象、抓现场、找根因、验修复”的闭环
这是我认为排查线程问题最核心的思维框架。别一上来就埋头看代码,先回答四个问题:
第一步,现象是什么?是请求超时、CPU 飙高、还是线程数暴涨?现象决定方向。如果是 CPU 高,优先查热点线程;如果是请求超时,优先查锁竞争和等待;如果是线程数暴涨,优先查线程池配置和连接池设置。
第二步,现场怎么抓?线程问题很多时候是“过了这村就没这店”,进程一重启,现场就没了。所以必须有一套可以快速执行的抓取命令组合,在问题发生时第一时间把线程栈、堆内存、GC 日志全部留下来。
第三步,根因怎么定位?这是最考验经验的一步。拿到线程栈之后,要能快速识别关键线程状态,定位到具体代码行,再结合业务流程判断这个等待是否合理。
第四步,修复后怎么验证?改完代码不是结束,要确认同样的压力下问题不再出现,最好是做了压测或者线上灰度观察一段时间再下结论。
这个闭环看起来简单,但每一步都有不少细节,后面我一个个展开。
2. 工具准备:能抓到现场才是关键
工欲善其事,必先利其器。排查线程问题最怕的是:问题复现了,但你手上没有趁手的工具,只能眼睁睁看着进程重启,所有线索全部丢失。所以我习惯提前把一套“急救包”准备好,有问题直接跑,绝不临时翻文档。
2.1 高频命令:从我实际经验里挑出来的
先说几个我用得最多的命令,都是在 Linux 环境下实测过的:
top 命令不用多说了,但要注意几个字段:%CPU 看谁在疯狂消耗 CPU,RES 看物理内存。排查线程问题时,我一般先按大写 H 切到线程视图,找到 CPU 高的线程,记下它的 PID(十进制的线程 ID),然后转成十六进制,拿到 jstack 里搜。
jstack 是 Java 排查线程问题时最核心的命令,没有之一。用法是 jstack
jstat 用来查 JVM 的 GC 情况和类加载情况。因为有时候线程卡顿只是表象,底层是 GC 频繁 Full GC 导致所有线程都被 STW 暂停。
jmap 用来导堆快照,配合 MAT 分析内存问题。
还有两个容易忽略但很实用的命令:pstack
2.2 一次标准“抓现场”操作:从发现到留档
我一般是这么操作的——发现服务异常后,按顺序执行:
BASH
复制
1
# 查看进程整体情况
2
top -Hp
3
4
# 导出线程栈,连续执行两三次,间隔3秒
5
jstack -l
6
sleep 3
7
jstack -l
8
9
# 查看GC情况
10
jstat -gcutil
11
12
# 如果怀疑内存问题,导堆快照
13
jmap -dump:format=b,file=heap_$(date +%s).hprof
14
15
# 查看打开的线程数
16
ls /proc/
17
18
# 查看TCP连接状态,排查连接池问题
19
ss -ant | grep
为什么要连续执行两三次 jstack?因为单次抓取只能看到一瞬间的状态,线程可能恰好处于某个正常切换点,造成误判。间隔几秒连续抓几次,对比线程栈的变化,才能区分“这个线程真的是卡住了”还是“它只是刚好在等待某件事完成”。
记住一个原则:抓现场宁可多抓,不可少抓。线程栈文件很便宜,多留几份没坏处。最怕的是只抓了一份,定位到一半发现关键线程没录全,还得等下次问题复现。
2.3 通过 Java VisualVM 和 Arthas 做交互式排查
如果你觉得命令行工具不够直观,还可以上可视化工具。Java 自带的 VisualVM 可以看到线程状态实时变化图,阿里开源的 Arthas 更是神器,可以直接在线上环境查看某个线程正在执行的代码,甚至能主动触发一次线程栈打印。
Arthas 的 thread 命令我很常用,它能直接列出当前所有线程及其状态,还可以通过 thread -n
BASH
复制
1
# 查看CPU占用最高的3个线程
2
thread -n 3
3
4
# 查看某个线程的完整栈信息
5
thread
这些工具覆盖了“快速抓现场”和“交互式定位”两个场景,命令行适合写进脚本自动化执行,可视化工具适合人工分析复杂问题。两边结合,排查效率会高很多。
3. 经典问题现场长什么样:死锁、阻塞、线程池耗尽、数据竞争
工具准备好了,接下来就是重点:怎么从线程栈里看出问题。我整理了四类高频问题的现场特征,每一类都用真实案例里最常见的形态来解说。
3.1 死锁现场:jstack 里的明确标记
死锁是最容易定位的一类问题,因为 JVM 自带的 jstack 就能直接检测出来。当你看到类似下面的输出时,基本可以确定死锁无疑:
TEXT
复制
1
Found one Java-level deadlock:
2
=============================
3
"Thread-A":
4
waiting to lock monitor 0x00007f4b2400a800 (object 0x00000000d9d2b830, a java.lang.String),
5
which is held by "Thread-B"
6
"Thread-B":
7
waiting to lock monitor 0x00007f4b2400a480 (object 0x00000000d9d2b888, a java.lang.String),
8
which is held by "Thread-A"
这行输出把死锁的两个主角和它们各自持有的锁都写得清清楚楚。实际排查中,更常见的定位思路是:看到一堆线程处于 BLOCKED 状态,然后去观察它们究竟被哪把锁挡住了,以及这把锁到底在谁手上。顺着这个链条追下去,就能找出循环等待的那条环。
我在参与一个报表系统的排查时,就遇到过一次银行转账与账户汇总互抢锁的死锁。两个线程分别持有账户 A 和账户 B 的锁,又都在等对方的锁,经典死锁。但当时第一眼没看出端倪,因为代码里没有明显的嵌套锁结构,是 AOP 切面把锁的粒度放大了,导致逻辑上并不嵌套的两个方法在实际执行时产生了嵌套关系。等到把所有线程栈导出来,用 markword 对比锁地址,真相才浮出水面。
3.2 阻塞现场:线程状态不等于问题严重级别
阻塞问题比死锁更常见,判断难度也更高。一份 jstack 日志里,你可能会看到大量线程处于 WAITING 或 TIMED_WAITING 状态。别急着下结论说系统有问题,先理清楚它们等的是什么。
WAITING on <锁>:等待一个对象锁或条件变量,如果不被别人唤醒,会一直等下去。比如 object.wait() 或 LockSupport.park()。
TIMED_WAITING on <锁>:带超时时间的等待,比如 Thread.sleep()、object.wait(1000)。超时之后自己会醒。
BLOCKED on <锁>:想拿锁但拿不到,直接进入阻塞。旁边一般会标注这把锁现在被谁持有。
最常见的阻塞坑是:线程池里的线程全卡在 WAITING 状态,它们可能在等数据库连接、等 Redis 响应、等消息队列消费,反正就是等什么外部资源。这时候光看线程栈不够,还得结合连接池监控、慢 SQL 日志、依赖服务状态一起分析。我曾经遇到过一个案例,所有线程都卡在 java.net.SocketInputStream.read 上,看栈以为是网络出了问题,后来一查,是下游服务在响应完请求后没有关闭连接,导致这边的线程在等一个永远不会到来的数据结尾。
所以排查阻塞问题的关键不是看线程栈本身,而是结合外部依赖的实际情况,判断这个“等待”是否合理、是否有超时兜底。
3.3 线程池耗尽:怎么和死锁区分开
线程池耗尽的现场,通常不是一片 BLOCKED,而是一片 WAITING,而且大量线程的名字都带着同一个线程池的前缀,比如 pool-3-thread-1 到 pool-3-thread-50。它们的栈停在执行任务的代码里,说明它们不是没活干,而是活干到一半被卡住了。
区分死锁和线程池耗尽有个很直观的办法:看线程栈的等待对象。死锁是“锁等锁”,线程 A 等线程 B 手里的锁,线程 B 又等线程 A 手里的锁;线程池耗尽则是“任务等线程”,任务全堆在阻塞队列里,前面的任务卡死不出来,后面的任务就只能干等。
问题根源往往是线程池配置不合理:核心线程数太小、队列容量太大、拒绝策略设置不当。我在排查一个导入功能时遇到过这种情况——大批量文件导入任务把线程池打满,每个任务都去解析大 Excel 文件,单个任务耗时十几秒,后面的任务全部堆队列,最终导致整个服务的健康检查请求也被排队,负载均衡器把实例判定为不健康,一连串的连锁反应。
这种问题的排查思路很简单:看线程栈是全部在忙正经事还是卡在某个 IO 上,再结合线程池活跃数、队列深度来判断是流量太大还是任务本身有 bug。
3.4 数据竞争与线程安全:难复现的隐形杀手
数据竞争是最难排查的一类线程问题。它的特点就是“运气差才复现”——数据被两个线程同时改,结果取决于线程调度顺序,跑十次可能只错一次。
我印象最深的是一次 HashMap 并发扩容导致的死循环问题。现象是服务不定时 CPU 飙到 100%,并且持续好几分钟不降。第一次遇到时,top 看到的是某个线程占用 CPU 最高,但 jstack 抓到的栈停在 HashMap 相关代码里,状态是 RUNNABLE。当时没在意,以为只是正常业务处理。后来频繁复现,才发现是多个线程同时往一个非线程安全的 HashMap 里写数据,在某个极端时刻触发了扩容竞态,put 方法陷入了无限循环。
这类问题的排查手段跟其他三类不太一样。线程栈往往看不出明显异常,需要结合代码走查、并发压测、甚至加入 JVM 参数如 -XX:+TraceClassLoading 之类的辅助手段。更重要的是,想根治数据竞争问题,防御性编程比排查更重要:所有多线程共享的容器,一律用 ConcurrentHashMap、CopyOnWriteArrayList,或者加锁保护。
4. 实战案例:从异常到定位的完整链路
理论再多不如实战一回。我重新把之前的“订单服务线程卡死”案例完整走一遍流程,从发现异常到找到根因,记录每一步的关键判断和踩过的坑。
4.1 事故现象与初步判断
当时线上一个订单系统的接口,平时 30ms 内就能返回,某个周四下午突然大面积超时,调用方开始疯狂重试,重试又进一步加重了系统压力。监控看下来:
CPU 使用率只有 20% 左右,排除 CPU 密集型问题
堆内存正常,GC 次数和耗时没有明显波动,排除 GC 问题
线程数从正常的 200 多个暴涨到 1500 多个
服务的健康检查接口也开始超时
线程数暴涨但 CPU 不高,这几乎可以直接锁定是“线程阻塞等待”类问题。我先用 top -Hp
BASH
复制
1
# 连续抓取3次线程栈,间隔5秒
2
for i in 1 2 3; do
3
jstack -l
4
sleep 5
5
done
4.2 jstack 线程栈里的关键线索
打开第一次抓取的线程栈后,我发现了非常明显的模式:有上百个 http-nio-8080-exec- 开头的线程,状态全部是 WAITING,而且等待的地址高度一致:
TEXT
复制
1
"http-nio-8080-exec-47" #68 daemon prio=5 os_prio=0 tid=0x00007f3e701f9800 nid=0x2a5e waiting on condition [0x00007f3e546e3000]
2
java.lang.Thread.State: WAITING (parking)
3
at sun.misc.Unsafe.park(Native Method)
4
- parking to wait for <0x00000000d9d2b830 (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
5
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
6
at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(AbstractQueuedSynchronizer.java:836)
7
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:870)
8
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:1199)
9
at java.util.concurrent.locks.ReentrantLock$NonfairSync.lock(ReentrantLock.java:209)
10
at java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:285)
11
at com.xxx.order.service.impl.OrderQueryServiceImpl.getOrderInfo(OrderQueryServiceImpl.java:87)
12
...
上百个线程都停在同一个位置:OrderQueryServiceImpl.getOrderInfo 的第 87 行,都在等同一把 ReentrantLock。这就有意思了——一个查询订单信息的接口,为什么需要加锁?而且为什么所有线程都卡在拿锁这一个环节?说明有一个线程拿到了锁,但迟迟没有释放。
我再往后翻,找到持有锁的线程。在 jstack 的输出里,状态为 RUNNABLE、名字同样是 http-nio-8080-exec- 的那个线程就是持锁者,它的栈停在:
TEXT
复制
1
at com.xxx.order.service.impl.OrderQueryServiceImpl.queryFromRemote(OrderQueryServiceImpl.java:102)
2
at com.xxx.order.service.impl.OrderQueryServiceImpl.getOrderInfo(OrderQueryServiceImpl.java:85)
3
- locked <0x00000000d9d2b830> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
这个线程在 getOrderInfo 里持锁调用了远程接口 queryFromRemote,而远程接口响应极慢,导致锁一直被占着,其他几百个线程只能排队等锁。
4.3 根本原因分析与修复方案
到这里,根因已经很明显了:代码在查询订单信息时加了一把范围过大的锁,锁内又包含了远程调用,而远程调用的响应时间完全不可控。一旦下游变慢,整个服务的所有查询线程都会被这把锁拖死。
为什么代码会这么写?我翻了提交记录,发现最初的目的是防止同一订单被并发修改时产生脏读,所以给“查询”也加了锁。但查询操作其实可以通过乐观锁或者版本号来解决,完全不需要这么重的互斥锁。
当时我的修复方案是分两层:
第一层,缩小锁范围。把锁从整个 getOrderInfo 缩到真正需要保护的那几行代码,也就是读写本地缓存和更新数据库状态的部分,绝对不把远程调用放进锁内。
第二层,引入超时。即使锁内操作偶发耗时,也不能让锁等待无限期。改用 tryLock 并设置超时时间,拿不到锁就快速失败,而不是无限阻塞。
JAVA
复制
1
// 修复后的伪代码示例
2
public OrderInfo getOrderInfo(String orderId) {
3
// 先读缓存,非关键路径不加锁
4
OrderInfo cached = localCache.get(orderId);
5
if (cached != null) {
6
return cached;
7
}
8
9
boolean locked = lock.tryLock(2, TimeUnit.SECONDS);
10
if (!locked) {
11
// 超时快速返回,不被拖死
12
throw new BizException("系统繁忙,请稍后重试");
13
}
14
try {
15
return doQuery(orderId);
16
} finally {
17
lock.unlock();
18
}
19
}
改完以后,用压测工具模拟了同样的下游慢响应场景,锁竞争明显降低,线程数峰值从 1500 回落到正常范围。上线后连续观察了一周,没有再出现类似超时。
4.4 这个案例教给我的三个排查经验
经历过这次事故,我总结出三条经验,现在排查类似问题已经形成肌肉记忆了。
第一条,看到大量线程阻塞在同一把锁上,先找持锁线程,再看它持锁期间在做什么。如果持锁线程在做 IO 操作、远程调用或者 sleep,那这把锁的粒度大概率是有问题的。
第二条,锁范围一定要和业务风险匹配。保护数据库写入可以加锁,但保护一个读操作通常不需要全链路加锁。多想想能不能用读写锁、乐观锁、版本号、CAS 等方式替代。
第三条,任何锁竞争都必须有超时兜底。即使你觉得锁内操作再快,也要设置超时。分布式场景下引入基于 Redis 的分布式锁时,更要记得设置过期时间,否则一旦持锁节点宕机,整个系统都会卡死。
5. 多语言排查要点差异对比
Java 的 jstack 很好用,但如果你在写 C++、Python、嵌入式或者 C# 程序,就不能完全复用同一套工具链了。我工作中也写过不同语言的多线程程序,每种生态都有自己的排查套路,这里分享几个实用差异点。
5.1 C++ 线程排查:gdb 与 pstack 组合拳
C++ 线程问题的排查比 Java 要原始不少,至少没有个官方自带的“一键看线程栈”的命令。但 Linux 下的 pstack 和 gdb 组合起来,几乎能做到同样的事。
pstack
C++ 里还有一个 Java 不太会遇到的高频问题——析构函数里的死锁。一个线程持锁执行,此时发生异常,栈上对象被析构,而析构函数里恰好又尝试获取同一把非递归锁,直接把自己锁死。JVM 的锁大多数是可重入的,C++ 的 std::mutex 却不是,这种差异化问题在排查时要有意识。
排查 C++ 线程问题时,我还会注意 std::thread 创建的子线程是否有 join 或 detach。之前遇到过一个诡异的现象:主线程正常退出后程序没有结束,后来才发现是有一个子线程没做任何处理,std::thread 析构时导致程序异常终止。这类问题看线程栈帮助不大,而是要靠代码审查。
5.2 Python 线程:faulthandler 和 py-spy 是利器
Python 的 GIL 让它在多线程方面的表现和 Java 差别很大,纯计算任务用多线程几乎不会提升效率,这点很多新手容易踩坑。但在 IO 密集型场景下,多线程还是有用的。
排查 Python 线程问题时,py-spy 是我强烈推荐的工具,它可以在不重启进程、不侵入代码的前提下,打印出 Python 进程里所有线程的当前执行位置。用法比 jstack 还简单:
BASH
复制
1
# 查看所有线程栈
2
py-spy dump --pid
3
4
# 以 top 模式实时查看线程占用
5
py-spy top --pid
Python 3.8 以上版本还可以用 faulthandler 模块,在信号触发时把每个 Python 线程的栈写到文件里,适合周期性抓取现场。我遇到过好几次 Python 线程卡在等某个 threading.Lock 的情形,用 py-spy dump 一看,栈停在 acquire() 的位置,顺藤摸瓜找到持锁线程在做什么,问题很快就定位了。
需要注意,py-spy 需要 root 权限或者跟目标进程同一用户下运行,否则可能无法读取进程内存。另外,如果 Python 里混用了 C 扩展模块,py-spy 的栈信息可能不够准确,这时需要结合 gdb 和 gdb python 的方式查看 C 层的堆栈。
5.3 嵌入式 RTOS 线程排查:栈溢出是第一怀疑对象
嵌入式领域的线程问题侧重点完全不同。在 FreeRTOS 这类 RTOS 系统里,线程任务的问题主要分成两类:一类是任务栈溢出,一类是优先级反转。
任务栈溢出的排查思路很简单:检查系统提供的钩子函数或者栈使用量接口。以 FreeRTOS 为例,uxTaskGetStackHighWaterMark() 可以直接返回任务历史最低剩余栈空间,低于某个阈值就该调大栈尺寸了。在嵌入式里,栈溢出往往不会立刻崩溃,而是静默踩踏相邻内存,导致各种玄学问题,排查起来非常难受,所以建议在开发阶段就把水位线监控打开。
优先级反转在嵌入式里也很常见。低优先级任务持有锁,高优先级任务等锁,导致中间优先级的任务把高优先级任务挤到几乎饿死的状态。FreeRTOS 支持互斥量的优先级继承机制,使用 xSemaphoreCreateMutex 创建互斥量时自动开启。很多开发者在裸机转 RTOS 时容易忽略这一点,用了二值信号量当互斥量,从而埋下优先级反转的隐患。
排查这类问题时,直接看线程栈用处有限,更多是依赖对调度策略的理解和系统全局状态的检测。
5.4 Java 应用里容易被忽略的线程细节
回到 Java,有几个和线程相关的坑我印象很深。一个是Tomcat 的 RMI 线程问题,Tomcat 启动后你会发现一堆 RMI TCP Connection 线程,其中 RMI TCP Connection(idle) 是 JMX 连接的空闲连接,如果不做配置,它会定时重连并可能导致内存泄漏问题。通过对 JMX 连接配置 -Dcom.sun.management.jmxremote.autodiscovery=true 和设置合理的超时参数可以缓解,但当时排查时确实花了不少时间才搞明白这些线程的来源和生命周期。
另一个是守护线程的坑。Thread.setDaemon(true) 表示该线程是守护线程,JVM 中所有非守护线程退出后,守护线程会直接被终止。如果业务逻辑跑在守护线程里,主线程退出后业务会突然消失,而且不会有任何异常日志。我之前遇到一个定时任务半夜突然不执行了,排查半天才发现是因为执行线程被标记成了守护线程,而宿主进程的主线程因某种原因退出了,连带把守护线程一起带走了。所以写生产代码时,重要的定时任务最好用非守护线程,或者干脆交给专门的调度框架来管理。
还有一个非常日常的问题:UI 线程与工作线程混用。在 Android 里用 Fragment 开启子线程、在 Qt 里把 Modbus 串口接收放到线程,这类跨线程操作 UI 的需求非常常见。关键在于:工作线程执行完任务后,必须以 runOnUiThread、Handler、QtConcurrent、信号槽等方式切回主线程更新界面,否则轻则界面无响应,重则直接崩溃。
Java 里查找线程名的信息也很常用,Thread.currentThread().getName() 可以定位当前执行代码的线程,排查问题时我会在关键路径上临时打一行日志打出线程名,有助于快速区分是哪个请求在执行、线程池是否复用了线程。
5.5 C# 线程排查要点速览
C# 生态下,我见过的最典型的线程问题集中在 await 的不正确使用上。很多人混淆了同步和异步的边界,在 async 方法里用 .Result 或 .Wait() 同步等待任务完成,结果在 UI 线程或 ASP.NET 请求上下文里造成死锁。
排查时我一般用 Visual Studio 的并行堆栈窗口和并行任务窗口,能够直接看到线程之间的等待关系。配合 dotnet-dump 工具,在服务器上也可以收集内存转储,离线分析线程调用栈。
如果遇到 C# 线程 waitone 这类问题,一般涉及 EventWaitHandle 或 RegisteredWaitHandle。用 AutoResetEvent.WaitOne() 时最容易踩的坑是超时参数没写,导致线程永久阻塞。我记得一个 Windows 服务就是在看门狗线程里 WaitOne() 无限期等待,某次异常重启后事件丢失,整个服务便卡死在等待状态,直到进程被外部手动杀掉。
6. 排错实战记录:我踩过的五个线程坑
这一节就是纯粹的踩坑记录了。有些问题当时折腾了我大半天,事后回头看发现都算不上复杂,但有个共性:没有第一时间想到“这是线程问题”。写出来给大家当个镜鉴。
6.1 坑一:HashMap 并发写入导致 CPU 飙高
现象很简单:服务运行几天后,CPU 突然 100%,且持续十几分钟不降。top 查看线程,锁定到一个 RUNNABLE 的线程,jstack 显示它正卡在 HashMap 的 putVal 方法里。当时第一反应是“代码膨胀了”,琢磨了好久才意识到是并发扩容问题——多个线程同时写同一个 HashMap,扩容时链表成环,某个查询在环里死循环,CPU 直接被打满。
修复方式很简单:换 ConcurrentHashMap。但排查过程绕了弯路,现在回想起来,如果一开始就养成“多线程共享的可变容器必须用并发容器”这个习惯,这类问题根本不会发生。
6.2 坑二:线程池队列无界导致服务假死
业务方上报“服务完全没有响应了”,我看了堆栈,发现线程池里有 200 多个线程,全卡在日志打印的锁上。再往根源查,原来是用了一个无界队列的线程池,某次大促流量暴涨时,任务全部堆积在队列里,每个任务都打一行大日志,日志框架内部锁竞争严重,线程池所有线程都在等着写日志,服务直接假死。
排查中发现,队列堆积的任务数量级已经到了几十万,单靠增加线程数已经没救了。最后还是靠报警机制先摘流量,再手动清空队列才恢复。这次之后,我给自己定了个规矩:线程池必须用有界队列,并且配上拒绝策略,核心线程数、最大线程数、队列容量三个参数必须做容量规划。
6.3 坑三:双锁嵌套造成的死锁
这是我在一个库存系统里踩的坑。代码逻辑里先后获取两个锁:锁A保护库存,锁B保护订单。某个新功能上线后,另一个方向的操作先拿锁B再拿锁A,死锁就这么产生了。jstack 里的 deadlock 提示很明显,但代码逻辑层面绕了两三层才定位到交叉点。
修复死锁的原则性做法是:所有线程都按相同的锁顺序获取锁,或者使用 tryLock 与超时机制避免无期限等待。还可以使用一把全局大锁,但那样并发度太低。我最后选择的是统一锁顺序,因为改动面最小、性能影响最小。
6.4 坑四:CountDownLatch 等待永不结束
多线程并发处理任务时,用 CountDownLatch 等待所有线程完成是非常常见的模式。但有一次遇到任务卡死,排查发现:其中一个子线程在处理时抛了异常,异常被 try-catch 吞掉,但循环里有一行代码被跳过导致 countDown() 没有执行,外层一直等着计数归零,整个主线程就吊在那里了。
这类问题的核心教训:CountDownLatch 的 countDown() 必须放在 finally 块里,而且尽量设置等待超时。await(30, TimeUnit.SECONDS) 能避免无限期等待,超时后主动判断是否还有未完成的任务,宁可重试也不要死等。
6.5 坑五:单例模式的线程安全
单例模式听起来很简单,但写不好就是个线程安全的大坑。双重检查锁(DCL)模式下,如果没有加 volatile 关键字修饰单例实例,在多线程环境下可能拿到一个没有完成构造的对象——因为指令重排导致了“已经分配内存但未执行构造函数”的中间状态被别人读到了。
别问我怎么知道这个坑的,问就是我确实在线上遇到过调用一个未完整初始化的单例导致空指针异常。现在写单例凡是持久的实例对象,一律 volatile + DCL,或者直接用枚举方式,从根上避免这个问题。
6.6 这些坑的共性教训
我把这些坑放一起总结了一下,发现有个明显的共性:几乎每个问题都是在并发量上来之后才暴露的。单线程环境下代码一切正常,一旦多个线程同时访问共享资源,隐藏的竞态条件、死锁、资源耗尽问题才会真正浮出水面。
所以现在写代码,我会习惯性地问自己几个问题:这个对象会不会被多个线程同时访问?这个锁的范围内有没有 IO 操作?线程池的队列有没有容量上限?等待某个信号有没有超时?这些习惯帮我规避了大量线上事故。
7. 高频问题排查技巧速查
最后这部分做成一个速查表吧,遇到问题时直接对着找答案。
异常现象
可能根因
推荐工具
快速定位方法
CPU飙高
死循环、无锁并发扩容、GC频繁
top、jstack、jstat
top找CPU最高线程,jstack找RUNNABLE栈
服务超时但CPU不高
锁竞争、IO等待、远程调用慢
jstack、strace
看线程是否大量WAITING/BLOCKED,找持锁者
线程数持续上涨
线程池耗尽、连接池无超时
jstack、ss
查看线程池活跃数与队列深度
进程卡死无响应
死锁、资源耗尽
jstack -l
搜索deadlock字样或大量BLOCKED
数据错乱但功能正常
数据竞争、非线程安全容器
代码走查、压测
并发压测,看是否复现
周期性卡顿
GC停顿、定时任务竞争
jstat -gcutil
看GC频率和耗时是否异常
启动后一段时间挂掉
线程泄漏、连接泄漏
pmap、ss
对比线程数和文件描述符数量变化
再说几个平时写代码就能用上的预防思路:
第一,所有多线程等待都给超时。不管是 await()、join()、tryLock()、还是 Future.get(),都设置一个合理的超时时间,超时后走降级逻辑或快速失败。这可能是最简单也最有效的防线程卡死手段。
第二,线程池参数一定要做容量评估。核心线程数、最大线程数、队列容量不是拍脑袋定的,要根据 QPS、任务耗时、依赖服务的处理能力来算。例如单任务平均耗时 100ms、目标 QPS 500,那么至少需要 50 个并发任务在执行;再留一些缓冲,核心线程数、队列容量都应当有依据。
第三,生产环境提前留好诊断接口。很多线程问题需要抓现场,所以我会在开发阶段就预留一个内部接口,可以主动触发线程栈打印、堆转储、线程列表查看,权限做好控制就行。这样线上出问题时,不用依赖运维临时执行命令,直接通过接口就能把现场数据拉下来。
第四,学会看线程转储的差异。连续抓取多次线程栈后,对比同一个线程的状态变化很有价值。如果某线程每次都停在同一行代码上,基本可以断定它被卡住了;如果它每次在不同位置间切换,说明只是正常调度。
写在最后的一点点经验之谈
做后端开发这些年,线程问题几乎成了“成长的必经之路”。踩过的坑多了,渐渐就有种感觉:线程问题虽然千奇百怪,但绝大多数都逃不过“共享资源的并发访问”和“任务之间的等待关系”这两个基本盘。排查方法万变不离其宗,无非是先抓现场、再定位、后治理。很多复杂的线上问题,静下心来分析线程栈,往往就藏在几行朴素的代码里。
如果让我给刚接触多线程开发的读者一条最实用的建议,那就是:先学会看线程栈,再谈优化并发性能。线程栈是一个程序在某一瞬间所有执行路径的快照,读得懂它,你就能在多数线程问题面前做到心里有底。平时写多线程代码时,要把“会不会死锁、会不会阻塞、有没有超时、线程池会不会被打满”这几个问题当成默认的自我检查项。
最后再分享一个小技巧:排查线程问题时,很少有人会注意到线程的名字和数量本身也透露出大量信息。给每个线程起一个有意义的名字,排查问题时能省你很多功夫。比如 order-async-exec-1 和 pool-3-thread-1,后者完全不知道是干什么的,前者一眼就知道是在哪个链路。如果你发现某个线程池的线程数量不受控地增长,通过名字前缀也能快速定位到是哪个模块创建的池子,这在大型系统里真的能救命。