Java应用CPU飙升到900%?这套排查套路让你10分钟定位根因
Phase 1: 紧急响应(0-5分钟)保留现场(thread dump, heap dump, 监控截图)通知相关人(开发、运维、业务)评估影响范围(是否影响核心业务)Phase 2: 快速止血(5-15分钟)如果有重启窗口,灰度重启1台服务器观察如果有降级开关,立即降级非核心功能扩容(如果是资源不足)Phase 3: 根因分析(15分钟-2小时)使用top + jstack定位高CPU线程使用
在这篇文章中,我将结合最近一次真实的生产事故,分享一套经过实战检验的CPU排查方法论。这不是教科书上那些”用jstack查看线程栈”的泛泛之谈,而是我们在72小时连续作战中总结出来的血泪经验。
一、告警响应该做什么?别急着重启!
很多团队的第一反应是:”重启大法好”。但在没有定位问题之前重启,就像是捂住温度计来”治疗”发烧——问题暂时消失了,但根因还在,而且你失去了最宝贵的现场信息。
黄金5分钟应该做的事:
- 保留现场:立即执行
top -H -p <pid>保存线程CPU占用快照 - 线程Dump:连续执行3次
jstack <pid> > thread_dump_$(date +%s).txt,间隔5秒 - 堆Dump:如果CPU高伴随内存异常,立即
jmap -dump:live,format=b,file=heap.hprof <pid> - 监控数据:截图或导出该时间段的JVM监控指标(GC次数、堆使用率、线程数)
我们上次事故中,就是因为运维同学”好心”地立即重启,导致只能从日志中反推问题,多花了4小时。
二、定位CPU热点的三板斧
2.1 第一板斧:top + jstack 定位高CPU线程
# 1. 找出占用CPU最高的进程
top -c
# 假设Java进程PID是12345
# 2. 查看该进程中各线程的CPU占用
top -H -p 12345
# 假设发现线程ID 12456占用CPU 80%
关键技巧:将十进制的线程ID转换为16进制(因为jstack中的线程ID是nid=0x十六进制)
# Linux/Mac
printf "%x\n" 12456
# 输出: 30a8
# 然后在jstack输出中搜索 nid=0x30a8
grep -A 20 "nid=0x30a8" thread_dump_*.txt
真实案例中的发现:
"payment-async-pool-32" #156 prio=5 os_prio=0 tid=0x00007f8a4c0a8000 nid=0x30a8 runnable [0x00007f89e1b4d000]
java.lang.Thread.State: RUNNABLE
at com.example.service.PaymentService.processPayment(PaymentService.java:187)
at com.example.async.AsyncPaymentTask.run(AsyncPaymentTask.java:45)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
看起来是支付异步处理线程在运行,但这正常吗?继续深挖。
2.2 第二板斧:arthas profiler 生成火焰图
jstack只能看到某一时刻的快照,要找到真正的CPU热点,需要采样分析。这里强烈推荐阿里开源的 arthas。
# 启动arthas
java -jar arthas-boot.jar
# 选择目标Java进程后,执行profiler
profiler start --event cpu --duration 30
# 等待30秒采样...
profiler getSamples
# 查看采样数
profiler stop --format flamegraph --file cpu_flamegraph.html
# 生成火焰图
如何解读火焰图?
- 宽度:代表CPU时间占比,越宽说明占用CPU越多
- 高度:代表调用栈深度
- 顶部平顶山:通常是热点代码的标志
在我们的案例中,火焰图暴露了一个惊人的事实:
[Top 5 CPU consumers]
1. com.example.service.PaymentService.processPayment() - 45.2%
└─ JSON.parseObject() - 38.7%
└─ com.alibaba.fastjson.parser.deserializer.MapDeserializer.parseMap() - 35.1%
2. java.util.concurrent.LinkedBlockingQueue.poll() - 12.3%
3. java.lang.String.hashCode() - 8.9%
问题定位:PaymentService.processPayment() 方法消耗了45%的CPU,其中38.7%花在了JSON反序列化上!
2.3 第三板斧:代码审查 + 性能分析
打开 PaymentService.java:187 的代码:
// 问题代码(已脱敏)
public class PaymentService {
private static final ObjectMapper objectMapper = new ObjectMapper();
public PaymentResult processPayment(PaymentRequest request) {
// 踩坑点1:每次请求都解析JSON配置
String configStr = redisTemplate.opsForValue().get("payment:config:" + request.getChannel());
Map<String, Object> config = JSON.parseObject(configStr, new TypeReference<Map<String, Object>>() {});
// 踩坑点2:在循环中进行JSON序列化
for (OrderItem item : request.getItems()) {
String itemJson = JSON.toJSONString(item);
log.debug("Processing item: {}", itemJson); // 生产环境开了DEBUG日志!
}
// 踩坑点3:大量字符串拼接
String key = "payment:" + request.getMerchantId() + ":" + request.getChannel() + ":" + System.currentTimeMillis();
// ... 业务逻辑
}
}
三个致命问题:
- 配置重复解析:每笔支付请求都从Redis读取并解析JSON配置,而这个配置每分钟才变一次
- DEBUG日志生产开启:
log.debug()虽然不会输出,但参数计算(JSON序列化)仍然执行 - 字符串拼接性能差:在高频场景下,字符串拼接产生大量临时对象
三、性能优化的三把手术刀
3.1 第一刀:缓存+预加载
// 优化后代码
public class PaymentService {
private static final ObjectMapper objectMapper = new ObjectMapper();
// 使用Guava Cache缓存配置,1分钟刷新一次
private final LoadingCache<String, Map<String, Object>> configCache = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build(this::loadConfig);
private Map<String, Object> loadConfig(String channel) {
String configStr = redisTemplate.opsForValue().get("payment:config:" + channel);
return JSON.parseObject(configStr, new TypeReference<Map<String, Object>>() {});
}
public PaymentResult processPayment(PaymentRequest request) {
// 优化1:从本地缓存读取,避免重复解析
Map<String, Object> config = configCache.get(request.getChannel());
// 优化2:生产环境避免JSON序列化,使用占位符
if (log.isDebugEnabled()) {
for (OrderItem item : request.getItems()) {
log.debug("Processing item: {}", item); // 直接打印对象,由日志框架处理
}
}
// 优化3:使用StringBuilder或String.format
String key = String.format("payment:%s:%s:%d",
request.getMerchantId(),
request.getChannel(),
System.currentTimeMillis());
// ... 业务逻辑
}
}
性能对比数据(基于JMeter 1000并发压测):
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 平均RT | 2876ms | 89ms | 97% |
| CPU使用率 | 900% | 120% | 87% |
| QPS | 347 | 8920 | 25倍 |
| 99%延迟 | 5632ms | 156ms | 97% |
3.2 第二刀:异步化 + 线程池隔离
在排查过程中,我们还发现另一个问题:支付回调处理是同步阻塞的,导致Tomcat线程池耗尽。
// 优化方案:使用线程池隔离 + 异步处理
@Configuration
public class AsyncConfig {
@Bean("paymentExecutor")
public ThreadPoolTaskExecutor paymentExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(20);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("payment-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
@Service
public class PaymentCallbackService {
@Async("paymentExecutor")
public CompletableFuture<CallbackResult> processCallback(CallbackRequest request) {
// 耗时的回调处理逻辑
return CompletableFuture.supplyAsync(() -> {
// 1. 验签
// 2. 更新订单状态
// 3. 发送通知
return new CallbackResult();
});
}
}
3.3 第三刀:JVM参数调优
针对高并发场景,我们还需要调整JVM参数:
# 优化后的JVM参数
-Xms4g -Xmx4g # 堆内存固定,避免动态扩容
-XX:+UseG1GC # 使用G1垃圾收集器
-XX:MaxGCPauseMillis=200 # 目标GC暂停时间
-XX:+ParallelRefProcEnabled # 并行处理Reference
-XX:+DisableExplicitGC # 禁止显式GC
-XX:+HeapDumpOnOutOfMemoryError # OOM时自动dump
-XX:HeapDumpPath=/data/logs/heapdump.hprof
四、避坑指南:这些坑我们替你踩过了
坑1:fastjson的autoType坑
// 危险代码
JSON.parseObject(jsonString, Object.class); // 可能触发autoType漏洞
// 安全做法
ParserConfig.getGlobalInstance().setAutoTypeSupport(false);
JSON.parseObject(jsonString, new TypeReference<PaymentRequest>() {}, Feature.DisableCircularReferenceDetect);
坑2:线程池队列无界导致OOM
// 危险:LinkedBlockingQueue默认Integer.MAX_VALUE
new ThreadPoolExecutor(10, 100, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>()); // 队列长度21亿!
// 正确:限制队列大小
new ThreadPoolExecutor(10, 100, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000)); // 最多1000个待处理任务
坑3:arthas在线调试影响性能
arthas的watch、trace等命令会对方法调用进行增强,生产环境慎用!我们曾经因为在生产开启trace命令,导致性能下降30%。
正确做法:
- 使用
profiler命令(基于async-profiler,性能影响%) - 限制采样时间(30秒~1分钟)
- 调试完成后立即
stop或quit
五、监控体系建设:让问题可视化
光有排查手段还不够,我们需要在问题发生前就建立监控体系:
5.1 JVM监控指标
# Prometheus + Grafana 监控配置示例
jvm_metrics:
- jvm_memory_used_bytes{area="heap"}
- jvm_gc_pause_seconds_sum
- jvm_threads_current
- jvm_cpu_usage_ratio
alert_rules:
- alert: HighCPUUsage
expr: jvm_cpu_usage_ratio > 0.8
for: 2m
annotations:
summary: "CPU使用率超过80%"
- alert: HighThreadCount
expr: jvm_threads_current > 500
for: 1m
annotations:
summary: "线程数超过500"
5.2 应用层监控
// 使用Micrometer埋点
@Service
public class PaymentService {
private final MeterRegistry meterRegistry;
private final Timer processTimer;
public PaymentService(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.processTimer = Timer.builder("payment.process.duration")
.description("Payment processing time")
.register(meterRegistry);
}
public PaymentResult processPayment(PaymentRequest request) {
return processTimer.recordCallable(() -> {
// 业务逻辑
return doProcess(request);
});
}
}
六、总结经验:建立排查SOP
经过这次事故,我们团队总结了CPU问题的排查SOP(标准操作流程):
Phase 1: 紧急响应(0-5分钟)
- 保留现场(thread dump, heap dump, 监控截图)
- 通知相关人(开发、运维、业务)
- 评估影响范围(是否影响核心业务)
Phase 2: 快速止血(5-15分钟)
- 如果有重启窗口,灰度重启1台服务器观察
- 如果有降级开关,立即降级非核心功能
- 扩容(如果是资源不足)
Phase 3: 根因分析(15分钟-2小时)
- 使用top + jstack定位高CPU线程
- 使用arthas生成火焰图
- 代码审查 + 日志分析
Phase 4: 长期优化(事后)
- 修复代码问题
- 完善监控告警
- 压测验证
- 复盘总结
七、工具箱推荐
最后,分享一下我们的CPU排查工具箱:
| 工具 | 用途 | 性能影响 |
|---|---|---|
| top/htop | 实时查看进程/线程CPU占用 | 无 |
| jstack | 线程栈快照 | 低(会触发Safepoint) |
| arthas | 在线诊断(profiler/watch/trace) | profiler %, 其他命令较高 |
| async-profiler | 生成火焰图(arthas底层使用) | % |
| JProfiler | IDE级性能分析(需离线分析) | 高(不适合生产) |
| Prometheus + Grafana | 监控 + 告警 | 低 |
结语
CPU飙升问题并不可怕,可怕的是遇到问题时手忙脚乱、无从下手。建立一套标准化的排查流程,配备合适的工具,才能在关键时刻快速定位问题。
记住:重启不是解决方案,保留现场才能根治问题。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)