内存分配 入门

C++ 里每个 new、C 里每个 malloc、 Rust 里每个 Box::new 最终都要向 OS 要内存,然后切分。 5 个 section 把画面搭出来:栈 vs 堆和 brk / mmap 怎么实际增长堆;malloc & free内部 + 可交互的堆布局;现代的jemalloc / tcmalloc / mimalloc 和它们存在的原因;碎片化 —— 内部、外部、泄漏;最后是速查表

01

栈 vs 堆 —— 以及堆实际怎么长

每个进程两个都有。栈便宜、自动、有界;堆灵活、手动、是所有 allocator 住的地方。 内核通过两个差别意外大的 syscall 按请求增长堆。

是固定区域(Linux 默认 8 MB),函数调用时向下增长、返回时向上收缩。 分配本质免费 —— 就是 rsp 减一下。释放也免费 —— 返回时 rsp 加一下就把所有东西扔掉。坑是栈上的每个字节,函数一返回就没了; 没法不拷贝就把栈数据跨函数调用共享。而且大小上限是硬的:溢出就 SIGSEGV (有时通过内核 guard 页)。

是其他一切。任何需要活得比函数调用长的、任何编译时不知道大小的、 任何运行时增长或收缩的 —— 都住堆。C 的 malloc、C++ 的 new、 Java 和 JS 的对象分配、Rust 的 Box、Go 的 escape 值 —— 全是堆。

内核怎么给堆内存

两个 syscall 增长进程的堆:

  • brk(和 sbrk):Unix 经典。 把 data segment 边界设到新的高水位,旧水位到新水位之间所有地址变合法。 allocator 用它做小分配,因为每次增长 / 收缩几 KB 便宜。
  • MAP_ANONYMOUSmmap: 在地址空间某处创建一个新的、无文件的 VMA。 allocator 用它做大分配(glibc 默认阈值 128 KB),因为 free 时能独立还给内核。

差别在运营上有意义。brk 只能从堆顶释放内存 —— 如果你在 top-of-heap 水位有一个小分配,即便它下面的一切都 free 了, 整个堆也还映射着。mmap 分配的区域能按任何顺序 munmap。 这就是为啥分配很多小对象的长寿命进程,在 ps 看起来像内存泄漏, 即便工作集没在涨:glibc allocator 没法把 brk 分配的堆还给 OS。

RSS vs VSZ

VSZ 是虚拟大小 —— 总映射地址空间。RSS(resident set size)是当前实际在 RAM 里的物理页。 VSZ 2 GB、RSS 100 MB 的进程分配了 2 GB 虚拟内存, 但只碰了 100 MB 值的页。RSS 是花你物理 RAM 的; VSZ 只在它超过页表能便宜描述的范围(4 KB 页下 ~256 GB 之前)才真正成为关心。

编译器也帮忙

现代编译器做 escape analysis: 如果一个堆分配从来不「逃出」当前函数(不存进任何字段、不返回、不传给任何可能保留它的东西), 编译器能把它挪到栈上。Go 激进地做;C++ 和 Rust 通过对小类型的内联 + 死代码消除做; Java 的 JIT 以「scalar replacement」做。结果是这些语言里地道的代码 在热循环里常常根本不碰堆,不管源码看起来像什么。

要点。「栈:便宜、有界、函数局部。 堆:灵活、手动、allocator 的领域。内核通过 brk(小 / 快 / 不容易还回去) 或带 MAP_ANONYMOUS 的 mmap(大 / 能 munmap)增长堆。 RSS 是消耗的物理内存;VSZ 是映射的虚拟空间 —— 前者是花 RAM 的那个。」

02

mallocfree 实际干什么

allocator 的工作是把 OS 给的连续大块切成请求大小的片、追踪哪些活着、 回收已 free 的、用完时增长大块。大部分复杂度都在跟碎片化斗。

malloc(n) 返回指向 n 字节未初始化内存的指针。free(p) 告诉 allocator 这个指针不再用。在这两次调用之间, allocator 负责:(1)找一个合适大小的 free 块、(2)记下簿记好让它在 free 时知道块大小、 (3)还回对齐的内存(为 SSE 兼容通常 16 字节对齐)。

Free list 和 size class

最简单的 allocator 维护一个单链 free 块链表,扫它找够大的(first-fit) 或者最小但能装下的(best-fit)。两个都是每次分配 O(n),大规模下糟糕。 真实 allocator 用 size class:按大小分桶 (通常 2 的幂或者某种几何级数,如 16、24、32、48、64……), 每桶维护单独的 free 链表。分配变 O(1):把请求向上舍入到最近的 size class、 弹出那桶 free 链表的头。

代价:内部碎片(internal fragmentation)。 17 字节请求舍入到 24 浪费每次分配 7 字节。几百万次分配下,加起来不少。 现代 allocator 用更细粒度的 size class(jemalloc 在几 KB 以下有 ~40 个桶) 把内部浪费压到 10% 以下。

堆 allocator —— alloc / free 序列 + 它留下的碎片Frame 0 —— 空堆,256 字节 free0(256)256B总 free: 256 字节
堆是连续的虚拟地址空间范围。brk 和 mmap 是两个增长它的 syscall。最初整个范围都是 free。
1 / 8
真实 allocator 比这个简单的「碰撞 + 合并」精巧得多 —— 它们维护按 size class 组织的 free list、合并相邻 free 块、能时复用洞。 但根本问题不变:即便所有 free 内存加起来够, 最大的连续段也可能不够请求。这就是外部碎片(external fragmentation), jemalloc / tcmalloc / mimalloc 大部分精力就是在跟它斗。

合并(coalescing)

一个块被 free 时,allocator 检查它的邻居是不是也 free;是的话, 合并成一个更大的 free 块。这个合并(coalescing)是防止外部碎片无限累积的东西。标准实现在每个块尾部放一个小 footer, 让合并能 O(1) 反向走找到前一块的 header。

每分配 header 住哪

在 malloc 给你的指针前面,allocator 藏几字节元数据(通常 8–16 字节), 记录块大小和一两个 flag。这意味着:(1)每个分配至少有那么多开销 —— 小分配比例上很浪费;(2)写过分配尾部会腐蚀下一个块的 header, 只有在下次分配 / free 时才被检测到、离 bug 很远; (3)free(p) 通过看 p 前面的字节知道大小。

为啥 C 自带的 malloc 很少是最好选择

glibc 的 malloc(ptmalloc)对很多工作负载够用,但在 3 条轴上输给专门 allocator: 多线程扩展(per-thread arena,下一节)、向 OS 还内存(它粘着)、 争用下的尾延迟(allocator 内部锁)。规模上的生产代码几乎总是通过LD_PRELOAD 用 jemalloc、tcmalloc、或 mimalloc 替换它 —— 通常 10–30% 吞吐胜利,99 分位延迟常常显著改善。

要点。malloc 找一个 free 块、打 header、返回。free 把它放回 free 链表、尝试和邻居合并。真实 allocator 按 size class 分桶做 O(1)、每桶维护 free 链表。最难的问题是外部碎片 —— 总 free 不等于最大连续 free,小洞随时间累积。」

03

jemalloc、tcmalloc、mimalloc —— 现代 allocator 为啥存在

单线程 malloc 本质上是个已解决的问题。32+ 核多线程 malloc 不是 —— 3 个里程碑式的 allocator 分别在取舍空间里凿出了不同的设计点。

线程争用问题

朴素 allocator 用锁包它的数据结构。单线程没事;32 个线程在紧凑循环里全调malloc 时,锁变成瓶颈,allocator 扩展性不比单线程好。 2008 年前的 glibc malloc 就这毛病。

大家定下来的修法:per-thread cache(或 arena)。 每个线程保留一小撮本地 free 块,可以完全无锁地为分配服务。 本地池耗光或溢出时,线程跟全局池同步 —— 但很少。 簿记更复杂(每线程状态、周期均衡、全局层的锁),但常见情况的分配是无锁的。

jemalloc

原本来自 FreeBSD(2005),~2009 年被 Facebook(现 Meta)采纳到他们后端。 per-thread cache(tcache)由少量全局 arena(通常 CPU 数 × 4)支撑。分配 size class 化到 ~14 KB 以下的 ~40 个桶里; 更大的走单独 slab 系统。在碎片控制(精巧合并)和向 OS 还内存 (主动 purging)上很强。高分配率服务器工作负载的行业默认; Rust 直到最近的默认、Redis 在很多构建里的默认。

tcmalloc

Google 的贡献(~2006、2019 年作为「tcmalloc next generation」重写)。 原变种用 per-thread cache + 全局 pageheap + 每 size class 一个 CentralFreeList。 为短寿命小分配优化(Google 的 trace 由哈希表 churn 和 protobuf 对象主导的工作负载)。 有一个基于采样的 profiler,免费给你 pprof 兼容的堆 profile。

mimalloc

Microsoft Research,2019 年。三个里最新的,设计目的是干净实现 (整个 allocator ~6000 LOC)和对嵌入语言 runtime 的一流支持。 per-thread 堆 + 每页 free 链表,显眼在「free 在你分配的线程之外」 (async runtime 和消息传递系统里常见)的明确设计。 数据局部性故事异常好 —— 兄弟分配在地址空间里彼此相邻,这帮 cache 行为。 宏 benchmark 上跟 jemalloc/tcmalloc 大约持平;有些更好,有些更差。

什么时候换

默认 glibc malloc 对很多 CLI 工具和单线程负载够用。在这些情况下换上面之一:

  • 高分配率的多线程服务器。per-thread cache 在 16+ 线程上给 2–5× 吞吐改进。
  • 工作集稳定但 RSS 随时间涨。glibc 难还内存; jemalloc 通过 madvise(MADV_DONTNEED) 主动 purge。
  • 尾延迟重要。allocator 锁争用表现为 p99 尖刺,跟别的东西不相关。
  • 你需要堆 profile。tcmalloc 的 pprof 集成是不重编就找出分配在哪发生的最简单方法。

换通常意味着命令行加 LD_PRELOAD=/path/to/libjemalloc.so, 或者链接为依赖。不要改代码。

要点。「单线程 malloc 解决了。多线程扩展需要 per-thread cache: jemalloc(FB 默认、碎片控制最好)、tcmalloc(Google 的、profile 最好)、 mimalloc(Microsoft 的、跨线程 free 和嵌入最好)。通过 LD_PRELOAD换通常免费拿到 10–30% 吞吐 + 更低 p99 延迟。」

04

碎片、泄漏、和它们的区别

一个进程的内存无界增长的 3 个不同方式,机制和修法都不同。 把它们搞混是内存 debug 困惑的最常见来源。

内部碎片(internal fragmentation)

你要 17 字节、allocator 给 24。浪费的 7 字节是内部碎片: 属于你的分配但你用不上。由 size class 舍入造成(每个现代 allocator 都有)、 加对齐要求(malloc 的内存通常 16 字节对齐)。 通用工作负载典型浪费:总分配内存的 5–15%。 size class 粗的便宜 allocator 浪费多;细粒度的浪费少。

修:选 size-class-aware 的 allocator(jemalloc、mimalloc); 避免许多刚好「在 class 边界以下」的不同分配 (65 字节 struct 在 jemalloc 里舍入到 80 —— 能压到 64 就压)。

外部碎片(external fragmentation)

堆总共有很多 free 字节,但没有单个连续块够大容纳新请求。 §2 的 HeapAllocator demo 展示了基本情况。allocator 要么让请求失败(罕见)、 要么通过求内核要更多内存来扩堆(常见 —— 那内存增长 RSS, 即便现有碎片本来能吸收很多小请求)。

修:合并帮忙(每个真实 allocator 都做)。每 size class free list 阻止 class 内的碎片但允许跨 class 的。混合分配大小的长寿命进程不可避免会累积一些 —— 问题在速率。重启进程是粗暴的修法,被广泛使用(生产里的滚动重启)。

内存泄漏

应用分配内存、丢掉所有引用、但从来不调 free。经典原因:C/C++ 里忘记 free; 引用计数数据里的循环(Rust 的 Rc、Python 的引用计数); 没淘汰的增长缓存;无界队列。净效应:RSS 随工作量单调增长, 而不是随并发负载。

垃圾回收语言没有经典泄漏但有保留 bug: 全局缓存、捕获了大对象闭包的事件 listener、无界增长的静态 map。 跟 C 泄漏一样的运营症状。堆 dump(jmap、V8 的 heap snapshot)识别保留 root。

怎么辨别

  • RSS 随吞吐爬升然后平台。很可能内部碎片 —— 总分配涨了因为工作量涨了,但每个块 class 利用率都不错。 换 allocator 可能帮忙;否则就给它预算。
  • 稳定吞吐下 RSS 在数小时 / 数天里慢慢爬。外部碎片。allocator 没法重新压缩堆,所以 brk 分配的页即便大部分没用也保持映射。 换一个主动 purge 回 OS 的 allocator(默认配置的 jemalloc)。
  • RSS 随累计已服务请求线性涨、从不平台。泄漏。每请求的某个分配从来没 free。工具:C/C++ 用 valgrind --leak-check=full; 编译语言用 ASan(-fsanitize=address); 堆 profiler(jemalloc 的、tcmalloc 的、V8 的)采样分配位点。

RSS = max() 问题

RSS 只增长(或保持) —— 几乎从不缩小。allocator 碰过一页它就在 RSS 里, 页只在显式 madvisemunmap 时还给内核。 所以工作集的短暂尖刺都能让进程长期 RSS 保持高位。现代 allocator 跑后台 purge 线程缓解; 老的不跑。

要点。「内部碎片:请求字节比 size class 桶装的少, 其余浪费但稳定(典型 5–15%)。外部碎片:总 free 没问题但没连续块够装 —— 随时间增长、靠合并和主动 purge 缓解。泄漏:从不调 free、 随累计工作单调增长。RSS 随时间的形状是诊断。」

05

速查表

6 道值得能冷讲清楚的核心问题、5 个 code review 时一眼看出来的红旗。

栈和堆分配的区别?

栈:分配是 rsp 减一下(零代价);内存只活到函数返回; 有界大小(Linux 上 ~8 MB)。堆:allocator 找 free 块、打 header、返回; 内存持续到 free;只受虚拟地址空间限制。 活得比函数调用长的一切都上堆。

哪两个 syscall 增长进程堆?

brk(和 sbrk)移动 data segment 边界,连续增长堆。 便宜,但内存只能从顶部还给 OS —— 顶部一个小分配就让整个下面的堆保持映射。mmap MAP_ANONYMOUS 创建独立 VMA;能按任何顺序 munmap。 glibc 默认对 < 128 KB 用 brk、> 128 KB 用 mmap。

为啥 jemalloc / tcmalloc / mimalloc 比 glibc malloc 快?

per-thread cache。glibc 默认(ptmalloc)用锁保护的 arena; 多线程争用下锁变瓶颈。jemalloc、tcmalloc、mimalloc 都给每个线程一份 私有 free 块 cache,能无锁服务。分配在常见情况下变成 O(1) 无争用。 16+ 线程上吞吐胜利 2–5×。

RSS 和 VSZ 的区别?

RSS(resident set size)是当前分配给进程的物理内存。 VSZ(virtual size)是映射的总虚拟地址空间 —— 包括还没碰过的部分(页表里还是 0)和换到磁盘的部分。 RSS 是花你 RAM 的;VSZ 只在大到撑膨页表本身时才重要。 1 TB VSZ + 200 MB RSS 的进程没事。

区分内部碎片、外部碎片、内存泄漏。

内部:你要 17 字节、allocator 给 24;多的 7 浪费但算「在用」。 稳定开销(典型 5–15%)。外部:总 free 字节够, 但没单个连续段够大;堆随时间增长。泄漏:分配从不被 free; RSS 随累计工作单调增长。RSS-时间图上形状不同;修法不同。

什么时候你会直接用 mmap 而不是 malloc?

3 种情况:(1)非常大的分配(几 MB),想容易还给 OS —— 直接 mmap 比依赖 allocator 决定干净;(2)进程间共享内存 —— 带 MAP_SHARED 的 mmap 在文件或 shm 区上; (3)memory-mapped 文件,你想要 OS 按需分页而不是 read/write开销。其他都让 allocator 处理。

Code review 红旗

  • 热内层循环里的 malloc/free。每次调用 ~50 ns 加潜在锁争用。 把分配 hoist 出来、用池、或者有界时通过 alloca/VLA 栈分配。
  • 许多类似大小的小分配。即便有 per-thread cache, 每块的开销也主导。用 arena allocator 或 slab。
  • 没淘汰策略的长寿命缓存。尤其在垃圾回收语言里, 一个无界 HashMap/dict/Map看起来像内存泄漏,即便它「可达」。用 LRU 或界大小。
  • 同一进程里混许多不同的定大小 allocator。每个都有自己的簿记开销。合并。
  • 用默认 glibc malloc、在跟分配不同的线程里调 freeptmalloc 的 arena 在这种模式下劣化得厉害;换到为它设计的 mimalloc 或 jemalloc。