4. Native内存管理基础
Native 内存问题之所以难查,根本原因在于它不像 Java 堆那样有统一的 GC 和清晰的托管边界。C/C++ 代码里,栈、堆、mmap 映射区、全局/静态区各自遵循不同的生命周期规则,而 malloc/free 只是堆管理的一层抽象,底层还可能落到 brk 或 mmap。理解这些层次,才能判断一次内存增长到底是栈溢出、堆泄漏、映射未释放,还是分配器自身的缓存行为。
栈与堆:两种完全不同的生命周期模型
栈由线程创建时固定分配(Android 主线程默认约 8MB,普通 pthread 常见 1MB 左右,可由 pthread_attr_setstacksize 调整),随函数调用自动伸缩。局部变量、返回地址、保存的寄存器都在栈上,函数返回即回收,因此栈上几乎不存在“泄漏”,只有溢出和悬垂指针问题。栈溢出典型表现是 SIGSEGV,且 fault address 往往紧邻栈顶或栈底保护页。
堆则相反:malloc 返回的地址需要显式 free,生命周期由程序员控制。堆的增长不是线性的,分配器会向内核申请大块虚拟内存(arena),再切分给小块请求。因此观察 RSS 增长时,不能简单认为“malloc 了多少就涨多少”,分配器的缓存、对齐填充、元数据都会放大实际占用。
malloc/free 与 mmap 的关系
glibc 与 Android bionic 的 malloc 在请求较大时(常见阈值 128KB,即 MMAP_THRESHOLD)会直接走 mmap,free 时立即 munmap 归还内核;小块则从 arena 中分配,free 后通常留在分配器的空闲链表里,不一定归还。这解释了一个常见现象:大量小对象释放后 RSS 不降,而单个大 buffer 释放后 RSS 立刻下降。
// 小块:留在 arena,RSS 可能不降
void *p = malloc(1024);
free(p);
// 大块:直接 mmap/munmap,RSS 立即回落
void *q = malloc(256 * 1024);
free(q);
mmap 也是文件映射、匿名共享内存、线程栈、加载 so 的底层机制。排查时可用 /proc/<pid>/maps 区分匿名映射与文件映射,用 smaps 看每段的 Rss/Pss,判断增长来自堆、栈还是 mmap 区。
内存对齐:被忽视的放大因素
malloc 返回的地址通常满足 8 或 16 字节对齐(64 位下 max_align_t 为 16)。若结构体成员顺序不当,编译器会插入 padding。例如 struct { char a; long b; char c; } 在 64 位上实际占 24 字节而非 10 字节。大量小结构体分配时,对齐填充和分配器元数据(每个 chunk 通常额外 8~16 字节)会让实际内存远高于理论值。优化手段包括按大小重排成员、使用 __attribute__((packed))(慎用,可能引发非对齐访问)、或改用紧凑数组/SOA 布局。
泄漏与碎片化:两种不同的“内存涨”
泄漏是分配后丢失指针、无法释放,表现为长期单调增长且与业务量正相关;碎片化是总空闲量足够但无法满足某次连续分配,表现为分配失败或 RSS 高但可用内存零散。碎片又分内部碎片(对齐/元数据浪费)和外部碎片(空闲块不连续)。
定位泄漏常用 malloc_debug(bionic)、LeakSanitizer、heapprofd/perfetto 的 native heap profiling;碎片化则更依赖分配大小分布统计,观察是否存在大量固定尺寸小块与偶发大块混用。
Android 分配器:Scudo 与 jemalloc
Android 10 起,bionic 默认使用 Scudo 作为 malloc 实现,替代了早期的 dlmalloc。Scudo 的核心设计是“按 size class 分桶 + 每个 chunk 带头部元数据 + 校验和”,并在分配和释放时校验头部,能较早发现越界写和 double free。它通过隔离区(quarantine)延迟复用刚释放的块,降低 use-after-free 被利用的概率,代价是内存占用略高、碎片行为与 dlmalloc 不同。
jemalloc 则常见于 App 自带或第三方库(如部分 Flutter、游戏引擎)。它用多个 arena 降低多线程锁竞争,size class 分级更细,配合线程缓存(tcache)提升小对象分配速度,碎片控制通常优于简单分配器。两者都会把释放的小块留在缓存中,因此“free 后 RSS 不降”在两者上都可能出现,不能直接判定为泄漏。
实践:一次内存增长的判断流程
假设某 Native 模块运行 10 分钟后 RSS 从 80MB 涨到 220MB。第一步读 /proc/<pid>/smaps,若增长集中在 [heap],说明是 malloc 侧;若在匿名 mmap 段,可能是大块分配或线程栈;若在文件映射,则考虑 so 或资源映射。第二步用 heapprofd 抓取 native heap 分配栈,按调用栈聚合,找出增长最快的分配点。第三步区分泄漏与碎片:若同一调用点持续增长且释放路径缺失,是泄漏;若分配大小分布中大量 32/64 字节小块与偶发 1MB 大块交替,且空闲总量足够但大块分配失败,则是碎片。
mallinfo 或 Scudo 统计接口观察 arena 空闲量。__attribute__((packed)) 盲目压缩结构体。非对齐访问在 ARM 上可能触发异常或性能骤降,应先确认对齐要求,再决定是否重排成员。总结来看,Native 内存管理的核心是分清“谁在管、何时还、还多少”。栈自动回收但会溢出,堆需显式释放且受分配器策略影响,mmap 直接面向内核。对齐和元数据会放大实际占用,泄漏与碎片是两种不同的增长模式,Scudo 与 jemalloc 在检测能力和碎片行为上各有取舍。排查时先定位增长区域,再区分泄漏与碎片,最后用工具落到具体调用栈,才能把“内存涨了”变成可修复的问题。