死锁活锁与线程问题排查
并发程序最难的地方不是写出多线程代码,而是在代码进入真实流量后,还能定位线程卡住、吞吐下降、CPU 飙高、请求超时和锁竞争等问题。
这一节重点讲清楚:
- 死锁、活锁、饥饿、线程池饥饿分别是什么。
- 线程问题为什么会导致服务假死。
- 如何通过线程 dump 还原线程状态。
- 线上遇到卡顿、CPU 飙高、锁等待时该怎么排查。
# 1. 线程问题全景
常见线程问题可以按“线程有没有继续做有效工作”来理解。
| 问题 | 现象 | 本质 | 常见原因 |
|---|---|---|---|
| 死锁 | 线程永久等待,业务卡住 | 互相持有对方需要的锁 | 多把锁加锁顺序不一致 |
| 活锁 | 线程一直运行但没有进展 | 反复让步、重试、状态互相干扰 | 过度礼让、无退避重试 |
| 饥饿 | 某些线程长期拿不到资源 | 调度或资源分配不公平 | 优先级不合理、锁竞争严重 |
| 线程池饥饿 | 任务排队不动或相互等待 | 工作线程被阻塞耗尽 | 同池任务互相 get/join |
| 锁竞争 | CPU 低但响应慢 | 大量线程等待同一把锁 | 锁粒度过大、热点数据集中 |
| CPU 飙高 | CPU 持续高位 | 线程忙循环或高频计算 | 死循环、频繁 GC、过度自旋 |
排查并发问题的核心思路是:先判断线程在运行、等待还是阻塞,再把线程状态和业务代码位置对应起来。
# 2. 死锁
死锁是指多个线程互相等待对方释放资源,导致所有相关线程都无法继续执行。
典型死锁:
线程 A
持有 lock1
等待 lock2
线程 B
持有 lock2
等待 lock1
示例:
Object lockA = new Object();
Object lockB = new Object();
Thread t1 = new Thread(() -> {
synchronized (lockA) {
sleepQuietly(100);
synchronized (lockB) {
updateOrder();
}
}
}, "order-update-1");
Thread t2 = new Thread(() -> {
synchronized (lockB) {
sleepQuietly(100);
synchronized (lockA) {
updateOrder();
}
}
}, "order-update-2");
这段代码的问题是两个线程获取锁的顺序不一致。
死锁产生通常需要四个条件同时成立。
| 条件 | 含义 | 破坏方式 |
|---|---|---|
| 互斥 | 资源一次只能被一个线程持有 | 减少独占资源 |
| 持有并等待 | 持有一个锁时继续等待另一个锁 | 一次性申请资源或释放后再申请 |
| 不可抢占 | 资源不能被外部强制夺走 | 使用可超时锁 |
| 循环等待 | 多个线程形成等待环 | 固定加锁顺序 |
生产代码最常用的预防方式是固定锁顺序。
void transfer(Account from, Account to, long amount) {
Account first = from.id() < to.id() ? from : to;
Account second = from.id() < to.id() ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
如果锁对象没有天然顺序,可以引入全局唯一序号,或者用额外的 tie lock 处理相同排序值。
# 3. 活锁
活锁不是线程阻塞,而是线程一直在运行,却因为互相改变策略导致没有实际进展。
示意:
线程 A 发现冲突 -> 让给 B -> 重新尝试
线程 B 发现冲突 -> 让给 A -> 重新尝试
线程 A 发现冲突 -> 让给 B -> 重新尝试
...
活锁常见于过度乐观重试:
while (!tryUpdate()) {
Thread.yield();
}
这段代码的问题不是一定错误,而是没有退避、没有上限、没有降级策略。在高竞争下,所有线程可能不断失败再重试。
更稳妥的写法:
boolean updated = false;
for (int retry = 0; retry < 5; retry++) {
if (tryUpdate()) {
updated = true;
break;
}
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10L * (retry + 1)));
}
if (!updated) {
throw new ConcurrentUpdateException("更新竞争过高,请稍后重试");
}
活锁排查时,线程 dump 可能显示线程处于 RUNNABLE,但业务日志一直重复相同动作。
# 4. 饥饿
饥饿是指某些线程长期得不到 CPU、锁或队列执行机会。
常见原因:
- 长任务占满线程池,短任务一直排队。
- 非公平锁下某些线程长期竞争失败。
- 单线程执行器中某个任务阻塞,后续任务全部无法执行。
- 任务优先级队列中低优先级任务长期得不到调度。
线程池饥饿示例:
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<String> orderFuture = executor.submit(() -> {
Future<String> userFuture = executor.submit(this::queryUser);
return userFuture.get();
});
Future<String> payFuture = executor.submit(() -> {
Future<String> accountFuture = executor.submit(this::queryAccount);
return accountFuture.get();
});
两个工作线程都在等待同一个线程池里的子任务,但子任务没有空闲线程执行,最终卡住。
修复思路:
- 不要在同一个小线程池里提交子任务后同步等待。
- 拆分不同类型线程池。
- 使用异步编排,例如
CompletableFuture.thenCompose。 - 为等待设置超时。
- 限制队列长度并监控排队时间。
# 5. 线程状态怎么看
Java 线程 dump 中常见状态如下。
| 状态 | 含义 | 排查重点 |
|---|---|---|
NEW | 已创建未启动 | 一般不是线上卡顿核心 |
RUNNABLE | 正在运行或等待 CPU/系统调用 | CPU 高、IO 调用、死循环 |
BLOCKED | 等待进入 synchronized | 锁竞争、死锁 |
WAITING | 无限期等待其他线程唤醒 | wait、join、park |
TIMED_WAITING | 带超时等待 | sleep、定时等待、超时锁 |
TERMINATED | 已结束 | 一般无需处理 |
状态转换示意:
NEW
│ start
▼
RUNNABLE
├─ 竞争 synchronized 失败 -> BLOCKED
├─ wait/join/park -> WAITING
├─ sleep/超时等待 -> TIMED_WAITING
└─ run 结束 -> TERMINATED
注意:RUNNABLE 不一定正在消耗 CPU,也可能在等待操作系统层面的网络或磁盘 IO。
# 6. 线程 dump 排查方法
常用命令:
jstack -l <pid> > thread-dump.txt
或:
jcmd <pid> Thread.print -l > thread-dump.txt
建议连续采集 3 到 5 次,每次间隔 5 到 10 秒。
第 1 次 dump -> 看瞬时状态
第 2 次 dump -> 看是否停在同一位置
第 3 次 dump -> 判断是偶发等待还是持续卡住
单次 dump 只能说明那一刻发生了什么,连续 dump 才能看出趋势。
阅读线程 dump 时重点看:
- 线程名称:是否能对应业务线程池。
- 线程状态:
RUNNABLE、BLOCKED、WAITING的比例。 - 栈顶方法:线程当前停在哪里。
- 锁信息:
locked、waiting to lock、parking to wait for。 - 是否出现 JVM 自动识别的 deadlock。
- 多个线程是否卡在同一个类、同一行代码。
典型 BLOCKED 片段:
"order-worker-17" #87 prio=5 os_prio=31 tid=0x... nid=0x...
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.OrderService.update(OrderService.java:88)
- waiting to lock <0x00000007012abcf0> (a java.lang.Object)
这说明线程正在等待进入某个 synchronized 保护的临界区。
# 7. CPU 飙高怎么定位到线程
排查流程:
top 找进程 PID
│
▼
top -H 找高 CPU 线程 ID
│
▼
线程 ID 转十六进制 nid
│
▼
jstack 中搜索 nid
│
▼
查看线程栈顶业务代码
Linux 示例:
top -H -p <pid>
printf "%x\n" <tid>
jstack -l <pid> > thread-dump.txt
如果高 CPU 线程栈顶反复停在同一段业务循环、正则、JSON 解析、加解密或集合遍历代码上,通常就是热点位置。
如果大量线程都在 GC 相关线程或 safepoint 附近,要结合 GC 日志和 JVM 章节继续排查。
# 8. 锁竞争怎么优化
锁竞争优化不是简单把 synchronized 换成 ReentrantLock。先确认竞争来自哪里。
| 优化方向 | 说明 | 示例 |
|---|---|---|
| 减小锁范围 | 锁住必要临界区 | IO、RPC 不放进锁内 |
| 降低共享 | 拆分热点对象 | 分段统计、分账户锁 |
| 固定顺序 | 避免死锁 | 多账户转账按 ID 排序 |
| 读写分离 | 读多写少可用读写锁 | 配置快照读取 |
| 无锁替代 | 用原子类或不可变对象 | 状态标记、计数器 |
| 异步削峰 | 避免请求线程直接争抢 | 队列化处理 |
错误示例:
synchronized void refresh() {
Config remote = configClient.load(); // 慢 IO
this.config = remote;
}
修复示例:
void refresh() {
Config remote = configClient.load();
synchronized (this) {
this.config = remote;
}
}
锁只保护内存状态替换,远程调用放在锁外。
# 9. 线上排查清单
遇到服务卡顿或超时时,可以按下面顺序处理。
- 确认影响范围:单机、单接口、全站、某个下游。
- 保存现场:线程 dump、GC 日志、应用日志、CPU/内存/连接池指标。
- 看线程池:活跃线程、队列长度、拒绝次数、任务耗时。
- 看线程状态:是否大量
BLOCKED、WAITING、同栈RUNNABLE。 - 看锁:是否出现 deadlock 或大量线程等待同一 monitor。
- 看外部依赖:数据库、缓存、HTTP 下游是否慢。
- 做止血:扩容、限流、熔断、重启、关闭问题入口。
- 做复盘:补超时、补监控、改锁顺序、拆线程池、加压测用例。
不要在没有保存现场的情况下反复重启。重启可能恢复服务,但也会抹掉最关键的证据。
# 10. 面试表达
如何排查 Java 死锁?
可以这样回答:
我会先用
jstack或jcmd Thread.print采集线程 dump,并连续采集几次确认是否稳定卡住。然后看 JVM 是否直接报告 deadlock,再看线程状态中是否有大量BLOCKED。重点关注waiting to lock和locked的对象,找到互相等待的锁链,回到业务代码检查多把锁的加锁顺序。修复时通常固定锁顺序、减少嵌套锁、使用可超时锁,必要时拆分共享资源。
如何排查 CPU 飙高?
可以这样回答:
我会先用
top找到高 CPU 进程,再用top -H -p pid找具体线程,把线程 ID 转成十六进制后去线程 dump 中搜索nid。如果多次 dump 中同一个线程都停在相同业务栈,说明那里可能有死循环、过度自旋或高频计算。如果高 CPU 与 GC 线程相关,还要结合 GC 日志、堆使用和对象分配速率排查。
# Tips 快问快答
Q:死锁和活锁有什么区别? A:死锁是线程互相等待不动,活锁是线程一直运行但没有实际进展。
Q:Java 能自动发现所有死锁吗? A:JVM 对对象监视器和部分 ownable synchronizer 死锁有识别能力,但业务层资源等待、数据库锁、分布式锁不一定能自动识别。
Q:线程 BLOCKED 一定是坏事吗?
A:不一定。短时间锁竞争正常,长期大量 BLOCKED 才说明锁粒度、热点资源或死锁可能有问题。
Q:WAITING 是不是死锁?
A:不是。WAITING 只是等待状态,可能是正常的队列消费者等待,也可能是任务互相等待,需要结合栈和持续时间判断。
Q:为什么要连续采集多次线程 dump? A:单次 dump 是瞬时快照,多次 dump 能判断线程是否一直卡在同一位置。
Q:线程池饥饿最常见的原因是什么? A:工作线程内又提交同池任务并同步等待,导致子任务没有线程执行。
Q:死锁怎么预防最有效? A:多把锁必须固定获取顺序,并避免在持有锁时调用外部慢操作。
Q:CPU 飙高时为什么要把线程 ID 转十六进制?
A:线程 dump 中的 nid 通常用十六进制显示,需要转换后才能对应操作系统线程。
Q:锁竞争优化第一步是什么? A:先通过线程 dump、监控或 profiling 确认竞争点,再决定缩小锁范围、拆分热点或改用其他并发结构。
Q:线上卡顿能不能直接重启? A:可以作为止血手段,但重启前最好保存线程 dump、日志和监控现场,否则复盘会缺关键证据。