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 节)。
七、本节小结
- 火焰图是时间面积图,不是调用顺序图——横轴左右不代表先后。
- 三种形状:宽而平(热点函数)、深而窄(调用链深)、平台(原生方法/紧凑代码)。
- 读图四步:看整体 → 找第一个大分叉 → 找宽而平 → 对照另一张图。
- 必须先有健康状态的参考火焰图,否则无法判断"什么算异常"。
- 四类图的分工:cpu 看谁在算、wall 看谁在等、alloc 看谁造垃圾、lock 看谁抢锁。
- cpu 有热点但 wall 里占比小 → 该热点不是瓶颈(可能在后台线程上)。
- 五个坑:内联丢帧、采样偏差、只看到 CPU 时间、忽略框架帧、采样本身有开销。
八、自测
- 你的 CPU 火焰图上有一个函数占 40% 宽度,但接口 P99 只有 50 ms(SLO 是 500 ms)。你该优化这个函数吗?怎么判断?
- cpu 火焰图很空,wall 火焰图上最宽的一段停在
HikariPool.getConnection。请说出根因方向,以及下一步要看什么指标。 - 为什么必须有一份「健康状态的火焰图」?如果没有,你会犯什么错误?
- 不该盲目优化。理由与判断方法:① 接口 P99 只有 50 ms,远低于 SLO 500 ms——说明当前不是问题(第 3 章 3.7 节的"什么时候不该测");② 火焰图的宽度是 CPU 时间占比,如果这个函数运行在后台线程上(定时任务、预热、GC 辅助线程),它占用 CPU 但不影响请求延迟;③ 判断方法:用
--threads参数按线程分色,看它是否在请求处理线程上;或者用-e wall采一份,看它在墙上时间里的占比——如果 cpu 里 40% 而 wall 里只有 2%,说明它是并行的后台工作,不影响请求延迟;④ 另一个验证:临时停掉那个后台任务,看 P99 是否改善(单变量实验)。只有当它确实影响 P99 时才优化。 - 根因方向:请求在排队等数据库连接(连接池排队),而不是数据库本身慢。下一步要看的指标与命令:①
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 调整池大小。 - 因为火焰图本身没有"绝对标准"——只有对比才能判断什么算异常。没有基线会犯的错误:① 把正常的热点当问题——例如序列化占 30% 可能是正常的(它本来就是这个比例),你可能会去优化一个不成问题的地方;② 忽略真正的异常——你可能看到一个函数占 20% 觉得"不多",但如果健康时它只占 2%,那这就是 10 倍的退化;③ 无法证明优化有效——优化后只有一张新图,你无法量化"改变了多少"(用 diff 火焰图才能直观显示)。实践建议:在系统健康时采集参考火焰图并存进仓库(
docs/experiments/baseline/),标注采集条件(负载、数据量、JVM 参数),作为后续所有对比的基准。