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["跳回原函数剩余部分"]

具体步骤:

  1. 拿到目标函数入口地址(dlsym、符号表、特征码都可以);
  2. 把 .text 所在页改成可写(mprotect 或 VirtualProtect);
  3. 备份入口处会被覆盖的原始指令;
  4. 写入跳到 hook 函数的指令;
  5. 把备份的指令搬到一块可执行内存(trampoline),末尾补一条跳回原函数剩余部分的跳转;
  6. 恢复页权限,刷新指令缓存。

跳转指令怎么选

指令集不同,写法差很多:

平台 跳转方式 长度 说明
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:注入目标进程,hook malloc / 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 这些工具本身的用法不在这里展开。