线上CPU 100%排查:jstack+top的组合技与代码定位
线上CPU 100%排查:jstack+top的组合技与代码定位
适读人群:Java中高级开发工程师 | 阅读时长:约15分钟 | 适用JDK版本:JDK 8 / 11 / 17 / 21
开篇故事
2020年3月,我们的推荐算法服务突然CPU使用率飙升到了400%(8核机器,相当于四个核全跑满),系统响应时间从平均30ms飙到了6000ms以上,请求开始大量超时。
第一反应是流量突增,看了看监控,QPS只有平时的1.2倍,不至于CPU暴涨这么多。
用top命令一看,一个Java进程的CPU占用率确实是400%。不是系统级别的CPU高,是这一个Java进程吃掉了四个核。
按照排查流程,用top -Hp <pid>看线程级别的CPU占用,发现有两个线程的CPU使用率一直稳定在99%,其他线程都正常。这两个高CPU线程的PID(LWP)分别是12345和12346,把它们转换成十六进制分别是0x3039和0x303A。
用jstack抓了线程栈,在输出文件里搜索0x3039和0x303a,找到了这两个线程正在执行的代码栈:
"pool-1-thread-1" #23 prio=5 os_prio=0 tid=0x... nid=0x3039 runnable
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
at java.util.regex.Pattern$GroupTail.match(Pattern.java:4717)
...
at com.example.RecommendService.extractKeywords(RecommendService.java:156)正则表达式!Pattern$Loop.match在无限循环。
查代码,发现extractKeywords方法里有一个邮件地址验证的正则:"^([a-zA-Z0-9]+)*@[a-zA-Z0-9]+\.[a-zA-Z]+$"。这个正则有严重的回溯问题(catastrophic backtracking)。当输入一个特定格式的字符串时(比如aaaaaaaaaaaaaaaaaaaaa@),正则引擎会陷入指数级的回溯,CPU跑满,永远出不来。
恰好这天有个爬虫在批量传入这种特殊格式的字符串,两个处理线程被卡死,CPU全吃掉了,其他请求都得不到处理。
解决方案:修复正则(改成非回溯的写法),同时加了超时保护,使用j.u.regex.Matcher.find()配合超时机制(JDK 21原生支持,JDK 8/11用Future+超时实现)。
一、CPU 100%的常见根因
Java应用CPU 100%,本质上是有线程一直在做高CPU消耗的操作,没有等待、没有睡眠、一直在跑。
常见的根因分几类:
根因一:死循环或无限递归。代码逻辑错误导致while/for循环永远退不出来,或者递归调用没有终止条件。这是最简单的情况,jstack一看就能找到。
根因二:正则表达式回溯。灾难性回溯(catastrophic backtracking)是正则引擎的固有问题。某些特定格式的输入会使引擎陷入指数级回溯,CPU跑满。
根因三:频繁GC(GC CPU占用高)。Full GC频繁触发时,GC线程会大量消耗CPU。这时候top看到的高CPU线程都是GC线程(名称带"GC task")。
根因四:死锁或锁竞争导致的自旋。虽然synchronized等锁会让线程进入BLOCKED状态,但java.util.concurrent.locks.LockSupport.park()相关的等待,以及一些自旋锁(SpinLock)实现,会让线程看起来CPU很高但实际什么都没做。
根因五:序列化/反序列化的CPU暴涨。对大对象频繁做JSON序列化、XML解析、Java序列化,也会造成CPU显著上升。
二、原理深度解析
2.1 jstack输出的线程状态
理解jstack的输出是排查CPU问题的基础:
"Thread-1" #25 daemon prio=5 os_prio=0 tid=0x00007f12345678 nid=0x3039 runnable
java.lang.Thread.State: RUNNABLE
at com.example.CpuHog.calculate(CpuHog.java:42)
...关键字段说明:
"Thread-1":线程名称nid=0x3039:线程的本地线程ID(LWP),十六进制,与top -Hp中的PID对应java.lang.Thread.State:线程状态
线程状态与CPU使用的关系:
| 线程状态 | CPU占用 | 说明 |
|---|---|---|
| RUNNABLE | 高 | 正在执行,是CPU高的嫌疑对象 |
| BLOCKED | 低 | 等待锁,不消耗CPU |
| WAITING | 低 | Object.wait()等,不消耗CPU |
| TIMED_WAITING | 低 | sleep()等,不消耗CPU |
CPU高的线程状态一定是RUNNABLE。
2.2 正则回溯问题详解
有问题的正则模式:([a-zA-Z0-9]+)*中,+和*的嵌套使用,当匹配失败时引擎需要尝试所有可能的分组方式,复杂度爆炸。
安全的写法:
// 危险:会触发灾难性回溯
String badRegex = "^([a-zA-Z0-9]+)*@[a-zA-Z0-9]+\\.[a-zA-Z]+$";
// 安全:消除嵌套量词
String goodRegex = "^[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z]+$";
// 更推荐:使用经过测试的邮箱验证库,如Apache Commons Validator2.3 GC线程的CPU识别
GC线程有固定的命名规则,在jstack或top中可以识别:
G1 GC线程命名:
- "GC Thread#0":并行GC工作线程
- "G1 Main Marker":G1并发标记主线程
- "G1 Conc#0":G1并发工作线程
CMS GC线程:
- "CMS Main Thread"
- "CMS-concurrent-mark"如果CPU高的线程都是GC线程,不要分析业务代码,要转去分析GC日志,排查是否是GC问题。
三、诊断工具与命令
3.1 标准排查流程(5分钟定位)
# Step 1: 确认是哪个Java进程CPU高
top
# 找到高CPU的PID,假设是 12300
# Step 2: 找到高CPU的线程
top -Hp 12300
# 或者
ps -mp 12300 -o THREAD,tid,time | sort -rn | head -10
# 找到CPU最高的线程PID,假设是 12345 和 12346
# Step 3: 转换为十六进制
printf "%x\n" 12345 # 输出: 3039
printf "%x\n" 12346 # 输出: 303a
# Step 4: 抓取jstack
jstack 12300 > /tmp/jstack.txt
# 或者抓多次取证(找到稳定的调用栈)
for i in 1 2 3; do jstack 12300 >> /tmp/jstack_${i}.txt; sleep 2; done
# Step 5: 在jstack中搜索线程
grep -A 20 "nid=0x3039" /tmp/jstack.txt
grep -A 20 "nid=0x303a" /tmp/jstack.txt3.2 一键排查脚本
#!/bin/bash
# cpu_hot_thread.sh - 一键找出高CPU Java线程
PID=$1
if [ -z "$PID" ]; then
echo "Usage: $0 <java_pid>"
exit 1
fi
echo "=== Top CPU Threads for PID $PID ==="
TOP_THREADS=$(top -Hp $PID -b -n 1 | grep java | sort -k9 -rn | head -5)
echo "$TOP_THREADS"
echo ""
echo "=== Capturing jstack ==="
JSTACK_FILE="/tmp/jstack_${PID}_$(date +%s).txt"
jstack $PID > $JSTACK_FILE
echo "jstack saved to: $JSTACK_FILE"
echo ""
echo "=== Hot Thread Stacks ==="
while IFS= read -r line; do
TID_DEC=$(echo "$line" | awk '{print $1}')
TID_HEX=$(printf "%x" $TID_DEC)
CPU=$(echo "$line" | awk '{print $9}')
echo "--- Thread PID: $TID_DEC (0x$TID_HEX), CPU: $CPU% ---"
grep -A 15 "nid=0x$TID_HEX" $JSTACK_FILE | head -16
echo ""
done <<< "$(top -Hp $PID -b -n 1 | grep java | sort -k9 -rn | head -5 | awk '{print $1, $9}')"3.3 使用Arthas定位热点方法
# Arthas的thread命令直接显示热点线程
# 无需手动转换PID
java -jar arthas-boot.jar
> thread -n 5 # 显示CPU最高的5个线程及其调用栈
# 输出示例:
# "pool-1-thread-1" Id=23 cpuUsage=98% RUNNABLE
# at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
# at com.example.RecommendService.extractKeywords(RecommendService.java:156)
# 还可以找出死锁
> thread -b # 检测死锁
# 找出阻塞其他线程最多的线程
> thread --all | grep BLOCKED | wc -l3.4 使用async-profiler生成CPU火焰图
# async-profiler是更强大的CPU分析工具
# 下载:https://github.com/async-profiler/async-profiler
# 采样30秒,生成CPU火焰图
./profiler.sh -e cpu -d 30 -f /tmp/cpu_flame.html <pid>
# 或者以SVG格式输出
./profiler.sh start -e cpu <pid>
sleep 30
./profiler.sh stop -f /tmp/cpu_flame.svg <pid>
# 火焰图解读:
# 横轴:各方法的CPU占用比例(越宽表示越热)
# 纵轴:调用栈深度(下面是调用者,上面是被调用者)
# 最顶层最宽的那个方法就是CPU热点四、完整调优方案
4.1 正则表达式安全优化
// 问题:带回溯的正则,在特定输入下CPU爆满
Pattern p = Pattern.compile("^(([a-z])+.)+[A-Z]([a-z])+$");
// 方案1:重写正则,消除嵌套量词
Pattern p = Pattern.compile("^[a-z]+([.][a-z]+)*[A-Z][a-z]+$");
// 方案2:JDK 9+的possessive quantifiers(占有量词)
// 使用 ++、*+、?+ 代替 +、*、?,禁止回溯
Pattern p = Pattern.compile("^([a-zA-Z0-9]++)*+@[a-zA-Z0-9]++\\.[a-zA-Z]++$");
// 方案3:加超时保护(JDK 8/11兼容)
public static boolean matchWithTimeout(Pattern pattern, String input, long timeoutMs) {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Boolean> future = executor.submit(() -> pattern.matcher(input).matches());
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
throw new RuntimeException("Regex timeout: " + input.substring(0, Math.min(50, input.length())));
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
executor.shutdownNow();
}
}4.2 死循环保护
// 在循环中加入安全上限
public List<String> processItems(List<String> items) {
List<String> result = new ArrayList<>();
int maxIterations = 100000; // 明确的上限
int iterations = 0;
Iterator<String> it = items.iterator();
while (it.hasNext()) {
if (++iterations > maxIterations) {
log.warn("Loop exceeded max iterations {}, breaking", maxIterations);
break;
}
result.add(process(it.next()));
}
return result;
}4.3 GC CPU问题的处理
# 如果CPU高是因为GC线程
# 1. 先检查GC频率
jstat -gcutil <pid> 1000 10
# 如果FGC列持续增加,说明Full GC频繁
# 2. 根据GC类型调优(参考GC调优篇)
# 减少Full GC触发:调整堆大小、调整IHOP阈值等
# 3. 如果是G1/ZGC的并发标记消耗CPU
# 可以调整并发GC线程数
-XX:ConcGCThreads=2 # 减少GC并发线程,让出更多CPU给业务五、踩坑实录
坑一:jstack时机不对,看到的是正常状态
CPU 100%的时候,立刻去jstack,结果发现所有线程都是WAITING或TIMED_WAITING,没有高CPU线程。
原因:抓jstack的时机恰好是CPU高的线程在yield或短暂等待。应该多抓几次(间隔2秒),对比多次结果,找到在多次截图中都出现在相同代码位置的线程。
坑二:top显示CPU 100%但jstack里找不到RUNNABLE的业务线程
jstack的输出里,RUNNABLE的线程全部都是GC线程("GC task thread#0"、"G1 Main Marker"等),业务线程全是WAITING。
这不是业务代码的CPU问题,是GC CPU问题。需要转去分析GC日志,通常是Full GC过于频繁。
坑三:生产环境没有安装async-profiler,临时排查很被动
某次生产CPU问题,jstack的调用栈看起来很正常,找不到明显的热点。需要用火焰图做更细粒度的分析,但生产服务器没有async-profiler,临时部署又要走安全审批流程,延误了排查时机。
教训:在发布流程中提前把async-profiler的jar包部署到生产服务器的固定目录,确保紧急情况下可以立即使用。
坑四:用printf "%x"转换线程ID时搞错进制
top -Hp显示的线程PID是十进制,jstack里的nid是十六进制。有一次手动转换时算错了,搜了半天找不到对应的线程,后来发现是转换错了。
建议写成脚本(如上面提供的cpu_hot_thread.sh),避免手动计算出错。或者直接用Arthas的thread -n命令,它会自动对应线程。
六、总结
线上CPU 100%的排查,本质上是"找到哪个线程在高CPU运行,分析它在干什么"。
黄金步骤:top → top -Hp <pid> → printf "%x" → jstack <pid> → grep nid=0xXXXX,整个流程熟练了5分钟内能完成定位。
工具升级路线:jstack(原始手工操作)→ Arthas thread命令(更便捷)→ async-profiler火焰图(更深度分析)。
几类常见根因:死循环、正则回溯、高频GC、序列化热点。其中正则回溯是最容易被忽视的,代码review时要格外注意嵌套量词的正则。
生产环境要提前准备好排查工具(async-profiler),同时配置好监控告警,在CPU异常时能立刻触发多次jstack自动采集,不要等到手动操作时才开始。
