摘要
CPU 使用率 100% 是线上最紧急的故障之一——服务响应变慢、接口超时、用户体验断崖式下降。本案例记录一次完整的 CPU 100% 问题排查全流程:从top -Hp定位高占用线程,到jstack获取线程快照,再到用printf '%x'转换线程 ID,最终在火焰图中精确定位到OrderMatcher.match()方法中的一个正则表达式预编译遗漏导致死循环。修复后 CPU 从 100% 降到 15%,服务恢复正常。文中还介绍了Async-profiler和Arthas profiler两种高级火焰图工具的使用方法。
一、问题背景
1.1 故障场景
某金融交易系统,运行在 8 核 16GB 的服务器上,JDK 11 + G1 GC。系统在下午 14:00 突然出现异常:
监控告警: [14:01] CPU 使用率告警:当前 98%(阈值 80%) [14:02] 接口 TP99 响应时间:> 30s(正常 < 500ms) [14:03] 服务开始拒绝请求,线程池耗尽 [14:05] 触发自动扩容,新 Pod 启动后同样 CPU 100% 运维初步排查: $ top Cpu(s): 100.0%us, 0.0%sy → 用户态 CPU 100%,不是系统调用 $ free -h Mem: 15Gi total → 内存充足,不是 GC 问题1.2 初步分析
CPU 100% 的常见原因: ┌──────────────────────────────────────────────────────────────────┐ │ 原因分类 │ 典型场景 │ 排查方向 │ ├──────────────────┼──────────────────────────┼──────────────────┤ │ 死循环 │ while(true) 无限循环 │ jstack + 线程状态 │ │ 密集计算 │ 大数据排序/加密/压缩 │ 火焰图定位热点 │ │ 正则回溯 │ 灾难性回溯(Catastrophic) │ 火焰图 + OQL │ │ JIT 热编译 │ 新代码路径频繁触发编译 │ 预热 + 分层编译 │ │ GC 密集 │ Metaspace 大量加载 │ GC 日志分析 │ └──────────────────────────────────────────────────────────────────┘ 排除法: - 内存充足 → 不是堆问题 - CPU 100%us → 不是系统调用 - 突然发生 → 不是正常的负载增长 → 最大可能是死循环或正则回溯二、排查流程
2.1 Step 1:找到高 CPU 线程
# 查看 Java 进程的线程 CPU 使用$top-Hp12345# 输出(截取高 CPU 线程):# PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND# 12456 app 20 0 16.0g 2.5g 50m R 95.5 16.0 12:34.56 java# 12458 app 20 0 16.0g 2.5g 50m R 88.2 16.0 9:12.34 java# 12460 app 20 0 16.0g 2.5g 50m R 45.1 16.0 3:45.67 java# 12478 app 20 0 16.0g 2.5g 50m R 2.5 16.0 0:12.34 java# 找到占用 CPU 最高的线程 PID:124562.2 Step 2:转换线程 ID
# 将 PID 转换为十六进制(jstack 输出的线程 ID 是十六进制)$printf'%x\n'124563038$printf'%x\n'12458303a2.3 Step 3:获取线程快照
# 获取完整线程快照$ jstack12345>/tmp/threaddump_$(date+%Y%m%d_%H%M%S).txt# 搜索高 CPU 线程$grep-A50"0x3038"/tmp/threaddump_20260315_140500.txt# 输出:"pool-1-thread-15"#12345 prio=5 os_prio=0 tid=0x00007f1234567890java.lang.Thread.State: RUNNABLE at com.example.OrderMatcher.match(OrderMatcher.java:45)at com.example.OrderService.processOrder(OrderService.java:123)at com.example.OrderController.handle(OrderController.java:67)at sun.reflect.GeneratedMethodAccessor45.invoke(Unknown Source)...2.4 Step 4:定位问题代码
// 定位到 OrderMatcher.java:45// 打开源码查看第 45 行publicclassOrderMatcher{publicList<Order>match(Stringsymbol,Stringpattern){List<Order>result=newArrayList<>();Patternp=Pattern.compile(pattern);// ← 问题!每次调用都编译for(Orderorder:allOrders){if(p.matcher(order.getSymbol()).matches()){// ← 第 45 行result.add(order);}}returnresult;}}// 问题分析:// - 传入的 pattern = ".*(A+|B+|C+)+.*" (灾难性回溯正则)// - Pattern.compile() 每秒调用 10000 次 → 每次都重新编译// - 正则表达式灾难性回溯 → 导致 CPU 100%三、根因分析
3.1 正则表达式灾难性回溯
灾难性回溯(Catastrophic Backtracking)示例: 正则:.*(A+|B+|C+)+.* 输入:"AAAAAAAAAAAAAAAAAX"(19 个 A + X) 匹配过程: - .* 贪婪匹配所有字符 - 然后回溯,发现 (A+)+ 需要匹配 - A+ 尝试匹配 19 个 A - (A+)+ 继续回溯,再次尝试匹配 A+ - 重复 19 次,指数级增长 - 总尝试次数 ≈ 2^19 = 524,288 次! 这就是正则回溯导致死循环的原理。3.2 问题代码完整版
// 问题代码publicclassOrderMatcher{// 问题1:每次调用都编译正则(浪费 CPU)// 问题2:用户输入的正则可能是灾难性回溯publicList<Order>match(Stringsymbol,Stringpattern){Patternp=Pattern.compile(pattern);// 危险!returnallOrders.stream().filter(o->p.matcher(o.getSymbol()).matches()).collect(Collectors.toList());}}// 调用链(从日志中还原)// orderMatch.match("AAPL", ".*(A+|B+|C+)+.*")// ↑↑↑ 用户查询传入// 恶意或低效正则四、解决方案
4.1 紧急修复:预编译正则 + 超时
// 修复方案 1:预编译正则 + 简化正则publicclassOrderMatcher{// 预编译常用正则模式privatestaticfinalMap<String,Pattern>PATTERN_CACHE=Caffeine.newBuilder().maximumSize(100).expireAfterWrite(Duration.ofMinutes(5)).build().asMap();// 使用简单通配符替代正则publicList<Order>match(Stringsymbol,Stringpattern){Patternp=PATTERN_CACHE.computeIfAbsent(pattern,Pattern::compile);// 添加超时保护longstart=System.nanoTime();for(Orderorder:allOrders){if(p.matcher(order.getSymbol()).matches()){result.add(order);}// 超时 1 秒则退出if(System.nanoTime()-start>1_000_000_000){thrownewOrderMatchTimeoutException("Pattern match timeout");}}returnresult;}}4.2 根本解决:限制用户输入的正则
// 修复方案 2:用户输入只允许简单通配符,不允许完整正则publicclassOrderMatcher{publicList<Order>match(Stringsymbol,StringwildcardPattern){// 将通配符转换为正则(安全)Stringregex=wildcardToRegex(wildcardPattern);Patternp=Pattern.compile(regex);returnallOrders.stream().filter(o->p.matcher(o.getSymbol()).matches()).collect(Collectors.toList());}// 安全的通配符转换privateStringwildcardToRegex(Stringwildcard){// 只允许 * 和 ? 通配符,不支持正则特殊字符return"^"+wildcard.replace(".","\\.").replace("*",".*").replace("?",".")+"$";}}4.3 上线配置
# 上线后 JVM 配置(添加 CPU 限制保护)JAVA_OPTS=" -XX:+UseG1GC -XX:MaxGCPauseMillis=200 # 如果问题没有彻底解决,设置 CPU 限制 # -XX:ActiveProcessorCount=4 # 限制使用 4 核 "五、效果验证
5.1 修复前后对比
# 修复前 CPU$top-bn1|grepjavaCpu(s):100.0%us,0.0%sy → CPU100%# 修复后 CPU$top-bn1|grepjavaCpu(s):15.2%us,0.8%sy → CPU 约16%# 修复前后对比┌──────────────────────────────────────────────────────────────────┐ │ 指标 │ 修复前 │ 修复后 │ 改善 │ ├────────────────────┼─────────────┼─────────────┼────────────┤ │ CPU 使用率 │100% │15% │85% ↓ │ │ 接口 TP99 │>30s │<500ms │99% ↓ │ │ 服务可用性 │20% │99.9% │80% ↑ │ │ 每秒请求处理数 │5│2000│ 400x ↑ │ └────────────────────┴─────────────┴─────────────┴────────────┘5.2 监控图表
CPU 使用率变化: ↑ 100% ─┐ │ │ ┌──────────────────修复点 │ │ │ │ └──────┐ ↓ │ └──────→ CPU 降到 15% │ └──────────────────────────────────────────────────────→ 时间六、高级工具:火焰图
6.1 Async-profiler 火焰图
# 安装 async-profilerwgethttps://github.com/async-profiler/async-profiler/releases/download/v2.9/async-profiler-2.9-linux-x64.tar.gztar-xzfasync-profiler-2.9-linux-x64.tar.gz# 生成 CPU 火焰图(30 秒)./profiler.sh-d30-f/tmp/cpu.svg-ecpu12345# 生成内存分配火焰图./profiler.sh-d30-f/tmp/alloc.svg-ealloc12345# 查看火焰图(上传到浏览器打开)# 火焰图解读:顶部越宽 = CPU 占用越多火焰图解读: ┌──────────────────────────────────────────────────────────────────┐ │ [全部] cpu │ │ 100% │ │ │ │ │ ┌─────────┴─────────┐ │ │ OrderMatcher.match() 80% │ │ │ │ │ ┌───────┴───────┐ │ │ Pattern.compile() Pattern.matcher() 75% │ │ │ │ 火焰图顶部越宽 = 该方法占用 CPU 越多 │ │ 从上往下看 = 完整的调用栈 │ │ │ └──────────────────────────────────────────────────────────────────┘6.2 Arthas profiler
# 使用 Arthas 生成火焰图# 1. 启动 Arthasjava-jararthas-boot.jar# 2. 连接后执行$ profiler start Started[cpu]profiling# 3. 运行一段时间后$ profiler stop--formathtml>/tmp/profile.html# 4. 查看火焰图$cat/tmp/profile.html|head-50七、经验总结
7.1 CPU 100% 排查流程图
┌──────────────────────────────────────────────────────────────────┐ │ CPU 100% 排查流程 │ ├──────────────────────────────────────────────────────────────────┤ │ │ │ 1. top -Hp <pid> │ │ └→ 找到 CPU 最高的线程 PID │ │ │ │ 2. printf '%x\n' <pid> │ │ └→ 转换为十六进制 │ │ │ │ 3. jstack <pid> | grep <hex_pid> │ │ └→ 查看该线程的堆栈,找到 RUNNABLE 方法 │ │ │ │ 4. 打开源码定位问题 │ │ └→ 死循环 / 正则回溯 / 密集计算 │ │ │ │ 5. 修复代码,重新上线 │ │ │ │ 备选工具: │ │ - Arthas: profiler start/stop │ │ - async-profiler: ./profiler.sh -f cpu.svg <pid> │ │ │ └──────────────────────────────────────────────────────────────────┘7.2 预防措施
代码审查 checklist: 1. 正则表达式必须预编译(Pattern.compile 放在 static/类初始化中) 2. 用户输入的正则需要白名单验证 3. 大数据量循环需要分批或超时保护 4. 加密/压缩等 CPU 密集操作需要限流系列导航
- 上一篇:【JVM深度解析】第15篇:JVM配置优化案例二:内存泄漏定位与修复
- 下一篇:【JVM深度解析】第17篇:JVM配置优化案例四:线程死锁与接口超时诊断
- 系列目录:JVM深度解析系列全集
参考资料
- Async-profiler GitHub
- Arthas Profiler
- FlameGraph
- 正则表达式灾难性回溯
- Netflix CPU Flame Graph Guide