本篇是「JVM 与性能调优系列」第 11 篇。
负载没变,CPU 却跑满,接口全超时。top 看是 Java 进程,但哪个线程干的?死锁更隐蔽——不报错、不崩溃,就是所有请求静静卡住。两件事,一套排查法。
一、CPU 飙高的典型成因
先别急着查,先分类,因为不同成因走不同路径:
- 死循环 / 频繁计算:某段代码在无出口地跑,单线程吃满一个核
- 频繁 GC:老年代涨、Full GC 不停,多个 GC 线程抢 CPU(呼应第 9、10 篇)
- 锁竞争激烈:大量线程抢同一把锁,上下文切换开销暴涨
- 序列化/正则/反射滥用:隐性 CPU 消耗大户
分类清楚了,才能选对工具。下面以最常见的「死循环 + 频繁 GC」为例走一遍。
二、CPU 飙高排查六步
这是每个 Java 工程师都该背下来的套路:
top:找到 CPU 最高的进程 PIDtop -Hp <PID>:找到该进程内 CPU 最高的线程 TIDprintf "%x\n" <TID>:把十进制线程 ID 转成十六进制(jstack 里线程号是十六进制)jstack <PID> | grep -A 20 <nid>:用十六进制 nid 定位线程栈- 看栈里这个线程正在执行什么方法(死循环?GC?业务?)
- 结合源码定位根因,修复
关键点:第 4 步要快,因为线程栈是瞬时的。生产上常用脚本一次抓完,或借助 Arthas 一步到位。
三、实战:死循环导致飙高
jstack抓到的栈里,如果同一个线程反复出现、停在类似位置:
"business-pool-3" #32 prio=5 os_prio=0 tid=0x... nid=0x4f2a runnable at com.xxx.Service.process(Service.java:88) at com.xxx.Service.loop(Service.java:75) // 卡在 while 里且nid=0x4f2a正好对应top -Hp里高 CPU 的线程,基本锁定:第 88 行那个 while 没有退出条件(或退出条件永远不满足)。修复加边界/超时即可。
四、实战:频繁 Full GC 导致飙高
如果top -Hp里高 CPU 的线程名是GC task thread#0、G1 Concurrent Refinement之类,说明CPU 被 GC 吃掉了。
路径就回到前面:看 GC 日志(-Xlog:gc*),老年代是不是一直在涨、Full GC 是不是很频繁。这本质是内存问题(第 10 篇),不是 CPU 问题——别在 CPU 上找错方向。
五、死锁排查
死锁比飙高更隐蔽:进程没崩、CPU 可能还很低,但某些请求永远不返回。
jstack <PID>会自动检测并直接打印死锁:
Found one Java-level deadlock: ============================= "Thread-A": waiting to lock monitor 0x... (a java.lang.Object) which is held by "Thread-B" "Thread-B": waiting to lock monitor 0x... (a java.lang.Object) which is held by "Thread-A"含义:线程 A 拿着锁 1 等锁 2,线程 B 拿着锁 2 等锁 1,互相等,永远卡住。
典型成因:synchronized嵌套且获取锁的顺序不一致。修法是所有线程按固定顺序获取锁,或改用tryLock(timeout)带超时。
六、Arthas 在线神器
上面六步要敲好几条命令、还要进制转换,生产紧张时容易出错。Arthas 一条命令解决:
thread -n 3:直接列出最忙的 3 个线程及其栈,死循环一眼看穿thread -b:直接找出死锁的线程(比 jstack 更友好)dashboard:实时看 CPU/内存/线程总览
强烈建议把 Arthas 作为线上排查的「第一响应工具」。
七、预防
- 锁按固定顺序获取,避免交叉嵌套
- 用
ReentrantLock.tryLock(timeout)替代无脑synchronized,超时可以释放而非死等 - 循环体加退出条件 + 超时保护,别写「理论上会退出」的死循环
- 限流 + 熔断,避免突发流量把 CPU 打满
八、锁竞争导致的飙高
还有一种常见但隐蔽的 CPU 高:没有明显死循环,但大量线程卡在lock/Unsafe.park上,上下文切换(context switch)暴增,CPU 花在「调度」而非「干活」。
top看sy(系统态 CPU)偏高、vmstat看cs(context switch)数值巨大,往往就是锁竞争。jstack里大量线程停在waiting to lock同一把锁。
解法:减小锁粒度(拆大锁)、用ConcurrentHashMap替代synchronized Map、降低共享可变状态。这本质是并发设计问题,不是 JVM 参数能解决的。
九、Arthas 实战一条龙
把前面六步浓缩成 Arthas 操作:
# 1. 启动并 attach 到目标进程java-jararthas-boot.jar# 2. 看最忙的 3 个线程(死循环直接现形)thread-n3# 3. 直接找死锁thread-b# 4. 看全局仪表盘dashboard# 5. 在线抓堆(等价于 jmap dump)heapdump /tmp/heap.hprof相比手工top -Hp+printf+jstack,Arthas 把进制转换、线程定位、死锁检测全包了,生产急救效率提升一个量级。
十、一条经验法则
CPU 高 + 线程名带GC→ 内存问题(回第 9、10 篇)
CPU 高 + 单个业务线程 runnable 卡住 → 死循环(六步定位)
CPU 高 + 大量线程 waiting to lock → 锁竞争(拆锁)
CPU 不高但请求卡死 → 死锁(thread -b)
先定性、再定量,别一上来就瞎改代码。
十一、CPU 飙高根因决策表
把前面散落的知识点收成一张表,遇事对号入座:
| 现象 | 可能根因 | 下一步 |
|---|---|---|
| 单线程 runnable 卡住 | 死循环 | jstack 定位代码行 |
| 线程名带 GC | 频繁 Full GC | 看 GC 日志(第 9 篇) |
| 大量 waiting to lock | 锁竞争 | 拆锁 / 换并发容器 |
| 线程数暴涨 | 无界线程池 | 改有界线程池(案例四) |
| CPU 不高但请求卡 | 死锁 | thread -b 找死锁 |
先定性、再定量,别一上来就瞎改代码。
十二、容器里 CPU 限额导致的「假飙高」
云原生一个坑:容器设了cpu limit=2,但 JVM 启动时的ParallelGCThreads按宿主机的核数(比如 32)初始化,于是 32 个 GC 线程抢 2 个 CPU 配额,被 CFS 调度限流,GC 反而更慢、业务也饿死,表现为 CPU 打满。
修复:用-XX:ParallelGCThreads显式设成容器配额对应的核数,或升级到能感知 cgroup 的 JDK 版本。这提醒我们:容器里的 JVM,参数要按「容器配额」而非「宿主机」来设。
十三、CPU 飙高排查三字诀
总结成口诀方便记:「看、定、改」。
- 看:top 看进程、top -Hp 看线程、jstack/Arthas 看栈
- 定:定是死循环、频繁 GC、还是锁竞争(用本文决策表)
- 改:改循环边界、调堆/收集器、拆锁或换并发容器
口诀之外,最重要的是别慌——CPU 高只是表象,顺着工具链一层层剥,根因总会现形。
总结
CPU 飙高先分类(死循环 / 频繁 GC / 锁竞争),再走「top → top -Hp → printf → jstack」六步定位到代码行;死锁靠 jstack 的自动检测或 Arthas
thread -b直接抓。记住:频繁 GC 引发的 CPU 高,根子在内存(回看第 9、10 篇),别在 CPU 上白费功夫。下一篇,我把整套 JVM 调优方法论收个尾。