深入 Android Native 内存分析全链路:从 malloc_debug 到 heapprofd 的 Native 堆内存泄漏排查实战

去年接手一个视频 SDK 的内存问题,Java 堆看起来一切正常,但进程的 PSS 每周稳定涨 30MB,一个月后必崩。LeakCanary 捞不出任何东西——问题出在 Native 层。

Native 内存泄漏为什么难搞

Java 层泄漏有成熟工具链:LeakCanary、MAT、Android Profiler,引用链一目了然。Native 层不同——malloc/free 是纯函数调用,没有 GC,也没有引用图可追溯。一块内存 free 了就是 free 了,没 free 就永远占着,没人告诉你这块内存是谁分配的、为什么没释放。

Android 给 Native 层提供了两套分析手段:malloc_debugheapprofd。前者是 libc 自带的轻量方案,后者是 Perfetto 生态下的采样跟踪工具。

malloc_debug:最轻量的入口

malloc_debug 是 bionic libc 内置的调试模块,无需额外依赖,通过系统属性开启:

# 开启 native 内存分配跟踪
adb shell setprop libc.debug.malloc.options backtrace
adb shell setprop libc.debug.malloc.program <your_process_name>
# 重启应用使其生效

开启后,每次 malloc/free 记录调用栈。用 dumpsys meminfo <pid> 查看 Native Heap 的详细分配,或者用 am dumpheap

adb shell am dumpheap -n <pid> /data/local/tmp/native.txt

输出文件里可以看按调用栈聚合的未释放分配。

malloc_debug 的优势是零依赖,Android 任何版本都能用。代价是严重拖慢性能——每次 malloc 都记录栈回溯,高频分配场景下帧率从 60 掉到个位数。而且它只记录分配栈,不记录释放栈,区分不了”忘了 free”和”提前 free 导致 double free”。

我在排查那个视频 SDK 时先用 malloc_debug 锁定了泄漏来源——都在视频解码线程的 av_malloc 调用上。但性能损耗太大,复杂场景根本跑不起来,只能切到 heapprofd。

heapprofd:Perfetto 的 Native 内存采样器

heapprofd 是 Android 10 引入的,整合在 Perfetto 体系里。核心思路是采样而非全量记录——不追踪每次 malloc/free,而是周期性采样进程的堆状态,比较两次快照之间多了哪些仍然存活的分配。

# 通过 Perfetto 命令行录制 heapprofd 数据
adb shell perfetto \
  -c - --txt \
  -o /data/misc/perfetto-traces/trace \
  <<EOF
buffers: {
  size_kb: 63488
}
data_sources: {
  config {
    name: "android.heapprofd"
    heapprofd_config {
      sampling_interval_bytes: 4096
      process_cmdline: "com.example.video"
      shmem_size_bytes: 8388608
      block_client: true
    }
  }
}
duration_ms: 30000
EOF

sampling_interval_bytes 设为 4096,平均每分配 4KB 采一次样。这个参数是性能与精度的平衡:太小影响性能,太大漏掉小块泄漏。我一般根据泄漏规模调整——几十 MB 级别的泄漏设 8192 足够,KB 级别的小泄漏降到 1024。

录制完成后把 trace 文件拉到本地,在 Perfetto UI 里打开,切到 heapprofd 面板:

  • Flamegraph 视图:按调用栈聚合分配次数,一眼看出哪个函数分配最多
  • 时序视图:看某个调用栈的分配量随时间增长趋势,确认泄漏
  • Allocations 列表:查看具体未释放的内存块和分配栈

那次排查的结果:解码线程的 av_malloc 分配了大量 buffer,但 av_frame_free 没有正确释放 AVFrame 持有的 data 指针。原因是一处 av_frame_ref 后没有对应的 av_frame_unref,引用计数错位了。

实战流程

我把 Native 内存泄漏排查总结成三步:

第一步:确认泄漏方向。dumpsys meminfo 观察 Native Heap 是否持续增长,排除 Java 堆和 Graphics 内存的干扰。Native Heap 稳定增长而 Java Heap 不变,问题基本在 Native 层。

# 每 5 秒 dump 一次,连续观察 10 次
for i in $(seq 1 10); do
  adb shell dumpsys meminfo com.example.app | grep "Native Heap"
  sleep 5
done

第二步:用 heapprofd 采样。 关键参数设置:

  • 针对已知泄漏场景录制 30-60 秒,覆盖一次完整的泄漏循环
  • 如果泄漏发生在特定操作后(比如播放一段视频),在操作前开始录制,操作结束后再录 10 秒
  • 采样间隔根据泄漏速度调整——泄漏越快间隔可以越大

第三步:Perfetto UI 定位。 打开 trace 文件后,先按”分配总量”排序火焰图,找到占比最高的调用栈;切到时序视图确认该调用栈的内存使用是否只涨不跌;最后点进具体分配记录,看完整调用链条。

malloc_debug vs heapprofd:怎么选

两个工具定位不同——malloc_debug 适合快速验证,heapprofd 适合深度分析

malloc_debugheapprofd
性能影响极大可控(采样)
数据完整性全量采样,小块可能漏
释放栈不记录记录配对
可视化仅文本Flamegraph + 时序
系统要求无版本限制Android 10+

实际项目中我习惯先用 malloc_debug 快速确认”是不是 Native 泄漏”和”哪个模块分配的”,5 分钟出结论。确认后再切 heapprofd 做精细分析,定位具体代码行。

一个绕过 heapprofd 限制的技巧

heapprofd 要求 Android 10+,某些定制 ROM 还可能关掉了这个 data source。低版本设备上可以退一步,用 LD_PRELOAD 注入的方式手动 hook malloc:

// 利用 LD_PRELOAD 拦截 malloc/free
void* malloc(size_t size) {
    void* ptr = __libc_malloc(size);
    // 记录分配信息到环形缓冲区
    record_allocation(ptr, size, __builtin_return_address(0));
    return ptr;
}

这种方式性能和稳定性都不如 heapprofd,但在老设备上是个兜底方案。Stack 回溯用 _Unwind_Backtrace 而不是 backtrace(),后者在 arm64 上依赖 frame pointer,编译优化(-O2)后可能不准确。

修复后的验证

修完代码后不要只看 Native Heap 有没有回落——要用 PSS 总量验证。Native 分配不直接对应 PSS,但泄漏修复后 PSS 波动应该显著减小。我习惯跑一轮 Monkey 测试前后做 diff:

# 测试前记录基线
adb shell dumpsys meminfo com.example.app | grep "TOTAL PSS"
# 跑 10000 次随机操作
adb shell monkey -p com.example.app -v 10000
# 测试后对比,PSS 增量应 < 5MB
adb shell dumpsys meminfo com.example.app | grep "TOTAL PSS"

那次视频 SDK 修完后,30 分钟压力测试下 PSS 增量从 80MB 降到了 4MB 以内。Native 内存问题一旦定位到 root cause,修复通常很简单——难的是在千万行 C/C++ 里找到那几行忘记 free 的代码。heapprofd 的火焰图加上正确的排查流程,能把”大海捞针”变成”按图索骥”。