承接的问题
3.1 用 JFR 回答了两个问题:锁竞争发生了多少次、每次等了多久、发生在哪一行。它回答不了第三个问题:整条调用路径上,哪一段最宽。事件是按次记录的,聚合表按类或站点分组;当你面对一棵 40 层的调用树,想知道「优化哪个入口收益最大」时,需要的是火焰图。
分配也是同一类问题。jdk.ObjectAllocationSample 能告诉你「哪个类、哪个站点分配得多」,但它不是图;当 String、byte[]、ArrayList 同时出现时,很难一眼看出谁在撑着分配压力。
这一节讲 async-profiler 的锁与分配视图。先把最重要的声明放在前面:
本节未在本机执行 async-profiler,下列命令未附输出。 本机 brew info async-profiler 的结果是 stable 4.5 (bottled), HEAD 和 Not installed,which asprof 也没有结果。所以本节只给安装方式、命令写法和读图方法,不贴任何自称来自 async-profiler 的火焰图、collapsed 行或统计数字。
为了不让这一节停在「纸上命令」,后半段用 JFR 的 jdk.ObjectAllocationSample 实跑一次同类观察:它回答的是同一个「谁在分配」的问题,只是输出形态是表而不是图。两份证据的口径不同,不能互相替代,但足以让读者在本机复现一次完整的「发现热点 → 改造 → 复测」。
本机口径不变:
Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8
load average 在写作时是 100 以上,所有数字都是单次运行、随调度波动、不作基准。
安装与最小验证
macOS 上最省事的是 Homebrew。本机已经能查到 formula,但没有安装:
==> async-profiler: stable 4.5 (bottled), HEAD
Sampling CPU & HEAP profiler for Java using AsyncGetCallTrace + perf_events
https://github.com/async-profiler/async-profiler
Not installed
安装并确认 asprof 在 PATH 里:
brew install async-profiler
export PATH="$(brew --prefix async-profiler)/bin:$PATH"
which asprof
asprof -h
Homebrew 的安装前缀在 Apple Silicon 上通常是 /opt/homebrew/opt/async-profiler,但不要把路径写死;用 brew --prefix async-profiler 查。asprof -h 会列出当前版本支持的事件和输出格式,安装后先跑这一条,确认版本与平台匹配。
不用包管理器时,从 GitHub Releases 下载对应平台的压缩包。Apple Silicon 选 macos-arm64,Linux x64 选 linux-x64;包名以 Releases 页面为准:
tar -xzf async-profiler-4.5-macos-arm64.tar.gz
./async-profiler-4.5-macos-arm64/bin/asprof -h
两个容易忽略的前提:
- 架构必须一致。 JDK 是 arm64,
asprof也要 arm64;在 Rosetta 下跑 x64 JDK、却用 arm64 的asprof,attach 会失败。 - attach 权限。 目标 JVM 要和当前用户相同;容器里还需要
ptrace权限和正确的 PID 命名空间。CPU 模式依赖平台提供的采样机制(Linux 常见perf_events,不可用时回退到 itimer;macOS 走平台接口),锁和分配模式走 JVMTI/AsyncGetCallTrace,受限程度不同;先做一次 5 秒试采,再决定用哪种模式。
三类事件与命令写法
async-profiler 的命令骨架是 asprof [选项] <pid>。本节只用到四个选项:
| 选项 | 作用 | 常用值 |
|---|---|---|
-e | 选择事件 | cpu、lock、alloc、wall |
-d | 采样时长,单位秒 | 30 |
-o | 输出格式 | flamegraph、collapsed、flat、traces |
-f | 输出文件 | /tmp/conc-lock.html |
CPU、锁、分配三条命令:
# CPU:谁在占 CPU
asprof -d 30 -e cpu -o flamegraph -f /tmp/conc-cpu.html <pid>
# 锁:谁在等锁,等在哪个调用路径
asprof -d 30 -e lock -o collapsed -f /tmp/conc-lock.txt <pid>
# 分配:谁在分配,分配在哪个调用路径
asprof -d 30 -e alloc -o flamegraph -f /tmp/conc-alloc.html <pid>
-o collapsed 输出的是纯文本,每行一条栈,分号分隔帧,行尾是样本数或权重;适合 rg、diff 和归档。-o flamegraph 输出 HTML,适合直接看。-e wall 记录墙钟时间,能把「不在 CPU 上跑、但也没在等 Java 锁」的阻塞也算进来,排查 IO 等待时比 -e cpu 更合适。
锁视图有一个必须提前说明的语义:-e lock 采样的是申请锁的线程的栈,不是持有锁的线程的栈。 图里最宽的塔告诉你「谁在等、等在哪个业务入口」,不告诉你「谁持有」。要找持有者,仍然要回到 3.3 的线程转储,或者用 3.1 的 previousOwner。把锁火焰图当成「持有者排行榜」是最常见的误读。
分配视图的宽度含义也不同:-e alloc 的宽度是分配字节数(按采样权重还原),不是对象个数。 不同版本的表头可能是 Allocated bytes 或 Allocation samples,看图前先确认标题。一个 4 KB 的 byte[] 会比一个 24 B 的 record 宽得多,即使 record 的数量多 100 倍。看分配图时先问「我在优化字节数还是对象数」,两者的结论经常相反。
火焰图怎么读
火焰图只有两个轴:
- 横轴是合并后的调用栈,不是时间轴。同一层的多个帧按字母或聚合顺序排列,左右位置没有先后含义。
- 纵轴是栈深度。默认火焰图根在底部、叶子在顶部;读法是自底向上,从入口一路看到最热的叶子。
宽度是样本数的占比(CPU/锁)或权重的占比(分配)。一个帧很宽,可能是它自己很热,也可能只是它下面有一个很热的子调用;判断时要看它的自身宽度,而不是把整座塔都算在它头上。
默认视图和反转视图回答不同问题:
- 自底向上(默认):从根往下读,回答「哪个入口进来的流量最热」。适合优化业务入口、判断某个模块整体占比。
- 自顶向下(反转 / inverted):把叶子放到下方,从热叶子往上看,回答「这个热点是被谁调用的」。适合看到一个陌生的
StringConcatHelper或Unsafe.park后,反查调用方。
颜色通常按包或随机分配,不代表温度;不要根据颜色下结论。真正要看的信号是宽度、栈深和叶子位置。
锁火焰图的读法:
- 先找最宽的塔,确认塔顶是不是
ReentrantLock.lock、LockSupport.park或监视器入口。 - 沿着塔往下走到第一个业务帧,那里就是「等待发生的业务入口」。
- 如果塔顶是
Unsafe.park,继续看下一帧是AQS.acquire还是Condition.await;两者一个是锁竞争,一个是条件等待,处置方式不同。 - 持锁者不在图里。拿等待者栈里的锁地址或线程名,回到转储里找
- locked <同一地址>。
分配火焰图的读法:
- 先看最宽的叶子对象类型,是
byte[]、String、Object[]还是业务 record。 - 再往下看分配站点,是字符串拼接、集合扩容、序列化缓冲,还是业务对象构造。
- 如果最宽的是
String/byte[],优先怀疑日志拼接、toString、JSON 序列化和异常消息;这些分配往往不在业务代码的正中间,而在工具方法里。 - 如果最宽的是业务 record,再看它的生命周期:是不是每个请求都新建、能不能复用、是否逃逸到堆外。
-o collapsed 的文本没有图直观,但适合做前后对比:同一段负载、同样 30 秒,修复前后各采一次,用 diff 看哪条栈的计数下降了。注意采样有随机性,计数下降 10% 可能只是噪声;要结合 JFR 的事件数和 GC 次数一起判断。
用 JFR 的 ObjectAllocationSample 做同类观察
async-profiler 不在本机,下面用 JFR 做一次真实分配采样。它不能替代火焰图,但能给出「哪个类、哪个站点占了多少分配压力」的可复现证据。
靶子每个批次构造 1000 个 Loan record,同时拼接三个字符串、分配一个 4 KB 的 byte[]:
import java.util.ArrayList;
import java.util.List;
public final class AllocProbe {
record Loan(String loanId, String isbn, String memberId, long dueAt) {}
private static volatile Object sink;
public static void main(String[] args) throws Exception {
long deadline = System.nanoTime() + 4_000_000_000L;
long total = 0;
while (System.nanoTime() < deadline) {
List<Loan> batch = new ArrayList<>(1_000);
for (int i = 0; i < 1_000; i++) {
batch.add(new Loan("L" + i, "isbn-" + i, "member-" + i,
System.currentTimeMillis()));
}
byte[] payload = new byte[4_096];
payload[0] = (byte) batch.size();
sink = payload;
total += batch.size();
}
System.out.println("objects=" + total + " payload=" + ((byte[]) sink)[0]);
}
}
用 settings=profile 录 4 秒:
javac --release 25 -d classes AllocProbe.java
java -XX:StartFlightRecording=filename=out/alloc.jfr,settings=profile,dumponexit=true \
-cp classes AllocProbe
本机标准输出是 objects=98507000 payload=-24。jfr summary 的关键行:
Duration: 4 s
jdk.ObjectAllocationSample 1238 17877
jdk.G1GarbageCollection 34 417
jdk.GarbageCollection 34 707
4 秒分配了约 9850 万个对象,却只留下 1238 条采样事件,约 300 条/秒。这不是漏采,而是 profile.jfc 给 jdk.ObjectAllocationSample 的 throttle 默认值就是 300/s。每条事件带一个 weight,表示这条样本代表的估算字节数;把同一类、同一站点的 weight 加起来,才是分配压力。
按类聚合:
jfr view --width 120 allocation-by-class out/alloc.jfr
Object Type Allocation Pressure
--------------------------------------------------------------------------------------------------- -------------------
java.lang.String 40.67%
byte[] 34.74%
AllocProbe$Loan 23.15%
java.lang.Object[] 1.44%
java.util.ArrayList$Itr 0.00%
按站点聚合:
jfr view --width 120 allocation-by-site out/alloc.jfr
Method Allocation Pressure
--------------------------------------------------------------------------------------------------- -------------------
java.lang.StringConcatHelper$Concat1.concat(int) 40.67%
jdk.internal.misc.Unsafe.allocateUninitializedArray(Class, int) 34.71%
AllocProbe.main(String[]) 23.18%
java.util.ArrayList.<init>(int) 1.44%
最显眼的不是 Loan,而是字符串拼接。"L" + i 这类表达式在 JDK 25 走 StringConcatHelper,每次都要算长度、分配 byte[]、再填内容;Loan 只占 23.15%,String 加 byte[] 加起来超过 75%。如果只看业务代码,很容易以为「我在创建领域对象」,实际大头在构造参数的路上。
jfr print 的单条样本把 weight 的含义暴露得更清楚:
jdk.ObjectAllocationSample {
startTime = 01:56:49.768 (2026-10-29)
objectClass = java.lang.Object[] (classLoader = bootstrap)
weight = 16.0 MB
eventThread = "main" (javaThreadId = 3)
stackTrace = [
java.util.ArrayList.<init>(int) line: 157
AllocProbe.main(String[]) line: 13
]
}
weight = 16.0 MB 不是这个 Object[] 有 16 MB,而是这条采样代表的估算总量。把 weight 当成单对象大小,会得出「一个 ArrayList 扩容占了 16 MB」的错误结论。
改造与复测
针对上面的结果做两件事:把三个字符串在循环外预计算,复用同一个 byte[]。
import java.util.ArrayList;
import java.util.List;
public final class AllocProbeFixed {
record Loan(String loanId, String isbn, String memberId, long dueAt) {}
private static volatile Object sink;
public static void main(String[] args) throws Exception {
String[] ids = new String[1_000];
String[] isbns = new String[1_000];
String[] members = new String[1_000];
for (int i = 0; i < 1_000; i++) {
ids[i] = "L" + i;
isbns[i] = "isbn-" + i;
members[i] = "member-" + i;
}
byte[] payload = new byte[4_096];
long deadline = System.nanoTime() + 4_000_000_000L;
long total = 0;
while (System.nanoTime() < deadline) {
List<Loan> batch = new ArrayList<>(1_000);
for (int i = 0; i < 1_000; i++) {
batch.add(new Loan(ids[i], isbns[i], members[i], System.currentTimeMillis()));
}
payload[0] = (byte) batch.size();
sink = payload;
total += batch.size();
}
System.out.println("objects=" + total + " payload=" + payload[0]);
}
}
同样的 4 秒录制,jfr view allocation-by-class 的结果变成:
Object Type Allocation Pressure
--------------------------------------------------------------------------------------------------- -------------------
AllocProbeFixed$Loan 93.27%
java.lang.Object[] 6.73%
String 和 byte[] 从榜单上消失,Loan 升到 93.27%。jdk.GarbageCollection 从 34 次降到 19 次。两次运行的对象总数分别是 9850 万和 1.61 亿,不能直接比吞吐;但分配压力的构成变化是稳定的:字符串拼接和每轮新建 4 KB 缓冲的宽塔没有了,剩下的是领域对象和 ArrayList 的底层数组。
这就是「发现热点 → 改造 → 复测」的闭环。把同样的改造放到 async-profiler 的 -e alloc 下,预期是 StringConcatHelper 和 Unsafe.allocateUninitializedArray 两座塔变窄;但本节没有执行 async-profiler,所以不给出图,也不编造宽度变化。
在 libraryd-conc 里落地
conc-ops 的采集脚本把三种视图分开跑,避免一次 attach 里混太多事件:
#!/bin/sh
set -eu
PID=$1
OUT=${2:-/tmp/conc-profile}
DURATION=${3:-30}
mkdir -p "$OUT"
asprof -d "$DURATION" -e cpu -o flamegraph -f "$OUT/cpu.html" "$PID"
asprof -d "$DURATION" -e lock -o collapsed -f "$OUT/lock.txt" "$PID"
asprof -d "$DURATION" -e alloc -o flamegraph -f "$OUT/alloc.html" "$PID"
printf 'cpu=%s\nlock=%s\nalloc=%s\n' "$OUT/cpu.html" "$OUT/lock.txt" "$OUT/alloc.html"
三条命令串行执行,总时长是 3 × DURATION,不是 DURATION。在生产节点上不要默认跑 90 秒;先跑 CPU 一条,确认 attach 和开销可接受,再决定要不要加锁和分配。锁视图建议用 collapsed 而不是 HTML,因为文本可以直接和上一轮 diff,HTML 更适合人看。
如果目标环境装不了 async-profiler,用 JFR 兜底:
jcmd "$PID" JFR.start name=hotspot settings=profile duration=30s \
filename=/tmp/conc-profile/hotspot.jfr
jfr view --width 140 hot-methods /tmp/conc-profile/hotspot.jfr
jfr view --width 140 allocation-by-class /tmp/conc-profile/hotspot.jfr
jfr view --width 140 contention-by-class /tmp/conc-profile/hotspot.jfr
两种工具的取舍:
| 维度 | JFR | async-profiler |
|---|---|---|
| 安装 | JDK 内置,零依赖 | 需要下载或 brew |
| 锁 | 事件 + 阈值 + previousOwner | 等待者火焰图,宽度是等待占比 |
| 分配 | ObjectAllocationSample 权重表 | alloc 火焰图,宽度是字节数 |
| CPU | ExecutionSample 采样 | CPU 火焰图,帧更完整 |
| 证据形态 | 可归档的 .jfr | HTML / collapsed 文本 |
| 适合 | 长窗口、可审计、生产常开 | 短窗口、快速定位热点 |
常见错误与边界
- 把
-e lock当持有者排行。 图里是等待者的栈;持有者要去转储或 JFR 的previousOwner找。 - 把 alloc 宽度当对象数量。 宽度是字节权重;大数组会盖过大量小对象。
- 采样为零就下结论「没有分配」。 采样是概率的,短窗口、低速率、被 JIT 优化掉的分配都可能不出现在图里;零样本不等于零分配。
-d太短。 JIT 还没预热、业务还没进入稳态,图里大量是编译和类加载;先跑够预热,再采样。- 三条命令一起跑。 CPU、锁、分配各自有开销,串行叠加会改变被测系统的行为;一次只开一种,记录时间窗口。
- 路径和架构不匹配。
asprof不在PATH、arm64/x64 混用、容器里没有ptrace,都会表现为 attach 失败,不是「Java 没有热点」。 - 用旧版本跑 JDK 25。 JDK 25 的类文件版本和新 API 需要 4.x;旧版可能直接报不支持。安装后先
asprof -h确认版本。 - 把火焰图当精确账单。 采样和权重是估算,不能用来做计费或精确容量承诺;要精确计数得用分配计数器或更高速率的专用工具。
什么时候不要用 async-profiler: 需要可审计的逐事件记录、需要长时间常开、或者容器权限不允许 attach 时,JFR 更合适;只需要一次「谁最宽」的快速判断时,async-profiler 的图更直接。两者不是替代关系,是同一问题的两种证据形态。
小结
async-profiler 的价值在于把「按事件聚合」变成「按调用栈聚合」:-e lock 把等待时间摊到等待者的栈上,-e alloc 把分配字节摊到分配站点上,-o flamegraph 给图,-o collapsed 给可 diff 的文本。读图时先确认宽度含义,再分自底向上(入口视角)和自顶向下(热点反查)两种读法。
本节没有在本机执行 async-profiler,所有命令都只到「可复制」为止。作为替代证据,JFR 的 jdk.ObjectAllocationSample 在 4 秒里采到 1238 条样本,allocation-by-class 显示 String 40.67%、byte[] 34.74%、Loan 23.15%;把字符串预计算、缓冲复用后,Loan 升到 93.27%,String/byte[] 退出榜单。方法可以复现,工具输出不能冒充;这是本节和后续章节共同的数据口径。
阅读导航:上一节 3.1 JFR 的 Monitor 事件 | 下一节 3.3 线程转储的读法
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。