并发问题排查实战

📋 概述

一句话:线上 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 使用率,三大指标必须有告警

📖 外部参考

相关推荐
newerp1 小时前
unsafe 包与底层编程
后端
枫叶V1 小时前
聊天框正在过时:Generative UI 与 A2UI/AG-UI 会怎样改变 AI 应用?
后端
Conan在掘金1 小时前
鸿蒙 7.0 分布式数据盾:DDO 跨端数据对象 + DID 数字身份——可信同步根因
后端
Conan在掘金1 小时前
鸿蒙 7.0 游戏快启:launchAcceleration 预启动 + 冷启预建链——秒开根因
后端
对象存储与RustFS1 小时前
为什么越来越多的企业选择RustFS作为对象存储?
后端·rust·开源
Conan在掘金1 小时前
鸿蒙 7.0 空间音频引擎:AudioSpatializationManager 空间渲染——立体声场根因
后端
会编程的吕洞宾1 小时前
Spring Boot 虚拟线程实战:从原理到生产环境
java·后端·spring
Conan在掘金1 小时前
鸿蒙 7.0 超丝滑方舟引擎:springMotion 物理弹簧动画——真实回弹手感根因
后端
程序员cxuan1 小时前
Codex 接入 DeepSeek-V4-Flash,丝滑的一批
人工智能·后端·程序员