3、CPU性能分析:CPU使用率过高问题定位、热点函数识别、多线程调度分析

说到CPU性能分析,我脑子里第一个蹦出来的场景,就是几年前那个深夜。

服务器报警,CPU直接飙到95%以上,用户反馈操作卡得像幻灯片。我那时候刚接手这个项目,第一反应是「完了,又要通宵了」。但后来我发现,只要掌握了正确的方法,CPU问题其实是最容易定位的一类性能瓶颈。

今天我们就来聊聊,怎么从CPU使用率过高,一路追查到热点函数,再到多线程调度。这套流程,我称之为「CPU三板斧」。

3.1 CPU使用率过高:别急着优化,先问三个问题

CPU飙高,很多人第一反应是「代码写得太烂了」。但我不这么看。我习惯先问自己三个问题:

  1. 是持续高,还是间歇性高? 持续高可能是死循环,间歇性高可能是GC或定时任务。
  2. 是用户态高,还是内核态高? 用户态高说明业务代码有问题,内核态高可能是系统调用太频繁。
  3. 是单核高,还是多核都高? 单核高可能是某个线程卡住了,多核高可能是并发设计有问题。

这三个问题问完,基本就能锁定方向了。我个人习惯用 top 命令先看一眼全局,然后按 1 查看每个核心的负载。嗯,这里要注意,别只看CPU百分比,还要看 wa(I/O等待)和 si(软中断),有时候CPU高其实是I/O在背锅。

实战经验: 我曾经遇到过一个案例,CPU使用率80%,但用户态只有20%,剩下60%全是内核态。追查下去发现是日志框架每秒钟刷盘1000次,系统调用把CPU吃光了。换成异步日志后,CPU直接降到15%。

3.2 热点函数识别:找到那个「吃CPU大户」

定位到进程之后,下一步就是找到具体是哪个函数在吃CPU。说白了,就是找出热点函数。

我常用的工具有两个:perfflamegraph(火焰图)。

3.2.1 用 perf 采样

perf 是Linux自带的性能分析工具,原理很简单——每隔一段时间打断CPU,记录当前正在执行的函数。采样次数多了,就能统计出哪些函数出现频率最高。

# 采样30秒,记录CPU调用栈
perf record -F 99 -p <PID> -g -- sleep 30

# 生成报告
perf report -g graph --no-children

这里 -F 99 表示每秒采样99次,为什么是99?不是100?其实是为了避免与系统时钟同步,产生采样偏差。这个小细节,是我从一位内核工程师那里学来的。

3.2.2 火焰图:一眼看出问题

我个人更喜欢火焰图,因为它直观。把 perf 采样的数据转成火焰图,一眼就能看到「那座山最高」——也就是CPU占用最多的调用路径。

# 生成火焰图数据
perf script -i perf.data > out.perf

# 使用 FlameGraph 工具生成SVG
./stackcollapse-perf.pl out.perf > out.folded
./flamegraph.pl out.folded > out.svg

火焰图怎么看?记住一句话:「平顶宽」的函数是问题所在。如果某个函数顶部很宽,说明它自己占用了大量CPU;如果顶部很窄但底部很宽,说明它在频繁调用子函数。

避坑指南: 我曾经在火焰图上看到一个函数占了大半,以为是它的问题。结果仔细一看,是它在频繁调用 mallocfree,真正的罪魁祸首是内存分配器。所以,看火焰图要结合上下文,别只看最顶层的函数名。

3.3 多线程调度分析:别让线程「打架」

找到热点函数之后,还有一个常见问题——多线程调度。说白了,就是线程之间互相抢CPU,或者因为锁等待导致CPU空转。

我常用的分析方法是看 上下文切换锁竞争

3.3.1 上下文切换:线程太多也是病

vmstatpidstat 可以看上下文切换次数:

# 每秒查看一次上下文切换
vmstat 1

# 查看具体进程的线程切换
pidstat -w -p <PID> 1

如果 cs(context switch)数值很高,比如每秒几万次,那说明线程调度太频繁了。为什么会这样?要么是线程数太多,要么是锁竞争激烈导致频繁阻塞和唤醒。

我记得有个项目,开了200个线程处理网络请求,结果CPU有一半时间花在线程切换上。后来改成线程池+协程,上下文切换直接降到原来的十分之一。

3.3.2 锁竞争:用 perf 看锁等待

锁竞争是另一个CPU杀手。当一个线程持有锁时,其他线程只能忙等(spin)或者阻塞。忙等会直接吃掉CPU,阻塞虽然不占CPU,但上下文切换的成本很高。

perf 可以看锁相关的热点:

# 查看锁竞争事件
perf stat -e sched:sched_stat_runtime -p <PID> -- sleep 10

如果发现大量时间花在 futex 系统调用上,那基本可以断定是锁竞争严重。这时候,我一般会建议:

  • 减小锁粒度:把大锁拆成小锁,比如分段锁、读写锁。
  • 用无锁数据结构:比如用 Atomic 替代 Mutex
  • 减少锁持有时间:把耗时操作移到锁外面。

注意: 别盲目用无锁编程。无锁数据结构虽然避免了锁竞争,但引入了ABA问题、内存序问题,调试起来比锁还痛苦。我个人的原则是:先优化锁,再考虑无锁。

3.4 实战案例:一个CPU飙高的排查过程

最后,我分享一个真实的排查过程,帮你把上面的知识点串起来。

步骤 操作 发现
1 top 查看全局 Java进程CPU 95%,用户态占80%
2 top -H -p <PID> 发现3个线程CPU占用特别高
3 perf record -F 99 -p <PID> -g 采样30秒
4 生成火焰图 看到 HashMap.get() 占了40%
5 检查代码 发现一个全局 HashMap 没有加锁,多线程并发导致死循环(JDK 1.7的bug)
6 修复 换成 ConcurrentHashMap,CPU降到20%

你看,从CPU飙高到定位到具体代码,其实就这几步。说白了,CPU性能分析没那么玄乎,关键是掌握工具和方法,然后——多踩坑。踩得多了,你看到火焰图就能直觉地知道问题在哪。

总结一下: CPU性能分析的核心就三件事——找到谁在吃CPU(热点函数),找到为什么吃CPU(锁/循环/系统调用),找到怎么优化(改代码/调参数)。把这三点吃透了,大部分CPU问题你都能搞定。

下一章,我们会聊内存泄漏的排查。嗯,那又是另一个故事了。