文档目录

6.3 火焰图读法

上一节:6.2 延迟分解 | 下一节:6.4 线程与协程快照 配套代码:06-analysis-and-profiling/03-flamegraph-reading 工具用法见 第 4 章 4.3;本节讲怎么读。


一句话结论

火焰图是一张「时间面积图」,不是调用顺序图。 横轴是采样数(≈ 时间占比),纵轴是调用栈深度。看宽度找大头,看叶子找自己耗时的函数,两张图对照找真相。


一、用「地图」理解火焰图

火焰图像一张地图:

地图 火焰图
面积 = 实际占地 宽度 = 时间占比
相邻区域不代表有先后关系 左右相邻不代表调用顺序
放大看细节 点击可折叠/展开
等高线 = 海拔 栈深度 = 调用层级

⚠️ 最容易犯的错误:把横轴当时间轴,以为「左边先发生、右边后发生」。它不是时间轴——左边和右边只是不同的调用栈,可能在同一毫秒内交替出现。


二、三种要认出的形状

形状一:宽而平(热点函数)

┌──────────────────────────────────────────────────┐
│                  handleRequest                    │
├───────┬──────────────────────────────────────────┤
│ 业务   │         serialize (很宽,下方无子帧)        │  ← 热点!
└───────┴──────────────────────────────────────────┘

含义:这个函数自己在消耗时间(不是子调用)。

行动:这就是优化目标。看它的实现——常见的是序列化、正则、加密、字符串处理、集合操作。

形状二:深而窄(深调用链)

handleRequest
 └─ service
     └─ repository
         └─ hibernate
             └─ jdbc
                 └─ socketRead   ← 深而窄

含义:调用链深,但每一层占的时间都不多——时间分散在整条链上。

行动:通常不是优化目标(除非总宽度可观)。但要注意:如果这条链是"等待"(如 socketRead),那它对应的是阻塞,要用 wall 火焰图看,且问题在链的末端(下游)。

形状三:平台(plateau)

┌──────────────────────────────────────────────────┐
│  GBK.decode / UTF-8.decode / String.format        │  ← 平坦的一层,无子帧
└──────────────────────────────────────────────────┘

含义:典型的热点——通常是原生方法或JIT 编译后的紧凑代码(没有 Java 栈帧)。

行动:这是优化的好目标(往往是编解码、格式化、正则)。


三、读图四步法

第 1 步:看整体宽度是否「有内容」
   ├─ 很空(几乎没有采样)→ 问题是"等待"或"不在这个进程里"(换 wall 图 / 查上游)
   └─ 有内容 → 继续

第 2 步:从根往下找「第一个明显的分叉」
   → 那就是大头所在的分支

第 3 步:在大头分支里找「宽而平」或「平台」形状
   → 那就是具体的热点函数

第 4 步:对照另一张图(cpu vs wall),确认它是"计算"还是"等待"

一个完整的读图实例

原始图(简化):
                                                    总宽度 100%
┌──────────────────────────────────────────────────────────────┐
│                      http-handler-thread                      │
├────────────┬─────────────────────────────────────────────────┤
│  auth 3%    │              orderService 95%                    │
│             ├──────────┬──────────────────┬───────────────────┤
│             │ db 20%    │ serialize 60%    │ 其他 15%           │
│             │          │  ┌────────────┐  │                   │
│             │          │  │ Jackson    │  │                   │
│             │          │  │ writeValue │  │  ← 宽而平          │
│             │          │  └────────────┘  │                   │
└─────────────┴──────────┴──────────────────┴───────────────────┘

读图结论:
  ① 从根往下,第一个大分叉是 orderService(95%)
  ② 在 orderService 里,serialize 占 60%(最大)
  ③ serialize 下面是 Jackson.writeValue(宽而平)→ 热点
  ④ 结论:序列化是最大的 CPU 开销,优化方向是换序列化器
     或减少序列化的数据量

四、必须先校准:健康状态的火焰图

没有基线的火焰图,你无法判断什么是"异常"。

❌ 只看故障时的图 → 「这个 60% 的序列化正常吗?」
✅ 对比健康时的图 → 「健康时序列化只有 15%,现在 60% → 异常」

建议:在系统健康时采一份「参考火焰图」,存进仓库,作为后续对比的基线。

# 健康时(基线)
asprof -d 60 -e cpu -o collapsed -f baseline-cpu.txt "$PID"

# 出问题时
asprof -d 60 -e cpu -o collapsed -f incident-cpu.txt "$PID"

# diff
difffolded.pl baseline-cpu.txt incident-cpu.txt | flamegraph.pl > diff.svg

五、四类火焰图的对照表(本章核心)

cpu wall alloc lock
采样依据 CPU 时间 墙上时间 分配对象 锁等待
总量含义 进程消耗的 CPU 线程存在的时间 分配的字节数 锁等待时长
看到什么 谁在算 谁在等 谁在造垃圾 谁在抢锁
典型热点 序列化、正则、加密 socketRead、getConnection 装箱、字符串拼接 synchronized 块
图很空说明 没在算(在等) 线程都空闲 分配很少 无锁竞争
最容易误用 用它分析等待类问题 把"空闲"当"问题" 忽略分配≠CPU 只看持有者不看被拖慢者

三个最实用的对照结论

观察 结论 下一步
cpu 图有热点,wall 图同样热点占比也大 纯 CPU 瓶颈 优化该热点函数
cpu 图空,wall 图有大量等待栈 等待类瓶颈 查 wall 图里最宽的等待栈
cpu 图有热点,但 wall 图里占比很小 该热点不是瓶颈 它可能在并行线程上,不影响请求延迟

第三条最容易误判:CPU 火焰图上有个函数占 40% 宽度,但它可能在后台任务(定时任务、预热、GC 线程)里——与请求延迟无关。

验证方法:用 --threads 参数按线程分色,或者用 --include 只看请求处理线程。


六、五个必须知道的「坑」

❶ 内联导致丢帧

现象:火焰图上找不到你确定被调用的方法。

原因:JIT 把它内联进调用方了(第 2 章 2.2 节)。

验证:

# 禁止内联后再看
java -XX:CompileCommand=dontinline,com/example/Service::process -jar app.jar

❷ 采样偏差

现象:短时间的方法在图上"看不见"或"忽大忽小"。

原因:采样是概率性的(默认 10ms 一次),执行时间远短于采样间隔的函数可能采不到。

处理:延长采样时长(-d 120),或调小采样间隔(-i 1,但开销增加)。

❸ 只看到 CPU 时间,看不到等待

已在第 4 章 4.2 节详述——这是最常见的分析错误。

❹ 把「框架帧」当噪声忽略掉

注意:底部那些 Netty/Ktor/协程调度帧确实可以忽略,但如果瓶颈就在框架里(比如序列化框架、连接池实现),忽略它就错了。

判断:看框架帧的宽度——如果某一层框架帧很宽,它可能就是瓶颈。

❺ 采样本身的开销

现象:开着 profiler 测性能,结果比不开时差。

原因:-i 1 或 alloc 模式的采样开销可观(可能 5%–15%)。

处理:基线组和实验组必须用同样的采样配置(第 5 章 5.1 节)。


七、本节小结

  1. 火焰图是时间面积图,不是调用顺序图——横轴左右不代表先后。
  2. 三种形状:宽而平(热点函数)、深而窄(调用链深)、平台(原生方法/紧凑代码)。
  3. 读图四步:看整体 → 找第一个大分叉 → 找宽而平 → 对照另一张图。
  4. 必须先有健康状态的参考火焰图,否则无法判断"什么算异常"。
  5. 四类图的分工:cpu 看谁在算、wall 看谁在等、alloc 看谁造垃圾、lock 看谁抢锁。
  6. cpu 有热点但 wall 里占比小 → 该热点不是瓶颈(可能在后台线程上)。
  7. 五个坑:内联丢帧、采样偏差、只看到 CPU 时间、忽略框架帧、采样本身有开销。

八、自测

  1. 你的 CPU 火焰图上有一个函数占 40% 宽度,但接口 P99 只有 50 ms(SLO 是 500 ms)。你该优化这个函数吗?怎么判断?
  2. cpu 火焰图很空,wall 火焰图上最宽的一段停在 HikariPool.getConnection。请说出根因方向,以及下一步要看什么指标。
  3. 为什么必须有一份「健康状态的火焰图」?如果没有,你会犯什么错误?
  1. 不该盲目优化。理由与判断方法:① 接口 P99 只有 50 ms,远低于 SLO 500 ms——说明当前不是问题(第 3 章 3.7 节的"什么时候不该测");② 火焰图的宽度是 CPU 时间占比,如果这个函数运行在后台线程上(定时任务、预热、GC 辅助线程),它占用 CPU 但不影响请求延迟;③ 判断方法:用 --threads 参数按线程分色,看它是否在请求处理线程上;或者用 -e wall 采一份,看它在墙上时间里的占比——如果 cpu 里 40% 而 wall 里只有 2%,说明它是并行的后台工作,不影响请求延迟;④ 另一个验证:临时停掉那个后台任务,看 P99 是否改善(单变量实验)。只有当它确实影响 P99 时才优化。
  2. 根因方向:请求在排队等数据库连接(连接池排队),而不是数据库本身慢。下一步要看的指标与命令:① db_pool_pending —— 如果 > 0,证实排队;同时看 db_pool_active / db_pool_total 是否已打满;② pg_stat_activity(第 4 章 4.7)——看数据库侧的连接数、是否有长事务、wait_event_type 是什么;③ pg_stat_statements ——查是否有慢查询导致连接被长时间占用;④ 确认是"池太小"还是"查询太慢":如果 pending > 0 且同时有慢查询,根因是慢查询(连接被占住),先优化 SQL,不要先加池;如果查询都很快但仍然 pending,才考虑用 Little’s Law 调整池大小。
  3. 因为火焰图本身没有"绝对标准"——只有对比才能判断什么算异常。没有基线会犯的错误:① 把正常的热点当问题——例如序列化占 30% 可能是正常的(它本来就是这个比例),你可能会去优化一个不成问题的地方;② 忽略真正的异常——你可能看到一个函数占 20% 觉得"不多",但如果健康时它只占 2%,那这就是 10 倍的退化;③ 无法证明优化有效——优化后只有一张新图,你无法量化"改变了多少"(用 diff 火焰图才能直观显示)。实践建议:在系统健康时采集参考火焰图并存进仓库(docs/experiments/baseline/),标注采集条件(负载、数据量、JVM 参数),作为后续所有对比的基准。