Native 内存监控探索
前面介绍了 Native Crash 监控方案,对 APM 而言,另一个重要的方面就是内存监控(泄漏 / OOM)。对于 Android,Java 层的内存监控比较成熟,这里主要讨论 Native 内存监控。
内存监控其实主要就两点:统计、归因(栈回溯)。
分配器视角
malloc / free 只是 libc 暴露的接口,真正的记账在分配器里。分配器自身怎么实现(size class、空闲链表、brk 与 mmap 的分界、线程缓存)见内存篇的「内置的内存管理函数」与「第三方内存分配」,这里只关心和监控有关的一点:分配器为每个块保留的元数据。
元数据不是 ABI
不同分配器的元数据形态差得很远:
| 分配器 | 用在哪 | 元数据形态 |
|---|---|---|
| glibc ptmalloc | 主流 Linux 发行版 | chunk 头(size、prev_size)、bins、arena、tcache |
| dlmalloc | Android 5.0 之前 | chunk 头 + 空闲链表,全在堆内 |
| jemalloc | Android 5.0 到 10、FreeBSD | arena / extent / run / size class 四层 |
| scudo | Android 11 起 | 紧凑 chunk 头,带 class id、状态位与校验和 |
| mimalloc、tcmalloc | 自建或特定业务 | 各自的 segment / span 结构 |
| malloc zone | macOS | 按 zone 划分,zone 内有自己的空闲表 |
| CRT 堆 | Windows MSVC | HeapAlloc 之上的小块管理 |
这些元数据都没有稳定 ABI。版本一换,布局就可能变,字段含义也可能变。所有依赖元数据的工具都得按分配器分别适配,这是 native 内存工具移植成本高的根源。
更麻烦的是,元数据就在堆上。堆溢出、double free、UAF 会直接把它写坏,而工具恰恰依赖这些元数据来枚举「当前哪些块还活着」。一旦写坏,后面的推断全部失真,这一点在「快照」那节还会展开。
读统计的接口
分配器大多提供读统计的接口,它们是「分配器内部视图」,不是进程内存的全貌。
glibc 的 mallinfo2()(man page)返回一组堆统计:
| 字段 | 含义 |
|---|---|
arena |
通过 brk 分配的堆区总字节数 |
ordblks |
普通空闲块数量 |
smblks |
fastbin 空闲块数量 |
hblks / hblkhd |
通过 mmap 分配的块数 / 字节数 |
uordblks |
已分配给程序使用的字节数 |
fordblks |
空闲字节数 |
keepcost |
堆顶可归还给系统的空闲字节数 |
mallinfo2() 相对老的 mallinfo() 只改了一件事:字段从 int 换成 size_t。老接口在堆超过 2 GB 左右时会计数溢出,结果不可信。
#include <malloc.h>
struct mallinfo2 mi = mallinfo2();
/* 已分配与空闲的字节数,单位是字节 */
size_t used = mi.uordblks;
size_t free_ = mi.fordblks;
配套的接口还有几个:
malloc_info()输出 XML,按 arena / bin 分层,信息更细;- jemalloc 的
mallctl能读各 arena 的统计; - macOS 的
malloc_zone_statistics()能看 zone 的 allocated / free / reserved; - Windows 的
_heapwalk、_CrtMemCheckpoint只覆盖 MSVC CRT 这一层。
这些接口都只反映某一个分配器的内部状态。进程里还有 mmap、图形缓冲区、代码段,mallinfo2 一个都看不见,所以它不能当进程内存指标用。
拦截点
要拿到「谁分配的」,本质是在分配函数上加一层钩子。按生效时机分四类,覆盖范围和落地难度各不相同。
flowchart TD
S["编译期"] --> S1["插桩<br/>ASan / HWASan / LSan"]
L["链接期"] --> L1["符号重定向<br/>ld --wrap"]
R["加载期"] --> R1["预加载替换<br/>LD_PRELOAD / DYLD_INSERT_LIBRARIES"]
X["运行期"] --> X1["改跳转<br/>PLT/GOT hook / inline hook"]
| 层次 | 机制 | 覆盖范围 | 是否要重编 |
|---|---|---|---|
| 编译期 | sanitizer 插桩 | 被插桩的代码 | 是 |
| 链接期 | ld --wrap=malloc |
链接期未定义的符号引用 | 要重新链接 |
| 加载期 | LD_PRELOAD、DYLD_INSERT_LIBRARIES、重定向 DLL |
动态链接的程序 | 否 |
| 运行期 | PLT/GOT hook、inline hook | 指定模块或指定函数 | 否 |
编译期插桩
在每次分配前后插入检查代码,典型的是 Sanitizers 套件:
- ASan:影子内存记录每个字节的状态,抓越界、UAF、double free;
- HWASan:用 ARM64 的标记指针(top-byte-ignore)替代影子内存,开销比 ASan 小;
- LSan:退出时报告泄漏;
- GWP-ASan:采样版本,只在少量分配上加保护页,线上也能开。
优点是有明确的错误判定,缺点是必须重新编译,运行时开销也不适合长期开启。原理与工程配置见内存篇的 Sanitizers。
链接期:--wrap
--wrap 是 GNU ld 的特性,不是编译器语法,也不是 C/C++ 标准(ld 手册)。规则只有一条:把对某个符号的未定义引用解析到 __wrap_<symbol>,需要调原函数时用 __real_<symbol>。
extern void *__real_malloc(size_t size);
extern void __real_free(void *ptr);
void *__wrap_malloc(size_t size) {
void *p = __real_malloc(size);
/* 记录 size、调用栈、返回地址 */
return p;
}
void __wrap_free(void *ptr) {
/* 从记录表里删掉 */
__real_free(ptr);
}
链接时加参数:
gcc -Wl,--wrap=malloc -Wl,--wrap=free -o app app.c
它的局限比想象中大:
| 限制 | 表现 |
|---|---|
| 只作用于未定义符号 | 同一编译单元内自己定义的函数不会被替换 |
| 动态库内部调用不生效 | .so 内部调用走自己的 GOT/PLT,主程序链接时加的 --wrap 管不到 |
| libc 内部调用拦不住 | strdup、fopen 内部的分配不经过 __wrap_malloc |
C++ 的 new / delete 不经过 malloc |
要单独 wrap operator new / delete,名字修饰后更难处理 |
| 平台支持有限 | Mach-O 与 PE 没有这个选项 |
所以它只适合「能控制主程序链接、只关心主程序自身分配」的场景,比如嵌入式、单元测试、内部工具、mock。它不是一个通用方案。
顺带澄清一个常见误解:--wrap 不是 GCC 专有,而是 GNU 链接器(ld / gold,lld 也有兼容实现)的特性。Clang 只要底层用 GNU ld 或 lld 也能用。Windows 的 link.exe 没有它,最接近的是 /ALTERNATENAME。
加载期:预加载与符号插入
LD_PRELOAD 的思路是让同名符号先被解析到自己的 so 上,再用 dlsym(RTLD_NEXT, "malloc") 拿原函数:
#define _GNU_SOURCE
#include <dlfcn.h>
static void *(*real_malloc)(size_t);
void *malloc(size_t size) {
if (!real_malloc) real_malloc = dlsym(RTLD_NEXT, "malloc");
void *p = real_malloc(size);
/* 记录 */
return p;
}
几个要点:
dlsym必须在真正分配之前完成初始化,否则可能递归(dlsym内部自己也可能分配);- 一般用
pthread_once或原子变量保护初始化,避免多线程重复进入; - 覆盖范围取决于程序是否动态链接,静态链接的程序完全不生效;
- Android 上还要考虑 namespace 隔离与 SELinux 策略。
macOS 的对应物是 DYLD_INSERT_LIBRARIES,但单纯导出同名 malloc 不一定可靠,更规范的是 dyld interposing:
#define DYLD_INTERPOSE(_replacement, _replacee) \
__attribute__((used)) static struct { \
const void *replacement; \
const void *replacee; \
} _interpose_##_replacee __attribute__((section("__DATA,__interpose"))) = { \
(const void *)(uintptr_t)&_replacement, \
(const void *)(uintptr_t)&_replacee, \
};
static void *my_malloc(size_t size);
DYLD_INTERPOSE(my_malloc, malloc);
interpose 表告诉 dyld:以后所有对 malloc 的绑定都指向 my_malloc。它比导出同名符号稳,因为不依赖符号查找顺序。
Windows 没有预加载概念,三条常见路子:
- 重定向 DLL:比如 mimalloc 的
mimalloc-redirect.dll,专门拦截 CRT 的分配路径; - IAT hook:改 PE 导入地址表,把
ucrtbase/msvcrt里的malloc指向自己的函数,只能拦截 IAT 中的调用; - Detours / inline hook:直接改函数入口指令,覆盖更广,但要处理多线程、内存保护与 CFG。
运行期:两种 hook 的取舍
运行期 hook 不需要重新链接,也不需要重启进程,是工具与线上方案最常用的方式。它分两种,改的位置完全不同:
| PLT/GOT hook | inline hook | |
|---|---|---|
| 改哪里 | 调用方模块的 GOT 表项 | 目标函数入口的机器码 |
| 覆盖范围 | 只影响通过 PLT 发起的外部调用 | 所有调用该地址的代码 |
| 实现难度 | 较低 | 高,要处理指令边界、重定位、缓存 |
| 稳定性 | 较稳 | 容易踩坑 |
| 可检测性 | 校验 GOT 即可发现 | 校验函数头即可发现 |
两种方式下面各用一节展开。
运行期 PLT/GOT hook
调用链
编译期不知道 malloc 的最终地址,所以对它的调用先落到 PLT:
flowchart TD
A["call malloc@plt"] --> B["plt 表项<br/>jmp *GOT[n]"]
B --> C{"GOT[n] 已解析"}
C -->|"否 首次调用"| D["跳到动态链接器解析"]
D --> E["把真实地址写回 GOT[n]"]
E --> F["跳到 libc 的 malloc"]
C -->|"是"| F
PLT hook 改的就是第 2 步里的 GOT[n]:把它换成自己的函数地址。之后同一模块里所有走 malloc@plt 的调用都会先进入 hook,hook 里再通过保存的原地址调真正的 malloc。
代码
从模块基址走到 GOT 槽位的那条链路(.dynamic → DT_JMPREL / DT_SYMTAB / DT_STRTAB → r_offset)已经在二进制篇的「从模块基址到 GOT」里讲过,下面这份示例直接沿用,只处理 DT_JMPREL 这条路径,目的是展示 hook 的完整流程:
#define _GNU_SOURCE
#include <dlfcn.h>
#include <elf.h>
#include <link.h>
#include <stdint.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
typedef void *(*malloc_fn)(size_t);
typedef void (*free_fn)(void *);
static malloc_fn real_malloc;
static free_fn real_free;
/* 保存原符号,必须在改 GOT 之前完成 */
static void init_real(void) {
if (!real_malloc) real_malloc = (malloc_fn)dlsym(RTLD_NEXT, "malloc");
if (!real_free) real_free = (free_fn)dlsym(RTLD_NEXT, "free");
}
/* GOT 所在页可能只读,改之前临时放开 */
static void patch_got(ElfW(Addr) *slot, ElfW(Addr) value) {
long ps = sysconf(_SC_PAGESIZE);
void *page = (void *)((uintptr_t)slot & ~(uintptr_t)(ps - 1));
mprotect(page, (size_t)ps, PROT_READ | PROT_WRITE);
*slot = value;
mprotect(page, (size_t)ps, PROT_READ);
}
void *my_malloc(size_t size) {
init_real();
void *p = real_malloc(size);
/* 记录 size、调用栈 */
return p;
}
void my_free(void *ptr) {
init_real();
/* 从记录表里删掉 */
real_free(ptr);
}
/* 对单个模块做 GOT 扫描 */
static int hook_one(struct dl_phdr_info *info, size_t size, void *arg) {
(void)size;
const char *target = arg;
if (!info->dlpi_name || !info->dlpi_name[0]) return 0; /* 跳过主程序 */
if (strstr(info->dlpi_name, target) == NULL) return 0;
/* 1) 找 PT_DYNAMIC */
ElfW(Dyn) *dyn = NULL;
for (ElfW(Half) i = 0; i < info->dlpi_phnum; ++i) {
if (info->dlpi_phdr[i].p_type == PT_DYNAMIC) {
dyn = (ElfW(Dyn) *)(info->dlpi_addr + info->dlpi_phdr[i].p_vaddr);
break;
}
}
if (!dyn) return 0;
/* 2) 从动态段取出符号表与 PLT 重定位表 */
ElfW(Sym) *symtab = NULL;
const char *strtab = NULL;
ElfW(Rel) *jmprel = NULL;
ElfW(Word) pltrelsz = 0;
int use_rela = 0;
for (ElfW(Dyn) *d = dyn; d->d_tag != DT_NULL; ++d) {
switch (d->d_tag) {
case DT_SYMTAB: symtab = (ElfW(Sym) *)d->d_un.d_ptr; break;
case DT_STRTAB: strtab = (const char *)d->d_un.d_ptr; break;
case DT_JMPREL: jmprel = (ElfW(Rel) *)d->d_un.d_ptr; break;
case DT_PLTRELSZ: pltrelsz = (ElfW(Word))d->d_un.d_val; break;
case DT_PLTREL: use_rela = (d->d_un.d_val == DT_RELA); break;
default: break;
}
}
if (!symtab || !strtab || !jmprel || !pltrelsz) return 0;
if (use_rela) return 0; /* 这里只演示 REL,RELA 需要换结构体与步长 */
/* 3) 逐个重定位项比对符号名 */
uint8_t *p = (uint8_t *)jmprel;
uint8_t *end = p + pltrelsz;
for (; p < end; p += sizeof(ElfW(Rel))) {
ElfW(Rel) *rel = (ElfW(Rel) *)p;
#if defined(__LP64__)
ElfW(Sym) *sym = &symtab[ELF64_R_SYM(rel->r_info)];
#else
ElfW(Sym) *sym = &symtab[ELF32_R_SYM(rel->r_info)];
#endif
const char *name = strtab + sym->st_name;
ElfW(Addr) *slot = (ElfW(Addr) *)(info->dlpi_addr + rel->r_offset);
init_real(); /* 先拿到原函数,避免递归 */
if (strcmp(name, "malloc") == 0)
patch_got(slot, (ElfW(Addr))(uintptr_t)&my_malloc);
else if (strcmp(name, "free") == 0)
patch_got(slot, (ElfW(Addr))(uintptr_t)&my_free);
}
return 0;
}
__attribute__((constructor))
static void install(void) {
init_real();
dl_iterate_phdr(hook_one, (void *)"libexample.so");
}
自动枚举所有模块
模块枚举本身见 Native Crash 篇的「读模块映射」,本进程用 dl_iterate_phdr,跨进程读 /proc/<pid>/maps。对 hook 来说只多一个要求:枚举出来的是快照,之后 dlopen 进来的模块要靠加载事件补上。
处理运行时加载
增量更新要 hook 加载入口,加载完成之后再对新模块做 GOT 扫描:
static void *(*real_dlopen)(const char *, int);
static int (*real_dlclose)(void *);
static void *my_dlopen(const char *name, int flags) {
void *h = real_dlopen(name, flags);
if (h && name) hook_new_module(name); /* 模块已就位,补扫 GOT */
return h;
}
static int my_dlclose(void *h) {
unhook_module(h); /* 先恢复 GOT,再真正卸载 */
return real_dlclose(h);
}
Android 上实际入口是 android_dlopen_ext,dlopen 只是 bionic 的薄包装,所以两个都 hook 更稳。android_dlopen_ext 比 dlopen 多一个 android_dlextinfo * 参数,可以指定加载地址、直接用 fd 加载、指定 linker namespace(NDK 文档):
void *android_dlopen_ext(const char *filename, int flags,
const android_dlextinfo *extinfo);
几条实践约束:
- 不要在
dlopen返回前解析新模块,此时重定位可能还没做完; dlclose的返回值不代表模块真的卸载了,引用计数可能没归零;- hook 库自己也会调
dlopen,要防递归; - Android 上如果不想自己 patch
dlopen,可以用 ShadowHook 的shadowhook_register_dl_init_callback(),模块加载完成直接回调,绕过 linker namespace 与__loader_dlopen这些坑。
限制
PLT hook 的边界很清楚,超过这个边界它一律看不见:
- 只拦截导入函数。模块内部自己实现的
my_malloc、静态链接进来的函数都拦不到; - 只拦截走 PLT 的调用。编译器内联、
-fno-plt、直接算绝对地址的调用都绕过; - 每个模块要单独 hook。
liba.so和libb.so各有一份 GOT; - Full RELRO 之后 GOT 只读,改的时候要
mprotect,改完恢复; - 对
mmap、VirtualAlloc、自定义内存池完全无效。
运行期 inline hook
流程
inline hook 改的是函数入口的机器码。执行流变成三次跳转:
flowchart LR
A["调用原函数"] --> B["入口已被改成 jmp<br/>跳到 hook 函数"]
B --> C["hook 函数执行自己的逻辑"]
C --> D["调用 trampoline"]
D --> E["执行被覆盖的原始指令"]
E --> F["跳回原函数剩余部分"]
具体步骤:
- 拿到目标函数入口地址(
dlsym、符号表、特征码都可以); - 把
.text所在页改成可写(mprotect或VirtualProtect); - 备份入口处会被覆盖的原始指令;
- 写入跳到 hook 函数的指令;
- 把备份的指令搬到一块可执行内存(trampoline),末尾补一条跳回原函数剩余部分的跳转;
- 恢复页权限,刷新指令缓存。
跳转指令怎么选
指令集不同,写法差很多:
| 平台 | 跳转方式 | 长度 | 说明 |
|---|---|---|---|
| x86 | E9 rel32 |
5 字节 | 相对跳转,偏移 = 目标 − 当前 − 5 |
| x64 | MOV RAX, imm64 + JMP RAX |
12 字节 | 地址空间太大,5 字节相对跳转不够用 |
| ARM64 | B target |
4 字节 | 只在 ±128 MB 内有效 |
| ARM64 | ADRP X16, page + BR X16 |
8 字节 | ±4 GB |
| ARM64 | LDR X16, #8 + BR X16 + 8 字节地址 |
16 字节 | 任意地址,最通用 |
ARM64 还要额外注意:不能直接读写 PC、跳转前要保存寄存器、trampoline 里要恢复被覆盖的原始指令、高版本要处理 PAC 与 BTI、LR/X30 可能被框架重写。
最小示例
以 ARM64 为例,只演示原理,不做指令重定位:
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
/* LDR X16, #8 / BR X16 / .quad target,共 16 字节 */
static void write_jump(void *dst, void *target) {
uint32_t *code = (uint32_t *)dst;
uint64_t addr = (uint64_t)(uintptr_t)target;
code[0] = 0x58000050; /* LDR X16, #8 */
code[1] = 0xD61F0200; /* BR X16 */
memcpy(&code[2], &addr, sizeof(addr));
}
typedef int (*add_fn)(int, int);
static add_fn orig_add;
int hooked_add(int a, int b) {
return orig_add(a, b);
}
static int install(void *target, void *hook, add_fn *out_orig) {
long ps = sysconf(_SC_PAGESIZE);
void *page = (void *)((uintptr_t)target & ~(uintptr_t)(ps - 1));
if (mprotect(page, (size_t)ps,
PROT_READ | PROT_WRITE | PROT_EXEC) != 0) return -1;
uint8_t backup[16];
memcpy(backup, target, sizeof(backup));
void *tramp = mmap(NULL, (size_t)ps, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (tramp == MAP_FAILED) return -1;
memcpy(tramp, backup, sizeof(backup)); /* 原始指令 */
write_jump((uint8_t *)tramp + sizeof(backup),
(uint8_t *)target + sizeof(backup)); /* 跳回去 */
write_jump(target, hook); /* 劫持入口 */
__builtin___clear_cache((char *)page, (char *)page + ps);
__builtin___clear_cache((char *)tramp, (char *)tramp + ps);
*out_orig = (add_fn)tramp;
return 0;
}
这份代码只适合理解原理,直接上生产会踩穿几个坑:
- 没有做指令边界分析,固定覆盖 16 字节可能切断一条指令;
- 没有重定位 PC 相对指令(
ADR、ADRP、B、BL),搬走后目标地址会错; - 没有保存寄存器,
X16被跳转模板占用; - 安装瞬间没有暂停其他线程,可能正好有线程在执行被改写的指令;
- 函数体不足 16 字节时无处可改。
生产环境基本都用成熟框架:Android 侧是 ShadowHook、Dobby、SandHook,通用调试用 Frida。它们内部处理了指令解码、寄存器保存、trampoline 生成与线程安全。
进程内存统计
同一份 VMA 信息,Linux 与 Android 上有三个粒度(Apple 与 Windows 的对应物见平台章节):
flowchart LR
A["maps<br/>段地址 权限 文件"] --> B["smaps<br/>每段追加 Rss Pss Private_Dirty"]
B --> C["smaps_rollup<br/>所有段求和成一行"]
C --> D["进程级 PSS / USS"]
从下往上读得更细,代价也更高:smaps_rollup 几十毫秒,smaps 几百毫秒到几秒,maps 最便宜但只有布局。
/proc/<pid>/maps
只给布局,不给占用:
7f1234000000-7f1234001000 r-xp 00000000 08:01 12345 /lib/libc.so
7f1234001000-7f1234002000 r--p 00001000 08:01 12345 /lib/libc.so
7f1234002000-7f1234003000 rw-p 00002000 08:01 12345 /lib/libc.so
每行是「起止地址、权限、文件内偏移、设备号、inode、路径」。它能回答「有哪些段、哪段可执行、映射了哪个文件」,但没有 RSS / PSS,判断不了物理占用。
/proc/<pid>/smaps
在 maps 的每段后追加统计字段:
7f1234000000-7f1234001000 r-xp 00000000 08:01 12345 /lib/libc.so
Size: 4 kB
Rss: 4 kB
Pss: 1 kB
Shared_Clean: 4 kB
Private_Dirty: 0 kB
Anonymous: 0 kB
Swap: 0 kB
关键字段:
| 字段 | 含义 |
|---|---|
Rss |
该段驻留的物理页,共享页重复计算 |
Pss |
共享页按映射进程数分摊后的值 |
Private_Dirty |
私有且被写过的页,杀进程能真正回收 |
Shared_Clean / Shared_Dirty |
与其他进程共享的干净 / 脏页 |
Anonymous |
不来自文件的匿名内存 |
Swap |
被换出的页 |
它能回答「哪一段在涨」,但一个 Java 进程可能有几百上千个 VMA,读一遍要几百毫秒到几秒。
/proc/<pid>/smaps_rollup
把所有段求和成一个 [rollup] 条目:
00100000-ff709000 ---p 00000000 00:00 0 [rollup]
Rss: 884 kB
Pss: 385 kB
Pss_Anon: 301 kB
Pss_File: 80 kB
Pss_Shmem: 4 kB
Private_Dirty: 68 kB
省掉用户态遍历,通常在几十毫秒量级,适合高频采集。
怎么选
| 需求 | 用哪个 |
|---|---|
| 看段布局、权限、映射文件 | maps |
| 定位哪个段在泄漏 | smaps |
| 高频监控进程总 PSS / USS | smaps_rollup |
| 按 Java / Native / Graphics / Code 分类 | dumpsys meminfo |
几个指标的口径要分清:
- PSS:私有页 + 共享页按比例分摊,评估进程真实占用用它;
- USS:完全独占,杀进程后能真正回收的量,看泄漏趋势最直观;
- RSS:含全部共享库,多个进程会重复计算;
- Private Dirty:最接近「这个进程真正占的物理内存」。
程序里读这几个数,smaps_rollup 最简单,按行找 Pss 即可:
/* 读 /proc/self/smaps_rollup,取出某个字段的 kB 值 */
static long read_rollup_kb(const char* field) {
FILE* f = fopen("/proc/self/smaps_rollup", "r");
if (!f) return -1;
char line[256];
long kb = -1;
size_t n = strlen(field);
while (fgets(line, sizeof line, f)) {
if (strncmp(line, field, n) != 0 || line[n] != ':') continue;
kb = strtol(line + n + 1, NULL, 10); /* 单位是 kB */
break;
}
fclose(f);
return kb;
}
/* 用法:long pss = read_rollup_kb("Pss"); */
smaps 要逐段解析,按 ^[0-9a-f]+-[0-9a-f]+ 判断新段开始,再把其后的字段累加到对应段上,成本与段数量成正比。
Android 的特殊情况
分配器换了三代
| 版本 | 分配器 | 特点 |
|---|---|---|
| Android 5.0 之前 | dlmalloc | chunk 头 + 空闲链表,元数据全在堆内 |
| Android 5.0 到 10 | jemalloc | arena / extent / run / size class 分层 |
| Android 11 起 | scudo | 紧凑 chunk 头带校验,状态分 allocated / available / quarantined |
这直接影响所有依赖元数据的工具,同一份逻辑在三个版本上要三套解析。scudo 的 quarantined 状态尤其要处理,否则延迟释放的块会被误判成泄漏。
系统工具:dumpsys meminfo
它由 system_server 侧的 AMS 聚合三路数据:/proc/<pid>/smaps 的逐段统计、ART 的堆信息、memtrack HAL 的图形内存。
flowchart TD
A["dumpsys meminfo 包名"] --> B["Binder 到 AMS 的 MemBinder"]
B --> C["dumpApplicationMemoryUsage"]
C --> D["Debug.getMemoryInfo<br/>读 smaps 算 PSS 与 RSS"]
C --> E["ART 堆信息<br/>Heap Size Alloc Free"]
C --> F["memtrack HAL<br/>图形与 GPU 内存"]
D --> G["按分类汇总输出"]
E --> G
F --> G
输出里的分类含义:
| 分类 | 内容 |
|---|---|
| Native Heap | C/C++、JNI、部分 Bitmap 的 native 内存 |
| Dalvik Heap | Java/Kotlin 对象堆 |
| Stack | 线程栈 |
| Ashmem | 匿名共享内存 |
| Graphics | 纹理、Surface 相关 |
| Code | .so、.dex、.oat、.apk 等代码映射 |
| Private / Shared | 私有与共享部分的 PSS |
看的时候重点在三处:PSS 总量是否在涨、Native Heap 与 Dalvik Heap 谁在涨、Activities / Views / AppContexts 数量是否回落(反复进出页面后不降,多半是泄漏)。
它的代价是一次调用要几百毫秒到十几秒,期间占着 system_server,不适合高频采集。高频监控用 smaps_rollup。
am dumpheap:两条完全不同的路径
flowchart TD
A["am dumpheap 包名 路径"] --> B["ActivityThread.dumpHeap"]
B --> C["Debug.dumpHprofData<br/>ART 导出 HPROF"]
D["am dumpheap -n 包名 路径"] --> E["Debug.dumpNativeHeap"]
E --> F["get_malloc_leak_info<br/>libc malloc_debug"]
F --> G{"已开启 malloc debug"}
G -->|"是"| H["导出 native 分配记录"]
G -->|"否"| I["返回空 生成几百字节空壳文件"]
| 维度 | 不加 -n |
加 -n |
|---|---|---|
| 目标 | Java/managed heap | native heap |
| 数据来源 | ART 虚拟机 | libc malloc_debug |
| 文件格式 | HPROF | native heap 文本或快照 |
| 权限 | 进程 debuggable 或系统可调试 | 在此之上还要求 malloc debug 已开启 |
am dumpheap 的参数只有三个有用的:
| 参数 | 作用 |
|---|---|
-n |
导出 native heap 而不是 managed heap |
-g |
dump 前先 GC,便于判断是否真泄漏 |
--user <id> |
指定用户,多用户或工作资料场景用 |
两种写法:
# Java 堆,普通 release 包也能用
adb shell am dumpheap <包名> /data/local/tmp/app.hprof
# native 堆,要求 debuggable 且 malloc debug 已开启
adb shell am dumpheap -n <pid> /data/local/tmp/native_heap.txt
它内部会做 debuggable 检查(ro.debuggable 或应用 FLAG_DEBUGGABLE 之一),不满足直接报 Process not debuggable。
这里有个实际踩过的坑:release 包上执行 am dumpheap -n,终端只打印 File: 和 Waiting for dump to finish...,没有 Done,拉下来的文件只有 170 字节。这不是命令写错,而是 native 路径没有数据源,malloc debug 没开时 get_malloc_leak_info 返回空。同一台机器上不加 -n 是能成功的,因为 Java 堆走 ART,不依赖 malloc debug。
HPROF 格式
不加 -n 拿到的 HPROF 是二进制索引文件。整体结构是「文件头 + 一串带 tag 的记录」,记录之间没有索引,必须顺序解析:
HPROF 文件
├── 文件头
│ ├── "JAVA PROFILE 1.0.3\0" 魔数与版本号,以 \0 结尾
│ ├── u4 id_size 每个 id 字段的宽度,4 或 8 字节
│ └── u8 timestamp 生成时间,毫秒
│
├── 记录(顺序排列,没有索引,只能从头解析)
│ ├── u1 tag 记录类型,决定 body 怎么解释
│ ├── u4 time 该记录的毫秒时间戳
│ ├── u4 length body 的字节数
│ └── body 按 tag 解释的负载
│
├── 0x01 STRING_IN_UTF8
│ ├── id string_id 字符串在本文件里的编号
│ └── UTF-8 字节 长度 = length − id_size
│
├── 0x02 LOAD_CLASS
│ ├── u4 class_serial 类的序号,与对象 id 是两套编号
│ ├── id class_obj_id 类对应的对象 id
│ └── id class_name_string_id 指向 STRING_IN_UTF8 里的类名
│
├── 0x05 STACK_TRACE(记录分配栈时需要)
│ ├── u4 trace_serial 栈编号,INSTANCE_DUMP 用它引用
│ ├── u4 thread_serial 线程编号
│ ├── u4 num_frames 帧数
│ └── id frame_id × num_frames 每帧指向 STACK_FRAME,自栈顶到栈底
│
├── 0x0C HEAP_DUMP(堆快照,子记录都挂在这里)
│ ├── 0x20 CLASS_DUMP
│ │ ├── id class_obj_id 类的对象 id
│ │ ├── u4 stack_trace_serial 分配该类的栈编号
│ │ ├── id super_obj_id 父类的对象 id
│ │ ├── id class_loader_id 类加载器对象 id
│ │ ├── id signers_obj_id 签名信息对象 id
│ │ ├── id protection_domain_id 保护域对象 id
│ │ ├── id reserved1 保留字段,恒为 0
│ │ ├── id reserved2 保留字段,恒为 0
│ │ ├── u4 instance_size 实例占用字节数
│ │ ├── u2 cp_count 常量池项数
│ │ │ └── (u2 index, u1 type, u1 len, name) × cp_count 常量池项
│ │ ├── u2 static_count 静态字段数
│ │ │ └── (id name, u1 type, value) × static_count 静态字段名与初值
│ │ └── u2 instance_count 实例字段数
│ │ └── (id name, u1 type) × instance_count 实例字段名与类型
│ ├── 0x21 INSTANCE_DUMP
│ │ ├── id obj_id 对象 id
│ │ ├── u4 stack_trace_serial 分配该对象的栈编号
│ │ ├── id class_obj_id 所属类的对象 id
│ │ ├── u4 num_bytes 实例字段占用字节数
│ │ └── 字段值 按 CLASS_DUMP 里的字段顺序排列
│ ├── 0x22 OBJECT_ARRAY_DUMP
│ │ ├── id array_obj_id 数组对象 id
│ │ ├── u4 stack_trace_serial 分配该数组的栈编号
│ │ ├── u4 count 元素个数
│ │ ├── id array_class_id 数组类的对象 id
│ │ └── id × count 每个元素指向的对象 id
│ ├── 0x23 PRIMITIVE_ARRAY_DUMP
│ │ ├── id array_obj_id 数组对象 id
│ │ ├── u4 stack_trace_serial 分配该数组的栈编号
│ │ ├── u4 count 元素个数
│ │ ├── u1 type 元素类型(boolean / char / int …)
│ │ └── value × count 按类型宽度排列的元素值
│ ├── 0xFE HEAP_DUMP_INFO Android 扩展,标明这段属于哪个堆
│ │ ├── u4 heap_id 堆编号(app / zygote / image 等)
│ │ └── id heap_name_string_id 堆名字的字符串 id
│ └── GC 根
│ ├── 0xFF ROOT_UNKNOWN id obj 来源未知的根
│ ├── 0x01 ROOT_JNI_GLOBAL id obj / id jni_ref JNI 全局引用
│ ├── 0x02 ROOT_JNI_LOCAL id obj / u4 thread / u4 depth JNI 局部引用
│ ├── 0x03 ROOT_JAVA_FRAME id obj / u4 thread / u4 depth Java 栈帧局部变量
│ ├── 0x04 ROOT_NATIVE_STACK id obj / u4 thread native 栈上的引用
│ ├── 0x05 ROOT_STICKY_CLASS id obj 系统类,不会被卸载
│ ├── 0x06 ROOT_THREAD_BLOCK id obj / u4 thread 线程对象引用的块
│ ├── 0x07 ROOT_MONITOR_USED id obj 被 monitor 持有的对象
│ └── 0x08 ROOT_THREAD_OBJECT id obj / u4 thread / u4 depth 线程对象本身
│
└── 0x1C HEAP_DUMP_SEGMENT / 0x2C HEAP_DUMP_END
大堆会拆成多个 SEGMENT,最后一个 END 收尾
record 的 tag 决定 body 怎么解释,而 id_size 决定所有 id 字段读 4 字节还是 8 字节——这两处读错,后面的解析就全乱了。
这几类记录对应的含义:
| Record | 存什么 |
|---|---|
STRING_IN_UTF8 |
类名、字段名、方法名 |
LOAD_CLASS |
class id 与名字的关联 |
CLASS_DUMP |
类的结构与字段表 |
INSTANCE_DUMP |
对象实例的字段值 |
OBJECT_ARRAY_DUMP / PRIMITIVE_ARRAY_DUMP |
数组内容 |
GC_ROOT |
GC 根的类别与对象 id |
HEAP_DUMP_END |
快照结束标记 |
MAT 打开后做的是图算法:解析 record 建对象表,从 GC 根出发建引用图,算支配树,再算 retained size。所以它给的是「谁在持有谁」,而不是「谁 malloc 了多少」。
三个常用的尺寸指标:
| 指标 | 含义 | 用途 |
|---|---|---|
| Shallow Size | 对象自身在 Java 堆里的大小 | 看这个类实例多不多、自身重不重 |
| Native Size | 对象关联的 native 内存(Bitmap 像素、DirectByteBuffer) | Java 堆不大但 Native Size 很大时要警惕 |
| Retained Size | 这个对象被回收后能一起释放的总量 | 定位泄漏最该看这个 |
MAT 里通常按 Retained Size 排序,再对大户看 incoming / outgoing references,一路追溯到 GC 根。Native Size 在 Android Studio Profiler 里从 Android 7.0 起才有,MAT 里不一定有这一列。
它做的是图算法,不是简单的对象计数:
flowchart TD
A["解析 HPROF 的 record"] --> B["建对象表<br/>id 类 字段值"]
B --> C["从 GC_ROOT 取根节点"]
C --> D["按字段引用建引用图"]
D --> E["算支配树"]
E --> F["retained size 等于自身加被支配对象"]
F --> G["Leak Suspects 回溯到 GC 根"]
泄漏对象在图上通常表现为两种情况:retained size 异常大,或者本该被回收却被静态字段、单例、监听器这类 GC 根一路引用着。
malloc debug
bionic 的 libc_malloc_debug.so 是一层 shim。进程初始化时,__libc_init_malloc 检查 libc.debug.malloc.options 或环境变量 LIBC_DEBUG_MALLOC_OPTIONS,命中就 dlopen 这个 so,把 malloc / free / calloc / realloc / memalign / posix_memalign / malloc_usable_size 替换成带调试逻辑的版本(README)。
flowchart LR
A["应用调用 malloc"] --> B["libc_malloc_debug shim<br/>记录地址 大小 调用栈"]
B --> C["分配器真实分配"]
D["am dumpheap -n"] --> E["get_malloc_leak_info"]
E --> F["导出存活记录"]
F --> G["native_heapdump_viewer.py"]
B -.-> E
之后每次分配/释放都经过这层 shim:
- 分配时记录地址、大小、调用栈、时间戳,按需加 guard 区或填充字节;
- 释放时校验 guard、更新追踪表,或延迟释放、填充已释放内存;
- dump 时把存活记录导成 native heap 文本,或通过 logcat 输出。
选项比较全:
| 选项 | 作用 | 开销 |
|---|---|---|
backtrace[=N] |
记录分配调用栈,默认 16 帧,最大 256 帧 | 中 |
leak_track |
进程退出时输出未释放块到 logcat | 高 |
front_guard[=SIZE] / rear_guard[=SIZE] |
分配前后加保护区,抓越界写,默认 32 字节 | 低 |
free_track |
延迟释放并记录释放栈,抓 UAF | 高 |
fill_on_alloc / fill_on_free |
分配填 0xeb、释放填 0xef,辅助发现野指针 |
低 |
abort_on_error |
检测到错误立即 abort | 无 |
backtrace_dump_on_exit |
退出时自动 dump | 中 |
backtrace_dump_prefix |
指定 dump 路径前缀 | 无 |
两种开启方式:
- 系统级:
setprop libc.debug.malloc.options backtrace,重启系统或 zygote 后对所有进程生效,需要 root 或 userdebug / eng 设备; - 应用级:debuggable APK 里放
wrap.sh,只影响当前应用进程。
#!/system/bin/sh
export LIBC_DEBUG_MALLOC_OPTIONS=backtrace,leak_track
exec "$@"
wrap.sh 的硬性要求:
- 放在每个架构目录下,如
lib/arm64-v8a/wrap.sh,架构必须与 so 对上; - 文件必须是 LF 换行,Windows 下写成 CRLF 会启动失败;
- 权限 755;
- APK 需要
android:extractNativeLibs="true"(Gradle 侧对应useLegacyPackaging = true),否则打包压缩后脚本取不出来; - 应用必须 debuggable。
它本质就是一个 shell 脚本,能配的东西是环境变量,同一个脚本位置常被不同工具复用:
| 环境变量 | 用途 |
|---|---|
LIBC_DEBUG_MALLOC_OPTIONS |
malloc debug 选项串,逗号分隔,如 backtrace,leak_track、front_guard=32 |
ASAN_OPTIONS |
ASan 行为,如 log_to_syslog=false,allow_user_segv_handler=1 |
HWASAN_OPTIONS |
HWASan 的同名配置 |
LD_PRELOAD |
注入运行时库,如 libclang_rt.asan-*-android.so,也可挂自研 hook 库 |
LD_LIBRARY_PATH |
指定依赖库搜索路径 |
#!/system/bin/sh
HERE="$(cd "$(dirname "$0")" && pwd)"
export LIBC_DEBUG_MALLOC_OPTIONS=backtrace,leak_track
export LD_PRELOAD="$HERE/libmyhook.so"
exec "$@"
注意 exec "$@" 必须保留,它才是真正启动应用进程的那一步,前面都是注入准备。
这个文件本身只是一个「应用启动前的注入脚本」,同一个位置也能用来注入 ASan 运行时库,两者常被混着问,但解决的问题完全不同:
| 工具 | 查什么 | 是否重编 so | 是否要 root |
|---|---|---|---|
| malloc debug | native 泄漏、分配栈 | 不需要 | 不需要,debuggable 即可 |
| ASan / HWASan | 越界、UAF、double free | 需要重新编译插桩 | 不需要,debuggable 即可 |
am dumpheap -n |
导出 native 堆 | 不需要 | 通常要 root 或 userdebug |
dump 出来的文件是地址格式,不能直接读,要用 AOSP 的 native_heapdump_viewer.py 转换:
python3 native_heapdump_viewer.py --symbols ./symbols/ native_heap.txt --html > report.html
报告按调用栈聚合,形如「1024000 bytes (512 allocations) in 1 unique backtraces」,下面跟着几层栈帧。没有 --symbols 时只有地址。
顺便说一个常被误用的头文件:<android/mallochook.h> 是 bionic 内部暴露 malloc hook 接口的头文件,不属于 NDK 公开 API,不同版本可能改名或删改,应用层不要直接 include。要用调试能力走 wrap.sh 或 heapprofd,要研究原理去看 bionic/libc/malloc_debug/。
libmemunreachable
Android 自带的零开销泄漏检测器(源码),走的是审计式路线:向分配器要活跃块清单,以栈和全局区为 Roots 做可达性扫描。
它的难点不在 mark-and-sweep,而在「怎么向分配器要到活跃块清单」。Android 三代分配器的枚举方式各不相同:
flowchart TD
A["触发泄漏检测"] --> B["暂停进程<br/>收集栈 寄存器 maps"]
B --> C{"当前分配器"}
C -->|"dlmalloc"| D["遍历堆内 chunk 链表<br/>按状态判断 allocated"]
C -->|"jemalloc"| E["遍历 arena / extent / run<br/>拿到起始地址与大小"]
C -->|"scudo"| F["解包 chunk 头<br/>校验 checksum 与状态位"]
D --> G["活跃块清单"]
E --> G
F --> G
G --> H["以栈与全局区为 Roots<br/>做 mark-and-sweep"]
H --> I["输出活跃但不可达的块"]
| 分配器 | 枚举方式 | 难点 |
|---|---|---|
| dlmalloc | 遍历 chunk 链表,解析 header / foot | 元数据全在堆内,布局传统但易被写坏 |
| jemalloc | 通过内部接口枚举 extent / run | 分层多,要找到 extent 元数据位置 |
| scudo | 解包紧凑 chunk 头,结合 size class region | 头带校验、状态含 quarantined、Primary 与 Secondary 两条路径 |
scudo 是最麻烦的一个,原因有四点:
- chunk 头紧凑且带校验和,不能按固定偏移猜大小,必须先验证再解析;
- 块状态不止「已分配 / 空闲」,还有
quarantined(延迟释放),必须排除,否则隔离区会被当成泄漏; - Primary(按 size class 管理的小块)与 Secondary(
mmap的大块)走不同路径; - 用户指针不一定等于块起始地址,头里有偏移需要还原。
被 UAF 改写 header 时,scudo 通常直接报 corrupted chunk header 或 invalid chunk state 并终止进程,属于「元数据被写坏」里最激烈的那种,不会安静地继续扫描。
触发方式:
adb shell dumpsys -t 600 meminfo --unreachable <包名或 pid>
C 接口也提供了 GetUnreachableMemory() 与 NoLeaks(),可以嵌到自己工具里。
输出默认只有泄漏块地址、近似大小、前 32 字节内容。要看调用栈必须同时开 malloc debug 的 backtrace,也就失去了零开销这个优点。另外它是不精确扫描,栈上残留的整数、恰好长得像指针的值都可能让泄漏块看起来可达,所以有漏报。
元数据被写坏时
前面说过,UAF、double free、堆溢出会在分配器元数据上动手脚。这时审计结果会出现三种情况:
- 漏报:被破坏的块从活跃链表里消失;
- 误报:块边界算错,把别的块当成泄漏;
- 直接失败:分配器自己发现 header 校验不通过,进程被 abort。
第三种常见于带校验和的分配器,被改写后直接终止进程,不会「安静地继续扫描」。
所以定位顺序上,堆破坏要先于泄漏排查。有 UAF 的进程,泄漏报告不可信,它不是被「误判」这么简单,而是底层元数据已经坏了。
通用化的约束
把这个思路搬到别的分配器或别的平台,会撞到四个约束:
- 元数据不是稳定 ABI,分配器升级就可能失效;
- 跨平台权限不同,读内存映射与栈的方式各平台不一样;
- 不精确扫描必然有误报,栈上残留值、整数巧合、自定义池都会让泄漏块看起来可达;
- 自定义分配器破坏假设,游戏引擎、数据库、浏览器常常自己管内存。
heapprofd:perfetto 的采样剖析
android.heapprofd 是 perfetto 的堆剖析数据源(整套代码在 google/perfetto 仓库),做的是「拦截分配 → 按概率抽样 → 回溯调用栈 → 共享内存 → 落盘 → 符号化」这条链路:注入 client、拦截分配、按间隔采样、libunwindstack 回溯(走法见 Native Crash 篇的「栈回溯」)、共享内存交给 traced。
flowchart LR
A["目标进程<br/>heapprofd client"] --> B["拦截 malloc / free"]
B --> C{"命中采样"}
C -->|"是"| D["libunwindstack 回溯"]
D --> E["写入共享内存 ring buffer"]
E --> F["traced 取走并落盘"]
F --> G[".pftrace"]
G --> H["trace_processor 与 UI 符号化"]
C -->|"否"| I["直接返回"]
它的采样实现是泊松到达(采样设计文档):
- 维护「下一个采样点」的字节计数,分配时减去本次大小;
- 计数跨过 0 就抽一次样本,并对同一块重复抽直到计数回到正数;
- 分配大到「至少命中一次的概率超过 99%」时,直接记真实大小,不再走采样。
Android 12 之前的分界点是采样间隔本身:超过间隔的分配直接记真实大小,导致「刚好小于间隔」与「刚好大于间隔」的覆盖率不连续,之后才改成按 99% 概率这个阈值切。
一份最小配置:
buffers { size_kb: 65536 }
data_sources {
config {
name: "android.heapprofd"
heapprofd_config {
sampling_interval_bytes: 4096
process_cmdline: "com.example.app"
shmem_size_bytes: 8388608
block_client: true
heaps: "libc.malloc"
continuous_dump_config { dump_phase_ms: 1000 dump_interval_ms: 1000 }
}
}
}
抓取与查看:
cat native_mem.pbtx | adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/trace.pftrace
adb pull /data/misc/perfetto-traces/trace.pftrace .
也可以直接用 perfetto 仓库的 tools/heap_profile 脚本,或者在 ui.perfetto.dev 里选 Native heap profiling 录制。
录制脚本
除了手写 textproto,perfetto 仓库还提供一个 Python 脚本 tools/heap_profile(仓库内路径),把上面的配置翻译成命令行参数:
# Android 设备,按进程名抓 30 秒,采样间隔 4 KB
tools/heap_profile -n com.example.app -d 30000 -i 4096 -o ./out
# 按 pid 抓,并每 5 秒 dump 一次
tools/heap_profile -p 12345 -c 5000 -d 60000 -o ./out
| 参数 | 作用 |
|---|---|
-n / -p |
目标进程名(可逗号分隔)/ 目标 pid |
-i |
采样间隔,默认 4096 |
-d |
采集时长(毫秒),0 表示直到手动中断 |
-c |
连续 dump 的间隔(毫秒),对应 continuous_dump_config |
--heaps / --all-heaps |
指定堆或采集所有堆,Android 12 起 |
--shmem-size |
共享内存大小,默认 8 MiB |
--block-client / --no-block-client |
缓冲满时阻塞或提前结束 |
--dump-at-max |
只在内存峰值处 dump |
--no-startup / --no-running |
只采已运行进程 / 只采启动中的进程 |
--print-config |
只打印生成的 textproto,不真正采集 |
-o |
输出目录 |
它有两个子命令:默认 android(通过 adb 驱动设备上的 perfetto),以及 host(Linux 上用 libheapprofd_glibc_preload.so 做 LD_PRELOAD 注入,只能跑 Linux)。产物是 raw-trace 加 traceconv profile 转出的 pprof,同样可以拖进 ui.perfetto.dev。
--print-config 这个参数值得单独记:它把脚本参数原样翻译成上一节那种 textproto,用它对照着看最省事。
需要注意,这是主机侧工具,不在 NDK 里:NDK 既没有这个脚本,也没有等价的「在应用内发起 heapprofd 采集」的 C/C++ 接口。要在设备上采集,要么用 adb shell perfetto,要么在应用里走 ProfilingManager。
trace 格式
.pftrace / .perfetto 只是后缀,内容是一串序列化的 TracePacket。堆剖析关心的字段大致是这样:
Trace(一串序列化的 TracePacket,按 trusted_packet_sequence_id 分组)
├── TracePacket
│ ├── timestamp 该 packet 的采样时间
│ ├── timestamp_clock_id timestamp 用的时钟域
│ ├── trusted_packet_sequence_id 序列号,增量编码与包序检测的基准
│ ├── interned_data 去重表,跨 packet 共用
│ │ ├── mapping
│ │ │ ├── iid 去重键,Frame.mapping_id 指向它
│ │ │ ├── build_id 模块标识,ELF 上是 build-id
│ │ │ ├── start 映射起始虚拟地址
│ │ │ ├── end 映射结束虚拟地址
│ │ │ ├── start_offset 该 so 第一个映射对应的文件偏移
│ │ │ ├── exact_offset 本段映射对应的文件偏移
│ │ │ ├── load_bias p_vaddr − p_offset,离线重符号化用
│ │ │ └── path_string_ids so 路径的字符串 id 序列
│ │ ├── frame
│ │ │ ├── iid 去重键,Callstack.frame_ids 指向它
│ │ │ ├── mapping_id 帧所属映射,0 表示无地址信息
│ │ │ ├── rel_pc 相对映射起点的 PC
│ │ │ ├── function_name_id 函数名,在线符号化成功才有
│ │ │ ├── source_path_iid 源文件路径,同上
│ │ │ └── line_number 源码行号,同上
│ │ └── callstack
│ │ ├── iid 去重键,HeapSample.callstack_id 指向它
│ │ └── frame_ids 帧 iid 序列,自栈底到栈顶
│ └── profile_packet heapprofd 产出的堆快照
│ ├── continued 为 true 表示快照未完,后续 packet 续上
│ ├── index 该序列内的序号,用来发现丢包
│ └── process_dumps[] 每个进程一份
│ ├── pid 被剖析的进程
│ ├── heap_name 堆名,如 libc.malloc
│ ├── sampling_interval_bytes 本次会话实际使用的采样间隔
│ ├── from_startup 是否从进程启动就开始采
│ ├── disconnected 采集期间进程断开
│ ├── client_error 断开原因(超时 / 栈越界)
│ ├── hit_guardrail 触发了 heapprofd 的资源上限
│ ├── buffer_overran 共享内存缓冲溢出
│ ├── timestamp dump 覆盖区间的结束时间
│ ├── start_timestamp dump 覆盖区间的开始时间
│ ├── stats heapprofd 自身的统计
│ │ ├── heap_samples 采样到的分配次数
│ │ ├── unwinding_errors 回溯失败次数
│ │ ├── map_reparses maps 重新解析次数
│ │ └── unwinding_time_us 回溯耗时的直方图
│ └── samples[](HeapSample) 每个调用栈一条
│ ├── callstack_id 指向 interned_data.callstack
│ ├── self_allocated 该调用栈累计分配的字节数
│ ├── self_freed 其中已释放的字节数
│ ├── alloc_count 采样到的分配次数
│ ├── free_count 采样到的释放次数
│ ├── self_max 峰值时未释放的字节数(dump_at_max)
│ └── self_max_count 峰值时未释放的块数(dump_at_max)
│
├── TraceConfig 录制参数,不进 trace 文件
└── 符号化 分析端做,多数帧只有 mapping_id 与 rel_pc
字段名与编号可以对照 profile_packet.proto 和 profile_common.proto。几个值得记的点:
interned_data是全局的,多个进程、多个 packet 共用同一份mapping/frame,所以大 trace 里重复的 so 路径和函数名只出现一次;frame里mapping_id与rel_pc是成对的,function_name_id只有在线符号化成功时才有,离线分析就靠mapping_id → build_id去找符号文件;continued = true表示快照还没完,要把同一序列后续 packet 的样本拼起来才是完整快照;ProcessHeapSamples里的client_error/buffer_overran/hit_guardrail直接告诉你这次采集有没有丢数据。
关键配置项分两层:trace 级的通用字段,以及 heapprofd_config 里的数据源字段(proto 定义)。
trace 级:
| 字段 | 作用 |
|---|---|
buffers { size_kb } |
环形缓冲大小,堆剖析一般给 32 到 64 MB |
buffers { fill_policy } |
缓冲满后的策略,DISCARD 丢新数据,RING_BUFFER 覆盖旧数据 |
data_sources { config { name } } |
数据源名,堆剖析是 android.heapprofd |
duration_ms |
采集时长,到点自动停 |
write_into_file + output_path |
直接写文件,配合 file_write_period_ms、max_file_size_bytes 控制落盘节奏与上限 |
heapprofd_config 级:
| 字段 | 作用 | 注意 |
|---|---|---|
process_cmdline |
目标进程名,可给多个 | 匹配前会规范化:去掉最后一个 / 之前的部分、去掉 @ 之后的部分;v57 起支持 * 通配,但必须配 no_startup |
pid |
按 pid 指定目标 | 用于本地调试或按阈值触发 |
sampling_interval_bytes |
采样间隔 | 必须显式设非 0,否则 producer 不启动;Android 12 之前设 0 会崩目标进程 |
heaps / all_heaps / exclude_heaps |
采哪些堆 | 空 heaps 时只采 libc.malloc;all_heaps 与 exclude_heaps 是 Android 12 起的字段 |
heap_sampling_intervals |
按堆分别设间隔 | 长度必须与 heaps 一致,且每项非 0 |
shmem_size_bytes |
共享内存大小 | 默认 8 MiB,要求 ≥ 8192、2 的幂、4096 的倍数,上限 500 MiB |
block_client / block_client_timeout_us |
缓冲满时阻塞目标而不是结束会话 | 会明显拖慢目标,timeout 需大于 100 微秒 |
no_startup / no_running |
只采已运行进程 / 只采启动中的进程 | 两者互斥 |
continuous_dump_config { dump_phase_ms, dump_interval_ms } |
周期性 dump | 首次延迟与间隔 |
dump_at_max |
只在内存峰值处 dump 一次 | 结果里 self_max 有值,self_allocated / self_freed 不填 |
min_anonymous_memory_kb |
匿名内存低于该值的进程不采 | Android 11 起 |
max_heapprofd_memory_kb / max_heapprofd_cpu_secs |
heapprofd 自身的内存 / CPU 上限 | 超限自动停 |
skip_symbol_prefix |
指定前缀的 mapping 不输出函数名 | 比如跳过 /system |
target_installed_by |
只采指定来源安装的进程 | 可选 @system / @product / @null,Android 12 起 |
all |
采整机上所有可剖析进程 | userdebug 上会让 zygote 崩,不要随便开 |
adaptive_sampling_shmem_threshold / adaptive_sampling_max_sampling_interval_bytes |
共享内存剩余不足时自动放大采样间隔 | 设 0 关闭自适应 |
限制要提前知道:
- 只能分析 debuggable 或声明了
<profileable android:shell="true"/>的应用; - 只记录采样窗口内的分配,抓取前已经存在的大块不会出现;
- 没有符号表时调用栈只有地址;
- 高频分配场景可能丢数据,
block_client: true能减少丢失但会拖慢应用; /data/misc/perfetto-traces/在低版本上可能无法直接adb pull,可以用adb shell cat导出。
它不需要 wrap.sh,wrap.sh 主要用于给进程加启动参数(ASan、GWP-ASan、自定义 LD_PRELOAD)。
火焰图怎么读
Perfetto UI 里切到堆剖析视图后,火焰图的宽度对应的统计维度通常是「未释放字节数」或「累计分配字节数」,可以在界面里切换。
优先看这几种形态:
- 最宽的叶子节点:这个函数自己直接分配得多;
- 宽但子节点很窄的父节点:它本身就是主要分配点;
- 宽且子节点也宽:真正的大户在下层调用里,要继续往下钻;
- 连续快照里持续变宽的栈:比单次宽度更可疑,可能是泄漏。
几个容易误读的地方:
- 宽度是统计估算值,受采样间隔影响;
- 颜色没有固定含义,不要凭颜色判断热点;
- X 轴不是时间,而是按占比聚合后的宽度;
- Y 轴是调用栈深度,上层是调用者。
更稳的用法是先看「未释放内存」的时间曲线,确认内存确实在持续增长,再切火焰图找持续变宽的栈。
ProfilingManager
Android 15(API 35)起,系统提供了 ProfilingManager:应用自己发起 profiling 请求,结果落在应用私有目录,通过回调取回。它是 Java/Kotlin API,NDK 侧只能通过 JNI 调。
三个方法:
registerForAllProfilingResults(executor, listener):注册全局回调;unregisterForAllProfilingResults(listener):注销;requestProfiling(type, params, tag, cancellationSignal, executor, listener):发起一次请求。
支持的采集类型:
| 类型 | 用途 |
|---|---|
PROFILING_TYPE_JAVA_HEAP_DUMP |
Java 堆转储 |
PROFILING_TYPE_HEAP_PROFILE |
堆 profile,含 native heap |
PROFILING_TYPE_STACK_SAMPLING |
栈采样 |
PROFILING_TYPE_SYSTEM_TRACE |
系统 trace |
val pm = context.getSystemService(ProfilingManager::class.java)
pm.requestProfiling(
ProfilingManager.PROFILING_TYPE_HEAP_PROFILE,
Bundle().apply { putInt("sampling_interval_bytes", 65536) },
"oom-triggered",
null, executor
) { result ->
if (result.errorCode == ProfilingResult.ERROR_NONE) upload(result.resultFilePath)
}
后续版本又加了事件驱动触发器,可以注册 OOM 或内存异常事件,由系统在崩溃时自动采集,下次启动通过 registerForAllProfilingResults 取回文件。
限制:
- 只有 Android 15 及以上有;
- 有速率限制,不是每次请求都执行;
- 结果是脱敏的,只含本应用数据;
HEAP_PROFILE底层仍是系统 heapprofd,参数通过Bundle传,可用字段以官方文档为准;- NDK 没有对应接口,native 侧只能 JNI 转到 Java 层。
请求到结果的路径:
flowchart LR
A["应用调用 requestProfiling"] --> B["system_server 的 ProfilingManager"]
B --> C["按类型分派<br/>HEAP_PROFILE 走 heapprofd"]
C --> D["结果写入应用私有目录"]
D --> E["回调 listener 拿到路径"]
E --> F["应用自行上传"]
HarmonyOS
OpenHarmony / HarmonyOS 的内核仍是 Linux,/proc/<pid>/maps、smaps、smaps_rollup 这些接口照样可用,进程内存统计的口径与 Android 一致。差别在用户态:libc 是 musl,分配器是 musl 的 mallocng,不是 scudo 或 jemalloc,所以 Android 上依赖分配器元数据的工具(libmemunreachable、malloc debug)不能直接搬过来。
系统侧统计
设备连接用 hdc(对应 Android 的 adb),系统信息查询用 hidumper:
hdc shell hidumper --mem <pid> # 进程内存统计,PSS / RSS 与私有共享分类
hdc shell hidumper --mem # 整机内存统计
口径与 smaps 一致,可以当轻量查询用。可用参数以 hidumper -h 为准。
采样剖析
HarmonyOS 的 native 内存采集由三部分组成:
libnativehook:注入目标进程,hookmalloc/free,可选 frame pointer 回栈;native_daemon:与 hook 库通过共享内存交换数据;hiprofiler:采集框架,负责配置下发与落盘。
抓取参数由 DevEco Studio 的 Profiler(Native Memory 模板)配置,界面里可以直接生成命令,复制到 hdc shell 执行,产物是 .htrace,默认落在 /data/local/tmp,再导入 Profiler 分析。
命令行的参数与界面配置项一一对应:
| 参数 | 作用 |
|---|---|
process_name |
目标进程名 |
filter_size |
只抓大于该字节数的 malloc(默认 4096,free 不受影响) |
smb_pages |
native_daemon 与 libnativehook 之间的共享内存页数 |
max_stack_depth |
最大回栈深度 |
fp_unwind |
是否用 frame pointer 回栈 |
callframe_compress |
是否压缩栈帧 |
string_compressed |
是否压缩字符串 |
record_accurately |
是否上报全量、不做过滤 |
offline_symbolization |
是否离线符号化 |
statistics_interval |
统计模式下的上报间隔 |
startup_mode |
是否抓应用启动阶段 |
save_file |
是否只把 hook 数据存文件,不上报 protobuf |
采集覆盖的不止 malloc:运行时堆有 arktsHeap、jsHeap、kmpHeap、globalHandle、localHandle,匿名与文件映射有 mmap、dma、so、ashmem。分析界面按内存类型、线程、库、函数四个维度聚合,也能看火焰图与快照差值。
与 Android 的对照
| 维度 | Android | HarmonyOS |
|---|---|---|
| 数据源 | perfetto 的 android.heapprofd |
native_daemon + libnativehook |
| 配置方式 | textproto 或 tools/heap_profile 脚本 |
Profiler 界面生成命令行参数 |
| 产物 | .pftrace |
.htrace |
| 分析工具 | ui.perfetto.dev、trace_processor |
DevEco Studio Profiler、smartperf |
| 符号化 | 端上存 PC 与偏移,分析端还原 | 可选离线符号化 |
Apple 与 Windows
Apple
macOS 的默认分配器不是 ptmalloc,所以没有 mallinfo2()。对应的能力分散在几处:
| 需求 | 手段 | 说明 |
|---|---|---|
| 分配器统计 | malloc_zone_statistics() |
拿到 zone 的 allocated / free / reserved,最接近 mallinfo2 |
| 进程内存总量 | task_info、vm_stat、活动监视器 |
vm_stat 是系统级页统计 |
| 虚拟内存布局 | vmmap <pid> |
类似 /proc/pid/maps,能看堆、栈、mmap 段 |
| 堆分配与泄漏 | Instruments 的 Allocations / Leaks | 图形化,能看对象生命周期 |
| 拦截分配 | DYLD_INSERT_LIBRARIES 加 interposing |
前文讲过写法 |
程序里读分配器统计要用 zone 遍历:
#include <malloc/malloc.h>
malloc_statistics_t stats;
mach_port_t task = mach_task_self();
unsigned int count = 0;
vm_address_t* zones = NULL;
/* 拿到进程所有 malloc zone,再逐 zone 取统计 */
malloc_get_all_zones(task, NULL, &zones, &count);
for (unsigned int i = 0; i < count; ++i) {
malloc_zone_t* zone = (malloc_zone_t*)zones[i];
malloc_zone_statistics(zone, &stats);
/* stats.size_in_use / size_allocated / blocks_in_use */
}
早期还有直接改 malloc_zone 函数表的老做法,系统安全加固之后基本不可用。
Windows
| 需求 | 手段 | 说明 |
|---|---|---|
| 进程内存 | GetProcessMemoryInfo() |
返回 WorkingSetSize / PrivateUsage / CommitSize |
| 系统内存 | GlobalMemoryStatusEx() |
总物理内存、可用、页面文件 |
| 虚拟内存布局 | Sysinternals VMMap | 粒度接近 vmmap |
| 堆块分析 | WinDbg !heap |
列堆块、统计大小、找泄漏嫌疑 |
| 内核池 | poolmon |
驱动与内核池泄漏 |
| CRT 堆统计 | _heapwalk、_CrtMemCheckpoint |
只覆盖 MSVC CRT |
| 拦截分配 | IAT hook、Detours、重定向 DLL | 前文讲过 |
进程级统计有两行代码就够:
PROCESS_MEMORY_COUNTERS_EX pmc{};
if (GetProcessMemoryInfo(GetCurrentProcess(),
reinterpret_cast<PROCESS_MEMORY_COUNTERS*>(&pmc),
sizeof(pmc))) {
// pmc.WorkingSetSize / pmc.PrivateUsage / pmc.PagefileUsage
}
Windows 没有 mallinfo2() 的等价物。GetProcessMemoryInfo 对应的是「进程级统计」,!heap 对应的是「堆块级分析」,两者不在一个层次。
三平台对照
| 需求 | Android | Apple | Windows |
|---|---|---|---|
| 进程内存统计 | smaps_rollup |
task_info / vmmap |
GetProcessMemoryInfo |
| 分段布局 | maps / smaps |
vmmap |
VMMap |
| 分配器统计 | mallinfo2、malloc debug |
malloc_zone_statistics |
_heapwalk、CRT 统计 |
| 堆块泄漏 | malloc debug + dumpheap | Instruments Allocations | WinDbg !heap |
| 分配拦截 | PLT / inline hook | interposing | IAT hook / Detours |
| 系统级采样 | heapprofd、ProfilingManager | Instruments 采样 | ETW / EventPipe |
本地排查
拿到「内存偏高」的反馈后,按成本从低到高走:
flowchart TD
A["内存偏高"] --> B["dumpsys meminfo 看分类"]
B --> C{"Java 堆在涨"}
C -->|"是"| D["am dumpheap 抓 Java 堆"]
C -->|"否"| E{"Native Heap 在涨"}
E -->|"是"| F["smaps 定位到段"]
F --> G["malloc debug 加 dumpheap 抓分配栈"]
G --> H{"还是定位不到"}
H -->|"怀疑泄漏"| I["libmemunreachable 做可达性扫描"]
H -->|"怀疑踩内存"| J["ASan / HWASan / GWP-ASan"]
E -->|"否"| K["看 Graphics、Code、Stack"]
确认涨在哪一类
adb shell dumpsys meminfo <包名>
看 PSS 总量和 Native Heap / Dalvik Heap / Graphics / Code 各项。Java 堆在涨就抓 HPROF 走 MAT,Native Heap 在涨继续往下。
这一步单次就要几秒,不要放进脚本里循环跑。
定位到段
adb shell cat /proc/<pid>/smaps | awk '/^[0-9a-f]/{name=$6} /^Pss:/{if($2>1024) print $2, name}'
按 PSS 排序取前几名,看是 [heap]、某个 so 的 .data、还是匿名 mmap。匿名大段往往是分配器 arena 或业务自己的内存池。
抓分配栈
先开 malloc debug(应用级用 wrap.sh,注意 debuggable 与 LF 换行),再抓 dump:
adb shell am dumpheap -n <pid> /data/local/tmp/native_heap.txt
adb pull /data/local/tmp/native_heap.txt
python3 native_heapdump_viewer.py --symbols ./symbols/ native_heap.txt --html > report.html
报告里按调用栈聚合,直接看哪个 so、哪个函数分配的字节数最多。如果文件只有几百字节,回头检查 malloc debug 是否真的生效。
查泄漏
内存统计上涨但 dump 里的活跃块看起来都合理时,用可达性扫描:
adb shell dumpsys -t 600 meminfo --unreachable <包名>
它给出「活跃但没有 root 可达」的块,适合抓「指针已经丢了但没 free」的泄漏。要分配栈得和 malloc debug 的 backtrace 配合。
区分泄漏与内存错误
如果怀疑是越界写、UAF、double free 引起的元数据损坏,用内存安全工具,而不是继续查泄漏:
- ASan:编译期插桩,抓越界、UAF、double free,需要在
wrap.sh里LD_PRELOAD运行时库(NDK 文档); - HWASan:用 ARM64 标记指针(top-byte-ignore)替代影子内存,内存开销比 ASan 小,是 arm64 上常用的替代方案;
- GWP-ASan:采样式,线上也能开,只覆盖一小部分分配(NDK 文档)。
这几者的原理、wrap.sh 配置和平台支持见内存篇的 Sanitizers。
看时间维度
需要看「内存随时间怎么长」时,用 perfetto 抓一段时间的 heapprofd:
adb shell perfetto -c - --txt -o /data/misc/perfetto-traces/mem.pftrace <<'EOF'
buffers { size_kb: 65536 }
data_sources {
config {
name: "android.heapprofd"
heapprofd_config {
sampling_interval_bytes: 4096
process_cmdline: "com.example.app"
}
}
}
duration_ms: 30000
EOF
adb pull /data/misc/perfetto-traces/mem.pftrace .
在 ui.perfetto.dev 里看堆火焰图与「未释放内存」曲线。判断泄漏看的是同一调用栈在连续快照里持续变宽,而不是单帧的宽度。
附:读内存内容的两个接口
排查时偶尔需要「把某段地址的内容读出来」,比如 dump 一块堆、验证某个指针指向什么。这类需求和「谁分配的」是两回事,接口也不同。
/proc/<pid>/mem 是 procfs 伪文件,按偏移读写另一个进程的地址空间:
int fd = open("/proc/<pid>/mem", O_RDONLY);
lseek(fd, addr, SEEK_SET);
read(fd, buf, len);
process_vm_readv() 是系统调用,一次能在两个地址空间之间传多段:
process_vm_readv(pid, local_iov, liovcnt, remote_iov, riovcnt, 0);
两者都要过 ptrace 权限检查,读无效地址会失败或部分失败。它们能回答「这段地址里有什么」,回答不了「这块内存是谁分配的、什么时候分配的、是否泄漏」——后者必须有分配拦截或分配器元数据。所以它们可以当排查的辅助(dump 堆内容、验证地址可读),不能当内存泄漏监控的核心手段。
顺带一提,读自身进程用这两个接口没有意义,等价于 memcpy 但更慢:/proc/self/mem 还要先解析 maps 找合法范围,process_vm_readv 也一样要过远程地址检查。
线上监控
线上的约束不同:不能要求用户开 debuggable,不能长时间挂 hook,采样开销必须可控。可用的手段分布在三个层面,也可以只取其中一层。
flowchart TD
A["内存统计采样<br/>smaps_rollup 每几秒"] --> B{"超过阈值或增长过快"}
B -->|"是"| C["触发采集"]
B -->|"否"| A
C --> D["ProfilingManager 或自研 hook 采样"]
D --> E["端上只留 PC 与模块偏移"]
E --> F["压缩上报"]
F --> G["服务端符号化与聚合"]
G --> H["按版本 机型 页面聚类"]
内存统计
端上定时读 /proc/self/smaps_rollup 或 status 里的 VmRSS / VmSize,只上报几个数。开销可以忽略,能回答「哪些设备、哪些页面内存在涨」。
| 来源 | 字段 | 用途 |
|---|---|---|
/proc/self/status |
VmSize、VmRSS、VmPTE、Threads |
粗看虚拟内存与线程数 |
/proc/self/smaps_rollup |
Pss、Pss_Anon、Pss_File、Private_Dirty |
细分到匿名与文件映射 |
/proc/self/maps |
段布局 | 需要在异常时附带证据 |
这一层不要用 dumpsys meminfo,它要跨进程调 system_server,几秒一次会拖垮系统。
触发
只有命中条件才开重采集。常见的触发条件可以叠加:
- PSS 超过阈值,或一定窗口内的增长率超过阈值;
- 发生 Java OOM、native 崩溃、进程被 LMK 杀掉前的最后一帧;
- 灰度开关命中(按设备、版本、用户分桶)。
触发之后走哪条路取决于系统版本:
| 版本 | 手段 |
|---|---|
| Android 15 及以上 | ProfilingManager 的 HEAP_PROFILE,或注册 OOM 触发器让系统采 |
| Android 15 以下 | 自研轻量采样,或依赖 profileable 加 adb 现场抓 |
采样
自研采样要覆盖的环节和 heapprofd 一致,每一环都有可选范围:
| 环节 | 可选项 |
|---|---|
| 拦截 | malloc / free(按需加 realloc、calloc),实现可用 LD_PRELOAD、PLT hook、inline hook 任一种 |
| 采样率 | 从 4 KB 到 256 KB 不等,间隔越大开销越小 |
| 保留策略 | 分配建样本、free 删样本,只报仍存活的块 |
| 回溯 | frame pointer 最快,CFI 更准但更慢,也可以在失败时直接丢弃样本 |
| 数据量 | 栈深度、栈条数、单次上报大小都可设上限 |
| 符号化 | 端上只存 PC 与模块偏移,服务端还原 |
采样率越低,单点数值的方差越大。同一条调用栈在多次上报里的增长趋势,比某一次上报的绝对值更能说明问题。
能不能直接复用 perfetto
经常有人问:heapprofd 是开源的,能不能直接塞进 app 用。答案是不能直接当库用,但要分清楚哪些部分能借鉴。
| 部分 | 能否复用 | 原因 |
|---|---|---|
| 采样思想 | 可以 | 泊松采样、按大小加权,这套模型与实现无关 |
| 回溯 | 可以参考 | libunwindstack 的思路,线上优先 frame pointer |
| 符号化 | 可以 | 端上只采 PC 与偏移,服务端还原 |
| heapprofd 源码 | 只能参考 | 它依赖系统守护进程与 client 注入,不是应用内库 |
原因在架构:heapprofd 是「系统守护进程 + 进程内 client + traced 服务」的组合(设计文档),client 通常通过 traced / traced_probes 注入到目标进程,还依赖 profileable / debuggable 授权。普通 release 包既没有这些服务可用,也没有注入权限。
所以线上可选的路径有两条:用 ProfilingManager(系统在背后做这件事),或者自己实现一个简化版 heapprofd。
符号化与聚合
端上只记录三样东西:PC、模块标识(build-id / UUID / 路径)、模块内偏移。地址到函数名与行号的还原链路见二进制篇的「堆栈符号还原」,这里只补一句前提:符号文件必须与线上二进制同一构建,否则偏移对不上,还原出来的函数名是错的。
还原之后才是聚合:按调用栈合并同类分配、算每个栈的总量与增量、再按版本 / 机型 / 页面聚类。addr2line、llvm-symbolizer 这些工具本身的用法不在这里展开。