jstack 线程栈分析
线程转储是某一时刻 JVM 内所有线程的现场快照。它适合定位线程卡在什么位置、等待哪把锁、谁持有锁,以及同一问题是否持续出现;它本身不能直接证明 CPU、GC 或下游依赖就是根因,必须和监控、日志及多个时间点的快照一起判断。
从发现异常到定位进程
识别 Java 服务异常信号
在开始线程转储之前,先确认服务确实出现异常。典型信号包括:
| 异常表现 | 可能原因 | 初步验证命令 |
|---|---|---|
| 接口响应变慢或超时 | 线程阻塞、锁竞争、下游依赖慢 | curl -w '%{time_total}\n' <API> |
| CPU 持续高位 | 死循环、GC 频繁、热点计算 | top 或 htop 查看进程 CPU |
| 内存持续增长 | 内存泄漏、大对象堆积 | free -h、ps aux 查看 RSS |
| 进程假死无响应 | 死锁、线程耗尽、STW 长时间 | jcmd <pid> VM.uptime 无响应 |
| 日志大量异常或停止输出 | 线程池满、日志写入阻塞 | tail -f application.log |
用 jps 定位 Java 进程
jps 是 JDK 自带工具,用于列出当前用户的所有 Java 进程。它比 ps 更精准,且直接显示主类名。
# 列出所有 Java 进程及其启动类
jps -l
# 输出示例:
# 12345 com.example.Application
# 67890 org.apache.kafka.Kafka
# 23456 org.elasticsearch.bootstrap.Elasticsearch
# 显示完整启动参数(用于确认服务身份)
jps -v
定位目标进程的方法:
- 按主类名匹配:如果知道服务的主类名,直接从
jps -l输出中找到对应 PID - 按端口匹配:如果只知道服务端口,用
netstat或ss反查进程
# 根据端口 8080 找到监听的 Java 进程
ss -tlnp | grep :8080
# 或
lsof -i :8080
- 按进程树匹配:容器或 systemd 管理的服务,可能需要
ps配合
# 找到所有 Java 进程及其父进程
ps -ef | grep '[j]ava'
容器环境特殊处理:
# 进入容器后再执行 jps
docker exec -it <container_id> jps -l
# Kubernetes Pod
kubectl exec -it <pod_name> -- jps -l
# 如果容器内没有 jps,用 ps 找 PID 1
ps -p 1 -o pid,user,cmd
采集前最终确认
定位到目标进程后,先确认变更授权、实例影响范围和进程状态:
# 确认目标进程 PID 和用户
ps -p <PID> -o pid,user,cmd
# 检查进程是否响应(VM.uptime 是轻量级命令)
jcmd <PID> VM.uptime
# 查看当前线程数(初步判断是否线程泄漏)
jstack <PID> | grep 'java.lang.Thread.State' | wc -l
用 jstack 采集线程转储
推荐采集方式
优先使用 jcmd Thread.print -l。它是 JDK 自带的诊断接口,-l 会输出 java.util.concurrent 锁相关信息;旧环境或没有 jcmd 时再使用 jstack -l。
PID=12345
OUT_DIR=/tmp/jstack-$PID-$(date +%Y%m%d-%H%M%S)
mkdir -p "$OUT_DIR"
# 保留 JVM 和宿主机上下文
jcmd "$PID" VM.command_line > "$OUT_DIR/vm-command-line.txt"
jcmd "$PID" VM.flags > "$OUT_DIR/vm-flags.txt"
ps -p "$PID" -o pid,ppid,user,etime,%cpu,%mem,cmd > "$OUT_DIR/process.txt"
top -b -H -n 1 -p "$PID" > "$OUT_DIR/top-threads.txt"
# 每 10 秒采集一次,共三次,观察栈是否持续不变
for n in 1 2 3; do
date '+%F %T %z' > "$OUT_DIR/timestamp-$n.txt"
jcmd "$PID" Thread.print -l > "$OUT_DIR/thread-$n.txt"
sleep 10
done
echo "采集完成,文件保存在: $OUT_DIR"
ls -lh "$OUT_DIR"
如果没有 jcmd,将循环中的命令替换为:
jstack -l "$PID" > "$OUT_DIR/thread-$n.txt"
为什么采集三次:
- 单次快照无法区分瞬时状态和持续阻塞
- 三份快照对比可以看出:线程栈是否一直卡在同一位置、锁持有者是否始终不变、CPU 高的线程是否稳定停在同一段代码
- 间隔 10 秒是经验值,可根据问题频率调整(频繁超时可缩短到 5 秒,偶发问题可延长到 30 秒)
分析线程转储
先看全局结论
从最后一份快照开始,按以下顺序检查:
- 文件末尾是否有
Found one Java-level deadlock。这是 JVM 已识别的 Java 锁死锁,应立即保留完整快照并标记涉及线程与锁对象。 - 是否存在大量相同业务线程停在同一调用栈。三份快照都没有变化时,通常是持续阻塞或线程池耗尽。
- 是否有大量
BLOCKED、大量新建线程,或线程数明显超过线程池的预期上限。 - 线程池工作线程是否都在等待外部依赖、连接池、队列或同一把锁。
- 高 CPU 场景下,是否有少数
RUNNABLE线程在多个快照中稳定停在同一段计算代码。
快速搜索命令:
# JVM 是否发现死锁
grep -n -A 80 -B 5 'Found one Java-level deadlock' "$OUT_DIR/thread-3.txt"
# 粗看线程状态分布
grep -E 'java.lang.Thread.State:' "$OUT_DIR/thread-3.txt" | sort | uniq -c
# 找出常见业务线程名(线程名需要按应用实际约定调整)
grep '^"' "$OUT_DIR/thread-3.txt" | sed -E 's/^"([^" ]+).*/\1/' | sort | uniq -c | sort -nr | head -30
# 统计 BLOCKED 线程数量
grep 'BLOCKED' "$OUT_DIR/thread-3.txt" | wc -l
# 找出所有等待同一个锁的线程
grep 'waiting to lock' "$OUT_DIR/thread-3.txt" | sort | uniq -c | sort -nr
线程状态如何解读
| 状态 | 常见含义 | 需要确认的证据 |
|---|---|---|
RUNNABLE | 正在运行,或处于可运行状态;也可能卡在本地 I/O | 线程 CPU、三份栈是否稳定、系统调用与下游延迟 |
BLOCKED | 等待进入 synchronized 监视器 | waiting to lock、locked、持锁线程的完整调用链 |
WAITING | 无超时等待,如队列、连接池、线程池任务获取 | 这是正常空闲还是所有工作线程都在等同一资源 |
TIMED_WAITING | 有超时等待,如 sleep、定时任务、连接池等待 | 等待时长、超时设置、是否反复超时重试 |
TERMINATED | 线程已结束 | 通常无需单独处理;若线程反复新建又结束,检查创建逻辑 |
WAITING 和 TIMED_WAITING 并不等于故障。以 Web 线程池为例,空闲线程通常在 BlockingQueue.take 或 Condition.await;只有请求堆积、全部工作线程都阻塞且服务延迟上升时,才是异常信号。
锁竞争与死锁
下面的片段表示 http-nio-8080-exec-42 正在等待对象 <0x000000076b1c5ae8>,而其他线程可能在自己的栈中以 - locked <0x000000076b1c5ae8> 持有它:
"http-nio-8080-exec-42" #216 daemon prio=5 os_prio=0 tid=0x00007f... nid=0x2a31 waiting for monitor entry
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.order.OrderService.confirm(OrderService.java:118)
- waiting to lock <0x000000076b1c5ae8> (a java.lang.Object)
at com.example.web.OrderController.confirm(OrderController.java:54)
分析步骤:
- 记录等待线程名、
nid、锁地址、业务入口和调用行号。 - 在同一份转储中搜索锁地址,找到
- locked <...>的持锁线程。 - 沿持锁线程向下看它为什么没有释放锁,例如同步块中进行了数据库、RPC、文件 I/O 或长时间计算。
- 比较三份快照。锁地址、持锁线程和等待线程始终不变,才是持续竞争;若持锁线程持续切换,可能是热点锁。
- 若 JVM 已报告 deadlock,找出循环等待链中的每个锁和线程。修复后还应检查锁顺序是否全局一致。
不要只根据同一个十六进制锁地址跨多次 JVM 重启做结论,地址只在当前进程生命周期内有意义。
高 CPU 线程映射
当遇到 CPU 持续高位时,需要定位具体是哪些线程消耗 CPU,并在线程转储中找到它们的调用栈。
完整排查流程
# 1. 找到 Java 进程 PID(假设是 12345)
jps -l
# 2. 查看该进程的所有线程 CPU 使用情况(十进制线程 ID)
top -H -p 12345
# 或使用 ps
ps -mp 12345 -o THREAD,tid,time,%cpu | sort -k4 -nr | head -20
# 3. 记录 CPU 高的线程 ID(十进制),例如 10801
# 4. 转换为十六进制(jstack 中的 nid 是十六进制)
printf '0x%x\n' 10801
# 输出:0x2a31
# 5. 在三份转储中定位该线程,并比较调用栈
grep -n -A 35 'nid=0x2a31' "$OUT_DIR/thread-1.txt"
grep -n -A 35 'nid=0x2a31' "$OUT_DIR/thread-2.txt"
grep -n -A 35 'nid=0x2a31' "$OUT_DIR/thread-3.txt"
判断标准
高 CPU 的有效证据通常同时满足:
- 该线程在
top -H中持续占用 CPU(不是瞬时峰值) - 多份线程转储的栈稳定落在相同的业务计算、序列化、正则、循环或锁自旋位置
- 栈中没有明显的 I/O 等待(如 socket read、数据库查询)
若栈多次变化或一直在正常业务逻辑之间跳转,优先结合火焰图、JFR(Java Flight Recorder)或应用指标进一步采样。
常见高 CPU 场景
| 栈特征 | 可能原因 | 下一步 |
|---|---|---|
| 稳定停在正则表达式匹配 | 复杂正则 + 大字符串,回溯爆炸 | 简化正则,限制输入长度,或用其他解析方式 |
| 稳定停在 JSON/XML 序列化 | 大对象、循环引用、反射开销 | 分页返回、减少嵌套层级、缓存序列化结果 |
| 稳定停在业务循环计算 | 数据量大、算法复杂度高 | 分批处理、异步化、算法优化 |
parker 或 CAS 自旋 | 锁竞争激烈,线程在自旋等锁 | 检查锁竞争,参考"锁竞争与死锁"章节 |
| GC 线程 CPU 高 | Full GC 频繁或长时间 STW | 检查 GC 日志,调整堆参数,排查内存泄漏 |
常见模式与处置方向
| 现象 | 线程转储特征 | 下一步 |
|---|---|---|
| 请求大量超时 | Web 工作线程集中在 HTTP、数据库或 Redis 客户端等待 | 对照连接池活跃数、下游延迟、超时与重试配置 |
| 线程池耗尽 | 固定数量工作线程全部停在同一阻塞操作,队列增长 | 查线程池大小、队列深度、任务耗时和拒绝策略 |
| 锁竞争 | 大量 BLOCKED 等待同一对象,持锁线程执行慢 I/O | 缩小临界区,避免在锁内调用外部服务,拆分热点状态 |
| Java 死锁 | 文件末尾有 deadlock 报告,线程形成循环等待 | 修复全局锁顺序或改用无锁/高层并发结构,补并发测试 |
| 线程泄漏 | 同类线程名数量持续增长,线程数长期不回落 | 检查线程工厂、定时任务重复注册、客户端生命周期和异常退出路径 |
| 停顿但栈无异常 | 线程多为正常等待,业务仍有长尾延迟 | 对照 GC 日志、STW、磁盘 I/O、网络与依赖服务指标 |
复盘记录模板
将下面信息写入事件记录,保证后续能够复现分析依据:
时间窗口:
服务 / 实例 / Pod:
JVM PID 与版本:
用户影响与指标表现:
采集命令和采集人:
转储文件位置与访问权限:
异常线程(名称、nid、状态、调用栈):
关联锁(地址、持锁线程、等待线程):
关联证据(CPU、GC、连接池、下游延迟、应用日志):
初步根因与置信度:
临时缓解措施:
长期修复与验证项:
结束检查
- 已保留至少三份带时间戳的线程转储
- 已确认是否存在 JVM 报告的 Java 级死锁
- 已对照线程状态、线程池指标、CPU 和下游依赖指标
- 已区分正常空闲等待与持续业务阻塞
- 已记录采集文件的存放位置、访问权限和清理时间