markdown
# Java 火焰图使用指南:CPU / 分配 / 锁 / 慢请求怎么查
@[TOC]
火焰图用来定位 **CPU 热点、分配垃圾、锁等待、慢请求卡在哪条调用链**,不是功能排查手册。本文按 Java 8 + Spring(含 Spring Cloud Gateway)来写,可直接对照采集和改法。
先有症状,再出图。没有对照的火焰图,宽段可能只是正常热点(例如 Jackson 解析本身就会宽)。
## 1. 火焰图是什么
火焰图把一段时间内的 **采样栈** 聚合成一张图:
**宽度**:成本。CPU 图是占用处理器的时间;分配图是分配的字节(或次数)。
**高度**:调用栈深度。层数多不等于更贵。
**颜色**:一般无含义,只为区分帧。
**上下关系**:谁调用谁。先确认根在上还是在下。
常见两种朝向:
- **Icicle(冰柱)**:根在上、叶子在下。async-profiler 的 HTML 多是这种。
- **Flame(火苗)**:根在下、叶子在上。Brendan Gregg 经典样式。
读栈时跟着根走:从进程 / 线程入口读到具体方法。
```text
+-------------------------------------------------------------+
| GatewayFilter.filter | 业务入口
| SignVerifyGatewayFilter | 其它 filter |
| HandlerStrategies.withDefaults | | <-- 越宽越值得看
| Jackson2JsonDecoder 构造 | 其它 codec | | 叶子
+-------------------------------------------------------------+
核心原则:先找最宽的平台,不要先盯最尖的塔。
2. 什么时候该出图
| 现象 | 先出哪张图 | 典型原因 |
|---|---|---|
| CPU 打满、机器热 | CPU | 热点计算、多余拷贝、加解密、正则、自旋 |
| CPU 不高,Young GC 勤、P99 漂、内存抖 | 分配 alloc | 热路径反复 new、每次新建 codec / ObjectMapper |
| 线程多、吞吐上不去、请求大面积卡住 | lock / wall | 锁竞争、等 I/O、等下游 |
| 单次请求偶发超时 | 链路追踪,不是火焰图 | 火焰图是聚合采样,容易漏偶发 |
| 内存持续涨、疑似泄漏 | Heap dump | 分配图只看分配速率,不看谁没释放 |
| 功能错误、NPE、错单 | 日志 / 单测 | 火焰图不查对错 |
3. 四种常用图
3.1 CPU 火焰图
宽代表占用 CPU 的采样次数。
适合查:算得慢、解释执行、系统态 syscall、CAS / 自旋空转。
不适合单独查:线程在等锁、等 Redis、等 HTTP。CPU 是闲的,图上几乎没有。
3.2 分配火焰图(alloc)
宽代表分配了多少字节(或按次数统计)。
适合查:乱 new、GC 压力、网关读 body 时反复建 codec。
典型栈:构造器、byte[]、StringBuilder、Jackson2JsonDecoder 的初始化。
3.3 lock 火焰图
宽代表等锁时间。
适合查:synchronized / ReentrantLock 竞争、连接池 / 缓存锁打满。
3.4 wall 火焰图(墙上时钟)
宽代表包含睡眠和 I/O 等待的真实耗时。
适合查:慢请求卡在 epoll、DNS、下游 HTTP、Thread.sleep。
和 CPU 的区别:CPU 只看"在跑",wall 看"在等"。
4. 怎么读(30 秒流程)
- 确认朝向:根在上还是在下。
- 找最宽的框,同一层里越宽越优先。
- 从根往叶子读:哪段业务 → 哪次调用 → 哪个 JDK / 框架方法。
- 定位自己的类:真正该改的通常是中间那层业务方法,不是叶子上的
arraycopy/HashMap.put。 - 问一句:这一层能不能少做,或做成一次?
- 无状态对象每次 new → 复用(成员变量 / 容器 Bean)
- 同样计算反复做 → 缓存
- 不该在请求里做的 I/O → 挪到启动或异步
- 点框下钻,用 Ctrl / Cmd + F 搜类名,确认改前改后宽度变化。
容易看错的点
| 误区 | 正确理解 |
|---|---|
| 高度当成本 | 栈深 30 层但很瘦,不如 3 层很宽 |
| 只看叶子 | 叶子常是 JDK;改中间自己的方法 |
| 框架很宽就怪框架 | 先看是每个请求 new Decoder,还是真的在解析超大 JSON |
| 图上全是 idle / health | 流量没打到热路径,换压测接口 |
| 很窄的框当问题 | 采样有噪声;稳定很宽才信 |
| 一张图定论 | 要有对照:改前改后,或压测 A/B |
5. 工具怎么选
面向 Java 8 + Spring 的常见组合:
| 工具 | CPU | 分配 | Java 8 | 适用 |
|---|---|---|---|---|
| async-profiler | 好 | 好 | 好 | 首选,直接出 HTML |
| Arthas profiler | 好 | 好 | 好 | 线上 attach,不用自己拷 so |
| JFR + JMC / jfrconv | 好 | 有 | 弱 | JDK 11+ 再作为主力 |
| perf + FlameGraph | 好 | 弱 | 可以 | Linux 系统 / CPU,不看 Java 分配 |
| YourKit / JProfiler / IDEA Profiler | 好 | 好 | 好 | 本地 GUI,生产一般不挂 |
结论:Java 8 用 async-profiler 或 Arthas。OpenJDK 8 通常没有可用 JFR。
macOS 本地 CPU 一般可用;分配图在 Linux 上更准。要拍板值不值得改,用和线上同架构的 Linux 打 alloc。
6. 采集命令
先找进程:
bash
jps -l
# 或
ps -ef | grep java
下面命令里的 PID 换成目标进程号。
6.1 async-profiler
bash
# CPU
asprof -e cpu -d 30 -f cpu.html PID
# 分配(按字节,查 GC / 乱 new 用这个)
asprof -e alloc -d 30 -f alloc.html PID
# 分配按次数(看 new 了多少次)
asprof -e alloc --alloc 1 -d 30 -f alloc-count.html PID
# 锁
asprof -e lock -d 30 -f lock.html PID
# 墙上时钟(慢请求)
asprof -e wall -d 30 -f wall.html PID
旧版脚本等价:
bash
./profiler.sh -e cpu -d 30 -f cpu.html PID
./profiler.sh -e alloc -d 30 -f alloc.html PID
浏览器打开 HTML:点击帧下钻,搜索 HandlerStrategies、SignVerifyGatewayFilter 等类名。
容器注意:
- 需要
perf_event权限(或 privileged / 合适的 seccomp)。 kernel.perf_event_paranoid过高会导致 CPU 栈缺失。- 缺 libc 符号时系统态帧会变成
[unknown]。
6.2 Arthas
bash
java -jar arthas-boot.jar
# 选中目标 PID
profiler start --event cpu
# 压测 30 秒
profiler stop --file /tmp/cpu.html
profiler start --event alloc
profiler stop --file /tmp/alloc.html
profiler start --event wall
profiler stop --file /tmp/wall.html
适合已经在跑的 Java 8 进程:attach 即可,不必重启。
6.3 JFR(JDK 11+)
bash
jcmd PID JFR.start name=gw settings=profile duration=30s filename=/tmp/gw.jfr
# 转火焰图(async-profiler 转换器)
jfrconv --cpu /tmp/gw.jfr cpu.html
jfrconv --alloc /tmp/gw.jfr alloc.html
或用 JDK Mission Control 打开 .jfr,看 Method Profiling / TLAB Allocation。
6.4 采集时注意
- 先打热路径流量再采。 只打健康检查,图上全是 idle。
- 时长建议 20 到 60 秒。太短噪声大,太长文件大且热点被冲淡。
- 生产采集开销通常可接受,仍避免在已经 CPU 100% 的机器上长时间开 alloc。
- 同一场景采两张:一张 CPU,一张 alloc,避免只看一种。
7. 场景、判断和改法
场景 A:CPU 打满
怎么看
- CPU 图上某业务方法稳定很宽。
- 叶子常是加密、JSON、正则、大循环、
System.arraycopy。
怎么改
| 栈上看到 | 处理 |
|---|---|
| 每次请求 MessageDigest.getInstance / 新建 cipher | 复用或用 ThreadLocal |
| 超大 JSON 反复序列化 | 缩小字段、缓存、换流式 |
| 复杂正则在热路径 | 预编译 Pattern,避免 String.matches |
| 深拷贝、重复组装 DTO | 少拷一次,原地改 |
| Unsafe / CAS 空转很宽 | 可能是自旋锁,转 lock 图确认 |
改完用同一压测再出 CPU 图,目标帧应变窄。
场景 B:CPU 不高,GC 勤、P99 漂
怎么看
- 出 alloc 图,不要只看 CPU。
- 宽段往往是构造器、
byte[]、ArrayList、StringBuilder。 - 中间出现自己的 Filter / Interceptor / 工具类,且和某个构造几乎一样宽,说明热路径在造对象。
Gateway 典型:每次请求 HandlerStrategies.withDefaults()
优化前分配栈类似:
text
SignVerifyGatewayFilter.lambda$apply$...
HandlerStrategies.withDefaults
DefaultHandlerStrategiesBuilder.build
Jackson2JsonDecoder 构造
FormHttpMessageReader 构造
...
SignVerifyGatewayFilter 与 withDefaults 几乎同宽,说明这个 Filter 的分配几乎全是在建 codec,不是验签逻辑。
怎么改
- 构造时注入
ServerCodecConfigurer,messageReaders做成成员变量。 - 同类问题:每次
new ObjectMapper()、每次HandlerStrategies.withDefaults()、每次 new 线程池。 - 判断标准:对象有没有请求级状态?没有就启动时建一次。
改完 alloc 图上 withDefaults 应消失(或只在启动出现),对应 Filter 整段变窄。
场景 C:吞吐上不去,线程很多,请求卡住
怎么看
- 先
jstack/ Arthasthread,看是 BLOCKED、WAITING 还是 TIMED_WAITING。 - 出 lock 图:宽在
ReentrantLock.lock/synchronized,就是锁竞争。 - 出 wall 图:宽在
socketRead、Redis 客户端、HttpClient、epollWait,就是等 I/O。 - CPU 图很瘦、wall 很宽 = 没在算,在等。
怎么改
| 栈上看到 | 处理 |
|---|---|
| 全局锁、大 synchronized | 缩小锁粒度,或改并发数据结构 |
| 连接池 borrow 很宽 | 加大池、查泄漏、查慢 SQL 占连接 |
| Redis / HTTP 客户端很宽 | 超时、批量、缓存、下游容量 |
| Thread.sleep / 同步等待异步结果 | 改响应式或真正异步,避免阻塞 reactor 线程 |
| Gateway 在 reactor 线程做阻塞调用 | 挪到 boundedElastic 或独立线程池 |
网关尤其危险:阻塞了 event loop,CPU 图可能不热,wall 图会爆。
场景 D:启动后一段时间 CPU 高,之后恢复
怎么看
- CPU 图上解释器 / C1 编译、某个冷方法刚被调用。
- 或启动期做全量预热、刷缓存、限流组件采集。
怎么改
- 预热关键路径(第一次请求不要当压测)。
- 启动宽限(例如系统规则不要在 CPU 爬升期误杀)。
- 把可延后的初始化移出关键请求路径。
场景 E:系统态(灰色 / 内核帧)很宽
怎么看
- CPU 图出现
copy_to_user、tcp_sendmsg、epoll_wait、do_sys_write。 - Java 帧相对窄,内核帧宽。
怎么改
- 日志 / 访问日志写盘过猛:降级、异步、采样。
- 过小的读写、过多的 syscall:缓冲、批量。
- 网络带宽或连接数打满:扩容或合并请求。
- 确认不是采集本身缺 Java 符号。栈全是
[unknown]时先修符号,再下结论。
场景 F:Jackson / Fastjson 很宽
不要直接判定"换 JSON 库"。先分清:
| 宽在哪 | 含义 | 处理 |
|---|---|---|
| Jackson2JsonDecoder 构造 | 每个请求 new decoder | 复用 HttpMessageReader |
| ObjectMapper 构造 | 每次 new mapper | 注入单例 ObjectMapper |
| readValue / parse 本身 | 真的在解析大 body | 限流 body 大小、DTO 瘦身 |
| Fastjson JSON.parseObject 在 Filter | 入站解析,可能还有安全问题 | 按规范改 Jackson,并避免重复解析 |
8. 推荐工作流
text
1. 描述症状:CPU / GC / 卡住 / P99
2. 选事件:cpu | alloc | lock | wall
3. 对热路径压测 20 到 60 秒(不要只打 health)
4. 打开 HTML,找最宽且含自己类名的帧
5. 判断:少做、做一次、还是挪走
6. 改代码
7. 同样条件再采一张,对比宽度
对照标准:
- 目标帧明显变窄,或从热路径消失。
- 业务指标同步改善:GC 次数、P99、CPU%。
- 没有把成本挪到另一条更宽的栈上。
9. Gateway 对照例:复用 HTTP Message Readers
网关读 body 的错误写法(热路径每次建一套 codec):
java
ServerRequest.create(exchange, HandlerStrategies.withDefaults().messageReaders());
推荐写法(启动时复用容器里的 readers):
java
private final List<HttpMessageReader<?>> messageReaders;
public XxxBodyFilter(ServerCodecConfigurer codecConfigurer) {
this.messageReaders = codecConfigurer.getReaders();
}
// 请求处理里
ServerRequest.create(exchange, messageReaders);
如何发现(不一定先上火焰图):
- 搜
HandlerStrategies.withDefaults,看是否在filter/apply里。 - 点进源码:
withDefaults()会builder().build(),每次 new 全套 Jackson / Form / Multipart reader。 - 对象无请求级状态,又在热路径每次 new,就该复用。
- 用 alloc 图定量:
withDefaults宽度约等于该 Filter 总宽度,就可以改。
同类热路径还有:开放接口鉴权 Filter、入站 JSON 巡检 Filter、内部探测 Filter。处理方式一样,都是构造期缓存 messageReaders。
代码审查时可固定搜:
bash
rg "HandlerStrategies.withDefaults"
rg "new ObjectMapper\\("
出现在请求处理路径上,优先当分配问题看。
10. 速查
| 我想查 | 命令要点 | 图上找 |
|---|---|---|
| 谁在烧 CPU | asprof -e cpu | 最宽的业务方法 |
| 谁在狂 new | asprof -e alloc | 构造器、byte\[\]、自己的 Filter |
| 谁在等锁 | asprof -e lock | lock / synchronized |
| 请求卡在哪 | asprof -e wall | socket / Redis / HTTP / sleep |
| 线上不便拷工具 | Arthas profiler start --event ... | 同上 |
一句话:先按症状选 CPU 还是分配(或 lock / wall),找稳定很宽、栈里有自己类名的那一层;无状态的反复创建就复用,算得慢就少算,卡住就去看锁和 I/O。
脱敏对照(只给你看,文里没有真名):
| 原文 | 文中 |
|---|---|
| `AppSignGatewayFilterFactory` | `SignVerifyGatewayFilter` |
| `FuturesOpenGatewayFilterFactory` 等业务 Filter | 「开放接口鉴权 Filter」等描述,或 `XxxBodyFilter` |
| `AppSign` 缩写 | 已去掉 |
| Jedis 等可推断客户端的,场景 C 改成「Redis 客户端」 |
| Sentinel | 「限流组件 / 系统规则」 |