4.3 async-profiler:四类火焰图
上一节:4.2 两类现象,两条路径 | 下一节:4.4 JFR 与 jcmd 配套代码:04-toolchain/03-async-profiler
一句话结论
async-profiler 是目前 JVM 上最好用的采样剖析器,它最大的价值是四种事件类型(cpu / alloc / lock / wall)——分别回答「谁在忙、谁在造垃圾、谁在抢锁、谁在干等」。只采 CPU 火焰图,等于只用了一个功能的四分之一。
一、用「X 光造影」理解四种事件
给病人做检查,不同的造影剂能看到不同的东西:
| 造影剂 | 看到什么 | 对应事件 |
|---|---|---|
| 看骨骼结构 | 硬组织的形态 | cpu:谁在消耗 CPU |
| 看血流 | 哪里堵住了 | wall:谁在等待 |
| 看代谢活跃区 | 哪里在异常增生 | alloc:谁在制造垃圾 |
| 看血管交叉点 | 哪里互相压迫 | lock:谁在抢锁 |
关键:不同的病要用不同的造影剂。用看骨骼的 X 光去查血管堵塞,只会得到「没问题」。
二、四类事件的命令与用途
PID=$(jcmd | grep app.jar | awk '{print $1}')
# ① CPU:谁在消耗 CPU(现象:CPU 高)
asprof -d 60 -e cpu -f cpu.html "$PID"
# ② 墙上时间:时间都花在哪(现象:CPU 不高但慢)⭐ 最容易被忽略
asprof -d 60 -e wall -f wall.html "$PID"
# ③ 分配:谁在制造垃圾(现象:GC 频繁、分配速率高)
asprof -d 60 -e alloc -f alloc.html "$PID"
# ④ 锁:谁在抢锁(现象:吞吐上不去、sys CPU 高)
asprof -d 60 -e lock -f lock.html "$PID"
asprof是 async-profiler 3.x 的启动器名(旧版本叫profiler.sh,2.x 也支持asprof别名)。如果你的版本较老,把asprof换成./profiler.sh。
三、火焰图怎么读
基本规则
横轴 = 采样数(≈ 时间占比),不是时间顺序!
纵轴 = 调用栈深度(下 → 上 = 调用关系)
宽度 = 该函数及其子调用占用的比例
⚠️ 最重要的一条:横轴不是时间轴。左边和右边没有先后关系,只是不同的调用栈。
三种要看出的模式
| 模式 | 图形特征 | 含义 |
|---|---|---|
| 宽而平 | 某一层有一个很宽的函数块,下面没有更深的调用 | 这个函数自己在消耗时间(热点) |
| 深而窄 | 很长的一条链,但每层都很窄 | 调用链深,但时间分散(可能是正常流程) |
| 平台(plateau) | 顶部有一块平坦的宽区域,没有子帧 | 典型的热点函数(如序列化、正则、加密) |
读图的三个具体技巧
技巧一:从下往上找「分叉点」
┌──────────────────────────────────────┐
│ handleRequest │
└───┬──────────────┬───────────────────┘
│ │
┌───────▼──────┐ ┌────▼─────────────────┐
│ 业务逻辑 5% │ │ 序列化 60% │ ← 问题在这里
└──────────────┘ └──────────────────────┘
从根节点往下走,找第一个明显「宽」的分支,那就是大头。
技巧二:忽视「框架帧」,盯住「叶子帧」
火焰图底部会有很多框架帧(Netty、Ktor、协程调度器)。真正的问题通常在叶子帧(最顶层那些没有子调用的宽块)。
技巧三:用 --include 过滤
# 只看某个类相关的栈
asprof -d 60 -e cpu --include "com.example.*" -f cpu-filtered.html "$PID"
# 排除某些包
asprof -d 60 -e cpu --exclude "kotlinx.coroutines.*" -f cpu-excluded.html "$PID"
这在栈很深、噪声很多时非常有用。
四、四类火焰图的典型发现
cpu 的典型发现
| 发现 | 可能原因 |
|---|---|
| 正则编译在顶部 | 每次都 Regex("...")(应该提到顶层常量) |
| JSON 序列化占比高 | 用了反射型序列化器、对象太大 |
字符串拼接 / StringBuilder |
循环里拼字符串 |
| GC 线程占比较高 | 分配速率过高(接着看 alloc) |
Object.hashCode / equals |
大量对象放进了 Map/Set |
| 锁相关的自旋 | 争用(接着看 lock) |
wall 的典型发现 ⭐
| 发现 | 可能原因 |
|---|---|
大量线程停在 socketRead0 |
等数据库 / 等下游 HTTP |
停在 HikariPool.getConnection |
连接池排队(第 7 节) |
停在 park / await |
等锁、等队列、等任务 |
停在 Thread.sleep |
某处有 sleep(可能是重试退避) |
停在 FileOutputStream.write |
同步日志刷盘 |
wall火焰图是「CPU 不高但慢」场景下最有价值的一张图,因为它直接显示「谁在等什么」。
alloc 的典型发现
| 发现 | 可能原因 |
|---|---|
Integer.valueOf / 装箱 |
集合里用了包装类型(List<Int> 而非 IntArray) |
String 构造 / StringBuilder |
字符串拼接 |
data class 的 copy |
热路径上频繁拷贝 |
Regex / Pattern |
正则重复编译 |
ByteArray / byte[] |
大缓冲反复分配(可能是 Humongous,第 2 章 2.5) |
协程 Continuation |
高频挂起(第 2 章 2.7) |
lock 的典型发现
| 发现 | 可能原因 |
|---|---|
synchronized 方法/块 |
临界区过大 |
ReentrantLock.lock |
显式锁争用 |
ConcurrentHashMap 内部锁 |
热点 key 集中在同一个桶 |
| 类加载锁 | 启动阶段类加载竞争 |
五、进阶用法
5.1 输出格式
asprof -d 60 -e cpu -f cpu.html "$PID" # 交互式 HTML(推荐,可搜索、可折叠)
asprof -d 60 -e cpu -o collapsed -f cpu.txt "$PID" # collapsed 文本(用于 diff)
asprof -d 60 -e cpu -o flamegraph -f cpu.svg "$PID" # 静态 SVG
asprof -d 60 -e cpu -o jfr -f cpu.jfr "$PID" # JFR 格式(可与其他 JFR 一起分析)
asprof -d 60 -e cpu -o tree -f cpu.txt "$PID" # 树形文本(便于 grep)
5.2 优化前后做 diff(非常有价值)
# 优化前
asprof -d 60 -e wall -o collapsed -f before.txt "$PID"
# 优化后(同样条件)
asprof -d 60 -e wall -o collapsed -f after.txt "$PID"
# 用 FlameGraph 工具做差量图
difffolded.pl before.txt after.txt | flamegraph.pl > diff.svg
差量图能直接显示「哪条链路变短了」——这是证明优化有效的最直观证据。
5.3 按线程过滤
# 只看特定线程的栈(例如 HTTP worker)
asprof -d 60 -e wall --threads -f wall-threads.html "$PID"
5.4 权限问题(最常见的卡点)
| 平台 | 要求 |
|---|---|
| Linux | perf_event_paranoid ≤ 1(echo 1 > /proc/sys/kernel/perf_event_paranoid);容器需要加 --cap-add SYS_ADMIN 或 --security-opt seccomp=unconfined |
| Linux(无 perf 权限) | async-profiler 会退化到 itimer 模式,精度降低但能用 |
| macOS | 需要调试权限;部分模式下受限,生产分析建议在 Linux 进行 |
| 容器内 | 与宿主内核共享,注意 capability 与 seccomp 配置 |
如果采不到数据,先检查权限——这是「火焰图很空」的常见原因之一(第 2 节自测题)。
六、和 JFR 的关系
| async-profiler | JFR | |
|---|---|---|
| 开销 | 中等(取决于模式) | 低(约 1%),可常开 |
| 事件类型 | 4 类,专注且精确 | 几十类(GC、锁、IO、异常、类加载…) |
| 输出 | 火焰图(直观) | JMC 图形化(信息全) |
| 适用 | 深挖(出问题时用) | 常开(事后取证) |
最佳组合:JFR 常年开着(滚动录制),出问题时用它定位到大致范围;然后用 async-profiler 深挖具体热点。
七、本节小结
- async-profiler 有四类事件:
cpu(谁在忙)、wall(谁在等)、alloc(谁在造垃圾)、lock(谁在抢锁)。 wall是最容易被忽略、也最有用的一类——它专治「CPU 不高但慢」。- 火焰图横轴是采样数不是时间;从下往上找第一个明显的分叉,盯住叶子帧。
--include/--exclude在栈很深时非常有用;collapsed+ diff 能证明优化效果。- 权限(
perf_event_paranoid、容器 capability)是采不到数据的头号原因。 - async-profiler 用于深挖,JFR 用于常开——两者互补。
八、自测
- 你的服务 CPU 只有 28%,P99 是 600 ms。请说出你要采哪种火焰图,以及你预期在图上看到什么。
- 火焰图上
kotlinx.serialization占了 45% 的宽度,但它位于一个后台定时任务的调用栈下(不在请求处理路径上)。这说明什么?该怎么判断它是否影响 P99? - 你执行
asprof -e cpu -f out.html <pid>后,打开 HTML 发现几乎是空的。列出三种可能原因和对应的检查方法。
- 采
wall(墙上时间)火焰图。预期看到:大量线程停在等待类调用栈上——最常见的是socketRead0(等数据库或下游 HTTP)、HikariPool.getConnection(等连接池)、park/await(等锁或队列)、FileOutputStream.write(等日志刷盘)。判读方法:看哪个等待栈最宽,那就是瓶颈所在;然后结合指标验证(例如宽的是HikariPool.getConnection,就去查db_pool_pending是否 > 0)。 - 说明这个热点不在请求路径上——它在后台线程执行,与用户请求的延迟无关(除非它争抢了 CPU/连接等共享资源)。判断方法:① 用 JFR 或 Prometheus 确认后台任务的执行时间与 P99 尖刺是否时间对齐(对齐 → 有影响;不对齐 → 无影响);② 看它在整个进程 CPU 中的占比(如果后台任务很重,可能间接影响请求);③ 用
--threads参数或按线程过滤,把请求线程和后台线程的火焰图分开看;④ 最直接的方法:临时关掉后台任务,看 P99 是否改善(单变量实验)。 - 可能原因与检查:① perf 权限不足——检查
cat /proc/sys/kernel/perf_event_paranoid(应 ≤ 1);容器内检查 capability 与 seccomp 配置;② 采样时长太短或请求量太小——栈没有累积起来;延长-d(比如 60 秒),并确认期间有真实流量(可以用k6同时压);③ 采错了进程——用jcmd确认 PID 是你的应用进程(而不是启动脚本或 shell);④ 另外还有两种可能:应用用了大量原生代码/直接内存(用户态栈看不到,需要-e cpu配合--all-native或看 JFR 的 native 事件);或JIT 内联严重导致栈上只剩框架帧(可以配合-XX:CompileCommand=dontinline验证)。