主题
并发问题排查实战:jstack、arthas 与线上案例
本文是 Java 并发系统学习系列的 L3 实战篇。前置:AQS 源码走读、线程池实战与监控。 学完可以配合面试题食用:死锁的检测与预防
从一次线上告警说起
假设这样一个场景:某天下午你负责的订单服务 RT 从 80ms 涨到 4s,CPU 使用率却只有 15%。看日志没有异常,重启之后过两小时又复现。这类"CPU 不高但服务变慢"的问题,十有八九卡在线程状态上:线程都在等锁、等连接、或者干脆死锁了。CPU 排查那套(top 找进程、找线程)只对 CPU 型问题有效,RT 型问题得看线程在干什么,这就是 jstack 和 arthas 的主场。
排查方法论:三步定位到线程
不管是 CPU 飙高还是 RT 变长,套路是固定的,核心动作是把"问题"从进程缩小到具体线程:
bash
# 1. 找到目标进程(CPU 高时按 CPU 排序,RT 高时按内存/句柄排查)
top
# 假设定位到 PID 12345
# 2. 找到进程内的问题线程
top -Hp 12345
# 假设定位到 TID 12378,CPU 98%
# 3. 线程号转十六进制(jstack 输出里的 nid 是十六进制)
printf '%x\n' 12378 # 输出 305a
# 4. 拿线程转储去搜这个 nid
jstack 12345 | grep -A 20 'nid=0x305a'这套流程背下来,CPU 型问题基本 5 分钟内能看到真凶栈。但 RT 型问题往往没有"某几个线程 CPU 高"的特征,这时候要换思路:先 jstack 连抓三次(间隔 5s),看哪些线程反复处于 BLOCKED/WAITING 且栈指向同一处代码,那就是争抢点或卡点。arthas 的 thread 命令本质就是把这活儿自动化了。
jstack 输出怎么读
jstack <pid> 一次输出几十上百个线程,重点看两样:线程状态和锁信息。挑一个典型片段:
"transfer-thread-1" #23 daemon prio=5 tid=0x00007f8b0c0e8000 nid=0x305a waiting for monitor entry [0x00007f8adf3fe000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.Transfer.transfer(Transfer.java:25)
- waiting to lock <0x000000076ab4cf20> (a java.lang.Object)
- locked <0x000000076ab4cf18> (a java.lang.Object)
at ...逐段拆解:nid=0x305a 是操作系统线程号(十六进制),对应 top 里的 TID;BLOCKED (on object monitor) 说明它在等 synchronized 的锁;waiting to lock <0x...f20> 是它在等的锁地址,locked <0x...f18> 是它自己已持有的锁。排查时拿锁地址做关联:如果线程 A 锁着 f18 等 f20,而线程 B 锁着 f20 等 f18,死锁实锤。
死锁场景下 jstack 末尾还会直接给出检测结论,不用自己人肉配对:
Found one Java-level deadlock:
=============================
"transfer-thread-1":
waiting to lock monitor 0x00007f8b0023c3b5 (object 0x000000076ab4cf20, a java.lang.Object),
which is held by "transfer-thread-2"
"transfer-thread-2":
waiting to lock monitor 0x00007f8b0023c163 (object 0x000000076ab4cf18, a java.lang.Object),
which is held by "transfer-thread-1"常用状态对照:RUNNABLE 是正在运行或等待 IO(注意,Socket read 在 jstack 里也显示 RUNNABLE,别看到 RUNNABLE 就以为在烧 CPU,要看栈里是不是 park/epoll);BLOCKED 是等 synchronized 锁;WAITING (parking) 多半是 LockSupport.park,线程池空闲线程就是这个状态;TIMED_WAITING (sleeping) 是 Thread.sleep。真正要警惕的是大量 BLOCKED 堆在同一把锁,和 WAITING 栈里出现业务代码(说明线程被业务逻辑 park 住了)。
arthas:把套路变成一条命令
JDK 自带工具链的问题是步骤多、容器里常常没有完整 JDK。Arthas 一个 jar attach 上去全搞定,排查并发问题的三板斧:
bash
# 附加到目标进程
java -jar arthas-boot.jar
# 1. 总览:线程、内存、CPU 一屏看完
dashboard
# 2. 看 CPU 最高的 3 个线程(自动做了 top -Hp + printf 转换)
thread -n 3
# 3. 找阻塞源头:谁持锁不放导致其他线程 BLOCKED
thread -b
# 输出类似:
# "transfer-thread-2" is blocking thread "transfer-thread-1", waiting ...
# at com.example.Transfer.transfer(Transfer.java:25)thread -b 是排查锁竞争的捷径,它直接回答"谁是最堵的那个人"。配合 thread --state BLOCKED 能列出所有等锁线程,thread 23 能看单个线程的实时栈。再进一步,watch com.example.Transfer transfer '{params,returnObj,throwExp}' -x 2 可以在线上不重启观察方法出入参,验证猜想时比加日志重启快得多。
三个命令的分工:dashboard 看全局找异常面,thread -n 处理 CPU 型,thread -b 处理锁型。容器环境记得用 --target-ip 或挂进同一个 pod,attach 不上一切白搭。
三个典型案例
案例一:死锁(转账互转)。A 给 B 转账时锁 A 再锁 B,B 给 A 转账时锁 B 再锁 A,并发一撞就死锁。特征是 RT 突增、涉及账户的所有请求全超时,CPU 反而极低(线程全 BLOCKED 了)。jstack 末尾的 deadlock 段直接点名两个线程和两行代码。修复方式常见两种:全局排序加锁(按账户 ID 排序后再锁),或用 tryLock(timeout) 拿不到就放弃并释放已持有的锁。
案例二:线程泄漏。监控里线程数每周涨两千,最后 unable to create new native thread 把服务打死。jstack 里几千个同名线程,栈顶全停在自定义 ThreadLocal 的 get 上——每次请求 new 一个带 ThreadLocal 的对象用完不 remove,更糟的是有个分支漏了 pool.shutdown()。这种问题在压测里就该暴露,漏 shutdown 的线程池在 jstack 里表现为线程存在但栈全在 WAITING (parking) 且长期不消亡。修复:池的生命周期跟着请求/应用走,ThreadLocal 用完 finally 里 remove。
案例三:锁竞争热点,CPU 不高但 RT 高。库存扣减服务用 synchronized 包住整段"查库存-计算-更新",单机 QPS 到 800 后 RT 从 30ms 涨到 900ms,CPU 才 20%。jstack 连抓三次,每次都有几十个线程 BLOCKED 在同一行,锁地址相同。这是典型的锁粒度问题:改成按 SKU 粒度的分段锁(或 ConcurrentHashMap + compute 原子操作)后,锁等待队列拆散,RT 回到 40ms。判断依据就一条:BLOCKED 线程数和锁地址集中度,CPU 数字在这类问题里没有参考价值。
动手实操:复现一次死锁并抓到它
下面这个程序故意制造转账死锁,跑起来用前面三步流程抓现场:
java
public class DeadlockDemo {
private static final Object LOCK_A = new Object();
private static final Object LOCK_B = new Object();
public static void main(String[] args) {
// 线程1:先锁 A 再锁 B(模拟 A->B 转账)
Thread t1 = new Thread(() -> {
synchronized (LOCK_A) {
sleep(200); // 留出窗口让 t2 拿到 B,制造交叉持有
synchronized (LOCK_B) {
System.out.println("t1 got both");
}
}
}, "transfer-thread-1");
// 线程2:先锁 B 再锁 A(模拟 B->A 转账),加锁顺序相反 -> 死锁条件
Thread t2 = new Thread(() -> {
synchronized (LOCK_B) {
sleep(200);
synchronized (LOCK_A) {
System.out.println("t2 got both");
}
}
}, "transfer-thread-2");
t1.start();
t2.start();
}
private static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}编译运行后进程会挂着不动(两个线程互相等,谁也不释放),这就是死锁现场。接下来复现排查三步:
bash
# 找到进程
jps -l | grep DeadlockDemo # 假设 PID 12345
# 抓线程转储,重点看最后一段
jstack 12345 | grep -B 2 -A 8 'Found one Java-level deadlock'
# 输出会指名道姓:transfer-thread-1 持有 0x...f18 等 0x...f20,
# transfer-thread-2 正好相反 -- 与上面案例一完全一致
# 用 arthas 再验证一遍(先 java -jar arthas-boot.jar 附加)
[arthas@12345]$ thread -b
# "transfer-thread-2" is blocking thread "transfer-thread-1" ...
[arthas@12345]$ thread --state BLOCKED
# 列出所有 BLOCKED 线程,核对栈里的行号修这个程序:把两个线程的加锁顺序统一成"先锁 ID 小的账户"(这里固定先 LOCK_A),死锁条件被破坏,程序正常退出。可以试着改完再跑一遍验证。
常见误区与小结
- 误区一:看到 RUNNABLE 就以为在烧 CPU。 网络 IO 阻塞在 jstack 里也是 RUNNABLE,栈里有 epoll/socket 调用说明在等 IO,要结合栈内容判断。
- 误区二:只抓一次 jstack 就下结论。 单次转储是瞬时快照,锁竞争要连抓三次对比,反复 BLOCKED 在同一锁地址才算实锤。
- 误区三:死锁只靠重启解决。 重启止血可以,但根因不修下次照样死;用 jstack 的 deadlock 段定位行号,改成有序加锁或 tryLock。
- 误区四:线程泄漏怪罪于"内存不够"。
unable to create new native thread是线程数打满(往往压到 ulimit),跟堆内存两码事,看 jstack 里同名线程数量即可确认。 - 误区五:容器里没有 JDK 就放弃排查。 Arthas 单 jar 即可 attach(注意文件挂载方式),或开
-XX:+HeapDumpOnOutOfMemoryError之外的jcmd Thread.print替代路径。
预防层面,CodeReview 过一遍这份清单能挡掉大半并发事故:synchronized/Lock 的范围是否最小化、多把锁加锁顺序是否全局一致、tryLock 是否带超时、线程池是否在生命周期结束时 shutdown、ThreadLocal 是否在 finally remove。排查工具再熟练也是事后补救,清单是事前疫苗。
小结:到这里 Java 并发系列的 9 篇走完了,从线程基础、内存模型、JUC 原理到容器、线程池、异步编程,最后用这篇排查实战收口。工具链记三样:top 三步定位、jstack 读状态和锁地址、arthas 的 thread -n/-b。下一篇进入 JVM & GC 系列,从 JVM 总览与运行时数据区 开始。
参考
参考:JDK 官方文档 jstack/Thread.getState 部分;Arthas 官方文档 thread 命令一节(https://arthas.aliyun.com/doc/thread);《Java 并发编程实战》第 10 章避免活跃性危险。