面试题再现:生产环境CPU占用率突然飙升到100%,如何定位和解决问题?
场景:假设你负责的一个核心微服务在某个下午突然报警,CPU使用率持续100%,导致服务响应缓慢。
📖 前言:当CPU遇上"猝死"
想象一下,某个阳光明媚的下午,你正端着咖啡,准备刷一刷技术博客。突然!手机疯狂震动,运维群炸了锅:
💥 紧急告警!💥
服务名称:order-service
CPU使用率:100% 🔥
响应时间:从50ms飙升到5000ms
用户投诉:电话都打爆了!
老板状态:正在赶来的路上...
这时候,你的内心OS是不是这样的:😱😱😱
别慌! 作为一个身经百战的10年老Java工程师,我见过的事故比你写过的代码都多!今天就让我手把手教你,如何像福尔摩斯一样,层层剥茧,找到那个"犯罪嫌疑人"!
🎯 核心思路:五步排查法
在讲具体操作之前,先给你画个全景图。CPU飙升排查就像看病,我们的诊断流程是这样的:
┌─────────────────────────────────────────────────────────────┐
│ CPU飙升排查全流程 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 第一步:系统层面 - 找到"病人" │
│ 使用 top 命令定位是哪个进程出问题 │
└────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 第二步:进程层面 - 揪出"元凶" │
│ 找到进程内哪个线程占用CPU最高 │
└────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 第三步:线程层面 - 获取"犯罪证据" │
│ 通过 jstack 获取线程堆栈信息 │
└────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 第四步:代码层面 - 分析"作案动机" │
│ 定位到具体的代码行,分析问题原因 │
└────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 第五步:解决方案 - 开出"药方" │
│ 根据问题原因制定解决方案并验证 │
└────────────────────────────────────────┘
🔍 第一步:系统全局诊断 - 确认"病情"
1.1 使用 top 命令俯瞰全局
首先,我们要像医生一样给系统"把脉"。登录到服务器,执行:
top
你会看到这样的画面:
top - 14:35:28 up 30 days, 5:46, 3 users, load average: 8.90, 7.32, 6.55
Tasks: 328 total, 4 running, 324 sleeping, 0 stopped, 0 zombie
%Cpu(s): 98.7 us, 1.3 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 16384.0 total, 2048.5 free, 12288.3 used, 2047.2 buff/cache
MiB Swap: 4096.0 total, 4096.0 free, 0.0 used. 3584.2 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
12345 admin 20 0 8192000 4096000 24576 R 99.9 25.0 123:45.67 java
789 mysql 20 0 2048000 1024000 16384 S 0.3 6.3 45:12.34 mysqld
456 nginx 20 0 524288 131072 8192 S 0.1 0.8 5:23.11 nginx
关键指标解读:
| 指标 | 含义 | 正常值 | 异常现象 |
|---|---|---|---|
load average | 系统负载(1分钟、5分钟、15分钟) | < CPU核心数 | 远超CPU核心数(如8核机器负载到8.9) |
%Cpu(s): us | 用户态CPU占用 | < 80% | 接近100% ⚠️ |
%Cpu(s): sy | 系统态CPU占用 | < 20% | 过高说明系统调用频繁 |
%Cpu(s): id | CPU空闲率 | > 20% | 接近0%说明CPU已跑满 |
%Cpu(s): wa | IO等待时间 | < 10% | 过高说明磁盘IO瓶颈 |
生活比喻: 想象你的服务器是一家餐厅:
us(用户态)= 厨师在炒菜做饭(应用程序工作)sy(系统态)= 服务员点菜收银(系统调用)id(空闲)= 厨师和服务员摸鱼休息的时间wa(IO等待)= 等外卖送达或等食材配送
当 us=98.7%,id=0% 时,说明你的"厨师"已经累到虚脱,根本没时间休息!
1.2 锁定"嫌疑人"进程
从上面的输出中,我们看到 PID 12345 的 Java 进程占用了 99.9% 的CPU!
重要信息记录:
进程PID:12345
CPU占用:99.9%
内存占用:25.0%(4GB)
进程名称:java
🎯 第一阶段小结:我们已经确认了是哪个Java进程出了问题。但是!一个Java进程可能有成百上千个线程,就像一个餐厅有很多个厨师,到底是哪个"厨师"在拖后腿呢?
🔬 第二步:进程内部透视 - 揪出"罪魁线程"
2.1 使用 top -H 查看线程级别信息
现在,我们要"深入敌后",看看这个Java进程内部都有哪些线程:
top -Hp 12345
参数说明:
-H:显示线程视图(Threads mode)-p 12345:只看PID为12345的进程
输出结果:
top - 14:36:15 up 30 days, 5:47, 3 users, load average: 8.95, 7.45, 6.60
Threads: 256 total, 2 running, 254 sleeping, 0 stopped, 0 zombie
%Cpu(s): 98.9 us, 1.1 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
15873 admin 20 0 8192000 4096000 24576 R 87.3 25.0 98:23.45 java
15874 admin 20 0 8192000 4096000 24576 R 12.0 25.0 15:12.67 java
15875 admin 20 0 8192000 4096000 24576 S 0.3 25.0 2:34.12 java
15876 admin 20 0 8192000 4096000 24576 S 0.0 25.0 0:45.89 java
发现了! 线程 15873 占用了 87.3% 的CPU!
关键线程信息:
线程ID(TID):15873
CPU占用:87.3%
状态:R(Running - 正在运行)
累计运行时间:98分23秒
2.2 线程状态详解
| 状态符号 | 含义 | 说明 |
|---|---|---|
| R | Running | 正在运行或等待运行(这是我们要关注的!) |
| S | Sleeping | 可中断睡眠(正常等待状态) |
| D | Disk sleep | 不可中断睡眠(通常在等待IO) |
| Z | Zombie | 僵尸进程(已结束但未被父进程回收) |
| T | Traced/Stopped | 被调试器暂停或收到停止信号 |
2.3 还有一个高级技巧
如果你不想看top界面,想直接获取信息,可以用这个命令:
# 一次性获取进程内所有线程的CPU占用情况,并按占用率排序
ps -mp 12345 -o THREAD,tid,time | sort -k2 -rn | head -20
输出:
USER %CPU PRI SCNT WCHAN USER SYSTEM TID TIME
admin 87.3 19 - - - - 15873 01:38:23
admin 12.0 19 - - - - 15874 00:15:12
admin 0.3 19 - - - - 15875 00:02:34
🧮 第三步:TID十六进制转换 - 破译"密码本"
3.1 为什么要转换?
好了,我们现在知道了罪魁线程是 15873。但是!Java的线程堆栈信息中,线程ID是用十六进制(hexadecimal)表示的,就像黑社会暗号一样。
举个栗子:
- 在操作系统里,线程ID是:
15873(十进制) - 在jstack输出里,线程ID是:
0x3e01(十六进制)
如果不转换,我们在堆栈信息里根本找不到这个线程!这就像你拿着身份证号去查护照号,肯定找不到啊!
3.2 转换方法
方法一:使用 printf 命令(推荐)
printf "%x\n" 15873
输出:
3e01
方法二:使用 Python(备用方案)
python -c "print(hex(15873))"
输出:
0x3e01
方法三:在线计算器 如果服务器上啥都没有,用你的浏览器打开计算器,切换到"程序员模式",输入15873,点击HEX按钮,搞定!
记住这个转换结果:
十进制TID:15873
十六进制TID:0x3e01(或 3e01)
生活比喻: 这就像你去银行取钱,柜台小姐姐说"先生,我们这里只认卡号,不认身份证号哦~"。你的身份证号是 十进制线程ID,银行卡号是 十六进制nid,要想取钱(查堆栈),必须先把身份证号转成卡号!
🕵️ 第四步:获取堆栈快照 - 抓取"犯罪现场"
4.1 使用 jstack 抓取堆栈
jstack 是Java虚拟机自带的线程堆栈分析工具,就像案发现场的监控录像,能告诉我们每个线程在干什么。
基础命令:
jstack 12345 > /tmp/jstack_12345.txt
提示:把输出重定向到文件,方便后续分析
加强版命令(推荐):
# 连续抓取3次,每次间隔1秒,方便对比分析
for i in {1..3}; do
echo "========== Dump $i at $(date) ==========" >> /tmp/jstack_12345.txt
jstack 12345 >> /tmp/jstack_12345.txt
sleep 1
done
为什么要抓3次?因为有些问题是瞬时的,有些是持续的。通过对比,我们能更准确地判断!
4.2 在堆栈文件中搜索目标线程
grep -A 30 "3e01" /tmp/jstack_12345.txt
参数说明:
grep:搜索命令-A 30:显示匹配行及其后30行(一般一个线程堆栈不会超过30行)"3e01":我们刚才转换的十六进制线程ID
输出示例:
"http-nio-8080-exec-23" #45 daemon prio=5 os_prio=0 tid=0x00007f8c2c0a1800 nid=0x3e01 runnable [0x00007f8be8ffd000]
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 java.util.regex.Pattern$BranchConn.match(Pattern.java:4568)
at java.util.regex.Pattern$CharProperty.match(Pattern.java:3777)
at java.util.regex.Pattern$Branch.match(Pattern.java:4604)
at java.util.regex.Pattern$GroupHead.match(Pattern.java:4658)
at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
at java.util.regex.Pattern$GroupTail.match(Pattern.java:4717)
... (循环重复多次) ...
at com.example.service.OrderService.validateOrderNo(OrderService.java:245)
at com.example.service.OrderService.processOrder(OrderService.java:128)
at com.example.controller.OrderController.createOrder(OrderController.java:67)
at sun.reflect.GeneratedMethodAccessor123.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:190)
...
4.3 解读堆栈信息(关键技能!)
让我们逐行分析这个"犯罪现场":
第一行:线程基本信息
"http-nio-8080-exec-23" #45 daemon prio=5 os_prio=0 tid=0x00007f8c2c0a1800 nid=0x3e01 runnable
| 字段 | 含义 | 示例值解读 |
|---|---|---|
"http-nio-8080-exec-23" | 线程名称 | 这是Tomcat的HTTP工作线程,处理第23个请求 |
#45 | 线程编号 | JVM内部编号 |
daemon | 是否守护线程 | daemon=守护线程,非daemon=用户线程 |
prio=5 | Java线程优先级 | 1-10,默认5 |
os_prio=0 | 操作系统线程优先级 | 操作系统层面的优先级 |
tid=0x... | 线程ID(JVM内部) | JVM内存地址 |
nid=0x3e01 | 本地线程ID | 就是我们要找的! |
runnable | 线程状态 | 正在运行中 |
第二行:Java线程状态
java.lang.Thread.State: RUNNABLE
Java线程6大状态:
┌─────────────────────────────────────────────────────────┐
│ Java线程状态机 │
└─────────────────────────────────────────────────────────┘
NEW(新建)
│
│ start()
▼
RUNNABLE(可运行/运行中) ◄────────────────┐
│ │
│ 等待锁/IO/sleep │
▼ │
BLOCKED(阻塞) │
WAITING(无限等待) │ notify/interrupt
TIMED_WAITING(限时等待) ─────────────────┘
│
│ 线程结束
▼
TERMINATED(终止)
| 状态 | 说明 | 常见场景 |
|---|---|---|
| RUNNABLE ⚠️ | 正在运行或等待CPU调度 | CPU密集型任务、死循环 |
| BLOCKED | 等待获取监视器锁 | synchronized锁等待 |
| WAITING | 无限期等待另一个线程的通知 | Object.wait(), Thread.join() |
| TIMED_WAITING | 限时等待 | Thread.sleep(), wait(timeout) |
第三行开始:堆栈调用链
at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
at java.util.regex.Pattern$GroupTail.match(Pattern.java:4717)
...
at com.example.service.OrderService.validateOrderNo(OrderService.java:245)
at com.example.service.OrderService.processOrder(OrderService.java:128)
at com.example.controller.OrderController.createOrder(OrderController.java:67)
堆栈怎么看?从下往上看!
这就像剥洋葱,从外到内:
- 最下面:
OrderController.createOrder()- Controller接收请求 - 中间:
OrderService.processOrder()- Service处理业务 - 关键位置:
OrderService.validateOrderNo()- 验证订单号 - 最上面:
Pattern$Loop.match()- 正则表达式匹配(重复出现多次!)
🚨 问题发现!
注意到 Pattern$Loop.match 重复出现很多次,这说明:
- 代码在执行正则表达式匹配
- 正则表达式陷入了回溯陷阱(Regular Expression Denial of Service, ReDoS)
4.4 多次抓取对比分析
如果你抓取了3次堆栈,对比一下:
# 提取所有3次抓取中,线程3e01的堆栈顶部方法
grep -A 5 "nid=0x3e01" /tmp/jstack_12345.txt | grep "at "
如果3次都是相同的堆栈,说明线程卡死在这里了! 如果3次堆栈不同,说明程序在正常运行,只是运算量大。
📊 第五步:问题分析 - 找到"作案动机"
5.1 定位问题代码
根据堆栈信息,我们定位到:
文件:com.example.service.OrderService
方法:validateOrderNo
行号:245
打开代码文件 OrderService.java,找到第245行:
public class OrderService {
// 第128行:处理订单的入口方法
public OrderResult processOrder(OrderRequest request) {
// ... 其他代码 ...
// 第245行:验证订单号格式
validateOrderNo(request.getOrderNo());
// ... 其他代码 ...
}
// 第240-250行:订单号验证方法
private void validateOrderNo(String orderNo) {
// 正则表达式验证订单号格式
String regex = "(a+)+b"; // ⚠️ 问题代码!
if (!orderNo.matches(regex)) { // 🔥 CPU在这里爆炸!
throw new IllegalArgumentException("订单号格式错误");
}
}
}
5.2 问题根因分析
问题类型:正则表达式回溯陷阱(ReDoS - Regular Expression Denial of Service)
为什么会CPU飙升?
让我用一个生活例子解释:
生活比喻:
假设你在玩一个迷宫游戏,正则表达式 (a+)+b 就像这样的规则:
- 找到一堆连续的 'a'(
a+) - 这一堆'a'可以重复出现多次(
(a+)+) - 最后必须以 'b' 结尾
当你输入 "aaaaaaaaaaaaaaaaaaaaac" (20个a后面跟个c)时:
- 正则引擎会尝试各种分组方式:
- 试法1:
(aaaaaaaaaaaaaaaaaaaaaa)- 失败,没有b - 试法2:
(aaaaaaaaaaaaaaaaaaaa)(a)- 失败,没有b - 试法3:
(aaaaaaaaaaaaaaaaaaa)(aa)- 失败,没有b - 试法4:
(aaaaaaaaaaaaaaaaaaa)(a)(a)- 失败,没有b - ... 有 2^20 = 1,048,576 种尝试方式!
- 试法1:
这就像你在迷宫里,每走到一个岔路口,都要尝试所有可能的路径,然后发现走不通,再回来,再尝试... 最后把自己累死!
理论解释:
输入字符串:"aaaaaaaaaaaaaaaaaaaaac" (20个a + 1个c)
正则表达式:(a+)+b
┌──────────────────────────────────────────────────────────┐
│ 回溯过程示意 │
└──────────────────────────────────────────────────────────┘
尝试次数与字符串长度的关系:O(2^n)
当n=10时,尝试次数 ≈ 1,024次
当n=20时,尝试次数 ≈ 1,048,576次
当n=30时,尝试次数 ≈ 1,073,741,824次 🔥
每次尝试都要消耗CPU资源,导致CPU占用率飙升!
5.3 常见CPU飙升原因总结
作为10年老Java工程师,我总结了以下常见的CPU飙升原因:
| 类型 | 问题描述 | 堆栈特征 | 占比 |
|---|---|---|---|
| 1. 死循环 | 代码逻辑错误,无限循环 | 堆栈始终停在循环体内 | 25% |
| 2. 正则回溯 | 复杂正则表达式遇到特殊输入 | 堆栈中有 Pattern.match() | 15% |
| 3. 频繁GC | 内存不足,Full GC频繁 | 堆栈中大量 GC thread | 20% |
| 4. 大量线程竞争 | synchronized锁竞争激烈 | 大量线程BLOCKED状态 | 15% |
| 5. 大数据量计算 | 未分页查询,一次性加载大量数据 | 堆栈中有数据处理逻辑 | 10% |
| 6. 序列化/反序列化 | JSON/XML处理大对象 | 堆栈中有Jackson/Fastjson | 8% |
| 7. 反射调用 | 大量反射操作 | 堆栈中有 Method.invoke | 5% |
| 8. 第三方SDK | 依赖包的Bug | 堆栈中有第三方类 | 2% |
本案例属于:第2类 - 正则回溯
💊 第六步:对症下药 - 制定解决方案
6.1 立即止血方案(5分钟内)
目标:先让服务恢复,再慢慢查问题
方案A:重启服务(最快但不根治)
# 找到Java进程并重启
systemctl restart order-service
# 或者使用kill命令(优雅停止)
kill -15 12345
# 等待30秒后检查是否停止
sleep 30
ps -ef | grep java
# 如果还没停止,强制杀死
kill -9 12345
# 启动服务
./startup.sh
⚠️ 注意:重启只是临时方案,问题还会复发!
方案B:限流(推荐)
如果你用了Spring Cloud Gateway或Nginx,立即启用限流:
# application.yml
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # 每秒允许10个请求
redis-rate-limiter.burstCapacity: 20 # 令牌桶容量20
方案C:熔断降级
@Service
public class OrderService {
// 使用Sentinel或Hystrix进行熔断
@SentinelResource(value = "validateOrderNo",
blockHandler = "handleBlock",
fallback = "handleFallback")
private void validateOrderNo(String orderNo) {
// 原有验证逻辑
}
// 熔断处理
public void handleBlock(String orderNo, BlockException ex) {
log.warn("订单号验证被限流,orderNo={}", orderNo);
// 跳过验证,或使用简单验证
}
}
6.2 根治方案(30分钟内)
修复正则表达式
public class OrderService {
// ❌ 错误的正则(会导致回溯)
private static final String BAD_REGEX = "(a+)+b";
// ✅ 修复后的正则(避免回溯)
private static final String GOOD_REGEX = "a+b";
// ✅ 或者使用更精确的订单号验证
private static final String ORDER_REGEX = "^[A-Z]{2}\\d{16}$"; // 例如:OR1234567890123456
private void validateOrderNo(String orderNo) {
// 1. 先进行简单的长度和格式检查
if (orderNo == null || orderNo.length() != 18) {
throw new IllegalArgumentException("订单号长度错误");
}
// 2. 使用安全的正则表达式
if (!orderNo.matches(ORDER_REGEX)) {
throw new IllegalArgumentException("订单号格式错误");
}
// 3. 添加超时保护
validateWithTimeout(orderNo, ORDER_REGEX, 100); // 100ms超时
}
/**
* 带超时保护的正则验证
*/
private boolean validateWithTimeout(String input, String regex, long timeoutMs) {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Boolean> future = executor.submit(() -> input.matches(regex));
try {
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
log.error("正则验证超时,input={}, regex={}", input, regex);
return false;
} catch (Exception e) {
log.error("正则验证异常", e);
return false;
} finally {
executor.shutdown();
}
}
}
正则表达式优化对比:
| 类型 | 正则表达式 | 时间复杂度 | 20字符耗时 | 安全性 |
|---|---|---|---|---|
| 💀 灾难级 | (a+)+b | O(2^n) | >10秒 | 危险 |
| ⚠️ 较差 | (a*)*b | O(2^n) | >5秒 | 危险 |
| ✅ 良好 | a+b | O(n) | <1ms | 安全 |
| ✅ 优秀 | ^[A-Z]{2}\d{16}$ | O(n) | <1ms | 安全 |
6.3 预防方案(长期)
1. 代码审查清单
## 正则表达式审查清单
- [ ] 避免嵌套量词:(a+)+, (a*)+, (a+)*
- [ ] 避免分支重叠:(a|a)+, (a|ab)+
- [ ] 为正则添加超时保护
- [ ] 对用户输入先做长度限制
- [ ] 使用正则性能测试工具检查
2. 添加监控和告警
@Aspect
@Component
public class PerformanceMonitorAspect {
@Around("execution(* com.example.service.*.*(..))")
public Object monitorPerformance(ProceedingJoinPoint pjp) throws Throwable {
long startTime = System.currentTimeMillis();
String methodName = pjp.getSignature().toShortString();
try {
Object result = pjp.proceed();
return result;
} finally {
long duration = System.currentTimeMillis() - startTime;
// 记录慢方法
if (duration > 1000) {
log.warn("慢方法告警:{} 耗时 {}ms", methodName, duration);
// 发送告警
alertService.sendSlowMethodAlert(methodName, duration);
}
// 记录到监控系统
Metrics.counter("method.duration",
"method", methodName).increment(duration);
}
}
}
3. 单元测试(防御性编程)
@Test
public void testRegexPerformance() {
String badInput = "a".repeat(20) + "c"; // 恶意输入
// 使用assertTimeout确保测试在100ms内完成
assertTimeout(Duration.ofMillis(100), () -> {
orderService.validateOrderNo(badInput);
}, "正则验证不应超过100ms");
}
@Test
public void testRegexSafety() {
// 测试各种边界情况
String[] testCases = {
"",
"a".repeat(100),
"a".repeat(1000),
"a".repeat(10000),
"OR" + "0".repeat(16),
};
for (String testCase : testCases) {
assertDoesNotThrow(() -> {
orderService.validateOrderNo(testCase);
}, "任何输入都不应导致程序崩溃");
}
}
🛠️ 进阶工具篇:让排查更高效
7.1 Arthas - 阿里开源的Java诊断利器
为什么需要Arthas?
传统的jstack、jmap需要多步操作,而Arthas可以在线诊断,无需重启!
安装Arthas(30秒搞定)
# 下载并启动
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
选择要诊断的Java进程
[INFO] arthas-boot version: 3.7.1
[INFO] Found existing java process, please choose one and input the serial number.
* [1]: 12345 com.example.OrderApplication
[2]: 67890 org.elasticsearch.bootstrap.Elasticsearch
输入 1,进入Arthas控制台。
一键定位CPU热点
# 1. 自动找出CPU占用最高的线程
thread -n 3
# 输出示例:
"http-nio-8080-exec-23" Id=45 cpuUsage=87.3% RUNNABLE
at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
at com.example.service.OrderService.validateOrderNo(OrderService.java:245)
...
# 2. 查看线程详细信息
thread 45
# 3. 查看线程的CPU采样
profiler start
# 等待30秒
profiler stop --format html
生成火焰图(可视化CPU热点)
profiler start
# 让系统运行30-60秒
profiler stop --format html --file /tmp/cpu-flame.html
然后下载 /tmp/cpu-flame.html 用浏览器打开,你会看到:
┌──────────────────────────────────────────────────────┐
│ CPU火焰图 │
│ (越宽的部分表示CPU占用越多) │
└──────────────────────────────────────────────────────┘
┌─────────────────────┐
│ Pattern$Loop.match │ ◄── 最宽,占用最多
└─────────────────────┘
┌────────────────┴───────────────┐
│ OrderService.validateOrderNo │
└────────────────┬───────────────┘
┌───────────┴──────────┐
│ OrderService.process │
└───────────┬──────────┘
┌──────┴──────┐
│ Tomcat │
└─────────────┘
监控方法执行
# 监控validateOrderNo方法的执行情况
watch com.example.service.OrderService validateOrderNo '{params, returnObj, throwExp}' -x 2
# 查看方法执行时间
trace com.example.service.OrderService validateOrderNo
# 输出示例:
`---ts=2024-10-20 14:35:28;thread_name=http-nio-8080-exec-23;id=45;is_daemon=true;priority=5;
`---[5234.56ms] com.example.service.OrderService:validateOrderNo()
+---[0.01ms] org.springframework.util.Assert:notNull()
+---[5234.50ms] java.lang.String:matches() ◄── 耗时最长!
7.2 其他高级工具
1. jinfo - 查看和修改JVM参数
# 查看所有JVM参数
jinfo 12345
# 动态修改JVM参数(部分参数支持)
jinfo -flag +PrintGCDetails 12345
2. jstat - 查看GC情况
# 每1秒打印一次GC统计,共10次
jstat -gc 12345 1000 10
# 输出示例:
S0C S1C S0U S1U EC EU OC OU MC MU YGC YGCT FGC FGCT GCT
10752 10752 0 0 65536 45678 131072 98765 51200 45678 1234 12.34 56 123.45 135.79
关键指标解读:
| 指标 | 含义 | 正常值 | 异常现象 |
|---|---|---|---|
| YGC | Young GC次数 | 平稳增长 | 暴增(说明对象创建过快) |
| YGCT | Young GC总耗时 | < 1s | > 10s |
| FGC | Full GC次数 | 很少 | 频繁(如每分钟多次) |
| FGCT | Full GC总耗时 | < 5s | > 60s |
如果 FGC频繁且FGCT很高,说明是GC导致的CPU飙升,而不是代码问题!
3. jmap - 分析内存
# 查看堆内存使用情况
jmap -heap 12345
# 查看对象统计
jmap -histo:live 12345 | head -20
# 导出堆转储文件(慎用,会暂停应用)
jmap -dump:live,format=b,file=/tmp/heap.hprof 12345
4. MAT (Memory Analyzer Tool) - 分析堆转储
下载heap.hprof到本地,使用MAT分析:
- 打开MAT,加载heap.hprof
- 点击 "Leak Suspects Report" 查看内存泄漏
- 查看 "Dominator Tree" 找出占用内存最多的对象
📚 案例集锦:真实战场的血泪史
案例1:正则表达式陷阱(本案例)
现象:用户提交订单时,偶尔CPU飙升到100%
原因:正则表达式 (a+)+b 遇到恶意输入导致回溯
解决:简化正则为 ^[A-Z]{2}\d{16}$
教训:永远不要相信用户输入,正则表达式要有超时保护
案例2:数据库查询未分页
现象:每天凌晨2点CPU飙升,持续30分钟 堆栈特征:
at com.mysql.jdbc.MysqlIO.readFully(MysqlIO.java:2914)
at com.example.service.ReportService.exportAllOrders(ReportService.java:89)
代码:
// ❌ 错误:一次性查询100万条订单
List<Order> orders = orderDao.selectAll(); // 没有分页!
解决:
// ✅ 正确:分页查询+流式处理
int pageSize = 1000;
int pageNum = 0;
while (true) {
List<Order> orders = orderDao.selectPage(pageNum, pageSize);
if (orders.isEmpty()) break;
// 处理这一页数据
processOrders(orders);
pageNum++;
}
案例3:线程池耗尽+大量线程自旋
现象:周五下午3点(流量高峰),CPU突然100% 堆栈特征:大量线程在自旋等待
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireQueued(AbstractQueuedSynchronizer.java:832)
原因:线程池配置过小
// ❌ 错误配置
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // 核心线程数:只有2个!
5, // 最大线程数:只有5个!
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10) // 队列长度:只能缓存10个任务!
);
解决:
// ✅ 合理配置
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2; // CPU核心数 × 2
int maxPoolSize = corePoolSize * 4;
int queueCapacity = 1000;
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(queueCapacity),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:让调用者线程执行
);
案例4:频繁Full GC
现象:服务时不时卡顿,CPU飙升到100%,响应时间从50ms增加到5秒 堆栈特征:大量GC线程
"GC task thread#0 (ParallelGC)" os_prio=0 tid=0x00007f8c2c001000 nid=0x1234 runnable
"GC task thread#1 (ParallelGC)" os_prio=0 tid=0x00007f8c2c002000 nid=0x1235 runnable
诊断:
jstat -gc 12345 1000 10
# 输出:FGC在1分钟内增加了10次!
原因:内存不足,老年代对象过多
解决:
# 增加堆内存
-Xms4g -Xmx4g # 原来只有1g
# 调整GC参数
-XX:+UseG1GC # 使用G1垃圾收集器
-XX:MaxGCPauseMillis=200 # 最大GC停顿时间200ms
-XX:+PrintGCDetails # 打印GC详情
-XX:+PrintGCDateStamps # 打印GC时间戳
🎓 排查思维导图
CPU飙升100%排查全流程
│
├── 1️⃣ 系统层面:定位进程
│ ├── top 命令查看进程CPU
│ ├── 记录进程PID
│ └── 判断CPU类型(us/sy/wa)
│
├── 2️⃣ 进程层面:定位线程
│ ├── top -Hp <PID> 查看线程
│ ├── 记录高CPU线程TID
│ └── 或使用 ps -mp <PID> -o THREAD,tid,time
│
├── 3️⃣ 线程层面:转换TID
│ ├── printf "%x\n" <TID>
│ ├── 得到十六进制nid
│ └── 记录转换结果
│
├── 4️⃣ 堆栈层面:抓取快照
│ ├── jstack <PID> > thread_dump.txt
│ ├── grep "nid=0x<HEX>" thread_dump.txt
│ ├── 分析堆栈调用链
│ └── 对比多次抓取结果
│
├── 5️⃣ 代码层面:分析问题
│ ├── 死循环?
│ ├── 正则回溯?
│ ├── 频繁GC?
│ ├── 锁竞争?
│ ├── 大数据量?
│ └── 其他逻辑问题?
│
├── 6️⃣ 解决层面:分步实施
│ ├── 🚑 立即止血(重启/限流/熔断)
│ ├── 💊 根治问题(修复代码)
│ ├── 🛡️ 预防复发(监控/告警/测试)
│ └── 📊 后续跟踪(观察指标)
│
└── 7️⃣ 进阶工具:提升效率
├── Arthas:在线诊断神器
├── jinfo:查看JVM参数
├── jstat:GC统计分析
├── jmap:内存快照导出
└── MAT:堆内存分析
🎯 面试回答模板(收藏版)
当面试官问你"生产环境CPU突然100%怎么办",按照这个模板回答,稳了!
## 排查思路(总分总结构)
### 第一步:系统层面定位(30秒)
我会先登录到服务器,使用 `top` 命令查看整体系统状态,确定是哪个Java进程导致的CPU飙升,记录进程PID。同时观察CPU的us、sy、wa等指标,判断是用户态CPU高还是内核态CPU高。
### 第二步:进程内部定位(30秒)
使用 `top -Hp <PID>` 命令,查看该进程内所有线程的CPU占用情况,找出CPU占用最高的线程TID。如果有多个线程占用都很高,需要分别记录。
### 第三步:TID转换(10秒)
因为jstack输出的线程ID是十六进制格式,所以需要使用 `printf "%x\n" <TID>` 将线程ID转换为十六进制,方便后续查找。
### 第四步:获取线程堆栈(1分钟)
使用 `jstack <PID> > thread_dump.txt` 获取线程堆栈快照。为了提高准确性,我会连续抓取3次,每次间隔1秒。然后在文件中搜索转换后的十六进制线程ID,找到对应的堆栈信息。
### 第五步:分析堆栈和代码(3-5分钟)
根据堆栈信息,定位到具体的代码行。从经验来看,常见原因包括:
1. **死循环**:代码逻辑错误导致
2. **正则表达式回溯**:复杂正则遇到恶意输入
3. **频繁Full GC**:内存不足或内存泄漏
4. **锁竞争**:多线程争抢同一个锁
5. **大数据量处理**:未分页查询等
我会结合业务代码和堆栈调用链,判断是哪种情况。
### 第六步:应急处理和根治(视情况而定)
- **紧急止血**:通过限流、熔断或重启服务快速恢复
- **根本解决**:修复代码问题,优化算法逻辑
- **预防措施**:添加监控告警,完善单元测试,进行压测
### 第七步:后续跟踪
修复上线后,持续观察CPU、内存、GC等关键指标,确保问题彻底解决。同时做好事故复盘,总结经验教训,避免类似问题再次发生。
## 进阶补充
如果传统方式排查困难,我还会使用Arthas等工具进行在线诊断,通过 `thread -n 3` 快速找出CPU热点,或使用 `profiler` 生成火焰图进行可视化分析。
如果怀疑是GC问题,我会使用 `jstat -gc <PID>` 查看GC统计,或使用 `jmap -dump` 导出堆内存快照进行深入分析。
��️ 预防胜于治疗:构建监控体系
8.1 监控指标清单
| 维度 | 指标 | 告警阈值 | 检查频率 |
|---|---|---|---|
| CPU | 使用率 | > 80% | 每30秒 |
| 内存 | 堆内存使用率 | > 85% | 每30秒 |
| GC | Full GC频率 | > 5次/小时 | 实时 |
| GC | GC停顿时间 | > 1秒 | 实时 |
| 线程 | 活跃线程数 | > 500 | 每1分钟 |
| 线程 | 死锁线程数 | > 0 | 每5分钟 |
| 响应时间 | P99延迟 | > 1秒 | 每30秒 |
| 错误率 | 5xx错误率 | > 1% | 每30秒 |
8.2 Spring Boot Actuator集成
// pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
# application.yml
management:
endpoints:
web:
exposure:
include: "*" # 暴露所有端点
metrics:
export:
prometheus:
enabled: true
tags:
application: ${spring.application.name}
访问 http://localhost:8080/actuator/metrics 查看所有指标。
8.3 Prometheus + Grafana监控大盘
# prometheus.yml
scrape_configs:
- job_name: 'order-service'
metrics_path: '/actuator/prometheus'
static_configs:
- targets: ['order-service:8080']
Grafana大盘配置(Dashboard JSON):
{
"panels": [
{
"title": "CPU使用率",
"targets": [
{
"expr": "system_cpu_usage"
}
]
},
{
"title": "JVM堆内存",
"targets": [
{
"expr": "jvm_memory_used_bytes{area=\"heap\"}"
}
]
},
{
"title": "GC次数",
"targets": [
{
"expr": "rate(jvm_gc_pause_seconds_count[1m])"
}
]
}
]
}
🎁 彩蛋:老司机的私房话
作为一个在生产环境踩过无数坑的老Java工程师,我想分享几点心得:
1. 永远保持冷静 🧘
CPU飙升不是世界末日,按部就班排查,99%的问题都能解决。慌乱只会让你思路混乱,反而浪费时间。
2. 提前准备工具 🛠️
不要等到出问题了才去现装工具。我的服务器上常备:
- jdk自带工具(jstack、jmap、jstat等)
- Arthas(已提前下载好)
- 常用脚本(一键抓取堆栈、分析日志等)
3. 建立知识库 📚
每次故障处理完,我都会写一份事故复盘报告,记录:
- 问题现象
- 排查过程
- 根本原因
- 解决方案
- 预防措施
时间长了,这就是你的"武功秘籍"。
4. 压测是必须的 💪
任何代码上线前,都要进行压力测试。不要等到生产环境才发现问题。
# 使用JMeter或ab进行压测
ab -n 10000 -c 100 http://localhost:8080/api/orders
# 观察CPU、内存、响应时间等指标
5. 代码审查要严格 👨⚖️
特别是涉及正则表达式、循环、递归的代码,一定要仔细review。一个小bug,可能导致整个系统瘫痪。
6. 监控告警是生命线 🚨
如果没有监控,你可能不知道系统已经出问题了。等用户投诉时,黄花菜都凉了。
7. 不要过度优化 ⚖️
优化要基于数据和瓶颈分析,不要凭感觉。"过早的优化是万恶之源"。
📝 总结:核心要点一张图
┌─────────────────────────────────────────────────────────────────┐
│ CPU飙升排查核心要点 │
└─────────────────────────────────────────────────────────────────┘
🔍 排查流程(5步法):
top → top -Hp → printf "%x" → jstack → 分析代码
🛠️ 常用工具:
基础:top、jstack、jmap、jstat
进阶:Arthas、MAT、Profiler
🐛 常见原因:
1️⃣ 死循环 (25%)
2️⃣ 正则回溯 (15%)
3️⃣ 频繁GC (20%)
4️⃣ 锁竞争 (15%)
5️⃣ 大数据量 (10%)
6️⃣ 其他 (15%)
💊 解决思路:
🚑 立即止血:重启/限流/熔断
💉 根本治疗:修复代码/优化算法
🛡️ 长期预防:监控/测试/review
📊 监控指标:
CPU、内存、GC、线程、响应时间、错误率
🎯 核心原则:
冷静、系统化、数据驱动、持续改进
🎓 课后作业(检验学习效果)
练习1:模拟CPU飙升场景
创建一个简单的Spring Boot应用,包含以下代码:
@RestController
public class TestController {
@GetMapping("/test1")
public String test1(@RequestParam String input) {
// 危险的正则表达式
String regex = "(a+)+b";
return input.matches(regex) ? "匹配" : "不匹配";
}
@GetMapping("/test2")
public String test2() {
// 死循环
while (true) {
Math.random();
}
}
@GetMapping("/test3")
public String test3() {
// 频繁创建大对象
List<byte[]> list = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
list.add(new byte[1024 * 1024]); // 每次1MB
}
return "完成";
}
}
任务:
- 启动应用并调用这些接口
- 使用本文所学方法,定位到问题代码
- 给出解决方案
练习2:编写监控脚本
编写一个Shell脚本,实现:
- 每30秒检查一次Java进程的CPU使用率
- 如果CPU超过80%,自动抓取jstack堆栈
- 发送告警邮件
提示:
#!/bin/bash
while true; do
cpu=$(top -b -n 1 -p <PID> | grep java | awk '{print $9}')
if [ $(echo "$cpu > 80" | bc) -eq 1 ]; then
# 抓取堆栈
jstack <PID> > /tmp/jstack_$(date +%s).txt
# 发送告警
echo "CPU飙升告警" | mail -s "CPU Alert" your@email.com
fi
sleep 30
done
🙏 结语
恭喜你!如果你完整读完了这篇文档,你已经掌握了生产环境CPU飙升问题的完整排查思路和方法。
记住,实践才是检验真理的唯一标准。不要只是看,一定要自己动手试试。可以在测试环境模拟各种问题,练习排查流程,直到形成肌肉记忆。
当你在凌晨3点接到告警电话时,能够从容不迫、有条不紊地解决问题,老板看你的眼神都会不一样!👍
最后,送大家一句话:
"没有解决不了的生产问题,只有不够熟练的排查技术。"
加油,未来的技术大牛!🚀
📚 参考资料
文档信息
- 作者:10年老Java工程师
- 版本:v1.0
- 最后更新:2024-10-20
- 适用人群:Java开发工程师、运维工程师、架构师
如果这份文档帮到了你,请记得:
- ⭐ 收藏起来,以备不时之需
- 📤 分享给你的小伙伴
- 💬 有问题随时讨论交流
祝你在技术道路上越走越远! 🎉