async-profiler 采样模式全解析:从 CPU 到分配、锁与原生内存泄漏
2026/9/16 18:25:35 网站建设 项目流程

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.cppsrc/mallocTracer.cppsrc/lockTracer.cppsrc/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)。采样间隔_intervalargs._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-dbg

CentOS、RHEL 等 RPM 系发行版,可用debuginfo-install工具:

# debuginfo-install java-1.8.0-openjdk

Gentoo 上,可为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模式记录带地址的mallocrealloccallocfree调用,使分配与释放可以相互匹配,从而让报告只聚焦于未释放的分配——这正是内存泄漏的根源。

基本用法:

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调整,参数接受ratiopercent%。例如忽略 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标志做判断——开启--nofreerealloc_hookfree_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.html

Wall-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_SAMPLEEXECUTION_SAMPLE,后者在 CPU_ONLY 模式下采样。

Java 锁剖析(lock)

-e lock用于测量被剖析应用中的锁竞争:帮助开发者理解锁获取模式、竞争程度(线程等待获取锁的时长)、等待锁的时间,以及哪些代码路径因锁而阻塞。

在 lock 模式下,栈顶帧是锁/监视器(monitor)的类,计数器值为进入该锁/监视器所耗费的纳秒数

asprof -e lock -t -i 5ms -f result.html 8983

底层实现见 src/lockTracer.cpp:_intervalargs._lock按 TSC 频率换算为纳秒(src/lockTracer.cpp),通过_total_duration累加器溢出判定是否采样(src/lockTracer.cpp)。JFR 输出中对应jdk.JavaMonitorEnterjdk.ThreadPark两类事件。

原生锁剖析(nativelock)

--nativelock用于测量被剖析应用中的pthread 锁竞争:帮助理解 pthread 锁获取模式、竞争程度、等待 pthread mutex / 读写锁的时间,以及被原生同步原语阻塞的代码路径。

原生锁剖析通过拦截以下调用实现:

  • pthread_mutex_lock
  • pthread_rwlock_rdlock
  • pthread_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标志可同时启用一组预定义的常用事件:cpuwallalloclivelocknativemem

重要提醒--all适合开发环境获取全局概览,不建议在生产环境(尤其持续剖析场景)开启,应谨慎选择要剖析的事件及其参数。

asprof --all -f profile.jfr

也可以叠加--alloc/--wall/--lock/--nativemem覆盖单个事件的设置:

asprof --all --alloc 2m --lock 10ms -f profile.jfr

agent 方式等价写法:

-agentpath:/path/to/libasyncProfiler.so=start,all,alloc=2m,lock=10ms,file=profile.jfr

此外,可用任意事件类型替换--all中的cpu。例如下面的命令剖析cycles加上wallalloclivelocknativemem

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-cyclesCPU 参考周期数,不受频率缩放影响
-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-missesL1 数据缓存缺失次数
-e LLC-load-misses末级缓存(LLC)缺失次数
-e dTLB-load-missesTLB(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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询