什么是火山图:它和 JVM 火焰图不是一回事
先给结论
在 Java OOM、CPU 高、接口慢的排查语境里,大家通常想说的是 火焰图(Flame Graph),不是火山图。
- 火山图(Volcano Plot):统计学、生物信息学中常见,用横轴表示变化幅度,纵轴表示统计显著性,用来筛选“变化大而且可信”的指标或基因。
- 火焰图(Flame Graph):性能分析中常见,把采样得到的调用栈聚合起来,用宽度表示样本占比,帮助定位 CPU 时间、分配热点、锁等待或其他资源热点。
- MAT Dominator Tree:堆转储分析结构,用 Retained Heap 找谁支配大量内存。它既不是火山图,也不是火焰图。
一、火山图为什么叫火山图
火山图每一个点代表一个被比较对象,例如一个基因、蛋白或指标:
- 横轴通常是
log2 Fold Change,表示变化方向和倍数。 - 纵轴通常是
-log10(p-value)或校正后的显著性,越高说明统计上越不容易由随机波动造成。 - 左上角表示显著下降,右上角表示显著上升,中下部表示变化小或证据不足。
大量普通点集中在中下部,显著变化点分散到左右上方,整体轮廓像火山喷发,所以叫火山图。
例如 log2FC = 2 表示实验组约为对照组的 4 倍;log2FC = -1 表示约降为一半。若 p = 0.001,则 -log10(p) = 3。点越靠左右表示变化越大,越靠上表示显著性越强。
火山图不能单独证明因果关系;大量同时检验还要关注 FDR 等多重检验校正。它通常不用于 JVM OOM 定位。
二、火焰图怎么看
火焰图来自周期性采样。采样器每隔一小段时间记录线程当前调用栈,随后把相同栈帧聚合。
flowchart TD
A["定期采样线程调用栈"] --> B["得到大量栈样本"]
B --> C["把相同调用路径聚合"]
C --> D["父方法放下层,子方法叠在上层"]
D --> E["栈帧宽度表示样本占比"]
E --> F["定位宽平台和异常调用路径"]正确读法:
- 横向宽度表示该方法或其子调用出现在多少采样中,通常可近似理解为资源占比。
- 纵向表示调用栈深度,不表示耗时长短。
- 横轴左右顺序通常没有时间先后意义。
- 颜色通常只是为了区分栈帧,不应默认“越红越危险”。
- 顶部很宽的平顶方法常是直接热点;底部很宽表示它的整个调用子树占比大。
CPU 火焰图与 OOM 的关系
CPU 火焰图主要回答“CPU 时间花在哪里”,不能直接告诉你“哪些对象为什么不能回收”。OOM 前如果因为频繁 GC 导致 CPU 高,CPU 火焰图可能显示大量 GC 相关执行;但最终定位堆泄漏仍应依赖 GC 日志、heap dump 和 MAT。
Allocation 火焰图
分配火焰图统计对象分配热点,回答“哪些调用路径创建了最多对象”。它适合定位高分配速率、短命对象风暴、序列化或字符串拼接开销。
注意:分配得多不等于泄漏。一个方法每秒创建 1 GB 短命对象但都能回收,是分配压力;一个静态 Map 每分钟只增加 10 MB 但永不释放,才可能形成慢性泄漏。泄漏要看存活对象和 GC Roots。
三、采样怎样变成火焰图
假设采样器在10秒内获取了10000次线程栈:
6000次:Controller;Service;JsonEncoder
2500次:Controller;Service;RuleEngine
1000次:GC或运行时
500次:其他聚合器把相同调用路径计数,生成常见 folded stack:
Controller;Service;JsonEncoder 6000
Controller;Service;RuleEngine 2500渲染器按父子调用关系堆叠矩形。Service 下方包含两条子路径,所以它的 Inclusive 样本约为8500;JsonEncoder 顶部没有更深子帧,它的宽度接近自身直接热点。
样本比例怎样理解
若采样时间、频率和线程范围合理,某栈帧占60% CPU样本,可以近似说明采样窗口内约60%的被观测CPU事件落在该方法及子调用上。它不是精确纳秒计时,也不能直接外推全天流量。
采样频率太低会漏短热点;太高会增加开销和数据量。生产应先短时间、较保守频率采样,根据SLO和环境评估后再调整。
四、Inclusive和Exclusive样本
Inclusive:方法本身 + 所有子调用的样本
Exclusive:栈顶恰好是该方法的样本,近似自身消耗例子:
OrderService很宽
└─ JSON序列化几乎占满其上方说明OrderService调用树很热,真正直接CPU热点更可能在JSON叶子。只因为父方法宽就重写父方法,可能没有效果。
若一个方法顶部形成很宽的平顶且没有更宽子调用,它更可能自身执行循环、计算、锁自旋或本地方法。
五、横轴、纵轴、颜色和时间的正确含义
| 元素 | 正确含义 | 常见误读 |
|---|---|---|
| 宽度 | 样本占比 | 方法单次调用耗时 |
| 高度 | 调用栈深度 | 时间更长或更严重 |
| 左右位置 | 聚合布局,通常无时间顺序 | 先执行左边再执行右边 |
| 颜色 | 默认多用于视觉区分 | 越红越危险 |
| 平顶 | 大量样本直接落在该帧 | 一定是Bug |
普通火焰图不是时间轴。要分析“第几秒发生什么”,应结合时间序列、JFR事件、日志或时间线型Profile。
六、不同事件的火焰图回答不同问题
6.1 CPU火焰图
回答线程在CPU上运行时主要执行哪些调用路径。适合CPU高、吞吐下降、序列化/计算/自旋热点。
线程阻塞在Socket、Lock或sleep时通常不消耗CPU,因此CPU图可能看不到接口慢的真正等待。
6.2 Wall Clock / Wall火焰图
按墙上时间采样运行和等待线程,能看到Socket等待、锁等待、sleep等路径。它更适合“接口慢但CPU不高”,但需要按线程状态和事件语义解释,宽并不等于CPU热点。
6.3 Allocation火焰图
回答谁在分配对象。需要确认工具统计的是分配字节、分配事件还是采样对象;采样配置不同不能直接比较绝对数。
6.4 Lock火焰图
回答锁等待或竞争主要来自哪些调用路径。需要结合锁持有者、线程转储和等待时间,不能只看到某个 synchronized 方法就下结论。
6.5 Native/Kernel栈
I/O、系统调用、内存分配和运行时开销可能落在Native或Kernel栈。工具没有权限、符号或展开信息时会出现 [unknown],这不代表CPU凭空消失。
七、可运行Demo:从采样栈聚合folded数据
下面用Python标准库演示火焰图最核心的数据过程。它不生成HTML,但能看懂“相同调用栈如何变成宽度”。
from collections import Counter
samples = [
("Controller", "Service", "JsonEncoder"),
("Controller", "Service", "JsonEncoder"),
("Controller", "Service", "JsonEncoder"),
("Controller", "Service", "RuleEngine"),
("Controller", "Service", "RuleEngine"),
("Scheduler", "CleanupTask"),
]
folded = Counter(";".join(stack) for stack in samples)
for stack, count in sorted(folded.items()):
print(f"{stack} {count}")
# Inclusive计数:一个样本中的每个帧都属于其调用子树。
inclusive = Counter()
for stack in samples:
for depth in range(1, len(stack) + 1):
inclusive[";".join(stack[:depth])] += 1
print("Service inclusive:", inclusive["Controller;Service"])
print("JsonEncoder exclusive:", folded["Controller;Service;JsonEncoder"])输出中 Controller;Service Inclusive 为5,说明5个样本进入其调用子树;JsonEncoder Exclusive路径为3。真实工具还要处理线程、Native栈、JIT内联、采样权重和符号。
八、JIT内联为什么让图和源码调用层级不同
HotSpot可能把小方法内联到调用方,机器执行时不再有普通调用边界。Profiler若能读取JIT元数据,可重建部分内联帧;否则图中可能看不到源码方法,或显示编译后的组合帧。
其他影响:
- C1/C2编译阶段不同,采样窗口图形可能变化。
- 去优化后方法回到解释执行。
- Lambda、反射、动态代理会增加合成框架帧。
- Native库缺符号时显示地址或unknown。
因此火焰图是“采样时运行态调用栈”的证据,不是静态源码调用图。
九、Safepoint Bias是什么
一些传统JVM采样方法只能在或偏向安全点取得Java栈。长时间不经过安全点的热代码可能被低估,采样结果偏向容易停下来的位置,这称为Safepoint Bias。
async-profiler等工具会利用操作系统性能事件和异步栈回溯降低这类偏差,但仍有平台、权限、栈展开和采样误差。不能因为用了某个工具就认为数据绝对无偏。
验证方式:
- 与进程/线程CPU时间、JFR、业务指标交叉验证。
- 改变采样频率和时长看热点是否稳定。
- 检查unknown、kernel和runtime占比。
- 确认采样的是正确PID、容器和线程。
十、async-profiler常用过程
命令随版本可能变化,先执行工具的 --help。常见思路:
# 对PID采集30秒CPU事件并输出HTML
./profiler.sh -d 30 -e cpu -f /data/diag/cpu.html <pid>
# 分配热点,事件名与支持情况按版本确认
./profiler.sh -d 30 -e alloc -f /data/diag/alloc.html <pid>
# 锁竞争/等待,按工具版本确认事件
./profiler.sh -d 30 -e lock -f /data/diag/lock.html <pid>执行前:
- 记录故障时间、实例、版本和流量。
- 确认磁盘、CPU和权限。
- 限定持续时间与输出目录。
- 先采CPU还是Wall取决于现象。
- 结果可能含类名、业务路径和参数信息,按敏感制品管理。
十一、Arthas profiler过程
profiler list
profiler start --event cpu
profiler status
profiler stop --format html --file /data/diag/cpu-flame.html不同Arthas/async-profiler版本支持的事件和参数可能不同,应以当前 profiler help、profiler list 为准。
若CPU已经接近100%,不要无限期运行Profiler。先短窗口采样,必要时在一个已摘流副本上继续;采样失败也要保留命令、错误和权限信息。
十二、JFR与JDK7/8版本边界
JFR可以记录执行采样、分配、锁、GC、线程和I/O等事件,再用JDK Mission Control分析。JDK 11+ 的开源JFR使用更统一;JDK 8不同发行版、更新版本和许可证条件存在差异,生产使用前必须确认实际JDK发行版、可用命令和许可,不能把JDK 17命令原样套到JDK 8。
常见新版本示意:
jcmd <pid> JFR.start name=incident settings=profile duration=60s filename=/data/diag/incident.jfrJDK 8环境先执行:
jcmd <pid> help确认该JVM实际支持的JFR命令。JFR是事件记录,不等于Heap Dump;两者回答的问题不同。
十三、容器中为什么经常采不到
常见原因:
- 连接了错误宿主机或容器PID。
- PID Namespace内外PID不同。
- 容器缺少perf事件权限或必要Capability。
- 宿主机
perf_event_paranoid等安全策略限制。 - 只读文件系统或输出目录空间不足。
- 运行用户与Java进程用户不兼容。
- 极简镜像缺工具,但工具可从宿主机或受控诊断容器运行。
不要为了采样直接给生产容器无限特权。先在安全策略允许范围内选择JFR、Sidecar/诊断容器、宿主机采样或复制到预发复现,并记录与生产差异。
十四、CPU高排查Runbook
flowchart TD
A["CPU高告警"] --> B["确认主机/容器/进程和时间窗"]
B --> C["定位高CPU线程并保存线程栈"]
C --> D["短时CPU采样生成火焰图"]
D --> E{"宽叶子属于哪类"}
E -- "业务计算" --> F["检查算法、循环、序列化和数据规模"]
E -- "锁/CAS自旋" --> G["检查竞争、重试和共享热点"]
E -- "GC" --> H["结合GC日志和分配图"]
E -- "Native/Kernel" --> I["检查系统调用、符号和I/O"]
F --> J["修复后同流量复测"]
G --> J
H --> J
I --> J火焰图要和线程CPU、请求量、P95、GC日志、发布变更一起解释。宽方法可能只是业务流量增加后的正常热点,优化前先确认单位请求成本是否异常。
十五、接口慢但CPU不高Runbook
- 先拆入口排队、线程池、数据库、HTTP、锁和模型/外部服务耗时。
- 连续多次线程转储,识别稳定阻塞栈。
- 选择Wall/lock事件,而不是只采CPU。
- 看连接池、队列、Socket超时和下游P95。
- 确认宽等待路径的线程数和等待时长。
- 修复超时、并发、锁范围或下游容量。
CPU图很“平”不代表应用没问题,它可能大部分时间都在等待。
十六、Allocation热点到泄漏的判断链
Allocation火焰图:谁创建得快
→ GC日志:创建后能否回收、分配速率多高
→ Class Histogram:当前哪些类多
→ Heap Dump/MAT:哪些存活对象Retained Heap大
→ Path To GC Roots:为什么不能回收
→ 代码和业务:谁把它放入长生命周期引用只有Allocation图不能宣布内存泄漏。反过来,慢性泄漏对象分配速率可能不高,Allocation图也未必显眼。
十七、差分火焰图是什么
差分图比较两个采样窗口或两个版本的栈样本差异,用颜色/方向突出增加和减少。它适合发布前后、基线/候选对比,但前提是:
- 相似流量和请求类型。
- 相近采样时长与频率。
- 相同事件、线程范围、符号和工具版本。
- 先归一化样本量。
差异不自动代表回归,可能只是流量结构变化。必须回到业务标签和单位请求指标。
十八、常见误区
| 误区 | 正确理解 |
|---|---|
| 红色就是严重 | 默认颜色多为视觉区分 |
| 横轴是时间 | 普通火焰图横向无时间顺序 |
| 父方法很宽就改父方法 | 查看上方子树和顶部Exclusive热点 |
| CPU图能解释所有慢请求 | 等待型问题看Wall/lock和链路指标 |
| Allocation高就是泄漏 | 还要看存活、Retained Heap和GC Roots |
| 采样百分比等于精确耗时 | 是窗口内概率近似,存在采样偏差 |
| 一张图可以代表全天 | 只代表采样窗口和流量 |
| unknown可以忽略 | 可能是符号、权限、Native或栈展开问题 |
十九、OOM 时各种图分别回答什么
| 图或视图 | 回答的问题 | 不能单独回答的问题 |
|---|---|---|
| 火山图 | 哪些指标变化幅度大且统计显著 | Java 对象为何不能回收 |
| CPU 火焰图 | CPU 样本主要落在哪些调用路径 | 堆中谁持有最多存活对象 |
| Allocation 火焰图 | 哪些代码路径分配对象最多 | 对象是否长期存活、是否泄漏 |
| MAT Histogram | 哪些类的实例多、浅大小大 | 谁持有这些对象 |
| MAT Dominator Tree | 哪些对象支配的 Retained Heap 最大 | 业务上为什么产生这些对象 |
| Path To GC Roots | 对象通过什么链路仍被 GC Root 引用 | 故障何时开始、流量为何变化 |
| GC 时间图 | GC 次数、暂停、回收前后变化 | 具体是哪段代码持有对象 |
二十、Java 性能排查常见工具
async-profiler:常用于 CPU、allocation、lock 等采样并生成火焰图,开销通常比传统插桩方式低,但生产仍需评估和限时采样。- JDK Mission Control / Java Flight Recorder:记录 CPU、分配、锁、GC、线程等事件,适合综合诊断。
- Arthas profiler:可借助 async-profiler 进行采样和生成结果,执行时要控制持续时间和事件类型。
- MAT:分析
.hprof堆转储,重点是 Histogram、Dominator Tree、Retained Heap、Path To GC Roots。
Arthas CPU 采样示意:
profiler start
profiler status
profiler stop --format html --file /data/diag/cpu-flame.html对象分配采样应根据环境支持情况选择 allocation 事件,并限制采样时长。不要在已经濒临 OOM 的实例上无评估地长时间采样,因为诊断工具本身也需要资源。
二十一、面试标准回答
什么是火山图
火山图是一种把变化倍数和统计显著性放在同一张散点图中的可视化,横轴通常是 log2 Fold Change,纵轴通常是负对数 p 值,左右上方代表变化大且显著的对象。它多见于生物信息学,不是 JVM 排障的主要图。
Java 排障中说的应该是什么图
Java CPU 高或接口慢排查通常说火焰图。火焰图由调用栈采样聚合生成,栈帧宽度表示样本占比,纵向表示调用深度。OOM 如果要看对象为什么不能回收,应使用 heap dump 和 MAT 的 Dominator Tree、Retained Heap、Path To GC Roots;如果要看谁创建对象最快,可看 allocation 火焰图,但高分配不等于泄漏。
