7AA7 博客

性能剖析入门:CPU、内存与 IO 的 profiling

Corin UID:76
2026-09-26 22:58
62 0 3

“这段应该很慢,我优化一下”——这是性能优化里最贵的一句话。凭直觉改代码,改完压测发现没变化,甚至更慢,这类事在大多数团队里都发生过。

原因不神秘:现代软件的性能瓶颈位置高度反直觉。一个看起来很重的序列化调用可能只占 2% 的时间,而一个不起眼的 HashMap 扩容占了 30%。经验可以缩小搜索范围,但替代不了测量。

别猜,先量

直觉定位的失败率高,有具体的机制性原因:

  • 调用次数比单次耗时更重要。 一个 100 微秒的函数被调用 50 万次,远比一个 10 毫秒的函数调用 10 次更值得优化。读代码看不出行数背后的调用频次。
  • 热点随数据分布漂移。 测试数据集和生产数据集的分布不同,热点位置也不同。
  • 瓶颈会转移。 优化掉 A 之后,B 会成为新的瓶颈,而且 B 往往和 A 不在同一个模块里。

profiling 的作用就是把”哪里慢”从主观判断变成有数据支撑的事实。

CPU 剖析:采样与插桩

两种采集方式,代价和精度不同。

采样(Sampling) 插桩(Instrumentation)
原理 定时中断,记录当前调用栈 在函数进出点注入计时代码
开销 低,通常个位数百分比 高,可能数倍放大
精度 统计意义上的近似 精确的调用次数与耗时
适用场景 线上持续采样 测试环境、短任务、需要调用计数

采样的工作方式是:每隔一个固定周期(例如 10 毫秒)触发一次中断,记录当前线程的完整调用栈。跑足够长时间后,某个函数出现在栈顶的次数越多,说明它消耗 CPU 的时间越多。这里有个关键点:采样统计的是”在栈上的时间”,栈顶样本对应正在执行,栈中样本对应正在被调用。

插桩则是在编译期或运行期给函数加上计时代码,能给出精确的调用次数和耗时,但会显著拖慢程序,而且开销分布不均——小函数被插桩后相对开销远大于大函数,容易扭曲结论。

对多数线上场景,采样是唯一现实的选择。

火焰图的读法

火焰图是采样结果最直观的呈现。

                    [main]                          <- 最底层是入口
              ________|___________
             |                    |
        [handleRequest]      [scheduleTask]
         ______|______
        |             |
   [serialize]    [queryDB]                <- queryDB 更宽,说明更耗时
    ___|___        ___|___
   |       |      |       |
[toJSON][gzip] [buildSQL][execute]         <- 最顶层是叶子函数

读法只有三条规则:

  • 宽度 = 耗时占比,不是调用次数。一个很宽的框意味着它(含它调用的所有子函数)占用了大量 CPU 时间。
  • 从下往上是调用栈,下层调用上层。最顶层没有任何子调用的”平台”,才是真正在执行代码的函数。
  • 看顶层平台的宽度,不要看中间层。中间层宽只是因为它调用了耗时的子函数,真正的热点在它的某个子调用里。

一个典型误判:看到 handleRequest 占了 60%,就去优化 handleRequest 本身的代码。但它的时间几乎全在 queryDB 里,优化它自己的逻辑毫无用处。

icicle(冰柱图)是火焰图的倒置版本,上层是入口、向下生长,语义完全一样,只是有些工具默认用它。

内存剖析:泄漏与占用高是两回事

内存问题要先分类,因为两类的诊断路径完全不同。

泄漏(leak): 对象被无意持有引用,无法回收,存活对象数持续增长,直到 OOM。

识别方法:强制 GC 后观察老年代/堆的基线。如果每次 GC 后的基线不断抬高,就是泄漏。

GC 后堆基线:  120MB -> 145MB -> 178MB -> 220MB   # 明显泄漏
GC 后堆基线:  120MB -> 118MB -> 125MB -> 121MB   # 只是占用高

定位泄漏靠的是支配树(dominator tree)和引用链。先看哪些类的实例数和保留大小(retained size)异常,再看是谁在持有它们。Java 生态里 jmap 导出堆转储后用 MAT 分析,Go 用 pprof 的 alloc_space/inuse_objects,.NET 用 dotMemory,路径不同但思路一致。

常见泄漏源:静态集合不断 add、未注销的监听器/回调、线程局部变量(ThreadLocal)未清理、连接池泄漏、缓存无上限无过期。

占用高(high footprint): 没有泄漏,但峰值内存需求本身就大。这类问题的解法不是找引用,而是改数据结构和访问模式——比如把一次性全量加载改成流式分页,把对象数组改成原始类型数组,调整 JVM 堆参数或 GC 策略。

分配热点与 GC 压力

即使没有泄漏,过高的分配速率也会拖垮性能。每次分配都要消耗 CPU,并且加速 GC 触发。

在 Go 里直接看分配:

go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap

在 Java 里开启分配采样(JFR 或 async-profiler):

asprof -e alloc -d 60 -f alloc.html <pid>

关注两个指标:

  • 分配速率(如 MB/s)。经验上,每秒分配量达到堆大小的相当比例时,GC 就会明显影响延迟。
  • 对象平均存活时间。短命小对象本该在新生代就被回收,若因晋升策略不当进入老年代,会放大 GC 成本。

典型分配热点:循环内的字符串拼接、自动装箱、每次调用都新建的临时集合、[]byte 转 string、正则 Pattern 未复用。

优化手段有两类:一是减少分配(对象池、复用缓冲区、预分配容量),二是降低单次分配成本(原始类型特化、栈上分配)。前者要小心——对象池用不好会造成内存占用长期不降,反而增加 GC 压力。

IO 剖析

IO 密集型的程序,CPU 火焰图往往是”平”的:大量时间耗在等待上,采不到正在执行的栈。

区别在于等待时间 vs 计算时间:

  • 等待:线程阻塞在 read、recv、poll、锁获取、条件变量上,CPU 空闲;
  • 计算:CPU 在忙于解析、压缩、序列化。

区分方法:看 CPU 使用率和吞吐的关系。CPU 上不去而吞吐上不去,基本是等待;CPU 打满了吞吐还不够,是计算。

# 系统调用层面看时间花在哪
strace -c -p <pid> -f -w -e trace=network,read,write

# off-cpu 分析:谁能看到"等待了多久、等的是什么"
offcputime-bpfcc -p <pid> -f 60 > out.stacks

off-cpu 火焰图是 IO 剖析里最有用的工具:它记录线程被调度出去时的栈和阻塞时长,直接告诉你”在等什么”。注意它的宽度含义和 CPU 火焰图相反——越宽表示阻塞时间越长。

数据库慢查询也属于 IO 剖析范畴,但通常交给数据库侧的慢日志和执行计划分析,不在应用进程内解决。

剖析开销与线上采样

profiling 不是免费的:

工具/方式 典型开销量级 线上可用性
采样 profiling(低频) 1%–5% 可持续开启
采样 profiling(高频) 5%–15% 短时开启
堆转储 会 STW 停顿,耗时秒级 谨慎,低峰期
插桩 profiling 数倍 仅测试环境
全量 trace 采集 高 需采样降级

线上采样的几条纪律:

  • 默认低频持续采样,按需提高频率。 罕见的尖峰问题往往只在高频采样下才能抓到,所以需要一个”随时可以临时调高频率”的开关。
  • 采到的样本要能关联到具体请求。 单纯的函数热点对排障帮助有限,能定位到”哪个租户的什么请求触发的”才有价值。
  • 注意安全。 profiling 端点(如 Go 的 pprof)不能暴露在公网,堆转储文件包含完整的内存数据,可能含敏感信息。
  • 容器环境要处理符号表。 stripped 的二进制或者 Alpine 基础镜像会让火焰图上全是地址而不是函数名。

决策路径:先定类型,再选工具

观察到的现象 瓶颈类型 该用什么
CPU 打满,吞吐上不去 CPU 计算 CPU 采样 + 火焰图
CPU 不高,延迟高,线程多阻塞 IO 等待 off-cpu 火焰图、strace、慢查询日志
内存持续增长,最终 OOM 内存泄漏 堆转储 + 支配树 + 引用链
内存占用高但不增长 内存占用 分配剖析 + 数据结构调整
GC 频繁,停顿明显 分配压力 分配采样(alloc)
偶发尖峰,平时正常 采样盲区 提高采样频率 + 持续采样

这张表的用法是自上而下:先用最便宜的信号(CPU 使用率、内存曲线、GC 日志)判断瓶颈属于哪一类,再上对应的工具。直接开火焰图而不看 CPU 使用率,就像不做血常规直接做 CT。

profiling 只解决”哪里慢”。知道哪里慢之后,还要回答”为什么慢”和”该不该改”——后者取决于这段代码在业务上的重要性和改造的风险,那是另一个话题了。

1.2k浏览量
62赞
0评论
0参与用户
11 分钟阅读时间

评论(0)

还没有评论,来抢沙发。