Java应用CPU飙升100%?一篇讲透排查思路与实战案例(附Linux命令)
目录导读
- 问题现象:一次“无声”的生产事故
- 初步排查:从
top命令到线程转储 - 代码级定位:利用
jstack与jstat锁定“真凶” - 根因深度剖析:死循环?锁竞争?还是GC瓶颈?
- 实战案例复盘:一个定时任务导致的CPU过山车
- 预防与优化:从代码规范到监控告警
- 常见问答(FAQ):血泪经验总结
问题现象:一次“无声”的生产事故
某日下午,运维监控大屏突然报警:核心订单服务order-service的CPU使用率从平时的15%瞬间飙升至95%以上,且持续不降,用户侧表现为接口响应时间从50ms恶化到3秒以上,部分请求直接超时,重启应用后CPU短暂回落,但半小时后再次飙升——典型的“假死”循环。

初步排查:从top命令到线程转储
SSH登录服务器,执行top -Hp <PID>查看进程内每个线程的CPU占用,发现CPU消耗集中在少数几个线程(例如TID 23456、23457),且这些线程状态为R(Running)或RUNNABLE。
紧接着,执行jstack <PID> > thread_dump.txt生成线程转储文件。关键技巧:将线程的十六进制ID(如printf "%x\n" 23456得到5ba0)在转储文件中搜索,定位到具体线程栈。
代码级定位:利用jstack与jstat锁定“真凶”
在thread_dump.txt中,搜索5ba0,发现线程栈指向了com.order.task.OrderTimeoutTask.run()方法,并且内部有一个while(true)循环,循环中调用了OrderService.checkTimeout(),再看jstat -gcutil <PID> 1000输出,发现Full GC频率从正常的每10分钟1次变成了每秒钟3次,且老年代占用始终接近100%。
初步结论:该线程在执行任务时,可能因为某个共享数据(如订单状态缓存)被错误地反复更新,导致GC压力剧增,CPU被垃圾回收线程耗尽。
根因深度剖析:死循环?锁竞争?还是GC瓶颈?
- 死循环:查看
checkTimeout()方法源码,发现一个经典的并发修改bug——ConcurrentHashMap的computeIfAbsent在Java 8下配合递归调用可能形成死循环(JDK-8161372),但此处并不是递归,排除。 - 锁竞争:该定时任务使用了
ReentrantLock,但锁内代码块极小,且未发现阻塞线程,排除。 - GC瓶颈:通过
jmap -dump:format=b,file=heap.bin <PID>导出堆,用MAT分析发现有一个容量为10万且不断增长的ArrayList,其中存放的是过期订单ID,该列表被定时任务每次扫描时清空并重新填充,但清空操作(list.clear())并未释放内存,导致老年代被快速占满,触发频繁Full GC。
根本原因:定时任务在每次执行时,将数据库中所有超时未支付的订单ID加载到内存,并对每个ID调用一次远程服务(模拟耗时),由于数据库连接池配置过大(100个连接),导致一次性加载10万条数据,内存瞬间膨胀。
实战案例复盘:一个定时任务导致的CPU过山车
代码简化示意(伪代码):
// 错误示例:一次性加载全量数据
List<Long> timeoutOrderIds = orderMapper.selectAllTimeoutOrders();
List<Order> orders = orderService.batchQueryByIds(timeoutOrderIds);
for (Order order : orders) {
processCancel(order); // 远程调用
}
优化方案:
- 分页处理:每批加载1000条,处理完再查下一批。
- 使用
WeakHashMap或Caffeine本地缓存:避免反复加载同一批数据。 - 增大年轻代、降低Full GC频率:调优JVM参数,如
-Xmn2g -XX:MaxGCPauseMillis=100。 - 增加熔断:若远程服务响应超时,则快速失败,避免线程阻塞。
预防与优化:从代码规范到监控告警
- 代码规范:禁止在业务循环内加载全量数据;使用
Stream的分页或LIMIT分页。 - 监控体系:为关键接口和线程池添加Micrometer指标,结合Prometheus + Grafana设置CPU使用率阈值告警。
- 压测演练:上线前用
JMeter模拟高并发,观察GC日志和线程栈。
常见问答(FAQ)
Q1:为什么top看到的是多个线程CPU高,而不是单线程?
A:可能是同一进程内的多个worker线程在处理不同的请求,但都因为同一个共享资源(如一个巨大的HashMap)竞争而耗CPU,也可能是因为GC线程(VM Thread)占用了多核。
Q2:jstack显示java.lang.Thread.State: RUNNABLE,但为什么CPU不高?
A:RUNNABLE状态包括正在执行和等待CPU时间片(就绪态),如果大量线程处于就绪态,但被系统调度器频繁切换,也可能导致CPU开销大,但单看线程栈无法区分,此时应结合top -H看线程CPU时间。
Q3:如何快速验证是死循环还是GC问题?
A:执行jstat -gcutil <PID> 1000 5,如果FGC(Full GC次数)快速增加且FGCT(Full GC耗时)很高,则GC是主因,如果FGC变化不大,但S0/S1(年轻代)交换频繁,则优先怀疑业务代码逻辑。
Q4:有没有一键诊断工具?
A:可以使用Arthas的thread -n 3命令直接列出CPU最高的前3个线程,并自动抓取栈信息,或者用async-profiler生成火焰图,直观展示热点方法。
Q5:修复后如何验证效果? A:观察CPU曲线是否平缓,Full GC间隔是否恢复,以及接口P99延迟是否回到基线,同时添加自动化测试,防止回归。
结尾寄语:Java CPU过高排查并非玄学,核心在于“线程栈 + GC日志 + 堆内存”三件套。先看进程,再看线程,后看代码,最终归因于数据量或并发模型,希望本文的实战案例能成为你手边的排查手册。