内存问题最麻烦的地方在于:数字涨了,不代表马上能知道谁在涨。dumpsys meminfo 能告诉你 RSS/PSS,Java heap dump 能告诉你对象保留关系,但 JNI/C++、Skia/Bitmap 的 CPU 侧分配、播放器里的 native wrapper 这些问题,经常还需要知道“哪个调用栈发起了 malloc/new”。
第 14 篇讲 heapprofd。它的价值是把 native allocation、free 和调用栈放进 Perfetto trace,再和业务事件、线程调度、Binder 放到同一个时间轴里看。
heapprofd 适合放在趋势确认之后。先用 RSS/PSS、meminfo 或线上监控确认趋势,再用 heapprofd 找 malloc/new 调用栈;这篇不解决 Java 对象引用和图形 backing 内存。
下文的顺序是:先说什么时候上 heapprofd、它和 Java heap dump、Java allocation sampling 的边界在哪里;接着抓一份最小可用的 native heap profile,并把它和系统 trace 合在一起抓,便于和 Binder、调度一起看;UI 里怎么看 Heap Profile,SQL 怎么查未释放分配;采样间隔怎么理解才不会被误读;符号化的可信度问题;以及发布报告前的检查清单,最后把 RSS/PSS 内存趋势对应到具体的调用栈上。
内存问题要分几张账
开始抓 heapprofd 前,先把内存数据分成几类。很多误判都来自把它们混在一起。
| 数据 | 常见来源 | 回答什么问题 |
|---|---|---|
| 进程 RSS / swap 趋势 | linux.process_stats |
目标进程 RSS、anon/file RSS、swap、oom_score_adj 有没有变化 |
| 进程 PSS / Private Dirty | dumpsys meminfo、/proc/<pid>/smaps_rollup、process_stats_config.scan_smaps_rollup |
PSS、Private Dirty、SwapPss 是否上涨;scan_smaps_rollup 受权限和目标进程限制 |
| 系统内存压力 | linux.sys_stats |
MemAvailable、Cached、ZRAM、vmstat、PSI 是否说明系统在回收或承压 |
| native allocation 调用栈 | android.heapprofd |
哪些 malloc/new 调用栈贡献了分配,哪些还没释放 |
| Java 对象保留关系 | android.java_hprof、MAT、Android Studio Profiler |
哪些 Java 对象还被谁持有 |
heapprofd 默认统计的是目标进程向 libc malloc/free、new/delete 这类 allocator 请求的字节数。它不等于 RSS,也不覆盖所有 native backing。
GraphicBuffer、dma-buf、GPU texture、驱动侧分配、直接 mmap/memfd/ashmem 或绕过 libc malloc 的 allocator 不在默认 malloc 账里。基于 malloc 的 arena 可能只看到 arena 大块,看不到内部对象。图形内存要先看 meminfo Graphics/GL、dmabuf、GPU/graphics 计数、SurfaceFlinger / BufferQueue 关系。
heapprofd 只补充 App/HWUI/Skia/JNI 路径上的 malloc/new 证据。
先用这张表决定下一步:
| 现象 | 优先工具 | heapprofd 角色 |
|---|---|---|
| Java heap 上涨,对象保留明显 | HPROF / MAT / Android Studio | 不作为第一入口 |
| Native Heap / malloc wrapper 上涨 | heapprofd | 找 malloc/new 调用栈 |
| Graphics / GL / dmabuf / Surface buffer 上涨 | meminfo、dmabuf、GPU/graphics、SurfaceFlinger | 只用于排除 App/native wrapper 是否也在分配 |
| RSS/PSS 上涨但 heapprofd 不高 | smaps_rollup、maps、mmap/syscall、dmabuf/graphics | 先拆账,不要直接判 heapprofd 抓错 |
| PSS 不涨但 RSS 涨 | smaps / file mapping / allocator cache | 先不要下泄漏结论 |
allocator 线程缓存、碎片、ZRAM、page 粒度都会让 RSS 和 heapprofd 对不上。看到 RSS 高而 heapprofd 不高时,要先考虑这些边界,不要立刻判成 heapprofd 抓错了。
反过来也一样:heapprofd 里某条调用栈累计分配很高,不代表进程 RSS 会同幅度上涨。它可能是分配 churn:频繁申请、很快释放,Total malloc size 高,但 Unreleased malloc size 不高。内存问题要先分清“占住不放”和“反复制造临时对象”。
怀疑 mmap/native heap 之外的大块分配时,改查 smaps 的 Private_Dirty/Rss/Pss、maps 区间、Android 14+ syscall sys_mmap/sys_munmap/sys_madvise,或者 perf/simpleperf 的 mmap syscall callstack。heapprofd 只用于确认 malloc wrapper 是否也在贡献。
什么时候用 heapprofd
Java heap dump 看不到增长来源时,先看增长落在哪张账。只有 native heap 或 native wrapper 明显可疑时,再进入 heapprofd。
heapprofd 适合这些问题:
- Native Heap / malloc wrapper 持续上涨,但 Java heap dump 看不出明显对象保留。
- Bitmap、Skia、播放器 buffer、JNI/C++ 模块怀疑有 libc malloc/new 路径上的 native backing 占用。
- C++ 模块、native service、daemon 的 malloc/new 异常。
- 想知道某次业务操作后未释放分配是否增加。
- 想把内存增长和线程运行、Binder、业务 Track Event 关联起来。
这些问题不该优先用 heapprofd:
- 只想看 Java 对象引用关系。用 Java heap dump。
- 想把每一次小分配都精确记成账本。heapprofd 默认是采样 profiler。
- 目标 App 在 user build 上没有
debuggable或profileable。这类进程通常不能被 shell profile。 - GraphicBuffer、dma-buf、GPU driver memory、Surface buffer 数量上涨。先走 meminfo Graphics/GL、dmabuf、GPU/graphics、SurfaceFlinger 的证据链,heapprofd 只用来排除或定位 malloc/new 包装层。
目标资格也要分开看。user build 主要面向 debuggable/profileable Java apps;userdebug/eng 可抓大多数 App 和 system services,但少量 critical services 可能被 SELinux never_profile_heap 之类策略禁止。system process 不靠 <profileable> 解锁。
快速抓一份 native heap profile
官方更推荐先用 tools/heap_profile 脚本。它会处理很多设备侧细节,适合单进程快速定位。
这条命令按进程名抓 15 秒,采样间隔 4096 bytes,每 5 秒产生一个 continuous dump:
1 | tools/heap_profile android \ |
输出目录里会有 raw-trace 和转换后的 profile 文件。把 raw-trace 拖到 Perfetto UI,就能看到 Heap Profile diamond。
按进程名抓和按 PID 抓差别很大。-n com.example.app 会命中已经运行的同名进程,也会等待后续启动的新进程;目标从 zygote specialize 到 App 进程时,heapprofd 可以尽早接上。多进程 App 要分别写多个 -n / process_cmdline,例如 com.example.app、com.example.app:remote、com.example.app:player;否则 remote/native 子进程的 malloc 不会进入 profile。按 PID 抓更适合已经稳定运行的现场,但会错过启动早期分配。官方也提醒,哪怕按进程名抓,zygote specialize 最早的一小段分配仍可能没记到。
如果要在 user build 上用 adb / shell 抓 App,需要 manifest 里允许 shell profiling:
1 | <application ...> |
这条路径只解决本地 shell profiling 权限,不等于线上可以远程抓任意用户进程。
Android 15 API 35 起,App 可以通过 ProfilingManager 请求 heap profile;Android 16 的 Profiling module 面向 public devices field profiling,但结果会脱敏、只包含请求进程相关信息,并且受系统限流,不保证每次请求都会满足。线上方案要把它和 adb/profileable 的实验室路径分开设计。
和系统 trace 合在一起抓
脚本适合快速定位,手写 TraceConfig 适合把 heapprofd 和系统数据源合并。先看最小 heapprofd 配置:android.heapprofd、process_cmdline、采样间隔、continuous dump、shared memory 和阻塞策略。
系统上下文是增强项:process_stats 看 RSS/PSS 趋势,sys_stats 看系统内存压力,ftrace 关联线程和时间窗,track_event 关联业务动作。allocation-heavy 场景里,heapprofd + ftrace 会互相增加扰动;heap+system-context preset 只适合短窗口实验室复现,并且要记录 ftrace/heapprofd stats、目标场景耗时是否被 profiler 改变。
1 | duration_ms: 15000 |
这仍然是 native heap 模板,不是图形内存模板。排查 GraphicBuffer、dma-buf、GPU texture、Surface buffer 时,要额外对照 dmabuf、GPU/graphics 计数、dumpsys meminfo Graphics/GL、SurfaceFlinger layer / buffer 信息;heapprofd 只能回答 malloc/new 侧有没有对应贡献。
heapprofd buffer 使用 DISCARD 时,buffer 必须足够大。heap profile 常在 dump 点集中写入,DISCARD 满后会丢新数据,对末尾 diamond 和未释放大头很危险。抓完必须检查 traced_buf_chunks_discarded、heapprofd_* stats;长 Trace 或写入量不确定时,优先给 heapprofd 独立大 buffer,必要时改 RING 并在报告里说明旧 dump 可能被覆盖。
临时排查时,直接把 PBTX 通过 stdin 传给 perfetto 最稳。输出路径在 Android 上放到 /data/misc/perfetto-traces/:
1 | adb push config.pbtx /data/local/tmp/config.pbtx |
手写 HeapprofdConfig 和 tools/heap_profile 的默认行为不完全一样。block_client 可以在 heapprofd shared memory 满时阻塞目标进程,换取数据完整性;脚本的 --block-client 是默认行为,但手写配置需要自己决定。
实验室 leak 复现可以设 block_client: true 并配 block_client_timeout_us,用目标进程 malloc 路径的阻塞换完整性;交互性能分析不要默认开,因为它可能反向影响 jank/latency。交互场景通常用 block_client: false,再通过更高采样间隔、更大 shmem_size_bytes 和 stats 检查接受不完整风险:必须检查 heapprofd_buffer_overran、heapprofd_non_finalized_profile、heapprofd_last_profile_timestamp 是否覆盖目标窗口。
把 heapprofd 放进系统 trace 时,建议同时保留少量业务 marker。App 内用 android.os.Trace / AndroidX Trace 会落到 atrace;Perfetto SDK Track Event 需要单独启用 track_event data source。
比如播放器场景至少要有 operation_start、steady_state、operation_end、release_done/gc_idle_done 四类 marker。没有 release marker 时,结论只能写“操作后保留增加”,不能写“泄漏”。android.log 数据源只支持 userdebug;user build 上不要把 logcat 当成这份配置默认能抓到的证据。
UI 怎么看 Heap Profile
导入 trace 后,在目标进程下找 Heap Profile 轨道。轨道上的 diamond 代表一次 dump。点击 diamond 后,底部会展示 flamegraph。
continuous dump 的每个 diamond 都是从记录开始到该时间点的累计结果。先看未释放量判断占住不放,再看总分配量判断 churn。读图时按这个顺序走:
- 看连续几个 diamond 的
Unreleased malloc size是否持续增加。 - 看
Total malloc size,判断是不是分配 churn 很高但释放也快。 - 用 Left Heavy 视图找最大的调用栈。
- 先找 allocator / ART glue 之上的第一个 App、SDK 或业务帧。
- 回到时间轴,关联触发操作、业务 Track Event、线程运行。
不要用单个 diamond 判泄漏。泄漏判断至少要看“操作前、操作后、回收后”几个点。如果操作后上涨,回收后仍然不降,再看未释放调用栈。
实验室里可以用 adb shell killall -USR1 heapprofd 额外触发一次 snapshot。比如“进入页面前、页面稳定后、退出页面并完成 release/close/析构后”各触发一次,比只靠固定 5 秒间隔更容易把业务动作和 diamond 对上。
只有 native backing 由 Java wrapper、NativeAllocationRegistry 或 Cleaner/finalizer 持有时,才把等待 idle、finalizer、GC 放进这个状态点。allocator cache 和碎片还可能导致 heapprofd 下降,但 RSS 不立刻下降。缓存不是“不释放”的委婉说法,必须有容量上限、命中/复用证据、退出或压力下回收策略;否则只能写“疑似长期保留”。
SQL 怎么查未释放分配
heapprofd 会写入调用栈相关表:
| 表 | 含义 |
|---|---|
heap_profile_allocation |
allocation/free 的样本和大小 |
stack_profile_callsite |
调用栈节点 |
stack_profile_frame |
函数帧 |
stack_profile_mapping |
so、apk、jar、binary 映射 |
stack_profile_symbol |
离线符号化结果 |
单目标 profile 快速读图时,优先用官方标准库里的 summary tree。它会沿着 stack_profile_callsite.parent_id 聚合整条调用栈,结果更接近 flamegraph 里的 cumulative view:
1 | INCLUDE PERFETTO MODULE android.memory.heap_profile.summary_tree; |
summary tree 适合单目标 profile 的快速读图。它没有进程列,不要直接用于多进程回归统计。多进程 App、:remote 进程或同名进程重启时,要回到原始表按 upid、PID、进程名和 diamond 时间点过滤,避免把别的进程归到目标进程。
如果要按进程、diamond 时间点和 leaf frame 做粗查,再回到原始表。这条 SQL 只看末尾一个 dump 时间点,重点看 SUM(a.size):allocation 是正数,free 是负数,求和后更接近该 diamond 上的 Unreleased malloc size。它不用于最终归因,最终报告必须回到 summary tree 或 flamegraph 取完整调用栈。
1 | WITH target_process AS ( |
SQL 只能帮你快速找大头。原始表查询把调用栈拆成了 frame 行,容易只看到 malloc、realloc 这类 leaf frame;summary tree 和 UI flamegraph 更适合读完整归因。写稳定脚本前,先用 PRAGMA table_info(heap_profile_allocation); 确认字段,避免不同 Perfetto 版本导致查询失效。
泄漏判断要做三点对比:操作前、操作后、释放后。这段是报告查询骨架,baseline_dump_ts、after_action_dump_ts、after_release_dump_ts 可以来自业务 marker 最近的 heap dump:
1 | WITH target_process AS ( |
释放后仍保留才偏泄漏;操作中升高但释放后回落是峰值;长期保留但有容量上限、复用证据和压力回收策略,才可能写成缓存。
报告字段可以固定成这一组:
1 | trace_name,process_name,upid,pid,scenario,dump_phase,dump_ts_ms,sampling_interval_bytes,dump_interval_ms,top_stack_frame,mapping_name,unreleased_mb,retained_delta_mb,total_malloc_mb,allocation_count,symbol_status,profileable_state,data_loss_status,evidence_grade,conclusion_type,next_action |
采样间隔怎么理解
heapprofd 不是默认记录每一次分配。sampling_interval_bytes: 4096 的含义是:平均每分配 4096 bytes 采一个样本,再按采样概率归因到调用栈。足够大的 allocation 可能按真实大小记录,但不要把采样间隔理解成“低于它就估算,高于它就精确”的硬分界;不同 Android 版本和 heap 配置还会影响实际行为。
怎么选:
- 4096 bytes 是常用起点,适合大多数本地分析。
- 怀疑大量小对象 native 分配时,可以降低间隔,但开销会上升。
- 分配速率太高导致 buffer overrun 时,先增大 shared memory 或提高采样间隔。
- 手写
HeapprofdConfig必须显式设置非 0 的采样间隔;想接近精确可以设 1,但开销很高。0 是无效配置,Android 12 之前还可能让目标进程崩溃。
报告里不要把采样结果写成“精确分配了 12.34MB”。更合适的写法是:“在 4096 bytes 采样间隔下,未释放分配主要集中在 A/B/C 三条调用栈,A 调用栈占比最高”。采样 profiler 的价值是找方向和排序,不是替代 allocator 账本。
小对象密集、样本少、窗口短、调用栈分散时,top stack 排名会不稳定。before/after 比较必须保持同一采样间隔、相近负载和同一目标窗口;不要比较 1% 或 2% 这类小差异,除非多次复现。报告里的 evidence_grade 可以按这个规则写:样本充足、连续 dump 趋势一致、符号完整为 strong;命中 data loss、profile incomplete、unknown symbols、样本少或只复现一次为 weak。weak 时写“候选调用栈”,不要写“根因调用栈”。
Java heap dump 和 Java allocation sampling
这部分是对开头几张内存账的补充:heapprofd 能看 native malloc/new,也能在 Android 12 或更高版本上切到 Java allocation sampling,但它仍不替代 Java heap dump。
| 能力 | 输出 | 适合问题 |
|---|---|---|
| heapprofd native profiling | malloc/new 调用栈 | 谁分配了 native 内存 |
| Java heap dump | 对象保留图 | 谁持有 Java 对象 |
| Java allocation sampling | Java 创建对象的调用栈 | 哪些代码制造了大量 Java allocation churn |
Java allocation sampling 可以通过 heapprofd 配置 heaps: "com.android.art" 打开,也可以用脚本参数 --heaps com.android.art。它需要 Android 12 或更高版本。它记录对象创建时的调用栈,不记录对象什么时候被 GC,也不等价于 Java heap dump 的保留关系。
符号化和可信度
如果 flamegraph 里都是地址、unknown 或混淆后的 Java/Kotlin 名字,先处理符号化和反混淆。官方推荐对 collected trace 运行 traceconv bundle 生成带符号信息的归档,再按符号化文档补 native symbols 和 ProGuard/R8 mapping。
报告里要记录 APK/build id、native symbols 版本、ProGuard/R8 mapping 版本;Build ID mismatch 时降级为地址级证据。第三方 SDK 或 stripped so 只剩地址时,下一步是补对应 build 的 unstripped symbols / mapping,而不是直接改业务代码。
读调用栈时也不要盲目过滤 libart.so、allocator glue 或系统库。ART 帧有时正是 Java 调 native、NativeAllocationRegistry 或对象创建路径的关键上下文。
先找 allocator/ART glue 之上的第一个 App、SDK 或业务帧;只有确认某类帧只是重复胶水层时再过滤。看到 DEDUPED、ICF 造成的函数折叠、Build ID mismatch、只有一帧等情况,要先按符号化问题处理。
unwind 可信度也要看:未知帧比例、单帧栈比例、Build ID 是否匹配、Java frame 是否可用、目标 ABI / Android 版本 known issues。缺符号会改变归因可信度;报告里要区分“已符号化可归因”和“只能定位到 so / allocator glue”。
报告里要写清楚三件事:
- 目标进程是否 profileable/debuggable。
sampling_interval_bytes和 continuous dump 间隔。stats里是否出现 heapprofd buffer overrun、client error、profile incomplete、heapprofd_unwind_time_us、heapprofd_client_spinlock_blocked、heapprofd_sampling_interval_adjusted或 data loss。
出现 data loss 或 heapprofd buffer overrun 时,不要把数字写成精确账本。更合适的说法是“该调用栈是未释放分配的大头,采样条件如下”。
heapprofd 专用 health check 要单独跑:
1 | SELECT name, idx, severity, source, value |
发布报告前检查
采集条件:
- heapprofd 需要 Android 10 或更高版本;Java allocation sampling 需要 Android 12 或更高版本。
- user build 上用 adb / shell 抓目标 App,通常需要
debuggable或<profileable android:shell="true"/>。 - 按 PID 抓运行中进程可能错过启动早期分配;按进程名等待启动能覆盖更多启动阶段。
- runtime profiling 不是立刻生效,目标进程很空闲时,下一波分配前可能还没启用。
- 同一个目标进程同时只能被一个相关 session profile;冲突时先确认是否有旧
perfettosession。
数据可信度:
- 32-bit 程序在部分旧 Android 版本上有限制,遇到空 profile 要回到官方 Known Issues 查版本差异。
- heapprofd stats、stackprofile stats、symbolization stats 必须过一遍;命中 overrun、non-finalized、unknown symbols 时降级。
- heapprofd 数字和 RSS 对不上很常见,碎片、线程缓存、allocator cache、ZRAM 都会造成差异。
结论边界:
Total malloc size高不等于泄漏,先和Unreleased malloc size、连续 diamond、业务操作时间点一起看。- 图形 backing 高不等于 heapprofd 一定高。Graphics/GL、dmabuf、GPU、Surface buffer 要单独成账。
- 把结论归类为
leak / peak / cache / churn / out-of-scope graphics memory / evidence insufficient,每类对应不同下一步动作。
把内存趋势落到调用栈
heapprofd 解决的是 native allocation 调用栈问题。它不替代 Java heap dump,也不替代 RSS/PSS 趋势;它补上的是“谁分配了 libc malloc/new 管辖的 native heap 内存”。GraphicBuffer、dma-buf、GPU texture 和 Surface buffer 要走另一张图形内存账,heapprofd 只能辅助看 App/native wrapper 是否也在制造 malloc/new 压力。
实战里可以按这个顺序走:
- 用 process stats、meminfo、smaps 或线上监控确认内存趋势。
- 如果增长落在 Native Heap / malloc wrapper / heapprofd 可见 allocator 路径,进入 heapprofd;如果只是 RSS/PSS 涨,先用 smaps_rollup、meminfo 分类、mmap/syscall 或 dmabuf/graphics 证据拆账。
- 在 UI 里看 Unreleased malloc size 和 flamegraph。
- 用 SQL 找大头,再回到 flamegraph 看完整调用栈。
- 补符号化、写清采样条件和可信度。
- 把结论归类为 leak、peak、cache、churn、图形内存账外问题或证据不足。
参考文档
- Memory: Callstack-based Allocation Profiling
- Memory: Java heap dumps
- Memory counters and events
- heapprofd design
- TraceConfig reference
- Track Events
- Android Log data source
- ProfilingManager
- Android Profiling module
关于我 && 博客
欢迎关注 Android Performance。