并发问题排查实战

📋 概述

一句话:线上 CPU 突然飙到 100%,接口响应从 50ms 变成 5s,用户疯狂投诉------怎么在 10 分钟内定位是哪个线程在捣乱?

这是每个 Java 后端工程师迟早会遇到的噩梦。并发问题是最难排查的线上问题之一,原因有三:

  1. 不可复现:同样的代码,测试环境跑一万次没问题,线上跑一次就死锁。
  2. 现象模糊:CPU 100%、响应变慢、数据不一致------到底是死锁、线程泄漏还是 GC?
  3. 工具门槛高:jstack、jconsole、arthas......工具多但不知道什么时候用哪个。

本篇用问题驱动 + 生活类比 + 流程图 + 可跑代码 的方式,把并发问题排查从"看到 CPU 100% 就慌"升级为"10 分钟定位根因"。覆盖 5 类高频并发问题:死锁、CPU 飙高、线程泄漏、OOM 关联线程、任务丢失,每类给排查步骤 + 实战命令 + demo 代码。

💡 生活类比:急诊分诊 + 侦探破案

并发问题排查 = 急诊分诊 + 侦探破案,分三步走:

步骤 急诊类比 侦探类比 对应操作
分诊 看体温、血压、心率 了解案发现场 top 看 CPU/内存,jps 确认进程
锁定嫌疑人 哪个器官出问题 用监控录像缩小范围 top -Hp 找问题线程,jstack 导出栈
取证定罪 检查化验单 分析指纹、时间线 分析线程状态、锁持有关系、等待链
对症下药 开药/手术 抓捕嫌疑人 修复代码、调整参数、加监控

核心心法:先看"生命体征"(CPU/内存/线程数),再定位"嫌疑人"(具体线程),最后"取证"(线程栈分析)。不要上来就看日志,那相当于不量体温就开处方。

🔍 并发问题分类

问题类型 根本原因 典型症状 排查工具 难度
死锁 两把锁交叉持有 应用卡死、CPU 正常、日志无异常 jstack、jconsole、arthas ⭐⭐⭐
CPU 飙高 死循环、自旋、正则回溯 CPU 100%、接口超时、GC 日志正常 top + jstack、arthas thread ⭐⭐⭐⭐
线程泄漏 线程池未关闭、线程未回收 线程数持续增长、最终 OOM jstack、jmap、jconsole ⭐⭐⭐
OOM 关联线程 递归过深/线程数超限 StackOverflowError 或 unable to create native thread jstack、jmap + MAT ⭐⭐
任务丢失 异常吞掉、拒绝策略不当 业务数据丢失、无报错 代码审查、arthas watch ⭐⭐

🔍 死锁排查 ⭐⭐

死锁的 4 个必要条件

死锁(Deadlock)必须同时满足以下 4 个条件,缺一不可:

条件 含义 生活类比
互斥 资源一次只能被一个线程持有 厕所一次只能一个人用
占有并等待 持有资源的线程还在等另一个资源 拿着 A 号等 B 号
不可抢占 不能强制释放已持有的资源 不能把正在上厕所的人拽出来
循环等待 线程之间形成等待环 A 等 B、B 等 A

制造死锁的 Demo

java 复制代码
public class DeadLockDemo {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            synchronized (lockA) {
                System.out.println("Thread-1: 持有 lockA,等待 lockB...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockB) {
                    System.out.println("Thread-1: 拿到 lockB");
                }
            }
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
            synchronized (lockB) {
                System.out.println("Thread-2: 持有 lockB,等待 lockA...");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (lockA) {
                    System.out.println("Thread-2: 拿到 lockA");
                }
            }
        }, "Thread-2");

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("程序结束");
    }
}

运行后程序永远卡住------两个线程互相等待对方释放锁,永远不会结束。

排查步骤

方式一:jstack

perl 复制代码
# 1. 找到 Java 进程 pid
jps -l
# 输出示例:
# 12345 com.example.DeadLockDemo

# 2. 导出线程栈
jstack 12345 > thread_dump.txt

# 3. 搜索 deadlock 关键字
grep -A 20 "Found one Java-level deadlock" thread_dump.txt

输出示例:

vbnet 复制代码
Found one Java-level deadlock:
===============================
"Thread-2":
  waiting to lock monitor 0x00007f8b5c003818 (object 0x00000007aab4a200, a java.lang.Object),
  which is held by "Thread-1"
"Thread-1":
  waiting to lock monitor 0x00007f8b5c006218 (object 0x00000007aab4a210, a java.lang.Object),
  which is held by "Thread-2"

Java level deadlock details:
=============================
"Thread-2":
  at com.example.DeadLockDemo.lambda$main$1(DeadLockDemo.java:18)
  - waiting to lock <0x00000007aab4a200> (a java.lang.Object)
  - locked <0x00000007aab4a210> (a java.lang.Object)

"Thread-1":
  at com.example.DeadLockDemo.lambda$main$0(DeadLockDemo.java:11)
  - waiting to lock <0x00000007aab4a210> (a java.lang.Object)
  - locked <0x00000007aab4a200> (a java.lang.Object)

方式二:jconsole

shell 复制代码
# 启动 jconsole 连接到目标进程
jconsole <pid>
# → 选 "Thread" 标签
# → 点击 "Detect Deadlock" 按钮
# → 自动列出死锁线程及锁对象

方式三:arthas

shell 复制代码
# 1. 启动 arthas 并连接
java -jar arthas-boot.jar

# 2. 一条命令定位死锁
thread -b

# 输出示例:
# "Thread-2" Id=12 BLOCKED on java.lang.Object@1a2b3c owned by "Thread-1" Id=11
#     at com.example.DeadLockDemo.lambda$main$1(DeadLockDemo.java:18)
#     - blocked on java.lang.Object@1a2b3c
#     - locked java.lang.Object@4d5e6f

死锁修复方案

方案 做法 适用场景
按序加锁 所有线程按固定顺序获取锁(A→B) 简单场景,锁数量少
tryLock 超时 ReentrantLock.tryLock(timeout) + 失败回滚 多锁场景,需要容错
锁粒度细化 减小锁范围,降低交叉概率 复杂业务逻辑
死锁检测+自动恢复 Timer 定期 jstack 检测,发现死锁强制释放 高可用系统兜底

❌ 错误做法:用 Thread.sleep() 假装"等一等"来避免死锁------这不是解决方案,只是降低了碰撞概率。

✅ 正确做法:从设计层面消除循环等待------所有线程按同一顺序获取锁

🔍 CPU 飙高排查 ⭐⭐

问题场景

应用 CPU 突然飙到 100%,接口响应变慢,但没有 OOM、没有死锁日志。常见原因:

  • 死循环(如 while 条件永远为 true)
  • 正则表达式回溯(灾难性回溯)
  • 频繁 Full GC(GC 线程占满 CPU)
  • 序列化/反序列化死循环
  • 线程自旋过多

排查步骤

完整 Bash 命令流程

perl 复制代码
# 第一步:找出 CPU 最高的 Java 进程
top
# 记录 PID,比如 12345

# 第二步:找出该进程中 CPU 最高的线程
top -Hp 12345
# 记录 CPU 最高的线程 tid,比如 12367

# 第三步:把 tid 转成 16 进制
printf "%x\n" 12367
# 输出:304f

# 第四步:在线程栈中搜索该 16 进制 nid
jstack 12345 | grep "304f" -A 30
# 输出示例:
# "pool-1-thread-3" Id=13 nid=0x304f RUNNABLE
#     at com.example.BusyLoop.process(BusyLoop.java:25)
#     at com.example.BusyLoop.run(BusyLoop.java:15)

方式二:arthas 一条命令

shell 复制代码
# 找出 CPU 使用率最高的 3 个线程
thread -n 3

# 输出示例:
# "pool-1-thread-3" Id=13 cpuUsage=95.2% RUNNABLE
#     at com.example.BusyLoop.process(BusyLoop.java:25)

# 直接反编译看源码
jad com.example.BusyLoop

制造 CPU 飙高的 Demo

arduino 复制代码
public class CpuHighDemo {
    public static void main(String[] args) {
        // 故意制造死循环
        new Thread(() -> {
            int i = 0;
            while (true) {   // 条件永远为 true,CPU 空转
                i++;
            }
        }, "BusyThread").start();

        System.out.println("主线程运行中,BusyThread 在后台空转");
    }
}

jstack 线程状态统计

拿到 jstack dump 后,先做线程状态统计,快速判断问题类型:

bash 复制代码
# 统计各状态线程数
jstack 12345 | grep "java.lang.Thread.State" | sort | uniq -c | sort -rn

# 输出示例:
#  45 RUNNABLE          ← 有 45 个线程在运行,可能 CPU 飙高
#  12 WAITING           ← 等待中
#   8 TIMED_WAITING     ← 超时等待
#   3 BLOCKED           ← 被阻塞,可能死锁
状态 含义 对应问题
RUNNABLE 数量异常多 大量线程在运行 CPU 飙高、死循环、自旋
BLOCKED 数量 > 0 有线程被阻塞 死锁、锁竞争激烈
WAITING 数量异常多 大量线程在等待 线程池队列积压、wait/notify 配对问题

🔍 线程泄漏排查

问题场景

应用运行一段时间后,线程数从 50 涨到 500、5000......最终抛出 OutOfMemoryError: unable to create new native thread

常见原因

原因 代码示例 后果
线程池未关闭 newFixedThreadPool() 用完不 shutdown 线程一直存活
线程没有回收 new Thread().start() 后不 join 守护线程泄漏
ThreadLocal 泄漏 线程池中使用 ThreadLocal 不 remove 线程复用导致内存泄漏
自定义线程没设 daemon 后台线程不设为守护线程 JVM 无法退出

排查步骤

bash 复制代码
# 1. 查看当前 Java 进程线程数
jstack 12345 | grep -c ""
# 或更精确地:
jstack 12345 | grep "^"" | wc -l

# 2. 按线程名分组统计
jstack 12345 | grep "^"" | sed 's/".*//' | sort | uniq -c | sort -rn | head -20

# 输出示例:
#  200 "pool-3-thread-"
#   50 "http-nio-8080-exec-"
#   10 "main"
# → pool-3-thread 有 200 个!说明某个线程池在泄漏

# 3. 看这些线程在干什么
jstack 12345 | grep -A 5 "pool-3-thread-1"

# 4. 用 arthas 实时监控线程数变化
thread | wc -l

线程泄漏 Demo

java 复制代码
public class ThreadLeakDemo {
    private static final ExecutorService executor = Executors.newFixedThreadPool(10);

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 100; i++) {
            executor.submit(() -> {
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }
        // ❌ 忘记 shutdown!线程池一直存活
        // executor.shutdown();
        System.out.println("任务已提交,但线程池未关闭");
    }
}
csharp 复制代码
# 排查命令
jstack <pid> | grep "^"" | sed 's/".*//' | sort | uniq -c | sort -rn
# 10 "pool-1-thread-"  ← 线程池 10 个线程一直存活

🔍 OOM 与线程

StackOverflowError:递归过深

typescript 复制代码
public class StackOverflowDemo {
    public static void main(String[] args) {
        recursiveMethod(0);
    }

    private static void recursiveMethod(int depth) {
        System.out.println("depth = " + depth);
        recursiveMethod(depth + 1);  // 无限递归
    }
}
perl 复制代码
# jstack 会看到大量重复的递归栈帧
jstack <pid> | grep -c "recursiveMethod"
# 输出:5000+  ← 栈帧深度爆炸

OutOfMemoryError: unable to create new native thread

当系统线程数达到 OS 限制时:

bash 复制代码
# 查看系统线程数限制
ulimit -u
# 输出:4096(Linux 默认)

# 查看当前 Java 进程线程数
ls /proc/<pid>/task | wc -l

# 调大限制(临时)
ulimit -u 65535
错误类型 根因 排查命令 解决方案
StackOverflowError 递归无终止条件 jstack 看重复栈帧 加终止条件、改循环
unable to create native thread 线程数超 OS 限制 `ls /proc//task wc -l`
OutOfMemoryError: heap 堆内存不足 jmap -heap <pid> 检查内存泄漏、调 -Xmx

💻 可跑代码 / 命令汇总

1. 制造死锁 + jstack 排查

java 复制代码
public class DeadLockDemo {
    private static final Object lockA = new Object();
    private static final Object lockB = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (lockA) {
                try { Thread.sleep(100); } catch (Exception e) {}
                synchronized (lockB) { System.out.println("T1 done"); }
            }
        }, "Thread-1").start();

        new Thread(() -> {
            synchronized (lockB) {
                try { Thread.sleep(100); } catch (Exception e) {}
                synchronized (lockA) { System.out.println("T2 done"); }
            }
        }, "Thread-2").start();
    }
}
perl 复制代码
# 编译运行
javac DeadLockDemo.java && java DeadLockDemo
# 程序卡住,Ctrl+C 或另开终端

# 排查
jps -l                        # 找 pid
jstack <pid> | grep -A 20 "Found deadlock"

2. 制造 CPU 飙高 + top/jstack 排查

typescript 复制代码
public class CpuHighDemo {
    public static void main(String[] args) {
        new Thread(() -> {
            while (true) { /* 死循环,CPU 空转 */ }
        }, "BusyThread").start();
    }
}
perl 复制代码
javac CpuHighDemo.java && java CpuHighDemo

# 排查
top -Hp <pid>                  # 找到 BusyThread 的 tid
printf "%x\n" <tid>            # 转 16 进制
jstack <pid> | grep "<hex>" -A 30  # 定位代码行

3. 线程池泄漏 + jstack 排查

java 复制代码
public class LeakDemo {
    private static final ExecutorService pool = Executors.newCachedThreadPool();

    public static void main(String[] args) {
        for (int i = 0; i < 500; i++) {
            pool.submit(() -> {
                try { Thread.sleep(60000); } catch (Exception e) {}
            });
        }
        // ❌ 忘记 shutdown
    }
}
bash 复制代码
javac LeakDemo.java && java LeakDemo

# 排查:线程数持续增长
jstack <pid> | grep "^"" | wc -l          # 线程总数
jstack <pid> | grep "^"" | sort | uniq -c | sort -rn | head -5  # 按名分组

4. Arthas 基础命令

bash 复制代码
# 启动 arthas
java -jar arthas-boot.jar

# 查看所有线程
thread

# 查看 CPU 最高的 N 个线程
thread -n 3

# 查看指定线程栈
thread <id>

# 查看死锁
thread -b

# 反编译类
jad com.example.MyClass

# 监控方法调用
watch com.example.MyClass method '{params, returnObj, throwExp}' -x 2

# 追踪方法调用链路
trace com.example.MyClass method

# 退出 arthas
quit

5. jstack 分析线程状态统计

bash 复制代码
#!/bin/bash
# thread-stats.sh - jstack 线程状态统计脚本
PID=$1
if [ -z "$PID" ]; then
    echo "Usage: $0 <pid>"
    exit 1
fi

echo "=== 线程总数 ==="
jstack $PID | grep "^"" | wc -l

echo ""
echo "=== 线程状态分布 ==="
jstack $PID | grep "java.lang.Thread.State" | sort | uniq -c | sort -rn

echo ""
echo "=== 按线程名分组 TOP10 ==="
jstack $PID | grep "^"" | cut -d'"' -f2 | sed 's/-[0-9]*$//' | sort | uniq -c | sort -rn | head -10

echo ""
echo "=== BLOCKED 线程详情 ==="
jstack $PID | grep -B 2 "BLOCKED" | head -20
arduino 复制代码
# 使用方式
bash thread-stats.sh 12345

⚠️ 常见问题与踩坑

Q1:死锁一定有 "Found one Java-level deadlock" 吗?

不一定。 jstack 能检测的是经典的 Java 级死锁(两把锁交叉持有)。以下情况 jstack 不报死锁:

情况 说明 如何排查
活锁 线程不断重试但永远失败(如 CAS 自旋) 看 CPU 是否飙高,线程栈是否重复
饥饿 低优先级线程永远拿不到锁 看线程状态是否长期 BLOCKED/WAITING
数据库死锁 SQL 层面的死锁 看数据库日志,不在 jstack 体现
线程池队列满 任务提交后永远不执行 看线程池状态、拒绝策略

Q2:jstack 无输出或 "nid" 找不到怎么办?

可能原因及解决:

bash 复制代码
# 1. 权限不足:需要用同用户执行
# 错误:jstack 12345 → Permission denied
# 解决:sudo -u <java_user> jstack 12345

# 2. 进程不存在:确认 pid 正确
jps -l

# 3. jstack 版本与 JDK 不匹配
java -version   # 确认 java 版本
which jstack    # 确认 jstack 路径一致

# 4. 线程已结束:dump 时线程已经退出
# 解决:循环多次 jstack 对比
for i in {1..5}; do jstack 12345 > dump_$i.txt; sleep 1; done

Q3:arthas 怎么安装和启动?

bash 复制代码
# 方式一:直接下载 jar 运行
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

# 方式二:通过arthas-agent attach 到运行中的 JVM
# 在应用启动时加入
-javaagent:arthas-agent.jar

# 方式三:Docker 容器内
# 进入容器后下载并运行
docker exec -it <container> bash
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

Q4:线上能直接 jstack 吗?有性能影响吗?

有影响,但通常可接受。

操作 性能影响 说明
jstack <pid> (STW 几毫秒) 只 dump 线程栈,不停应用
jstack -F <pid> (可能 STW 几十毫秒) force 模式,用 serviceability agent
jmap -dump (STW 几秒到分钟) dump 堆,生产慎用
arthas thread 底层也是 jstack

最佳实践

  • jstack 在生产环境可以使用,建议在低峰期
  • 避免频繁执行(每分钟不超过 1 次)
  • 使用 jstack <pid> > dump.txt 重定向到文件,不要直接看控制台
  • 需要多次 dump 时,间隔 > 5 秒

Q5:如何监控线程数并设置告警?

ini 复制代码
# 方式一:jstack 定期统计
# crontab 每分钟执行
*/1 * * * * jstack <pid> | grep "^"" | wc -l >> /tmp/thread_count.log

# 方式二:JMX 监控(推荐)
# 在 Java 启用 JMX
-Dcom.sun.management.jmxremote.port=9999
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=false

# 方式三:Micrometer + Prometheus(生产推荐)
# 在 Spring Boot 中
# management.endpoints.web.exposure.include=metrics
# 访问 /actuator/metrics/jvm.threads.live

Q6:arthas watch/trace 命令输出太多怎么办?

perl 复制代码
# watch:限制输出次数
watch com.example.MyClass method '{params}' -x 2 #10

# trace:只看耗时 > 100ms 的调用
trace com.example.MyClass method '#cost > 100'

# trace:只看前 5 层调用
trace com.example.MyClass method -n 5

# 清除所有增强
reset

🎯 最佳实践

排查流程图(完整版)

工具选型表

工具 适用场景 优势 劣势 安装方式
jstack 死锁、线程状态分析 JDK 自带,无依赖 静态快照,无实时性 JDK 自带
jconsole 死锁检测、线程监控 GUI 可视化,一键检测 需要图形界面 JDK 自带
arthas 实时排查、方法级追踪 动态增强,不重启 学习曲线较陡 需下载 jar
jmap + MAT 内存泄漏、OOM 堆分析权威 dump 文件大,耗时 MAT 需额外安装
VisualVM 综合监控、线程/内存 可视化好 性能一般 JDK 自带
async-profiler CPU 火焰图 低开销,生产可用 需要额外部署 需下载

监控建议

监控项 告警阈值 推荐工具
线程数 > 500(根据业务调整) Micrometer / JMX
BLOCKED 线程数 > 0 持续 5 分钟 jstack 定期 dump
CPU 使用率 > 80% 持续 3 分钟 Prometheus + Grafana
死锁检测 每 5 分钟 jstack 自动检测 自定义脚本 + 告警
线程池队列 队列积压 > 100 自定义指标上报

检查清单

  • 生产环境有 jstack/arthas 快速接入能力
  • 线程数监控已配置,有告警
  • 线程池都有名称(便于 jstack 识别)
  • 多锁场景使用 tryLock 超时防死锁
  • 所有锁按固定顺序获取
  • 线程池使用后正确 shutdown
  • ThreadLocal 在线程池中使用后 remove

💡 面试要点

  1. 线上 CPU 100% 怎么排查?top -Hp <pid> 找线程 → printf "%x" 转 16 进制 → jstack <pid> | grep <hex> 定位代码行。面试时能说出完整 bash 命令流程,比只说"用 jstack"有区分度。
  2. jstack 能检测所有死锁吗? → 不能。jstack 只能检测 Java 级别的经典死锁(两把锁交叉持有)。数据库死锁、活锁、饥饿、线程池队列满等不在其检测范围。
  3. 死锁的 4 个必要条件是什么? → 互斥、占有并等待、不可抢占、循环等待。破坏任一条件即可预防死锁。最常见的方案是按序加锁 (破坏循环等待)和 tryLock 超时(破坏不可抢占)。
  4. 线程泄漏怎么排查?jstack | grep "^"" | sort | uniq -c | sort -rn 按线程名分组统计,找出数量异常的线程池。常见原因:线程池未 shutdown、ThreadLocal 未 remove、未设 daemon。
  5. jstack 和 arthas thread 有什么区别? → jstack 是 JDK 自带,获取静态快照;arthas thread 底层也是 jstack,但支持 -n N 找 CPU 最高线程、-b 检测死锁、实时监控等高级功能。
  6. 线上能直接 jstack 吗? → 可以,jstack 性能影响很低(STW 几毫秒)。但避免频繁执行(每分钟 ≤ 1 次),建议输出到文件而非控制台。
  7. arthas 怎么用?java -jar arthas-boot.jar 启动 → thread -b 检测死锁 → thread -n 3 找 CPU 最高线程 → jad 反编译 → watch/trace 方法级追踪。
  8. 如何从设计上预防死锁? → ① 所有线程按固定顺序获取锁(破坏循环等待);② 使用 tryLock(timeout) 超时放弃(破坏不可抢占);③ 减小锁粒度降低交叉概率。

📝 总结

要点 记住这一句
排查心法 先分诊(看大盘)→ 锁嫌疑人(定位线程)→ 取证(分析栈)→ 下药(修复)
CPU 飙高 top -Hpprintf "%x"jstack,三步定位代码行
死锁检测 jstack 看 "Found deadlock",arthas thread -b 一条命令
线程泄漏 jstack 按名分组统计,找数量异常的线程池
工具选型 静态分析用 jstack,实时追踪用 arthas,堆分析用 MAT
预防死锁 按序加锁 + tryLock 超时 + 锁粒度最小化
监控先行 线程数、BLOCKED 数、CPU 使用率,三大指标必须有告警

📖 外部参考

相关推荐
SomeB1oody12 小时前
【RustyML入门】7.4. 按需裁剪与模块化集成
开发语言·后端·机器学习·rust·教程
罗超驿12 小时前
SpringBoot 快速入门
java·spring boot·后端
卷无止境13 小时前
除了开发api,FastAPI其实也可以配合jinja2模板写页面
后端·python·fastapi
newerp13 小时前
Redis 操作与缓存策略
后端·程序员·go
newerp13 小时前
GORM ORM 基础
后端·程序员·go
小岛前端13 小时前
AI Skills 已经封神,但新的问题却越来越严重!
前端·后端·github
拖孩13 小时前
一个人 + AI 做的小程序,上线 15 天赚了 10 块 5
前端·后端·微信小程序
newerp13 小时前
CRUD 操作与预处理语句
后端·程序员·go
wei_shuo13 小时前
KES 数据同步与ETL实战:数据集成、转换与实时同步方案
后端
乒乓狂魔147867399700013 小时前
LangGraph:用一张图,编排一群 AI 助手
后端