内存分配与回收:从 malloc 到 slab 分配器

引言:堆内存的演进之路 在 Linux 系统中,当程序请求一块内存时,操作系统并不会直接暴露物理页。相反,一个复杂的分配器层次逐步将大块虚拟地址空间切割成程序所需的小块。从用户态的 到底层的 / ,再到内核的 slab/slub 分配器,每一层都在性能、碎片率与并发能力之间做出权衡。

引言:堆内存的演进之路

在 Linux 系统中,当程序请求一块内存时,操作系统并不会直接暴露物理页。相反,一个复杂的分配器层次逐步将大块虚拟地址空间切割成程序所需的小块。从用户态的 malloc() 到底层的 brk()/mmap(),再到内核的 slab/slub 分配器,每一层都在性能、碎片率与并发能力之间做出权衡。本文将从系统调用出发,逐层剖析现代内存分配的核心机制。

系统级分配:brk 与 mmap

brk() 移动程序断点

进程的堆通过 brk() 系统调用扩展。程序断点(program break)是堆顶部的 VMA 边界,调用 brk(addr) 会将断点上移,内核通过 do_brk() 分配新的虚拟页,但物理页在首次访问时才通过缺页异常分配。sbrk() 是 glibc 提供的封装,返回旧的断点地址并增量 delta,但它是线程不安全的,POSIX 已将其标记为弃用。

#include <unistd.h>

void *brk_example() {
    void *old = sbrk(0);        // 获取当前断点
    brk(old + 4096);            // 扩展 4KB(仅虚拟页)
    return sbrk(0);             // 返回新断点
}

malloc 何时使用 mmap

malloc 并不是总在 brk 区域分配。当请求量超过 M_MMAP_THRESHOLD(默认 128KB)时,ptmalloc 会改用 mmap 分配匿名页,通过 Munmap 回收,避免大对象造成堆不可释放的问题。通常较小对象仍放在连续堆中,减少 TLB miss。

mmap 的两种形态

mmap 既有文件映射,也有匿名映射。匿名映射通过 MAP_ANONYMOUS | MAP_PRIVATE 分配零初始化的页,共享内存则用 MAP_SHAREDMAP_PRIVATE 写时复制,MAP_SHARED 对同一映射的所有进程可见。mmap 的粒度是页(通常为 4KB),比 brk 的细粒度分配语义更粗,更适合大块管理。

#include <sys/mman.h>

void *anon_mmap(size_t len) {
    return mmap(NULL, len, PROT_READ | PROT_WRITE,
                MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
}

// 查看进程映射
cat /proc/self/maps

ptmalloc:glibc 的默认分配器

ptmalloc 源自 Doug Lea 的 dlmalloc,历经 Perez 的线程安全改进,最终成为 glibc 的默认分配器。它的核心结构是多 arena + 多 bin 的二维管理。

Arena 与线程绑定

为减少锁竞争,ptmalloc 维护多个 arena,每个线程首次分配时绑定到一个 arena。主线程使用主 heap(brk 区域),其他线程从 arena 池中获取,每个 arena 有独立的锁。当线程退出时,其 arena 回收到池中供重绑定。极端多线程场景下 arena 数量受 M_ARENA_MAX 限制。

Fast bins、Small bins、Large bins

释放后的 chunk 按大小分类放入 bin 数组:

  • Fast bins(索引 0-6):固定大小为 16-80 字节,单链表不回合并,释放即挂链,重新分配时命中极快。
  • Small bins(索引 2-63):固定宽度 8 字节的双向循环链表(32-1008 字节),分配与释放都是 O(1)。
  • Large bins(索引 64-126):按固定区间组织,如 1024-1087 字节归为一个 bin,采用最佳适配策略,时间复杂度接近 O(n)。

Unsorted bin 与合并策略

释放的 chunk 会先挂到 unsorted bin 中,不急于分类。在下一次分配时,遍历 unsorted bin,尝试与相邻 free chunk 合并(修改 size 与 prev_size),然后放归对应 bin。这种延迟整理的做法有效降低了 chunk 碎化率。如果 unsorted bin 中恰好有满足请求大小的 chunk,则直接分配,避免后续遍历。

Top chunk 与 sbrk_trim

每个 arena 的顶部有一块未切割的大 chunk,称为 top chunk。当所有 bin 都不满足分配时,从 top chunk 切割。如果 top chunk 毗邻 brk 边界且足够大,可以通过 brk 收缩释放内存回内核,但 free() 通常不会主动收缩,这导致长期运行的大堆内存未必回落到 RSS 指标上。

内部碎片与外部碎片

内部碎片指已分配 chunk 中用户未使用、因对齐要求浪费的部分。ptmalloc 的最小 chunk 大小为 32 字节(x86-64),即使 malloc(1) 也会占用 32 字节。外部碎片则是 free chunk 无法被合并,导致大量离散小空洞。Fast bin 的从不合并策略显著加剧了外部碎片问题,这也是某些长生命周期服务需要替代分配器的原因。

替代分配器:jemalloc、tcmalloc、mimalloc

针对高并发、低碎片、安全性的不同需求,业界出现了多种高性能分配器。

jemalloc:面向 slab 的多线程利器(Facebook/Meta)

jemalloc 将内存按 size class(如 8、16、32…)划分成 slab,每个线程有独立 tcache(thread cache),大多数分配无锁。其多级结构 tcache -> arena -> chunk -> run -> region 实现精细管理。jemalloc 还内置内存占用分析工具,支持按调用栈统计分配量,是 Redis、MySQL 等众多系统的首选。

tcmalloc:线程缓存 + 中心堆(Google)

tcmalloc 的核心是每个线程维护一个 thread-local free list,小对象(< 32KB)直接在本地缓存分配与回收,无锁。当本地为空时,从 central free list 批量获取或归还 128KB 块。大对象(> 32KB)由 central page heap 通过 span 管理。span 是连续页的集合,分配器按 span 的 size class 组织。它还支持 heap checker/profiler,广泛用于 Chrome 和 gRPC。

mimalloc:安全与紧凑兼得(Microsoft Research)

mimalloc 的设计极为紧凑:free list 直接内联在已分配页的头部元数据中,而不是单独存放,这降低了元数据损坏的风险。释放时仅将页标记为可用,延迟归还给线程,减少锁争用。其布局具有良好的局部性,缓存命中率高,同时提供安全加固模式(guard pages, free list canaries),适用于对安全性要求高的场景。

特性ptmallocjemalloctcmallocmimalloc
碎片率极低
并发性能一般(arena 锁)优秀优秀优秀
内存开销中等中等紧凑
诊断能力中等
适用场景通用兼容长生命周期服务短期高并发安全敏感/嵌入式

内核级分配器:slab、slub、slob

用户态分配器管理的是进程的虚拟地址,而内核需要为 task_structinodedentry 等固定大小的结构体频繁分配和释放对象。内核分配器追求的是确定对象大小、高速命中、零碎片。

Slab 分配器

传统 slab 为每种对象类型维护一个 kmem_cache,每个 cache 包含多个 slab(1 页或连续多页组成的物理块)。slab 有三种状态:full、partial、empty。分配从 partial slab 取对象,释放时标记对象可用。对象对齐到硬件缓存行(L1 cache line),减少伪共享。kmalloc 实际上是对通用 size cache 的调用。

#include <linux/slab.h>

struct my_struct {
    int data;
};

static struct kmem_cache *my_cache;

void init_cache(void) {
    my_cache = kmem_cache_create("my_struct",
        sizeof(struct my_struct), 0,
        SLAB_HWCACHE_ALIGN, NULL);
}

void *alloc_obj(void) {
    return kmem_cache_alloc(my_cache, GFP_KERNEL);
}

Slub:简化但保持性能

Slub 是 Linux 当前默认的 slab 实现,它摒弃了复杂的 per-CPU 数组和 slab 着色,使用无锁 per-CPU partial list,将空闲对象直接链在对象内的指针上,大幅降低代码复杂度。对于大多数 Workload,slub 与 slab 性能持平,调试和可读性更好。

Slob:面向嵌入式

Slob 使用简单的首次适配链表管理页内空闲块,支持 kmalloc 的通用请求但性能较差,仅用于嵌入式系统或内存极度受限环境。

诊断:/proc/slabinfo

cat /proc/slabinfo | head -10
# 输出示例:
# kmalloc-128    512  640  128  32  1 : tunables   0  0  0 : slabdata  20  20  0

字段依次为:cache 名、活跃对象数、总对象数、对象大小、每 slab 对象数、每 slab 页数。持续监控可以识别内核内存泄漏。

内存池:确定性与无碎片

数据库、网络框架、游戏引擎等场景常使用内存池,以换取 O(1) 分配时间和零外部碎片。常见的池化策略有三种:

Bump Allocator(Arena)

Arena 分配器维护一个指针,分配时直接前移,永不单独释放个体对象,只能整体销毁。这是编译器、解析器的典型选择,分配代价仅是一条加法指令。

typedef struct {
    char *buf;
    size_t size;
    size_t used;
} arena_t;

void *arena_alloc(arena_t *a, size_t n) {
    n = (n + 7) & ~7;  // 8 字节对齐
    if (a->used + n > a->size) return NULL;
    void *p = a->buf + a->used;
    a->used += n;
    return p;
}

void arena_free_all(arena_t *a) { a->used = 0; }

Free List Allocator

预分配一块大缓冲,按固定大小(如 64 字节)划分成 slot,释放时将 slot 挂到单链表头部,分配时从头部摘下。这是经典的内存池,实现简单且绝对无外部碎片。

typedef struct slot {
    struct slot *next;
} slot_t;

typedef struct {
    char *pool;
    size_t slot_size;
    size_t slot_count;
    slot_t *free_list;
} pool_t;

void pool_init(pool_t *p, size_t slot_size, size_t count) {
    p->slot_size = (slot_size + 7) & ~7;
    p->slot_count = count;
    p->pool = mmap(NULL, p->slot_size * count,
                   PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    p->free_list = NULL;
    for (size_t i = 0; i < count; i++) {
        slot_t *s = (slot_t *)(p->pool + i * p->slot_size);
        s->next = p->free_list;
        p->free_list = s;
    }
}

void *pool_alloc(pool_t *p) {
    if (!p->free_list) return NULL;
    slot_t *s = p->free_list;
    p->free_list = s->next;
    return s;
}

void pool_free(pool_t *p, void *ptr) {
    slot_t *s = ptr;
    s->next = p->free_list;
    p->free_list = s;
}

完整池化方案

实际系统中往往结合 arena 与 free list:arena 管理大块页,free list 管理小块切片,兼顾大对象与小对象的性能。关键原则是在初始化阶段完成所有 mmap/malloc 的批量请求,运行期仅执行指针操作。

总结:如何选择分配策略

  • 通用桌面或短时进程:ptmalloc 已足够,但长期运行应考虑 malopt(M_TRIM_THRESHOLD) 或换用 jemalloc。
  • 高并发、长生命周期服务(数据库、缓存):jemalloc 的切面统计和低碎片优势显著。
  • 短生命周期、高吞吐 RPC/批处理:tcmalloc 的线程缓存和批量策略更合适。
  • 安全敏感(浏览器沙箱、嵌入式固件):mimalloc 的紧凑元数据布局和加固模式值得优先考虑。
  • 内核模块或驱动编程:遵循 kmalloc/kmem_cache_create 的 slab/slub 体系,避免直接操作页表。
  • 需要稳定延迟、无碎片:自研内存池或 bump arena 是最直接的工程解。

理解每一层分配器的设计取舍,才能在面对内存泄漏、RSS 膨胀或分配热点时做出有效的调优决策。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace