深入 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_debug 和 heapprofd。前者是 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_debug | heapprofd | |
|---|---|---|
| 性能影响 | 极大 | 可控(采样) |
| 数据完整性 | 全量 | 采样,小块可能漏 |
| 释放栈 | 不记录 | 记录配对 |
| 可视化 | 仅文本 | 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 的火焰图加上正确的排查流程,能把”大海捞针”变成”按图索骥”。