malloc 和 free 底层原理
-
- [malloc 会直接找内核吗?](#malloc 会直接找内核吗?)
- 内存从哪里来?
-
- [brk 和 sbrk 有什么区别?](#brk 和 sbrk 有什么区别?)
- [mmap 有什么不同?](#mmap 有什么不同?)
- [13 字节到底占多少?](#13 字节到底占多少?)
- [malloc 为什么这么快?](#malloc 为什么这么快?)
- [free 怎么知道大小?](#free 怎么知道大小?)
- [free 后内存去哪了?](#free 后内存去哪了?)
- [再看一次 malloc(13)](#再看一次 malloc(13))
在 C/C++ 中,申请和释放动态内存似乎只有两行代码:
c
void *ptr = malloc(13);
free(ptr);
可如果继续追问,这两行代码很快就会变得不简单:
- 操作系统通常按 4KB 的页管理内存,
malloc怎么只申请 13 字节? malloc是不是每次都要进入内核?- glibc 为什么有时使用
brk,有时又使用mmap? free没有接收大小,它怎么知道该释放多少?- 明明执行了
free,为什么进程的内存占用经常没有下降?
这些问题看似分散,其实都指向同一个角色:用户态内存分配器。
接下来,我们就沿着 malloc(13) 返回的那根指针一路向下,看看一块内存怎样从内核来到程序,又怎样被 free 回收。
malloc 会直接找内核吗?
不会,至少大多数时候不会。
malloc 是 C 标准库函数,不是系统调用。程序调用 malloc 时,首先进入 glibc 的用户态内存分配器。分配器会先检查自己手里有没有可以复用的空闲内存,实在不够时才向内核申请更多空间。

之所以需要中间这一层,是因为内核与应用程序管理内存的粒度不同。
内核以页为基本单位建立虚拟地址映射,常见页大小是 4KB;应用程序却会申请 13 字节、100 字节等各种大小。如果每个小对象都单独建立页映射,不仅会浪费大量空间,也会产生频繁的系统调用和映射管理开销。
glibc 的做法是先从内核取得一片较大的虚拟地址空间,再把它切成许多小块交给程序。之后的多次 malloc 和 free,往往只需要修改用户态的数据结构。
还有一点很重要:取得虚拟地址空间,不等于立即占用了等量物理内存。Linux 通常采用按需分页,程序第一次访问某个页面时,才可能触发缺页异常并建立对应的物理页映射。
因此,下面三个时刻并不一定同时发生:
malloc返回一个非空指针;- 进程的虚拟地址空间增大;
- 进程的物理内存驻留量增加。
那么,当 glibc 手里的空间确实不够时,它怎样向内核要内存?主要有两条路径:brk 和 mmap。
内存从哪里来?
brk 和 mmap 都能为分配器提供虚拟地址空间,但它们最本质的区别不是"一个用于小内存、一个用于大内存",而是新空间出现在什么位置 :前者把传统堆的边界向后推,后者在地址空间的其他位置建立一段映射。

绿色部分是本次获得的内存;
brk/sbrk扩展原 heap,mmap创建独立映射
左图中,新增空间紧挨着原来的 heap,program break 从旧位置移动到了新位置。右图中,传统 heap 和 program break 都没有变化,内核只是在另一段空白地址中建立了新的映射。这就是两条路径最直观的差别。
brk 和 sbrk 有什么区别?
传统堆位于进程数据段之后,它的末端边界称为 program break。把这条边界向高地址移动,堆就变大;向低地址移动,堆尾的一段地址空间就被收回。
brk 与 sbrk 操纵的是同一条边界,区别在于一个使用绝对位置,另一个使用相对增量:
c
int brk(void *addr);
void *sbrk(intptr_t increment);
假设当前 program break 位于地址 B:
brk(B + 4096)的意思是:把 program break 设置到指定地址B + 4096;sbrk(4096)的意思是:让 program break 从当前位置向后移动 4096 字节;sbrk(0)不移动边界,只返回当前 program break。
可以把它们理解成两种移动方式:
text
brk(addr) :移动到哪里
sbrk(delta) :移动多少字节
Linux 内核提供 brk 系统调用,glibc 又在其上提供了程序可调用的 brk/sbrk 接口。普通程序不应该一边使用 malloc,一边自行调用 sbrk 分配内存,因为两者同时修改同一条堆边界,会破坏分配器对堆布局的管理。
brk 路径很适合支撑大量普通对象:glibc 偶尔扩展一次堆,之后就可以在这片连续空间中反复切分和复用内存。
但连续堆也有一个限制。即使堆中间已经出现很多空洞,只要堆尾仍有对象存活,就不能简单地降低 program break,把中间某一段地址空间单独挖掉。
mmap 有什么不同?
mmap 不移动传统堆的边界。它让内核在进程地址空间中寻找一段合适的范围,并建立新的匿名映射:
c
void *addr = mmap(NULL, length,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
成功时,mmap 返回映射的起始地址;失败时返回 MAP_FAILED。映射长度会按页处理,其中的物理页通常仍在真正访问时按需分配。
内核使用 VMA(Virtual Memory Area)描述一段属性一致的虚拟地址区间,例如地址范围、访问权限和映射方式。VMA 是内核中的管理结构,不是物理内存,也不能简单理解成"每次 malloc 都会创建一个 VMA"。
一段不再需要的映射可以通过 munmap 解除:
c
munmap(addr, length);
这使 mmap 很适合直接服务较大的独立请求:释放时可以解除对应映射,不必等待传统堆尾的其他对象消失。
不过,"小对象走 brk,大对象走 mmap"只是便于入门的近似说法,并不是固定规则:
- 只要现有空闲块足够,即使请求较大,glibc 也可能直接复用已有空间;
- 多线程程序的非主 arena 通常也使用
mmap建立 heap,而这些 heap 同样会被切成许多小块; - glibc 会结合请求大小、可复用空间、动态阈值和运行配置选择路径。
所以,网络上常见的"超过 128KB 一定使用 mmap"不能当成不变的结论。
13 字节到底占多少?
现在回到最初的 malloc(13)。
glibc 管理内存的基本单元通常称为 chunk 。malloc 返回的指针指向 chunk 中的用户区域,而用户区域附近还需要保存分配器使用的元数据,例如 chunk 的大小和状态。
text
较低地址 较高地址
┌──────────────────┬───────────────────────────────┐
│ chunk 元数据 │ 用户可用区域 │
└──────────────────┴───────────────────────────────┘
↑
malloc 返回值
除此之外,返回地址还需要满足对齐要求,chunk 也有最小尺寸。因此,13 字节只是用户提出的请求,不等于分配器真正消耗的空间。
以常见的 64 位 glibc 配置为例,13 字节请求往往会落入一个 32 字节的 chunk,用户实际可用空间可能是 24 字节。具体数字依赖平台和版本,但这里真正需要区分的是五个概念:
- 用户请求的大小;
- 用户实际可用的大小;
- glibc 内部 chunk 的大小;
- 新增的虚拟地址空间;
- 最终驻留的物理内存。
它们并不是同一个数字。
malloc 为什么这么快?
因为 glibc 不会从头扫描所有空闲内存,更不会每次都找内核。它为不同状态和大小的 chunk 维护了多层结构:
- tcache:每个线程自己的小块缓存,常见的小额申请通常先从这里查找;
- fastbins:arena 中用于部分小 chunk 的快速链表;
- small bins、large bins:按大小组织空闲 chunk;
- unsorted bin:普通 chunk 释放后常见的临时中转站;
- top chunk:arena 尾部尚未分配的空闲空间。
一次 malloc(13) 通常会先检查 tcache,再尝试从各种 bin 中复用空闲 chunk。如果仍然找不到,glibc 才会从 top chunk 中切下一块。
这里再区分一下前面出现过的 program break:program break 是内核记录的传统堆边界,top chunk 则是 glibc 管理的空闲 chunk。在主 arena 中,top chunk 通常紧邻 program break,但二者不是同一个概念。
如果 top chunk 也不够,分配器才需要向内核取得更多虚拟地址空间:主 arena 常通过 sbrk/brk 扩展传统堆;非主 arena 或适合独立映射的较大请求则可能使用 mmap。
因此,连续执行很多次 malloc(13),通常不会连续产生很多次系统调用。一次扩展取得的空间,可以支持后续许多次小额分配。
free 怎么知道大小?
free 只接收一个指针,并没有大小参数:
c
void free(void *ptr);
它之所以知道该释放多大的内存,是因为 glibc 可以从合法指针定位对应的 chunk 元数据,从中恢复 chunk 的大小和来源。
这也解释了为什么传给 free 的必须是分配函数返回的原始指针:
c
char *ptr = malloc(100);
free(ptr + 1); // 未定义行为
ptr + 1 已经不是原始地址。glibc 无法从它正确定位 chunk,随后可能读到错误的元数据并破坏整个堆。
free 后内存去哪了?
最常见的答案不是"回到内核",而是"留在 glibc 中等待下次使用"。
小 chunk 释放后,通常先进入 tcache 或 fastbin;普通 chunk 可能与相邻空闲块合并,再进入 unsorted bin 等结构。这样,下一次 malloc 就可以直接复用,不必再次向内核申请。

只有两类情况更可能真正影响操作系统看到的内存:
第一类是直接通过 mmap 获得的独立大 chunk。glibc 可以从元数据中恢复映射范围,free 时通常直接执行 munmap。
第二类是传统堆尾出现了足够大的连续空闲空间。普通 chunk 与相邻空闲块合并后,如果最终连接到 top chunk,top chunk 就会变大;只有它满足页对齐、回收阈值和保留策略等条件,glibc 才可能收缩 program break。
glibc 还可能使用 madvise 告诉内核某些页面内容已经不再需要。此时虚拟地址范围仍然保留,但对应的物理页可能被回收。
无论底层采取哪条路径,free(ptr) 之后程序都已经失去了访问该对象的权利。地址值和旧数据即使暂时还在,也不能继续使用:
c
int *ptr = malloc(sizeof *ptr);
*ptr = 42;
free(ptr);
printf("%d\n", *ptr); // Use After Free,未定义行为
再看一次 malloc(13)
现在重新回到最初的两行代码:
c
void *ptr = malloc(13);
free(ptr);
这一次,我们已经可以看到它背后的完整过程:
- glibc 将 13 字节请求换算成满足元数据和对齐要求的 chunk;
malloc优先从 tcache 和各种 bin 中寻找可复用空间;- 找不到时,从 top chunk 切分;top 仍不足时,才通过
brk或mmap向内核扩展; free根据 chunk 元数据恢复大小和来源;- 普通 chunk 通常留在分配器中等待复用,独立 mmap chunk 则可能直接
munmap; - 只有虚拟映射或物理页进一步被回收,进程的相关内存指标才可能下降。
所以,malloc/free 的本质并不是简单地向操作系统"申请多少、归还多少",而是由 glibc 在应用程序与内核之间完成一次粒度转换:向下管理页和虚拟地址区域,向上提供对象大小的内存块,并在速度、碎片与内存占用之间不断权衡。
从程序员的视角,malloc(13) 只返回了一个指针;从系统的视角,这根指针背后连接着 chunk、tcache、bin、arena、program break、VMA、页表和物理内存。理解这条路径,也就理解了 malloc 和 free 最核心的工作方式。