JProfiler与async-profiler:CPU火焰图与内存快照的对比分析
JProfiler与async-profiler:CPU火焰图与内存快照的对比分析
适读人群:Java中高级开发工程师、性能调优工程师 | 阅读时长:约18分钟 | 适用JDK版本:JDK 8 / 11 / 17 / 21
开篇故事
2021年,我们做了一次认真的性能调优,目标是把一个核心接口的P99延迟从300ms降到100ms以内。
最初的排查路线是:看GC日志(正常)、看线程dump(没有死锁或长时间BLOCKED)、看数据库监控(SQL都在5ms以内)。按照常规思路,找不到明显的瓶颈。
后来用async-profiler采样了60秒的CPU火焰图,打开HTML文件,整个调用栈在页面上一览无余。花了大概5分钟看火焰图,发现了两个可疑的"宽列"(表示高CPU占用的方法):
第一个宽列是com.fasterxml.jackson.databind.ser.BeanSerializer.serialize,占CPU时间约18%。 第二个宽列是java.util.regex.Pattern.matcher,占CPU时间约12%。
Jackson序列化和正则匹配,合计占了30%的CPU时间。进一步看调用路径,发现:
- Jackson序列化在每次响应时都序列化一个包含50+字段的大对象,而客户端实际只用到其中5个字段
- 正则匹配是参数校验逻辑,每个请求都要编译一次Pattern(没有缓存
Pattern.compile()的结果)
两个优化:一是把序列化改为只返回需要的字段(@JsonView或DTO精简),二是把Pattern对象缓存为静态常量。
优化后重新做了性能测试,P99延迟从300ms降到了78ms,完全达标。这两个优化如果单靠猜是很难想到的,是火焰图直接指出来的。
一、两种工具的定位与对比
在深入使用之前,先理解两个工具的定位差异:
JProfiler:商业工具,功能全面,GUI友好,适合开发环境的深度分析。提供CPU分析、内存分析、线程分析、JDBC分析等完整功能。需要在目标JVM启动时attach,或者通过JVM参数预先配置。
async-profiler:开源免费,低开销,专为生产环境设计。主要提供CPU火焰图和内存分配分析。使用OS原生profiling机制(Linux的perf_events),不需要safepoint,能捕获到传统profiler看不到的问题(如JVM内部的CPU消耗、native方法、GC线程)。
| 维度 | JProfiler | async-profiler |
|---|---|---|
| 费用 | 商业收费(约599美元/许可证) | 免费开源 |
| 环境 | 开发/测试优先 | 生产友好 |
| 开销 | 中等(约10-20%) | 低(约1-3%) |
| 功能广度 | 全面(JDBC、IO、线程) | 聚焦CPU+内存分配 |
| 可视化 | GUI界面,交互丰富 | 火焰图HTML,浏览器查看 |
| 安装 | 需要代理或GUI连接 | 只需一个jar/so文件 |
| safepoint偏差 | 有(基于JVM API采样) | 无(OS级别采样) |
二、原理深度解析
2.1 传统profiler的Safepoint偏差问题
传统的Java profiler(包括JProfiler的某些模式)基于JVM的AsyncGetCallTrace或JVMTI接口做采样,这些接口只在safepoint时才能获取调用栈。
Safepoint是JVM的特殊状态,线程只能在特定的安全点(如方法返回、循环回边)才能进入safepoint。如果某个CPU密集型循环很长(几百次迭代才有一个循环回边),在这个循环内部采样的概率就很低,分析结果会低估这段代码的CPU消耗。
这就是"Safepoint偏差(Safepoint Bias)"——传统profiler会系统性地低估某些代码段的CPU消耗。
2.2 async-profiler的AsyncGetCallTrace+perf_events
async-profiler使用了两种机制的组合:
AsyncGetCallTrace(AGCT):HotSpot JVM提供的私有接口,可以在任意时刻(不需要safepoint)获取线程的Java调用栈。
OS perf_events(Linux)/ kperf(macOS):操作系统级别的性能采样机制,可以在任意时刻发出信号中断CPU,此时AGCT捕获调用栈。
这种组合避免了Safepoint偏差,能捕获到真实的CPU热点,包括:
- JVM内部代码(GC、JIT编译)
- Native方法
- 没有safepoint检查的热循环
2.3 火焰图的原理
火焰图(Flame Graph)是由Brendan Gregg发明的可视化工具:
- 横轴:方法名按字母排序(不代表时间顺序),宽度代表该方法在所有采样中出现的次数(即CPU占用比例)
- 纵轴:调用栈深度,下面是调用者,上面是被调用者
- 顶部的宽方法:是真正的CPU热点(叶子节点,没有被调用者)
- "平顶山":顶部很宽但没有更上层调用,是最直接的CPU消耗源
火焰图示意(越宽表示CPU占用越多):
[String.indexOf 8%]
[Pattern.match 12%]
[RequestValidator.validate 15%] [BeanSerializer.serialize 18%]
[Controller.handle 45%]
[DispatcherServlet.doDispatch 50%]
[main 100%]2.4 内存分配火焰图 vs CPU火焰图
async-profiler不仅可以做CPU火焰图,还可以做内存分配火焰图(-e alloc):
- CPU火焰图:采样CPU使用,找CPU热点
- 内存分配火焰图:追踪对象分配,找GC热点。宽度代表分配字节量(不是对象数量)
- Wall-clock火焰图(
-e wall):采样实际挂钟时间,包括等待IO、锁等待等,找响应时间热点
三、诊断工具与命令
3.1 async-profiler使用
# 下载async-profiler
# https://github.com/async-profiler/async-profiler/releases
# Linux x64版本:async-profiler-x.x-linux-x64.tar.gz
tar xzf async-profiler-2.9-linux-x64.tar.gz
cd async-profiler-2.9-linux-x64
# === CPU火焰图(最常用)===
# 采样30秒,生成HTML火焰图
./profiler.sh -e cpu -d 30 -f /tmp/cpu_flame.html <pid>
# 或者使用Java API
./profiler.sh start -e cpu <pid>
# ... 等待一段时间后 ...
./profiler.sh stop -f /tmp/cpu_flame.html <pid>
# === 内存分配火焰图 ===
./profiler.sh -e alloc -d 30 -f /tmp/alloc_flame.html <pid>
# === Wall-clock火焰图(包含IO等待,适合延迟分析)===
./profiler.sh -e wall -d 30 -f /tmp/wall_flame.html <pid>
# === 同时采样多个事件 ===
./profiler.sh -e cpu,alloc -d 30 -f /tmp/mixed_flame.html <pid>
# === 过滤特定线程 ===
./profiler.sh -e cpu -d 30 --filter "http-nio-*" -f /tmp/cpu_flame.html <pid>
# === 生成不同格式 ===
./profiler.sh -e cpu -d 30 -o flamegraph -f /tmp/flame.svg <pid> # SVG
./profiler.sh -e cpu -d 30 -o jfr -f /tmp/profile.jfr <pid> # JFR格式(JMC分析)
./profiler.sh -e cpu -d 30 -o collapsed -f /tmp/flame.txt <pid> # 文本(可发给FlameGraph工具)3.2 JProfiler核心功能使用
# 以agent模式启动JVM(配置好端口等待连接)
java -agentpath:/opt/jprofiler/bin/linux-x64/libjprofilerti.so=port=8849 \
-jar app.jar
# 或者动态attach到运行中的JVM(JProfiler GUI菜单:Session → Attach to JVM Process)JProfiler关键分析功能:
CPU Views(CPU视图):
- Call Tree:按调用层次展示方法的累计时间和自身时间
- Hot Spots:直接按消耗时间排序,快速找到热点
- Call Graph:方法之间的调用关系图
- CPU Timeline:随时间变化的CPU使用趋势
Heap Walker(堆分析):
- Reference Graph:对象的引用关系,找内存泄漏
- Retained Size:找占用内存最多的对象
- Dominators:支配树分析
Memory Views(内存视图):
- Allocation Hot Spots:找分配最多对象的代码
- Heap Walker:分析堆快照3.3 两者配合使用的工作流
# 工作流1:生产性能问题快速定位
# Step 1: async-profiler在生产上采样(低开销)
./profiler.sh -e cpu -d 60 -f /tmp/cpu.html <pid>
# Step 2: 浏览器打开cpu.html,找到CPU热点方法
# Step 3: 如果需要深度分析某个模块,在测试环境重现,用JProfiler做深度分析
# 工作流2:内存泄漏排查
# Step 1: async-profiler做内存分配分析,找哪里分配最多对象
./profiler.sh -e alloc -d 60 -f /tmp/alloc.html <pid>
# Step 2: 用JProfiler的Heap Walker追踪对象引用链(或者MAT分析Heap Dump)
# 工作流3:高延迟接口分析
# 使用wall-clock模式(包含IO等待、锁等待)
./profiler.sh -e wall -d 60 -f /tmp/wall.html <pid>四、完整调优方案
4.1 CPU火焰图分析模式
打开火焰图HTML后,分析要点:
找最宽的顶部方法:这些是直接消耗CPU的叶子方法,是最直接的优化目标。
找"平顶山":顶部一片平坦的区域,意味着某个方法下的所有子调用都很耗时,需要重构这个方法的逻辑。
找"高柱子":调用栈很深但顶部很窄,通常是某个深度递归或调用链很长但单次开销不大的路径,可以考虑减少调用层次。
关注比例而不是绝对时间:火焰图的横轴宽度是采样比例,不是绝对时间。某个方法占40%的宽度,说明40%的CPU时间花在它上面。
4.2 内存分配火焰图分析
# 常见的高分配热点类型:
1. [B (byte[]) 或 [C (char[]):字符串或IO操作产生大量字节数组
→ 分析:是否有不必要的字符串拼接,或者可以使用字节缓冲池
2. java.lang.String:字符串大量创建
→ 分析:字符串拼接(用StringBuilder)、频繁的字符串转换
3. java.util.ArrayList 或 HashMap:集合频繁创建
→ 分析:是否可以预分配大小,或者复用集合对象
4. 业务对象(如OrderDto):DTO/VO对象大量创建
→ 分析:是否可以合并查询,减少对象创建次数4.3 减少序列化开销(来自开篇故事的优化)
// 优化1:只序列化需要的字段
@JsonView
public class Order {
@JsonView(Views.Summary.class)
private Long id;
@JsonView(Views.Summary.class)
private String status;
// 下面这些只在详情视图中序列化
@JsonView(Views.Detail.class)
private List<OrderItem> items;
// ...50+个字段...
}
// 优化2:缓存Pattern对象(不要每次compile)
// 差:每次调用都编译Pattern
public boolean isValid(String input) {
Pattern p = Pattern.compile("^[a-z0-9]+$"); // 每次编译!
return p.matcher(input).matches();
}
// 好:静态常量缓存Pattern
private static final Pattern VALID_PATTERN = Pattern.compile("^[a-z0-9]+$");
public boolean isValid(String input) {
return VALID_PATTERN.matcher(input).matches(); // 复用编译好的Pattern
}五、踩坑实录
坑一:async-profiler在JDK 11+的Broken Pipe问题
在JDK 11的某些版本上,async-profiler启动后很快报错退出,错误信息是"broken pipe"或权限相关错误。
原因:JDK 11引入了新的jcmd和attach机制,async-profiler的attach方式需要适配。
解决方案:使用--libpath明确指定async-profiler的so文件路径,或者用-agentpath方式在JVM启动时就加载(不需要运行时attach):
# 启动时加载(不需要运行时attach)
java -agentpath:/opt/async-profiler/build/libasyncProfiler.so=start,event=cpu,file=/tmp/cpu.html \
-jar app.jar坑二:火焰图中大量unknown或[native]
分析火焰图时,发现很多采样点显示为[unknown]或[native],无法对应到Java代码。
原因:
- JIT编译的内联可能导致某些方法不出现在调用栈中
- Native方法没有Java符号信息
- JVM内部代码(GC等)
解决方案:
- 对于JIT内联问题,async-profiler的
--demangle-symbols选项有帮助 - 对于GC线程的CPU消耗,用
-e cpu --all-user过滤,只看用户空间;或者专门用-e cpu+--include-gcthread分析GC占用 - 开启JVM的perf map:
-XX:+PreserveFramePointer(JDK 8+,有约1%性能开销但符号解析更准确)
坑三:JProfiler影响了GC,使测试数据失真
在测试环境用JProfiler做性能分析,发现内存分析模式下(Track Object Creation),GC频率明显下降,老年代增长也变慢了。
原因:JProfiler的内存追踪模式为每个对象创建都插入了探针代码,这些探针代码会影响JIT的优化(特别是内联),从而改变了对象的分配模式。
解决方案:JProfiler的CPU分析对GC影响较小(采样模式,不是每次方法调用都触发),尽量用CPU分析做性能调优,内存分析只在专门排查内存问题时使用。同时做多次测试,排除偶然因素。
坑四:wall-clock火焰图误导了排查方向
用wall-clock模式分析一个高延迟接口,发现大量时间花在了Object.wait()上,以为是线程争锁导致延迟高。
其实:wall-clock模式会采样到线程在等待(IO、锁、sleep)的时间,Object.wait()占多说明线程池的工作线程在等待新任务,不是争锁。真正的业务线程可能只有几个在处理请求。
判断是否是锁争用:用CPU火焰图而不是wall-clock火焰图。CPU火焰图只采样CPU实际在运行的时间,不包括等待时间。如果CPU火焰图里没有lock相关的高占比,就不是锁争用。
六、总结
JProfiler和async-profiler是互补的工具,在不同场景各有优势:
async-profiler适合:生产环境、快速分析、CPU热点和内存分配热点的定位。低开销(1-3%),不需要提前配置,随时可以attach,是生产问题排查的首选。学会看CPU火焰图的顶部宽方法,就能解决大多数CPU性能问题。
JProfiler适合:开发测试环境的深度分析、JDBC性能分析(能看到SQL执行情况)、对象引用关系的可视化追踪。功能更全面,GUI更直观,适合系统性的性能调优工作。
两者的配合工作流:用async-profiler的火焰图做快速定位(哪个方法热),再用JProfiler的详细分析确认根因(为什么热)。
关键技能:会读火焰图。横轴宽=CPU占用多,顶部宽=直接消耗CPU的代码,这两点理解了,火焰图分析就入门了。
