☰
Linux火焰图实战:从原理到Java性能排查
2026/9/26 12:06:57 网站建设 项目流程

1. 为什么学了这么多年Linux,我还是强烈建议你掌握火焰图

大概半年前,我接手一个Java线上服务,高峰期CPU直接飙到300%以上,top命令里看到的是java进程占满,但具体是哪段代码在烧CPU,完全抓瞎。按老套路,先top -Hp 找到线程PID,再jstack打印线程栈,来回对比十几层调用关系,折腾了两个小时,定位到的居然是一个已经在凌晨三点修复过的历史问题。那一刻我意识到,靠线程栈手工翻堆栈,效率太低,而且非常依赖经验,换一个复杂点的调用链,眼睛根本看不过来。

后来我才系统地把火焰图这套方法用起来。所谓Linux火焰图,是Brendan Gregg发明的一种性能分析可视化方式,它把采样到的函数调用堆栈画成一张横向的、像火焰一样的SVG图。每一条"火苗"就是一个调用栈,火苗越宽,说明这个函数在采样周期里出现的次数越多,也就是CPU时间占比越高。相比vmstat、top这种系统级指标,火焰图能直接告诉你CPU时间到底烧在哪个函数、哪条调用链上,而且不需要在代码里埋任何点,不需要重启进程,对线上服务几乎零侵入。

如果你日常工作涉及Linux服务性能排查、JVM应用调优、容器化部署后的疑难杂症定位,或者是做SRE、运维、后端开发的,我强烈建议你把火焰图纳入自己的排查工具箱。它除了能看CPU,还能看内存分配、锁竞争、Off-CPU阻塞时间,同一个思路能解决好几类问题。这篇文章不是那种贴一堆命令就结束的教程,我会把"为什么这个参数这么写""图上这个形状到底什么含义""生成之后怎么看"全部讲透,最后附上我在真实环境中踩过的坑,希望能帮你少走弯路。

2. 火焰图的原理其实很简单:采样堆栈,然后按占比横向堆叠

2.1 从一个"愚蠢"的计时问题说起

要理解火焰图,先理解它的数据来源。它不需要像很多APM工具那样在代码里埋桩,而是用系统的性能计数器周期性地打断CPU,记录当前正在执行的函数调用栈。打个比方:你在一个工厂里随机按快门拍照,拍一万张,然后统计每个工位被拍到的次数。哪个工位在照片里出现的频次高,就说明哪个工序最耗时。这个思路听上去很简单,但非常有效。

Linux里干这件事的主角是perf,由内核的perf_event子系统提供支持。核心命令是perf record,它会按照你指定的频率去采样调用栈。火焰图的横轴不是时间,而是"采样到的次数占比"。注意,整个图从头到尾没有时间先后顺序,只有"占比"这一个语义维度。这一点经常被刚接触的人误解,老觉得火苗从左到右是时间流动,其实不是,它是把所有采样到的栈按调用路径做了归类、排序和堆叠。

2.2 为什么采样频率喜欢用99Hz

你可能在很多教程里看到过perf record -F 99。第一次看的时候我就在想,为什么不是100、不是1000,偏偏是99。当时觉得这些博主肯定是在装深沉,后来查了Brendan Gregg的原话才搞明白,这里有个非常隐蔽的坑:很多内核的采样定时器或者某些操作系统的心跳本身就是100Hz。如果你也把采样频率设成100Hz,就有可能与系统自身的事件产生频率共振(beat),造成采样结果周期性偏差。比如说,你的程序每10毫秒刚好做一次特定操作,而采样器恰好也每10毫秒打一次点,那这次操作就可能被反复采样或完全漏掉,数据严重失真。

于是99Hz就成了一种经验默认值,它和100Hz错开一点,尽量避免整倍数共振。当然,如果你的场景需要更细粒度,可以提高到499Hz甚至997Hz,但没有特殊必要不用上几千赫兹,因为采样本身有开销,频率越高对目标进程的干扰越大。一般排查CPU问题时99Hz、持续30到60秒已经能拿到足够的样本。

2.3 从perf record到SVG的完整链路

整个生成过程可以分成三步:

# 第一步:采样,采集目标进程的调用栈 perf record -F 99 -p 12345 -g -- sleep 30 # 第二步:把perf的原始数据导出成文本格式 perf script > out.perf # 第三步:把文本堆栈折叠成一行一行的路径 ./stackcollapse-perf.pl out.perf > out.folded # 第四步:用折叠后的数据生成火焰图SVG ./flamegraph.pl out.folded > flamegraph.svg

第二步里的out.perf长什么样?里面每一行记录是一条采样样本,包含进程名、PID、时间戳、以及从内核到用户态的一系列函数符号。第三步的"折叠"是关键:它把每个采样点记录的完整堆栈,从栈底到栈顶用分号拼接成一行,然后在行尾统计出现次数。比如某个调用路径start_thread;JavaMain;main;run;run0出现了42次,最后文件里就是这行加上数字42。

第四步的flamegraph.pl会读取每一行和对应的次数,按次数比例画横条。整个流程脚本都在Brendan Gregg的GitHub仓库FlameGraph里,直接git clone下来就能用,不需要编译,依赖的只是Perl环境,几乎每台Linux都有。

2.4 如果没有root权限怎么办

这是很多线上环境会遇到的问题。perf record采集全系统调用栈通常需要perf_event_paranoid允许,内核默认值往往是2或3,普通用户只能采样自己的进程。很多同学第一步就卡在permission denied上。

解决办法有三条:一是如果你们公司运维可以调内核参数,把/proc/sys/kernel/perf_event_paranoid改成-1,但这种改动在银行、政企类环境很难申请下来;二是用sudo perf直接跑,采集完生成的文件归root所有,随后chown给普通用户再执行perf script;三是用perf record配合--uid或者干脆让当前用户在容器外以root身份跑,然后到容器内去分析,这个后文讲容器场景时会展开。如果连perf命令都不存在,多半是linux-tools-common这类包没装,在Ubuntu/Debian上apt install linux-tools-common linux-tools-$(uname -r)能解决。

3. 一张图摆在面前,到底该看什么

3.1 火苗的宽度、高度和颜色分别代表什么

火焰图长得很像一场正在燃烧的山火,底部是根,顶部是叶。底部通常是进程入口或者内核的入口函数,比如start_thread、entry_SYSCALL_64_after_hwframe,越往上越接近你实际业务代码。每个横条的宽度代表它在总采样样本中出现次数占的比例,所以看宽度就知道CPU时间的主要去向。

颜色默认是暖色调,从黄到红再到紫,但它和性能没有直接关系,更多是一种视觉分组。比如在Java场景里,颜色深浅经常用来区分用户态代码和内核态代码,或者按函数名哈希取色,方便肉眼区分不同的调用栈。不建议根据颜色深浅来判断热点,要看宽度。

高度代表调用栈深度。如果整张图看起来是一座又高又窄的"塔",说明某个路径上函数嵌套很深,这种形状经常出现在递归或者层层封装的框架代码里;如果是一座又矮又胖的"平顶山",说明热点集中在少数几层,往往可以直接定位到一个函数。

3.2 先看顶部,再看底部

拿到一张火焰图,我自己的阅读习惯是从上往下:看那些顶部横条特别宽的函数。因为顶部意味着栈顶,也就是CPU真正执行到的函数。如果顶部的热点是一个Native Method或者GC相关的函数,那瓶颈大概率在JVM内部而不是业务代码;如果顶部热点是你自己项目的类,恭喜你,直接打开源码改就完事了。

然后看底部和整体形态。底部如果特别宽,说明大量采样都是从底层框架发起的,例如Tomcat的Http11Processor处理连接,这时候业务代码的优化空间已经被框架主导,重点要检查线程模型。再看横向的"峡谷",火焰图里那些明显的凹陷往往意味着两个热点路径之间存在过渡,比如锁等待结束、IO返回,具体是哪种得结合Off-CPU火焰图一起判断。

3.3 典型形态速查

形态特征大概率的含义进一步动作
顶部有一个又宽又平的横条某个函数吃掉了大部分CPU打开源码看实现,确认是否自旋或者做了大量计算
图上出现大量"锯齿"状细条采样样本偏少或函数符号解析不全提高采样时长或安装符号表
某条调用栈像针一样细长该路径只执行了很短时间但嵌套很深可忽略,或在Off-CPU图上确认是否和IO阻塞有关
整图呈现多个"烟囱"多个线程并发执行类似逻辑检查线程数与任务切分,可能存在锁竞争
底部非常宽且顶部很快变窄大范围调用集合到少数热点函数典型的框架封装的聚合优势,重点查热点函数入参

3.4 一个最容易忽略的细节:点击和搜索

如果你用浏览器打开生成的SVG,你会发现火焰图是可以交互的。点击任意一个横条,它会以这个横条为底重新放大,看它的完整调用链,这个功能在排查多层框架问题时极其好用。要回到完整视图,直接点击左上角的"Reset Zoom"。另外,浏览器里按Ctrl+F搜索函数名,SVG会高亮所有带这个名字的横条,这个技巧在确认某个工具类是否被大量调用时特别高效。

我记得第一次实操时,SVG文件有十几MB,浏览器一度卡爆,后来才反应过来:火焰图在采样量很大的情况下SVG节点非常庞大,建议先用grep过滤只包含目标关键字的调用栈,再生成图。比如只关注某个包下的堆栈:

grep "com.example" out.folded > out-filter.folded ./flamegraph.pl out-filter.folded > filtered.svg

4. 一次Java服务CPU飚高的完整定位实例

4.1 现场情况和采集参数选择

那是一个典型的Spring Boot应用,部署在8核16G的容器里,流量高峰期CPU超过300%。我先用top确认是java进程,拿到PID为11221,然后执行:

perf record -F 99 -p 11221 -g -- sleep 30

注意命令最后的-- sleep 30,它的意思是让perf record作为采样器,sleep 30作为采样持续时间的载体。很多人写成perf record -F 99 -p 11221 -g sleep 30,如果sleep放在-p后面,会被解析成对进程11221发起sleep信号而不是指定时长,这是个容易踩的命令顺序坑。perf record前还有一个-o参数可以指定输出文件名,我习惯命名成perf.data.$(date +%s),方便保留多份历史数据对比。

采样完成后,接着走标准流程:

perf script > out.perf ./stackcollapse-perf.pl out.perf > out.folded ./flamegraph.pl out.folded > flamegraph.svg

当时生成的火焰图里,顶部出现了一个非常扎眼的宽条:com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalLoadingCache下的一个方法。我第一反应是"本地缓存"怎么成了CPU热点?继续沿火苗往下看,发现调用源头是某个定时任务在批量刷新缓存时,对单个key执行了成千上万次get,每次get又走了Caffeine的异步刷新的复杂分支。优化方案很简单:把批量操作改成getAll,并且在更新时只向缓存触发一次invalidate,落库操作合并成批次。这个修改不到半小时,但定位到这一步,如果不用火焰图,光靠jstack不知道要翻多少遍线程栈。

4.2 用Arthas做交叉验证,火焰图不背锅

这里必须说一个很实际的场景:Java应用跑在JVM里,很多调用栈扒到JVM内部后,函数名是_ZN2...这一类C++符号,非常难读。如果光看火焰图,很容易在JVM内部函数里迷失。我在那次排查里,同步用Arthas的thread -n 3看了CPU占用最高的几个线程,再对应火焰图的顶部热点,两边一交叉,确认问题就在缓存刷新线程上,不是JVM的GC或者编译器导致的伪热点。

这种交叉验证很有必要。火焰图是统计采样,它只能说明"这段代码大部分时间在被采样",不能说明"这段代码是因为等待还是计算占的CPU"。要区分是计算密集还是阻塞等待,可以用Arthas看线程的状态,如果是RUNNABLE但火焰图又宽,说明纯计算;如果是TIMED_WAITING还有其他线索,那火焰图顶部的宽条可能只是反映线程反复被唤醒的现场,真正的瓶颈在阻塞点,这就要用到下一节要讲的Off-CPU火焰图。

4.3 修复后对比:怎么确认优化有效

代码改完之后,我重新采样30秒,生成一张新的火焰图,对比修复前后两版。对比时的标准不是肉眼看图觉得"好像窄了一点",而是看热点函数的采样占比:修复前某个热点函数占大概38%,修复后直接降到5%以下,整张图的主峰形状都变了。同时配合top看实时CPU使用率,确认从300%左右回落到120%以内,再观察业务侧请求RT曲线。三份数据一起看,才能确定这次优化不是噪声波动,而是真实收益。

这里建议所有排查性能问题的同学,都养成保留"前后对比图"的习惯。被怀疑的性能问题,修复后必须重新出图验证,否则就是在盲人摸象,有可能你改的代码根本没被采到。以前我就干过这种事,优化完自我感觉良好,第二天发现同一类问题换个接口又冒出来了,就是因为根本没复测。

5. 工具链不是只有perf一家的:不同语言和场景的选型

5.1 一张表看清常见方案

火焰图的采集方式非常多,很多方案不止能采CPU,还能采内存、锁等其他资源。我把常用的列出来:

工具适用场景采集能力上手难度关注点
perf通用Linux,C/C++/Java/Go均可CPU、硬件事件、tracepoint中命令原生但Symbol解析需额外处理
async-profilerJVM系服务CPU、内存分配、锁竞争低自带Java符号解析,能直接生成火焰图
ArthasJava服务在线诊断CPU、线程、方法调用耗时低命令行交互,不落盘,适合临时排查
py-spyPython进程CPU低无需修改代码,采样性能开销可忽略
bpftrace内核定制分析动态追踪高适合深度内核问题,不适合日常业务
pstack/gdb轻量看线程栈当前时刻栈极低无法生成真正的火焰图,只能做瞬间快照

再补充一个和网络热词很相关的点:很多人搜索"arthas火焰图",其实Arthas本身不直接输出和flamegraph.pl一样的SVG,它是通过profiler命令调用了async-profiler的采集能力。比如在Arthas里执行:

profiler start -e cpu # 等几十秒 profiler stop --format svg --file /tmp/cpu.svg

这个命令生成的SVG和直接用async-profiler生成的大同小异。好处是你不用离开现有的Arthas环境,对已经喜欢用Arthas做Java诊断的同学非常友好。

5.2 Java服务为什么要优先考虑async-profiler

当目标进程是Java时,我强烈不建议直接用perf去采,而推荐async-profiler。原因是JVM内部有JIT编译、类卸载、栈帧优化,纯perf采样到的栈经常是一堆JavaMain、Interpreter的符号,根本看不懂。async-profiler自己实现了Java线程栈的获取,它通过JVMTI和AsyncGetCallTrace拿到Java层的真实方法名,能直接输出方法级别的火焰图。它的命令也非常简洁:

./profiler.sh -d 30 -o svg -f /tmp/java-cpu.svg 11221

-d 30表示采样30秒,-o svg指定输出格式,-f指定输出文件。async-profiler还支持alloc事件来采样内存分配热点,格式是-e alloc;支持锁竞争事件-e lock。草根养成的习惯是,先看CPU火焰图,CPU没问题再看锁火焰图,这个顺序基本能覆盖大多数Java线上问题。

5.3 大数据场景里的Flink为什么也能用

网络热词里出现了"flink火焰图",其实Flink集群里的性能分析思路和单机Linux没有本质区别,只是采样对象从单个PID变成了TaskManager的JVM进程。Flink的TaskManager往往一个节点上跑多个Slot,如果你拿top看到某个TaskManager进程CPU飙高,先找到进程PID,然后在节点上用async-profiler或perf采样,定位到具体算子方法。我见过很多Flink作业延迟变大的问题,到最后发现是反压导致的死循环式重试,火焰图上会有非常典型的宽而高的调用栈形态。再往下追溯就到了AsyncWaitOperator这类异步等待点,结合Off-CPU火焰图看阻塞时间,基本就能还原完整链路。

6. Off-CPU火焰图和连续火焰图:CPU之外的另一半故事

6.1 为什么只盯着CPU火焰图会漏掉一大半问题

CPU火焰图回答的问题是"CPU时间花在哪里",但线上服务大量问题其实是线程在等待:等待IO、等待锁、等待网络返回。这些等待过程CPU几乎为零,perf record -F 99根本采不到有效样本,因此CPU火焰图会表现为白茫茫一片或者只看到稀稀拉拉几条栈。这时候需要的是Off-CPU火焰图,它基于追踪线程被调度出CPU的事件,把"线程为什么被移出CPU、阻塞了多久"画出来。Brendan Gregg的原话是:"如果只画On-CPU火焰图,你只看到了一棵树上的叶子,看不到树根的腐烂。"

常用的采集方法是用perf的sched事件或者offcputime脚本。FlameGraph仓库里自带的offcputime工具可以一条命令生成:

./offcputime -K -p 11221 -d 30 > off.folded ./flamegraph.pl --color=io --title="Off-CPU Time Flame Graph" off.folded > off.svg

Off-CPU图横轴的语义不是"CPU时间占比",而是"线程离开运行的等待时间占比"。图上宽的横条代表某段阻塞路径累计等待时间最长。你会清楚看到是卡在FileInputStream.readBytes这种文件IO,还是Object.wait这种锁等待,还是SocketInputStream.read这种网络IO。接入层服务出问题时,这张图往往比CPU图更一针见血。

6.2 连续火焰图适合看什么

还有一种连续火焰图(Flame Graph Over Time),本质上是在横轴上加了时间维度,把不同时间段的火焰图拼成一张热图,纵轴是调用栈,横轴是时间,颜色深浅表示该时刻的占用强度。它适合看"CPU超时段的波形变化",比如流量高峰每隔多久波动一次、某个热点函数是不是周期性出现。FlameGraph仓库里有flamegraph.pl --timeline参数可以做简单版本,或者用perf timechart导出数据再转换。实际生产中,我一般不会一上来就用连续图,而是先在单张图上定位了热点函数,再用连续图看它的出现规律,确认它是持续占CPU还是周期性突发。

6.3 在容器环境里的采集姿势

现在服务基本都是容器化部署。容器里跑perf record常常因为缺少权限而失败,一个非常实用的思路是:在宿主机上直接以root运行perf record -g -p 容器内java进程的宿主PID。容器内进程的PID在宿主机上依然可见,perf采集到的是这个进程的真实调用栈,不会受容器隔离影响。但要注意,如果在容器镜像里缺了perf工具,在宿主机上用perf采完数据,可以把perf.data拷贝到容器内,或者用annotate、script在宿主机上分析,两者等价。

另外在容器里跑Java应用时,JIT编译器产生的可执行文件位于临时文件系统里,perf找不到符号时会把栈显示成一段地址而不是方法名。解决办法是让JVM禁用Anonymous Classes相关优化,或者用async-profiler带上-i参数和--all-user。最省事的方法还是节里写过的:在容器内直接用Arthas的profiler命令,它不需要额外的系统权限,也不依赖宿主机perf。

7. 火焰图使用中那几个让我折腾到深夜的坑

7.1 采样时长不是越长越好,而是越有代表性越好

最早用火焰图的时候,我犯过一个经典错误:为了"采样充分",直接采10分钟,结果图是出了,但SVG大得打开都要半分钟,而且顶部热点反而被拉平了。原因是长时间采样会把多个阶段的CPU行为混在一起,比如前2分钟在做正常业务、后8分钟出现GC风暴,混合后的火焰图会呈现出两条热点路径,看不出单一阶段的真实瓶颈。

我的经验是:先观察top里目标进程的CPU占用率是否稳定,如果稳定,采30-60秒足够;如果CPU是周期性波峰,就挑波峰出现时同步启动采样,用top抓到波峰后立即执行perf record。最好是分多次采样,分别覆盖波峰、波谷、常态三个区间,生成三张图分别分析。这个习惯帮我解决过不少间歇性问题,比一张超长采样图信息量大多了。

7.2 函数符号解析:看到的全是地址,怎么办

很多刚上手的朋友会碰到一个更普遍的坑:生成的火焰图上,大量横条的函数名是一串十六进制地址,或者如0x7f34a1d2b8这种。原因很简单,采样到进程调用栈时,需要把指令地址翻译成函数名,翻译靠的是符号表。Linux原生环境下,C/C++程序编译时如果没有加-g或者没保留符号,或者Java的JIT代码没有上报给perf,就会出现一堆地址。

C/C++场景的解法是安装debuginfo包,或者编译时打开-g -fno-omit-frame-pointer,后者比前者重要得多。很多C++项目为了性能优化会默认开-fomit-frame-pointer,导致perf采不到栈帧寄存器,只能看到零星几条栈。火焰图仓库里有一名专门讲这个的文档,如果栈都很浅且全是地址,第一反应就应该是检查编译选项,别急着怀疑perf工具坏了。

Java场景的解法是我前面反复强调的用async-profiler或者Arthasprofiler。实在要用perf,可以在启动Java时加上-XX:+PreserveFramePointer,这样perf能抓到JVM内部C++栈和部分Java栈。注意这个参数对性能有微小影响,测试环境验证没问题再上生产。

7.3 折叠脚本对sh脚本的处理细节

stackcollapse-perf.pl对不同的调用栈字符有细微处理,比如当进程和线程名里带空格时,折叠后的行可能错位。遇到过一两次grep过滤后栈变成乱数据,后来才知道perf script输出里第一列是"进程名/PID",如果进程名里有空格,整个行解析就乱了。稳妥的做法是,先grep掉以空格开头的行,再跑stackcollapse-perf.pl:

perf script | grep -E '^[^ ]+ +[0-9]+ +\[[0-9]+\]' > clean.perf

这个过滤可以去掉那些不完整的采样记录,让折叠脚本更稳定。另外,如果你用容器环境,perf script输出里进程名经常是一长串UUID或者哈希值,折叠后对不齐,这时候可以加一句sed把进程名统一替换成目标名,保证折叠脚本正常计数。

7.4 误以为自己已经定位到根因的时候,先抽根烟

这是我最想说的一条经验。火焰图最危险的地方在于它看起来太直观了——一个又宽又亮的横条在图上,很容易让人立刻打开源码开始改。但火焰图告诉你的只是"热点在这里",它没有告诉你"为什么在这里"。

举个例子,有一次我定位到一个ConcurrentHashMap.put的热点,整个横条宽到霸屏。正常人第一反应是改数据结构或者降低并发度。但我去看了日志后才发现,真正的问题是上游调用方在循环里反复调这个接口,每次传参都不一样,导致缓存失效,热点只是个结果。也就是Brendan Gregg说的那句名言:"采样只能告诉你函数占了多少时间,不能告诉你它为什么占这么多时间。"每次定位到热点函数后,应该做两件事:一是读源码看有没有明显的计算浪费;二是看调用方是谁,为什么在这个时间点调这么频繁。两件事都做了,再动手改代码。

8. 写在最后:我现在的火焰图排查姿势

上面这些内容写下来,算是把自己折腾火焰图的过程复盘了一遍。如果让我用一句话总结现在的工作流,就是:先用top和Arthas确认现场,再分场景采样,优先用async-profiler看Java CPU,用perf看系统级调用和内核态,热点函数太多或CPU不高但排障无头绪时,立刻补一张Off-CPU火焰图找阻塞源,最后用前后对比图验证优化效果。

日常使用中我最依赖的两个习惯,一是每张火焰图都按时间命名并保留原始perf.data,方便以后回溯;二是看到横条宽度异常时,先双击放大看完整调用路径,再按Ctrl+F搜业务关键字,确认不是符号乱掉的假数据。这套流程看起来朴素,但已经帮我处理过不下几十个线上性能问题,成功率相当高。

如果你是第一次接触火焰图,建议不要一次性学完全部工具,先用perf record跑通一张CPU火焰图,然后对照本机的一个明显热点函数看几遍。等你对"宽条=热点"建立了直觉,再遇到性能问题就会自然地想到这个工具。剩下的,就是多踩几次坑、多积累几个案例的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询