async-profiler 采样模式全解析:从 CPU 到分配、锁与原生内存泄漏
【免费下载链接】async-profilerSampling CPU and HEAP profiler for Java featuring AsyncGetCallTrace + perf_events项目地址: https://gitcode.com/GitHub_Trending/as/async-profiler
async-profiler 的核心能力远不止 CPU 采样。本文以仓库文档 docs/ProfilingModes.md 为骨架,系统梳理其全部采样模式——CPU、Allocation、Wall Clock、Java Method、Lock、Native Lock、Native Memory,以及 Multiple Events 多事件联合采样与 Continuous Profiling 持续剖析,并结合仓库 C++ 源码(src/allocTracer.cpp、src/mallocTracer.cpp、src/lockTracer.cpp、src/arguments.cpp等)解释各模式底层实现。读完本文,你将能针对不同性能问题选择正确的采样事件,并熟练组合asprof/jfrconv参数完成从采集到报告生成的全流程。
CPU 采样:perf_events 与 AsyncGetCallTrace 的协作
CPU 模式下,剖析器采集的堆栈样本同时覆盖Java 方法、native 调用、JVM 代码与内核函数。其总体思路是:接收perf_events产生的调用栈,并与AsyncGetCallTrace生成的调用栈进行匹配,从而同时得到 Java 与 native 代码的精确 profile。此外,async-profiler 还针对AsyncGetCallTrace在某些边界场景下失效的问题(OpenJDK 已知问题 JDK-8178287)提供了恢复栈的 workaround。
相比「直接使用perf_events+ Java agent 将地址翻译为方法名」的传统方案,该方案有四点优势:
- 无需
-XX:+PreserveFramePointer——该参数会带来有时高达 10% 的性能开销; - 无需在启动 JVM 时挂载 agent来翻译 Java 代码地址;
- 能显示解释器帧(interpreter frames);
- 不产生大量中间文件(如 perf.data),无需在用户态脚本中二次处理。
如需解析libjvm内部的栈帧,还需要安装调试符号(详见下文「Allocation 采样」一节的安装说明)。更多 CPU 采样引擎的选型(如 itimer、perf_events 等)可参考 docs/CpuSamplingEngines.md。
Allocation 采样:基于 TLAB 驱动的堆分配剖析
Allocation 模式用于采集堆内存分配量最大的调用点。async-profiler 不使用字节码插桩或 DTrace probe 这类侵入性强、性能影响大的技术,也不影响 Escape Analysis,更不会阻碍 JIT 的分配消除(allocation elimination)等优化——只有真正发生的堆分配才会被计量。
该模式依赖 HotSpot 特有的回调,接收两类通知:
- 对象在**新建 TLAB(Thread-Local Allocation Buffer)**中分配;
- 对象在TLAB 之外(慢路径,如大对象直接分配)分配。
采样间隔可用--alloc选项调整。例如--alloc 500k表示平均每分配 500 KB 取一个样本。JDK 11 之前,小于 TLAB 大小的间隔不会生效(因为小于 TLAB 的分配不会触发回调)。
源码层面,src/allocTracer.cpp 通过 trap 机制捕获分配点:AllocTracer::trapHandler判断 PC 是否落在_in_new_tlab或_outside_tlab两个断点区域内,据此区分ALLOC_SAMPLE(TLAB 内)与ALLOC_OUTSIDE_TLAB(TLAB 外)事件,并通过updateCounter(_allocated_bytes, total_size, _interval)实现按字节数的周期采样(见 src/allocTracer.cpp)。采样间隔_interval由args._alloc换算而来(src/allocTracer.cpp)。
在 allocation 模式下,每个调用栈的栈顶帧是所分配对象的类,计数器值表示堆压力(新建 TLAB 或 TLAB 外对象的总大小)。配合--live选项可以只统计存活对象(见 src/main/main.cpp 的用法说明),相关测试可参考 test/test/alloc/AllocTests.java。
安装调试符号
JDK 11 之前,分配剖析器需要 HotSpot 调试符号。部分 OpenJDK 发行版(Amazon Corretto、Liberica JDK、Azul Zulu)已将其内嵌于libjvm.so;其他 OpenJDK 构建通常以独立包形式提供调试符号。安装示例:
Debian / Ubuntu(把17换成所需 JDK 版本):
# apt install openjdk-17-dbgCentOS、RHEL 等 RPM 系发行版,可用debuginfo-install工具:
# debuginfo-install java-1.8.0-openjdkGentoo 上,可为icedteaOpenJDK 包设置 per-package 选项FEATURES="nostrip"以保留符号。
安装是否成功,可用gdb验证libjvm的符号:
$ gdb $JAVA_HOME/lib/server/libjvm.so -ex 'info address UseG1GC'输出若包含Symbol "UseG1GC" is at 0xxxxx则说明符号已就绪;若为No symbol "UseG1GC" in current context则尚未生效。
原生内存泄漏剖析(nativemem)
nativemem模式记录带地址的malloc、realloc、calloc与free调用,使分配与释放可以相互匹配,从而让报告只聚焦于未释放的分配——这正是内存泄漏的根源。
基本用法:
asprof start -e nativemem -f app.jfr <YourApp> # 或 asprof start --nativemem N -f app.jfr <YourApp> # 或只关心分配调用,不采集 free 调用: asprof start --nativemem N --nofree -f app.jfr <YourApp> asprof stop <YourApp>随后用jfrconv处理 JFR 文件以定位泄漏:
# --total 按字节累计,默认按调用次数统计 jfrconv --total --nativemem --leak app.jfr app-leak.html # 不做泄漏分析,包含全部原生分配: jfrconv --total --nativemem app.jfr app-malloc.html使用--leak时,生成的火焰图只显示没有对应free调用的分配路径:
为避免对「剖析结束前尚未释放的最年轻分配」产生偏置,泄漏剖析器默认忽略剖析时段最后 10%的尾部分配。尾部长度可用--tail调整,参数接受ratio或percent%。例如忽略 10 分钟剖析中最后 2 分钟的分配:
jfrconv --nativemem --leak --tail 20% app.jfr app-leak.html这些选项与jfrconv的用法对应关系可在 src/converter/one/convert/Main.java 的 usage 中确认:--nativemem(malloc profile)、--leak(仅保留泄漏)、--tail RATIO(默认 10%)。JFR 转火焰图的完整工作流另见 docs/ConverterUsage.md。
nativemem的开销取决于原生分配频率,但通常足够小,可承受生产环境使用。如需进一步降低开销,可配置采样间隔,例如添加 profiler 选项nativemem=1m,则分配样本被限制为平均每分配 1 MB 至多一个样本。
源码实现方面,src/mallocTracer.cpp 通过malloc_hook/calloc_hook/realloc_hook/free_hook四个钩子包装libc中对应的导入符号(patchImport),并对_nofree标志做判断——开启--nofree时realloc_hook与free_hook不再上报(见 src/mallocTracer.cpp)。该实现还专门处理了 musl 等 libc 上calloc内部调用malloc导致的重复计数问题(detectNestedMalloc,见 src/mallocTracer.cpp)。
用 LD_PRELOAD 剖析非 Java 进程的原生内存泄漏
与 Java 应用类似,nativemem也适用于非 Java 进程(详见 docs/ProfilingNonJavaApplications.md)。以下命令以LD_PRELOAD方式启动应用,每 10 分钟输出一个 JFR 记录:
LD_PRELOAD=/path/to/libasyncProfiler.so ASPROF_COMMAND=start,nativemem,total,loop=10m,cstack=dwarf,file=profile-%t.jfr NativeApp [args]再用jfrconv生成泄漏火焰图:
jfrconv --total --nativemem --leak <profile>.jfr <profile>-leak.htmlWall-clock 采样:等间隔采样所有线程
-e wall让剖析器每隔固定周期对所有线程等概率采样,无论线程处于 Running、Sleeping 还是 Blocked 状态。例如剖析应用启动耗时场景就非常合适。
Wall-clock 剖析在按线程模式(-t)下最为有用:
asprof -e wall -t -i 50ms -f result.html 8983源码中 src/wallClock.cpp 展示了实现细节:_interval默认取args._wall,未指定时若为纯 wall 模式会放大为DEFAULT_INTERVAL * 5(因为采样线程数更多),并对线程遍历间隔设有 100 微秒的硬下限以避免过高的开销(src/wallClock.cpp)。它同时兼容两类事件:WALL_CLOCK_SAMPLE与EXECUTION_SAMPLE,后者在 CPU_ONLY 模式下采样。
Java 锁剖析(lock)
-e lock用于测量被剖析应用中的锁竞争:帮助开发者理解锁获取模式、竞争程度(线程等待获取锁的时长)、等待锁的时间,以及哪些代码路径因锁而阻塞。
在 lock 模式下,栈顶帧是锁/监视器(monitor)的类,计数器值为进入该锁/监视器所耗费的纳秒数。
asprof -e lock -t -i 5ms -f result.html 8983底层实现见 src/lockTracer.cpp:_interval由args._lock按 TSC 频率换算为纳秒(src/lockTracer.cpp),通过_total_duration累加器溢出判定是否采样(src/lockTracer.cpp)。JFR 输出中对应jdk.JavaMonitorEnter与jdk.ThreadPark两类事件。
原生锁剖析(nativelock)
--nativelock用于测量被剖析应用中的pthread 锁竞争:帮助理解 pthread 锁获取模式、竞争程度、等待 pthread mutex / 读写锁的时间,以及被原生同步原语阻塞的代码路径。
原生锁剖析通过拦截以下调用实现:
pthread_mutex_lockpthread_rwlock_rdlockpthread_rwlock_wrlock
该模式下,栈顶帧是经历竞争的原生函数(例如pthread_mutex_lock_hook),计数器表示线程等待获取锁的纳秒数。
与 Java 锁剖析的关键区别:
- 剖析的是原生 pthread 锁而非 Java monitor;
- 适用于C/C++ 应用以及 Java 应用使用的原生库;
- 能捕获 Java 锁剖析看不到的原生代码路径竞争。
asprof --nativelock 5ms -t -f result.html 8983实现上,src/nativeLockTracer.cpp 与 Java 锁追踪器并列;参数解析见 src/arguments.cpp,未指定阈值时使用DEFAULT_LOCK_INTERVAL(src/arguments.cpp)。相关测试见 test/native/nativeLockTest.cpp 与 test/test/nativelock/NativelockTests.java。
Java 方法剖析与原生函数剖析
-e ClassName.methodName会对指定 Java 方法做插桩,记录该方法的所有调用点及其堆栈:
-e java.util.Properties.getProperty上面的例子会剖析所有调用getProperty的位置。注意:
- 仅支持非 native的 Java 方法;
- 要剖析 native 方法,应改用硬件断点事件,例如
-e Java_java_lang_Throwable_fillInStackTrace; - 运行时 attach时,对某个非 native Java 方法的首次插桩可能触发全部已编译方法的 deoptimization(与
jvmtiRedefineClasses中的相关逻辑一致);后续插桩只冲刷 dependent code。以 agent 方式 attach 则不会发生大规模 CodeCache 冲刷。
从源码看,-e ClassName.methodName与--trace(带延迟阈值的插桩剖析)在参数类别中被统一归类(src/arguments.cpp 中事件类别包含trace),实现位于 src/instrument.cpp,测试见 test/test/instrument/InstrumentTests.java。
除 Java 方法外,以下原生函数也常被用于剖析特定行为:
G1CollectedHeap::humongous_obj_allocate——追踪 G1 GC 的humongous 分配(大对象);JVM_StartThread——追踪新 Java 线程的创建;Java_java_lang_ClassLoader_defineClass1——追踪类加载。
多事件联合采样(Multiple Events)
async-profiler 可以同时剖析 CPU、分配与锁。除 CPU 外,也可替换为 wall-clock、perf event、tracepoint、Java method 等任何执行事件。
唯一支持多事件并存输出的格式是JFR,其中包含的事件类型:
jdk.ExecutionSample(CPU)jdk.ObjectAllocationInNewTLAB(alloc)jdk.ObjectAllocationOutsideTLAB(alloc)jdk.JavaMonitorEnter(lock)jdk.ThreadPark(lock)
同时开启 cpu + alloc + lock:
asprof -e cpu,alloc,lock -f profile.jfr ...或用--alloc与--lock指定各自的阈值:
asprof -e cpu --alloc 2m --lock 10ms -f profile.jfr ...以 agent 方式启动时等价写法:
-agentpath:/path/to/libasyncProfiler.so=start,event=cpu,alloc=2m,lock=10ms,file=profile.jfr用--all一键开启常用事件集
--all标志可同时启用一组预定义的常用事件:cpu、wall、alloc、live、lock、nativemem。
重要提醒:--all适合开发环境获取全局概览,不建议在生产环境(尤其持续剖析场景)开启,应谨慎选择要剖析的事件及其参数。
asprof --all -f profile.jfr也可以叠加--alloc/--wall/--lock/--nativemem覆盖单个事件的设置:
asprof --all --alloc 2m --lock 10ms -f profile.jfragent 方式等价写法:
-agentpath:/path/to/libasyncProfiler.so=start,all,alloc=2m,lock=10ms,file=profile.jfr此外,可用任意事件类型替换--all中的cpu。例如下面的命令剖析cycles加上wall、alloc、live、lock、nativemem:
asprof --all -e cycles -f profile.jfr源码层面,src/main/main.cpp 的 usage 明确--all是同时启用 cpu、wall、alloc、live、nativemem 与 lock 的简写;src/arguments.cpp 在解析--all时将其展开为对应事件的默认阈值(_alloc、_lock、_nativemem等),随后在--alloc/--lock/--nativemem显式覆盖时生效。
持续剖析(Continuous profiling)
持续剖析是指应用被持续剖析、并每隔指定周期转储一次剖析结果。它能主动、高效地发现性能退化,帮助理解同一应用不同版本间的性能差异:将最新输出与历史输出对比,定位退化并优化引入的变更。
async-profiler 通过loop选项实现持续剖析。务必在文件名中包含时间戳模式,否则每次迭代都会覆盖输出:
asprof --loop 1h -f /var/log/profile-%t.jfr 8983--loop的时间参数与-d等时长参数共用一套单位解析(parseUnits),%t在每次循环时展开为时间戳。
Linux 上支持的 perf 事件类型
在 Linux 上,-e选项支持丰富的硬件 / 软件事件。下表整理了文档 docs/ProfilingModes.md 列出的全部事件类型:
| 用法 | 说明 |
|---|---|
| 预定义事件: | |
-e cpu-clock | 高精度 per-CPU 定时器。与-e cpu类似,但强制走 perf_events |
-e page-faults | 软件缺页(page faults) |
-e context-switches | 上下文切换 |
-e cycles | 总 CPU 周期数 |
-e ref-cycles | CPU 参考周期数,不受频率缩放影响 |
-e instructions | 退休(retired)的 CPU 指令数 |
-e cache-references | 缓存访问次数(通常为 Last Level Cache,视架构而定) |
-e cache-misses | 需要从更高层缓存或主存取数的缓存访问次数 |
-e branch-instructions | 退休的分支指令数 |
-e branch-misses | 预测错误的分支指令数 |
-e bus-cycles | 总线周期数 |
-e L1-dcache-load-misses | L1 数据缓存缺失次数 |
-e LLC-load-misses | 末级缓存(LLC)缺失次数 |
-e dTLB-load-misses | TLB(Translation Lookaside Buffer)数据装载缺失次数 |
| 断点: | |
-e mem:<addr> | 在十进制或十六进制(0x)地址上设断点 |
-e mem:<func> | 在公开或私有符号上设断点 |
-e mem:<func>[+<offset>][/<len>][:rwx>] | 在带偏移、长度与读/写/执行权限的符号或地址上设断点。地址、偏移与长度可为十六进制或十进制,mem事件格式与perf-record相同 |
-e <symbol> | 等价于在符号上设执行断点mem:<symbol>:x。示例:-e strcmp追踪所有strcmp原生调用 |
| Tracepoint: | |
-e trace:<id> | 指定数值 id 的内核 tracepoint |
-e <tracepoint> | 指定名称的内核 tracepoint。示例:-e syscalls:sys_enter_open追踪所有open系统调用 |
| 探针: | |
-e kprobe:<func>[+<offset>] | 内核探针。示例:-e kprobe:do_sys_open |
-e kretprobe:<func>[+<offset>] | 内核返回探针。示例:-e kretprobe:do_sys_open |
-e uprobe:<func>[+<offset>] | 用户态探针。示例:-e uprobe:/usr/lib64/libc-2.17.so+0x114790 |
-e uretprobe:<func>[+<offset>] | 用户态返回探针 |
| PMU: | |
-e r<NNN> | 指定编号的架构相关 PMU 事件。示例:-e r4d2选择MEM_LOAD_L3_HIT_RETIRED.XSNP_HITM(event 0xd2, umask 0x4) |
-e <pmu descriptor> | PMU 事件描述符。示例:-e cpu/cache-misses/、-e cpu/event=0xd2,umask=4/;同样语法可用于 uncore 与厂商特定事件,如amd_l3/event=0x01,umask=0x80/ |
小结与选型建议
综合上述各模式,可按问题类型快速选型:定位热点用-e cpu;定位堆分配热点用-e alloc(或--live看存活对象);启动耗时与线程阻塞用-e wall -t;Java 锁竞争用-e lock;原生锁竞争用--nativelock;原生内存泄漏用-e nativemem配合jfrconv --leak --total。需要全景概览时用--all(注意生产环境慎用),需要长期观察性能退化时用--loop+ 时间戳文件名。所有命令的完整参数语义可在 src/main/main.cpp 的 usage 字符串中核对,事件类别编号(cpu/alloc/lock/wall/nativemem/nativelock/trace/span)定义于 src/arguments.cpp。更多选项细节还可查阅 docs/ProfilerOptions.md 与 docs/GettingStarted.md。
【免费下载链接】async-profilerSampling CPU and HEAP profiler for Java featuring AsyncGetCallTrace + perf_events项目地址: https://gitcode.com/GitHub_Trending/as/async-profiler
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考