一句话结论(30s)
malloc 底层不是一套机制,而是「双引擎」——小块(< 128KB)走 brk 移动堆顶、大块(≥ 128KB)走 mmap 匿名映射。因为 brk 的释放受「堆顶」限制、堆顶有对象占用就无法归还、容易产生永久碎片,而 mmap 每块独立 munmap、零碎片,所以大块必须用 mmap;理解这一区别是诊断内存泄漏和 top/RSS 异常的关键。
核心原理(2min)
- 阈值:
M_MMAP_THRESHOLD默认 128KB,且 glibc 会根据运行时分配模式动态调整 - brk:移动 program break(堆顶指针)扩展堆,分配小块;致命缺陷是释放不灵活,只有堆顶空闲才能归还 OS
- mmap:
MAP_PRIVATE | MAP_ANONYMOUS私有匿名映射,独立映射、munmap立即释放物理页,无碎片 - ptmalloc2 缓解:free 进 bins 链表、相邻块合并(coalescing)、堆顶连续空闲超过阈值时
brk下移归还 - 为什么大块用 mmap:释放灵活、页对齐(DMA 友好)、避免与 brk 碎片相互影响
- 根本权衡:brk 系统调用频率低但碎片严重;mmap 每次分配/释放都需系统调用但释放灵活
底层深入(5-10min)
malloc 不止是”分配内存”
绝大多数程序员每天使用 malloc,但很少了解这个看似简单的函数背后有两套完全不同的内存分配机制在协作。glibc 的 malloc 实现(基于 ptmalloc2)在不同场景下会调用不同的系统调用来获得虚拟内存:
- 小块内存(默认 < 128KB):使用
brk,移动进程的 program break(堆顶指针) - 大块内存(默认 ≥ 128KB):使用
mmap,在堆之外创建私有匿名映射
这个 128KB 的阈值不是固定的——它由 glibc 的 M_MMAP_THRESHOLD 参数控制,并且 glibc 会根据运行时分配模式动态调整。理解 brk 和 mmap 的区别,是编写内存高效应用的关键。
思考:为什么一个小块用
brk、大块用mmap的区分?直觉上”不就是分配内存嘛,一套就够了”,但两种机制各有致命软肋:brk移动堆顶,一次扩展后可以复用,系统调用频率低,但释放受”堆顶”卡死、碎片严重;mmap每块独立、munmap就能干净归还、零碎片,但每次分配/释放都要系统调用。小块要的是”快、省调用”,大块要的是”能干净释放”,于是各取所长。
brk:移动堆顶指针
原理
每个进程的虚拟地址空间中有一段专门用于堆的区域,起始于 BSS 段末尾之上(start_brk),终止于 program break(brk)。堆向高地址方向增长。
低地址
┌─────────┐
│ Text │ 代码段
├─────────┤
│ Data │ 数据段
├─────────┤
│ BSS │ 未初始化全局变量
├─────────┤
│ Heap │ ← start_brk(堆起始地址)
│ │ 已分配的堆块
│ │
│ │ ← brk(当前堆顶)
│ ↓↓↓ │ 未分配(可增长空间)
│ │
├─ - - - -┤
│ ↓↓↓ │ mmap 映射区域(向低地址增长)
├─────────┤
│ Stack │ ← 栈顶(向低地址增长)
└─────────┘
高地址
brk(void *addr) 系统调用将 program break 设置到指定地址,sbrk(intptr_t increment) 则是相对移动(内部调用 brk)。当 malloc 需要小块内存时:
- 先从 glibc 维护的 free list(空闲链表)中查找有无合适的空闲块
- 如果 free list 中没有合适块,调用
sbrk向上移动 brk 指针,扩展堆 - 扩展出的虚拟地址空间在第一次访问时,通过缺页中断分配物理页
为什么 brk 容易产生内存碎片
brk 的致命缺陷在于释放不灵活。考虑以下场景:
char *a = malloc(10KB); // brk 向上移动 10KB
char *b = malloc(10KB); // brk 再向上移动 10KB
char *c = malloc(10KB); // brk 再向上移动 10KB
free(b); // 释放中间的 10KB
free(a); // 释放起始的 10KB
// 此时只有 c 还在使用,但 brk 无法下移(因为 c 在堆顶)
// 释放的 20KB 成了永久碎片,无法还给操作系统
brk 只能通过向下移动来缩减堆,但只有堆顶的空闲内存才能被释放。如果堆顶有一小块内存在使用,下面即使有再大的空闲区域也无法归还给操作系统。在长期运行的服务中,这个问题可能导致进程的虚拟内存占用持续膨胀,尽管实际使用的物理内存并不多。
思考:为什么
brk释放这么”不灵活”?因为brk本质只是一个指针——堆是一个连续区间,只有一个”顶”。想归还内存只能把”顶”往下移,而堆顶一旦有存活对象就挡在那里,下面的空闲区域就永远归还不掉。这跟栈的”只能从顶出”是一个道理:连续区间的收缩,天然受”最高占用点”的限制。
glibc 的缓解策略
glibc 使用 ptmalloc2 算法管理 brk 分配的内存块,通过 bins(fastbins、smallbins、largebins、unsorted bin)的复杂分类和合并策略来减少碎片:
free时不是立即归还给操作系统,而是加入对应大小的 bin 链表中- 相邻的空闲块会自动合并(coalescing)
- 如果释放后堆顶有足够大的连续空闲空间(
trim_threshold,默认 128KB),通过brk向下移动归还给操作系统
但对于复杂的长生命周期分配模式,这些策略的能力是有限的。
mmap:私有匿名映射
原理
mmap(NULL, size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
关键标志:
- MAP_PRIVATE:私有映射,对映射区域的修改不会写回底层文件(如果有文件的话),而是触发 Copy on Write
- MAP_ANONYMOUS:不关联任何文件,映射区域初始化为零(demand-zero pages)
- fd = -1:MAP_ANONYMOUS 时忽略文件描述符
malloc 中的 mmap 使用
当 malloc 请求的内存达到 M_MMAP_THRESHOLD(默认 128KB)时,glibc 不再使用 brk,而是直接调用 mmap 创建一个独立的匿名映射。这块内存不在堆区,而是位于 mmap 区域(见上文虚拟地址空间示意图)。
为什么大块用 mmap
释放灵活性:mmap 分配的每一块内存都可以通过 munmap 独立归还给操作系统,不受”堆顶在不在使用”的限制。free 一个大 mmap 块时,整个映射被撤销,物理页立即释放。没有碎片问题。
对齐和大小灵活性:mmap 可以提供对齐到页边界(4KB)的内存,对于大块数据的 DMA 传输、文件缓存等场景很重要。
避免与 brk 碎片相互影响:如果将大块内存分配在 brk 堆中,一旦它被释放,堆顶可能还卡着其他小对象,导致大块的连续空闲空间无法归还。
为什么阈值选 128KB
实际上 glibc 的默认 M_MMAP_THRESHOLD 是 128KB,但这不是固定不变的,glibc 使用了动态调整策略:
- 初始阈值 = 128 * 1024
- 动态调整范围:
pagesize到DEFAULT_MMAP_THRESHOLD_MAX(32位系统 512KB,64位系统 32MB) - glibc 会追踪 mmap 分配的大小,如果发现所有 mmap 分配的块的大小波动很大,就调大阈值(尽量用 brk,减少 mmap/munmap 系统调用开销);如果发现大小比较稳定,就调小阈值(用 mmap 避免碎片)
这种动态调整反映了 brk 和 mmap 之间的根本权衡:
| 维度 | brk | mmap |
|---|---|---|
| 系统调用频率 | 低(一次扩展,后续复用) | 高(每次分配和释放都需系统调用) |
| 释放灵活性 | 低(受堆顶限制) | 高(独立 munmap) |
| 碎片问题 | 严重 | 无(每次都是独立映射) |
| 分配粒度 | 任意字节 | 页对齐(至少 4KB) |
| 适用场景 | 频繁的小块分配 | 稀疏的大块分配 |
释放方式的关键区别
char *p1 = malloc(1KB); // 在 brk 堆中分配
char *p2 = malloc(200KB); // 通过 mmap 分配
free(p1); // 内存块加入 glibc 的空闲链表(bins),可能不会立即还给 OS
free(p2); // 调用 munmap,立即释放虚拟内存和物理页还给 OS
这意味着一个分配了大量大块内存的程序,在这些大块 free 后 top 命令可以看到 RES(物理内存)立即下降。但如果全都是 brk 分配的小块,即使 free 了,RES 也可能居高不下——内存还在 glibc 的池子里。
思考:为什么
free之后top的 RES 常常不降?这不是内存泄漏,而是 glibc 的”池化”策略在起作用——小块free后并不归还 OS,而是进 bins 复用;只有堆顶连续空闲超过trim_threshold才真正下移brk归还。所以在排查”内存只增不减”时,要先分清”真的泄漏”还是”glibc 池化导致的高水位”,二者处理方式完全不同。
总结
malloc 的双引擎设计(brk + mmap)反映了内存管理中”池化复用”与”按需归还”之间的永恒张力。理解这个设计,能帮助我们诊断内存泄漏(真的泄漏 vs glibc 池化导致的高水位)、优化分配策略(调整阈值或直接用 mmap 管理大块)、以及理解那些令人困惑的 top/htop 输出(VSZ 高但 RSS 低、free 后 RSS 不降等)。在面试中能讲清楚 brk 和 mmap 的区别及其对内存碎片的影响,是区分”用过 C”和”理解内存管理”的分水岭。
章末提问
追问 1:free 之后 top 里 RSS 为什么不下降?
回答思路:结论先行——大概率不是内存泄漏,而是 glibc 的池化策略。因为小块内存 free 后并不会立即归还 OS,而是进入 bins 空闲链表复用;只有堆顶连续空闲超过 trim_threshold(默认 128KB)时,glibc 才会下移 brk 真正归还。而大块(mmap)free 会立刻 munmap,所以 RES 立刻下降。
追问 2:为什么大块用 mmap、小块用 brk?
回答思路:结论先行——因为两者权衡不同:小块要”系统调用频率低”,大块要”释放灵活、零碎片”。brk 一次扩展堆顶后可复用、调用少,但释放受堆顶限制、碎片严重;mmap 每块独立映射、munmap 干净归还、页对齐,但每次分配/释放都要系统调用。所以频繁的小块用 brk,稀疏的大块用 mmap。
追问 3:brk 和 mmap 的本质区别是什么?
回答思路:结论先行——本质是”一个连续区间、只动一个堆顶指针”vs”一堆独立映射、各自可随时撤销”。因为 brk 只移动 program break,堆是连续区间,收缩只能从顶开始,所以释放不灵活、易碎片;mmap 是独立匿名映射,每块都能单独 munmap 立即归还物理页,所以零碎片但调用开销高。这决定了它们在系统调用频率、释放灵活性、碎片、分配粒度四个维度上的差异。