内存分配器原理:malloc/free 底层实现、brk/sbrk 系统调用、mmap 映射机制

聊到内存管理,绕不开的就是 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 复用。

只有两种情况会真正归还:

  1. 通过 mmap 分配的大块,free 时调用 munmap。
  2. 堆顶部的连续空闲块达到一定阈值,glibc 会调 brk 往回缩。

我曾经遇到一个诡异的内存泄漏:程序 RSS 持续增长,但 valgrind 查不出泄漏。后来发现是频繁分配释放小对象,导致堆顶部始终有“空洞”,brk 缩不回去。解决方案是改用内存池,或者调 M_TRIM_THRESHOLD 让 glibc 更积极地收缩。

一张图看懂整体流程

下面这张 SVG 图,把 malloc 的决策路径画清楚了:

malloc 分配决策流程 调用 malloc(size) size >= 128KB? mmap 分配 brk 分配 检查空闲链表 找到合适块? 分割/返回块 扩展堆 返回用户指针 mmap 匿名映射 返回映射地址 注:实际 glibc 实现更复杂,含 bins、tcache 等优化

实际工程中的坑

讲几个我踩过的坑:

  • 碎片化:频繁分配释放不同大小的对象,会导致空闲链表碎片化。最终 malloc 明明有足够总空闲内存,却找不到连续块。解决方案是内存池或 jemalloc。
  • 锁竞争:多线程下,glibc 的 malloc 使用全局锁(旧版本)或 per-thread cache(新版本)。但高并发场景下,还是建议用 tcmalloc 或 jemalloc。
  • mmap 阈值:默认 128KB 不一定适合你的场景。我建议用 mallopt 调优,或者干脆用 mmap 自己管理大块内存。
小技巧: 用 malloc_usable_size(ptr) 可以查看实际分配的大小,往往比你申请的大。因为分配器有对齐和头部开销。

嗯,今天就先聊到这里。理解 malloc/free 的底层,能帮你写出更可控、更高效的内存管理代码。下次遇到性能问题,别急着甩锅给“内存泄漏”,先看看分配器是怎么工作的。

可可精品资料库