2. 内存分配器原理
聊到内存管理,绕不开的就是 malloc 和 free。很多工程师用了十年八年,觉得这就是个黑盒子——调了就完事。但我个人习惯是,搞懂底层,才能写出真正高性能的代码。
今天我们就把它拆开看看。
malloc/free 到底干了什么?
说白了,malloc 不是直接从操作系统拿内存的。它是个中间商。它先向操作系统批发一大块内存,然后零售给应用程序。你每次调 malloc(100),它就从自己的“仓库”里切一小块给你。
那这个“仓库”是怎么来的?
两种方式:
- brk/sbrk:调整程序堆的边界。适合小内存分配。
- mmap:映射匿名内存页。适合大块分配。
我当年在做一个嵌入式数据库项目时,发现频繁 malloc/free 导致性能抖动。后来一查,原来是 glibc 的分配器在 brk 和 mmap 之间切换时产生了碎片。嗯,这里要注意,不同分配策略的切换成本是不一样的。
brk/sbrk 系统调用
brk 和 sbrk 是操作系统提供的最原始的内存管理接口。它们控制着进程的“堆顶”位置。
你可以把堆想象成一个向上生长的栈。brk 设置堆顶的绝对地址,sbrk 则是相对增量。
#include <unistd.h>
void *sbrk(intptr_t increment);
int brk(void *addr);
举个例子:
void *old_brk = sbrk(0); // 获取当前堆顶
printf("当前堆顶: %p\n", old_brk);
sbrk(4096); // 扩展堆 4KB
void *new_brk = sbrk(0);
printf("新堆顶: %p\n", new_brk);
你想想看,这其实很原始。它只能线性扩展,不能释放中间的内存。所以 free 不能直接调 sbrk 往回缩——那会把别人的内存也缩没了。
brk/sbrk 不是线程安全的。glibc 内部会加锁,但这也带来了竞争开销。我在一个高并发服务中遇到过,大量线程同时 malloc 导致 brk 锁成为瓶颈。
mmap 映射机制
mmap 是另一种思路。它直接向内核申请一页或多页内存,映射到进程的虚拟地址空间。
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
分配匿名内存时,fd 传 -1,flags 带上 MAP_ANONYMOUS:
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) {
perror("mmap");
exit(1);
}
// 使用...
munmap(p, 4096);
mmap 的好处是:
- 释放时直接
munmap,内存还给操作系统,没有碎片问题。 - 每个映射是独立的,不会互相干扰。
但代价是:每次 mmap 都要陷入内核,开销比 brk 大得多。所以 glibc 的策略是:
| 分配大小 | 使用策略 | 原因 |
|---|---|---|
| < 128KB | brk/sbrk | 系统调用开销小,适合频繁小分配 |
| >= 128KB | mmap | 避免大块内存长期占用堆空间,释放即归还 |
这个阈值(128KB)是 glibc 的默认值,可以通过 mallopt(M_MMAP_THRESHOLD, ...) 调整。我曾经在一个图形处理项目中,把阈值调到了 1MB,因为我们的工作集都是几百 KB 的纹理数据,频繁 mmap 反而更慢。
malloc 的内部结构
glibc 的 malloc 实现是 ptmalloc(基于 Doug Lea 的 dlmalloc)。它维护了一个 空闲链表(free list),把不同大小的空闲块分类管理。
核心数据结构:
struct malloc_chunk {
size_t prev_size; // 前一个块的大小(如果前一个块空闲)
size_t size; // 当前块的大小(含标志位)
struct malloc_chunk *fd; // 空闲链表前向指针
struct malloc_chunk *bk; // 空闲链表后向指针
// ... 用户数据从这里开始
};
每个 malloc 返回的指针,其实指向的是 chunk 的数据区,而不是 chunk 头部。头部信息藏在前面几个字节里。
关键点: free(p) 时,分配器通过 p - 8(32位)或 p - 16(64位)找到 chunk 头部,然后根据 size 判断这个块该放回哪个空闲链表。
free 不是真的“释放”
你调 free 时,内存并没有还给操作系统。它只是被放回了空闲链表,供后续 malloc 复用。
只有两种情况会真正归还:
- 通过
mmap分配的大块,free时调用munmap。 - 堆顶部的连续空闲块达到一定阈值,glibc 会调
brk往回缩。
我曾经遇到一个诡异的内存泄漏:程序 RSS 持续增长,但 valgrind 查不出泄漏。后来发现是频繁分配释放小对象,导致堆顶部始终有“空洞”,brk 缩不回去。解决方案是改用内存池,或者调 M_TRIM_THRESHOLD 让 glibc 更积极地收缩。
一张图看懂整体流程
下面这张 SVG 图,把 malloc 的决策路径画清楚了:
实际工程中的坑
讲几个我踩过的坑:
- 碎片化:频繁分配释放不同大小的对象,会导致空闲链表碎片化。最终
malloc明明有足够总空闲内存,却找不到连续块。解决方案是内存池或 jemalloc。 - 锁竞争:多线程下,glibc 的
malloc使用全局锁(旧版本)或 per-thread cache(新版本)。但高并发场景下,还是建议用 tcmalloc 或 jemalloc。 - mmap 阈值:默认 128KB 不一定适合你的场景。我建议用
mallopt调优,或者干脆用mmap自己管理大块内存。
malloc_usable_size(ptr) 可以查看实际分配的大小,往往比你申请的大。因为分配器有对齐和头部开销。
嗯,今天就先聊到这里。理解 malloc/free 的底层,能帮你写出更可控、更高效的内存管理代码。下次遇到性能问题,别急着甩锅给“内存泄漏”,先看看分配器是怎么工作的。