这段时间在对服务做一些压力测试,尝试了一下 CPU 火焰图,来自这里:Flame Graphs 。虽然这个页面以及 PPT、视频都比较详细记录了火焰图的生成过程,但实际操作中依然遇到问题无数。考虑还是记录一下以备后用。

过程

打算先主要记录制作火焰图的过程,各种操作的原因或目的如果简单的话会直接写下来,几句话说不完的就在下一节再详细写。

需要说明的是我的测试服务使用 Docker Container 部署,所以制作火焰图过程会因此而有所波折。

准备工作

  • 登上测试机器,安装 perf。我这里是 Ubuntu 的机器,这么来安装:
sudo apt-get install linux-tools-4.4.0-47-generic

如果没安装 perf 直接执行在 Ubuntu 上会收到安装提示,可以按照提示来安装。

  • 还需要去下载这个工具 GitHub - jvm-profiling-tools/perf-map-agent,并进行安装。下载的话在它 github 工程下 Release 页面内找到 tar.gz 文件用 wget 下载。后续要用它来从 Java 进程获取符号表。有了符号表才能将 perf record 记录的 method 的地址跟代码中的 method 名称对应起来,才能被阅读。
wget https://github.com/jvm-profiling-tools/perf-map-agent/releases/tag/v0.9
tar zxvf v0.9.tar.gz
cmake .
make

编译好后会多出来一个 out 文件夹,有用的工具藏在其中。

wget https://github.com/brendangregg/FlameGraph/archive/v1.0.tar.gz
tar zxvf v0.1.tar.gz 

里面是 perl 脚本,不用编译。

  • 启动被测服务,带着 -XX:+PreserveFramePointer这个 JVM 参数。

获取数据说明

上面有提到,我的测试程序部署在 Docker Container 中。从上面工具准备一节有看到,我们需要收集两种数据,一种是 perf record 的结果,另一种是通过 perf-map-agent 导出得到的 Java 进程符号表。perf record 可以在 Container 外的 Host 上执行,这样只用在 Host 上安装 perf 即可。但用 perf-map-agent 导出符号表比较麻烦,它麻烦在两个事情上:

  • 它必须得在 Container 内部执行。因为它要想导出 Java 进程符号表,得通过 Dynamic Attach Mechanism 与目标 Java 进程建立联系。这个机制依赖于在 /tmp路径下存放一个文件,依靠这个文件来与目标进程进行通信,原理与 jstackjmap 工具类似。但如果在 Host 上执行 perf-map-agent 其在 Host 的 /tmp 路径下放一个文件在 Container 内的进程是访问不到的,就只好在 Container 内去执行 perf-map-agent 才行;
  • 因为 Java JIT 的存在,随着程序运行期数据的收集它会尝试再进行优化,针对程序运行期的表现去编译代码,就导致 Java 程序的符号表可能随着程序运行在不断变化。于是可能出现你执行完 perf record后再去导符号表,某个函数地址可能已经变化,在导出的符号表内找不到这个函数。所以一方面得在程序充分预热后再执行 perf record,另一方面得在执行完 perf record 后尽可能快的去导符号表,两者间隔越久可能差生的差别越大;

于是,为了解决上面说的问题,我们可能需要开两个 Terminal 都登录被测服务所在机器后,一个 Terminal 保持在 Host 上,一个 Terminal 进入被测服务 Docker Container 内。在 Host 上先执行 perf record ,看执行结束后立即切换到另一个在 Container 内的 Terminal 上导出 Java 的符号表。

获取数据步骤

打开目标服务的压测程序并运行一段时间,主要是 Warm up 等待 JIT 尽力把该编译的代码编译完,从而能让 perf 获取数据时符号表尽力稳定。可以通过压测程序进行多轮压测观察服务运行状况,发现压测结果比如 QPS 等波动不大的时候开始启动 perf 正式获取数据。

找到打算制作火焰图的目标服务进程,假设这里是 16429 执行 perf record来记录 profile 数据:

sudo perf record -p 16429 -F 99 -a -g  -- sleep 40

使用 perf record -help 能看到上述参数都是什么意思。-p 表示指定获取数据的进程号,-F 是采样频率,99 表示 99 赫兹,-a 表示从所有 CPU 上获取采样数据,-g 是获取调用栈,带上这个才能通过火焰图看到完整的堆栈信息,最后 sleep 就是执行 record 40 秒,时间越长最终得到的采样数据越大,分析起来越慢,符号表发生变化的可能越大。一般来说几十秒应该是完全够用了。

执行完成后,会在当前路径下生成一个名为 perf.data 的文件,里面就是各种采样数据。

perf record 执行完成返回后立即切换到被测服务所在 Container 内,先找到被测服务在 Docker Container 内的进程号,假设是 48 。再进入 perf-map-agent 工具编译好后生成的 out 目录下,这个目录内有生成好的 attach-main.jar 文件,用于导出目标 Java 服务的符号表。假设 /usr/lib/jvm/java-8-oracle/ 是 Java Home 的地址,目标服务的 Owner 用户名为 ubuntu,进入 out 后执行:

sudo -u ubuntu java -cp attach-main.jar:/usr/lib/jvm/java-8-oracle/lib/tools.jar net.virtualvoid.perf.AttachOnce 48

依然是前面说的 Dynamic Attach Mechanism 的原因,其要求 Attach 进程和当前进程的 UID 必须相同。因为我被测程序是 ubuntu 这个用户启动的,owner 是 ubuntu,所以在 Docker Container 内需要带上 sudo -u ubuntu

执行结束后符号表就是 /tmp/perf-48.map 这个文件。同时在 /tmp/ 目录下还会看到一个名为 .java_pid48 的文件,这个文件就是 Dynamic Attach Mechanism 机制下 Attach 进程和目标进程之间通信的桥梁。

最后,得想办法把 /tmp/perf-48.map 这个文件从 Docker Container 内搬到 Host 上。可以考虑在 Host 上使用 sudo docker cp CONTAINERID:/tmp/perf-48.map ./,也可以考虑使用 Docker 的 Data Volume 将 Host 上某个路径 Mount 到 Container 的文件系统上,从而让这个路径作为 Host 和 Container 之间文件传输的桥梁。至此,perf record 和符号表都准备好了。

制作火焰图

在 Host 上,找到之前下载的 FlameGraph,将 perf record 生成的 perf.data 文件挪到 FlameGraph 目录内。

perf-map-agent 生成的符号表 perf-48.map挪至 Host 发的 /tmp/ 路径下改名为 /tmp/perf-16429.map。这里 16429 就是被测 Java 服务在 Host 上的进程号。perf script 读取 perf.data 后发现是进程 16429 的采样数据,会默认找 /tmp/perf-16429.map 文件为其符号表,因为进程在 Docker Container 内和在 Host 上拥有不同的 PID,所以做了这么个改名处理。perf script 有个参数 --symfs 但这个只能指定符号表目录,具体的符号表名字还是会用默认的 perf-PID.map 还是得做这个改名处理。

处理好符号表之后,在 FlameGraph 目录内执行:

sudo perf script | ./stackcollapse-perf.pl | ./flamegraph.pl --color=java --hash > flamegraph.svg

如果采样数据不多的话上面语句会很快执行完,当前目录下名为 flamegraph.svg 的文件就是生成的火焰图。下载到本地后用浏览器能打开,类似这样:

1547F7F8-3A00-45C8-92DE-308889B758A0

火焰图怎么看可以参看这个文章:如何读懂火焰图? - 阮一峰的网络日志。关键的点是:

  • 每个格子都能点击,点击后会将其占满整个横轴,起到放大效果;点左上角 Reset Zoom 能恢复原状;右上角有 Search 能搜索某个函数名;
  • 同一高度的函数之间没有时间先后关系,越宽的函数表示其所在 “火焰” 占用 CPU 越多
  • 竖直方向每条 “火焰” 就是一个调用栈。上下相邻两个函数框格宽度上的差距,就是处于下方函数自身消耗掉的 CPU 百分数;

最后,这个文章也说了怎么在 Docker Container 内制作火焰图,也可以看看作为参考 Making FlameGraphs with Containerized Java – Alice Goldfuss

Frame Pointer

前面有提到,被测服务启动时候得带着 -XX:+PreserveFramePointer这个 JVM 参数,不带的话画出来的火焰图会成这个样子:

屏幕快照 2019-03-21 上午9 47 40

即整个火焰被压得非常 ”扁“,基本看不见 Java 的堆栈,主要是 C/C++ 的栈。也就是说缺少了 Frame Pointer 后,perf 无法正确的绘制堆栈。

Frame Pointer 是什么

在 Wiki 中有对这个东西做说明:Call stack - Wikipedia 这篇 Wiki 都值得看一看。

我觉得讲的最清楚的是 Computer Systems: A Programmer's Perspective, 3 Edition 一书的第三章节。其中讲 Frame Pointer 的在 3.10.5 一节。

下图是程序运行时期的一个栈,会向下扩展。函数调用时,首先会将返回地址压栈,之后将函数所需要的参数压栈,最后函数编译好时一般能知道当前函数最多能有多少个本地变量,所以函数调用时就能立即规划出要用多大的栈空间,将 %rsp 寄存器指向给当前被调用函数分配的栈空间末尾,就如下图所示。访问函数的参数或者局部变量时,就通过 %rsp 加上一个偏移来访问。比如下图函数有两个参数,A 和 B,要访问 A 这个参数,就是 %rsp + 48,B 就是 %rsp + 40。参数和局部变量都按 8 字节对齐。

Untitled Diagram(3)

但有些时候,栈大小并不是编译期就能确定的,比如有个 alloca 函数,具体说明在这里:alloca(3) - Linux manual page,它能动态的在栈上分配一个数组,从而做到函数调用结束后自动释放数组空间。使用它的话在函数编译期无法确认需要多大的栈空间,需要在运行期去调整分配的栈空间大小,也即调整 %rsp 的位置,最终导致无法只使用 %rsp 通过固定偏移去方便的访问任意一个参数以及本地变量,因为这些参数和本地变量相对 %rsp 的位置会随着 %rsp 的移动而不断变化。这时候就引入了 Frame Pointer,让栈变成这个样子:

Untitled Diagram(1)

Frame Pointer 用 %rbp 寄存器指向函数的栈底,这样无论 %rsp 怎么变化都依然可以用 %rbp 使用一个固定偏移去访问函数的任意一个参数和本地变量。另外要记得在函数调用时将 %rbp 入栈,也即将 caller 函数的 %rbp 存放起来,这样当前 callee 函数返回的时候能将 caller 的 %rbp 寄存器恢复。

Frame Pointer 在火焰图中的用途

从图中就能看到 Frame Pointer 的另一个好处,它紧挨着 caller 函数的返回地址,并且紧挨着 caller 的 %rbp。也就是说如果所有函数都维护了 %rbp 为 Frame Pointer,那从栈顶函数开始,顺着 %rbp 能一路将整条栈上所有函数的返回地址都拿到,有了这些返回地址就能获取到 caller 函数所在地址,再通过符号表根据 caller 函数地址就能将整个调用栈上的函数名构建出来。perf 正是这么获取到函数完整调用链的。

试想如果没有 %rbp 只有 %rsp 是无论如何不可能沿着这么一条路拿到整条调用栈上所有的函数地址的。通过 %rsp 最多能找到栈顶函数的 caller 函数地址,但因为函数调用时候 %rsp 并不入栈,所以不能进一步向上追踪了。

Frame Pointer 的坏处是多占用一个寄存器,并且每个函数调用和返回都要有维护 %rbp 的额外开销。并且对于编译器来说大多数函数在编译期就能方便的知道当前函数最多有多少参数,最多有多少局部变量,从而知道该分配多大的栈空间,于是只用 %rsp 就能方便的访问所有的参数以及局部变量,让 %rbp 的存在变得没有意义。

所以,优化掉 Frame Pointer ,只在必要时候才使用,大多数时候将 %rbp 当做通用寄存器使用是很多编译器的默认选择,比如 gcc 中有 -fomit-frame-pointer 这个参数是默认开启的,编译的程序会尽可能不使用 Frame Pointer。对于这种方式编译出的程序perf是无法完整绘制出整条调用栈的。需要给 gcc 指定 -fno-omit-frame-pointer参数后才会在函数调用时默认维护 Frame Pointer。

关于 -fomit-frame-pointer参数的说明可以参考 gcc 的说明:

-fomit-frame-pointer

Don't keep the frame pointer in a register for functions that don't need one. This avoids the instructions to save, set up and restore frame pointers; it also makes an extra register available in many functions. It also makes debugging impossible on some machines.

也可以看这个文章说的对这个参数的解释:c - Trying to understand gcc option -fomit-frame-pointer - Stack Overflow

还有这里比较的有没有 Frame Pointer 前后编译好的程序的变化:关于-fno-omit-frame-pointer与-fomit-frame-pointer

回到 Java 上,HotSpot JVM 本身编译时候默认就带着 -fno-omit-frame-pointer,维护了 Frame Pointer,从而实现了 Best effort 的 jstack -F -m 功能,也能打印出混合了 C++ 和 Java 的调用栈,但 JIT 编译后的程序并没有继续带着 -fno-omit-frame-pointer,而 Java 程序的高效运行完全依赖于 JIT 去编译代码,如果 JIT 编译的代码不带着 Frame Pointer,那势必无法很好的绘制每个线程的调用栈,因为程序运行一段时间后,关键的代码很可能都会被 JIT 编译过。

所以,最后引入了 -XX:+PreserveFramePointer这个 JVM 参数让 JIT 编译的程序也带着 Frame Pointer,从而让 perf 能绘制完整的调用栈。

补充一句,jstack -F -m 也能绘制调用栈,但问题在于一个是 jstack -F 很慢,会拖住目标 Java 进程,并且调用栈并不是任意时刻都能获取,而是得在线程走到 Safe Point 后才能获取。所以相比 perf 其在绘制调用栈上能力弱很多。

推荐这个: A hotspot patch for stack profiling (frame pointer) 是 Brendan Gregg 跟做 HotSpot 的人讨论希望增加 -XX:+PreserveFramePointer 这个参数的邮件,从中能看到很多 Frame Pointer 相关以及 perf 相关的东西。

Symbol Table

符号表是将函数地址与代码中函数名称对应起来的表。Java 中又是因为 JIT 的存在,这个符号表会不断变化,无法固定的拿到一个准确的符号表,需要在 Java 程序运行期通过 Attach 的方式去拿。如果没有从 Java 导出符号表,那它绘制出来的火焰图会是这个样子,也是矮矮的,不完整的,因为很多函数地址都找不到对应的在代码中的符号:

CEEFF169-A9BF-42F9-A3E1-8EC4023B0654

Perf

perf 工具使用可以看这个 perf CPU Sampling 和这个 Linux perf Examples

perf 如何工作可以看 Perf Wiki,以及这个文章:How does perf work? (in which we read the Linux kernel source) - Julia Evans

但是目前来看没有什么地方能完整详细的介绍 perf 工作原理。可能是因为它支持的功能太多,很难都记录下来,也可能是这个东西本身就比较复杂,都很难看明白。总之我很想知道 perf 的原理,但目前为止没有搜到详细的说明,并且看了这个 linux/tools/perf at master · torvalds/linux · GitHub 的代码量就放弃了直接阅读源码的念头。毕竟技术是研究不完的,还是别走火入魔。说不定等积累了更多知识后再看这块就立即能明白了。

其它参考