文档目录

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 深挖具体热点。


七、本节小结

  1. async-profiler 有四类事件:cpu(谁在忙)、wall(谁在等)、alloc(谁在造垃圾)、lock(谁在抢锁)。
  2. wall 是最容易被忽略、也最有用的一类——它专治「CPU 不高但慢」。
  3. 火焰图横轴是采样数不是时间;从下往上找第一个明显的分叉,盯住叶子帧。
  4. --include / --exclude 在栈很深时非常有用;collapsed + diff 能证明优化效果。
  5. 权限(perf_event_paranoid、容器 capability)是采不到数据的头号原因。
  6. async-profiler 用于深挖,JFR 用于常开——两者互补。

八、自测

  1. 你的服务 CPU 只有 28%,P99 是 600 ms。请说出你要采哪种火焰图,以及你预期在图上看到什么。
  2. 火焰图上 kotlinx.serialization 占了 45% 的宽度,但它位于一个后台定时任务的调用栈下(不在请求处理路径上)。这说明什么?该怎么判断它是否影响 P99?
  3. 你执行 asprof -e cpu -f out.html <pid> 后,打开 HTML 发现几乎是空的。列出三种可能原因和对应的检查方法。
  1. 采 wall(墙上时间)火焰图。预期看到:大量线程停在等待类调用栈上——最常见的是 socketRead0(等数据库或下游 HTTP)、HikariPool.getConnection(等连接池)、park/await(等锁或队列)、FileOutputStream.write(等日志刷盘)。判读方法:看哪个等待栈最宽,那就是瓶颈所在;然后结合指标验证(例如宽的是 HikariPool.getConnection,就去查 db_pool_pending 是否 > 0)。
  2. 说明这个热点不在请求路径上——它在后台线程执行,与用户请求的延迟无关(除非它争抢了 CPU/连接等共享资源)。判断方法:① 用 JFR 或 Prometheus 确认后台任务的执行时间与 P99 尖刺是否时间对齐(对齐 → 有影响;不对齐 → 无影响);② 看它在整个进程 CPU 中的占比(如果后台任务很重,可能间接影响请求);③ 用 --threads 参数或按线程过滤,把请求线程和后台线程的火焰图分开看;④ 最直接的方法:临时关掉后台任务,看 P99 是否改善(单变量实验)。
  3. 可能原因与检查:① 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 验证)。