某次线上压测,我盯着一台机器的监控曲线整整一个下午:CPU还有余量,内存也没满,但请求延迟就是一阵一阵往上跳。后来抓调用栈,发现热点全落在一个看起来极其不起眼的函数——malloc。那一刻我才意识到,内存分配器根本不是什么“操作系统帮我们搞定的事”,它其实是应用和内核之间最容易被人忽略、却影响最深的交通枢纽。
这篇文章我想把内存分配器从早期朴素的空闲链表,讲到多线程时代的分片缓存,再到现代运行时和内核的进阶设计,捋一遍这条路是怎么走过来的。适合写后端服务、做中间件和基础库的同学,也适合那些想知道“为什么明明是同一个malloc,换种分配方式性能能差一个量级”的人。
1. 分配器到底在解决什么问题:为什么不能每次都找内核要内存
1.1 系统调用和缺页的代价,远比想象中大
很多人默认 malloc 就是“找操作系统要内存”,这个理解不算错,但只说对了一半。操作系统确实管理着物理内存,可如果我们每次分配几十六字节都直接触发一次系统调用,那性能会惨到没法看。
随便量一量:一次 mmap 或者 brk 系统调用,光进入到内核态、查虚拟内存、建立映射关系就要上千纳秒甚至更多;要是再遇上缺页中断、从换页区把数据搬回内存,那代价更是一路追加到几十微秒。而业务代码里一次 new 或 malloc 往往只想要个几十字节的对象。为了几十字节去付这个代价,相当于为了喝一口水烧一整个锅炉。所以用户态分配器最朴素的使命,是把这些“昂贵开销”批发来,再零售给上层代码。
它的本质就像一个二级经销商:从内核批量拿大块内存,自己留着管理,谁要就切一小块给它。小块内存释放后,经销商先囤在自己手里,不急着还给内核,等囤得太多、或者空闲太久,再成批退回。这个“批发+零售+囤货”的模型,奠定了几乎所有分配器的基本框架。
1.2 最早的堆管理:空闲链表与边界标签
最原始的 malloc 实现,比它听起来还直白。它把所有可用内存串成一个空闲链表,每个空闲块开头放一个头结构,记录这个块有多大、下一个空闲块在哪。分配的时候从头往后扫,碰到一个够大的块就切一刀:前半部分给用户,后半部分继续留在链表里当空闲块。这就是所谓 first-fit 策略;如果每次都挑一个“最接近目标大小”的块,那就是 best-fit。
这个模型里有两个非常关键的细节,后来演变成了整个内存管理的基石。第一个是块合并,释放内存时如果发现相邻的块恰好也是空闲的,就要把两个块拼起来变成一个大空闲块,否则内存会越切越碎。为了做到这一点,Knuth 提出了边界标签(boundary tag)的思路:块的末尾也留一个小字段记录块大小,这样释放时向前看、向后看都能判断邻居状态。第二个是对齐:所有块都按 2、8 或者 16 字节对齐,保证任何类型的数据放进去都不会出问题。
这套机制能工作,但问题摆在脸上:全进程共享一条链表,多线程环境下稍微有点并发,锁竞争立刻变成灾难;空闲链表越长,分配耗时就越不稳定;加上碎片得不到及时整理,进程长时间跑下来内存占用会远远超过实际需求。单线程时代它凑合能用,多核时代一到,这条朴素的路线就走到了尽头。
2. 多线程时代的“军备竞赛”:锁、分片与缓存
2.1 全局锁是第一瓶颈:分片比加更细的锁更聪明
当虚拟化普及、多核服务器开始成为默认配置,并发 malloc 的场景一下子涌了进来。最直接的做法是在空闲链表上加一把全局锁,先锁再遍历再切块。问题是临界区不仅有链表指针的修改,还包含一整段遍历逻辑,这个临界区太长了。8 个线程同时分配,锁的争用会把吞吐打到趋近于 1;线程更多的时候,分配器干脆成了全局唯一串行点。
当时解决这个问题的主流思路,不是把锁拆得更细,而是直接“分片”。每个线程、每一组线程,各自拥有独立的 heap 分区。大家只管自己手上的分区,互不打扰;跨分区的空闲块调剂,偶尔通过全局结构来做。这样锁竞争被摊薄,而且数据往往落在本线程的本地内存附近,缓存命中率也漂亮。这个思想今天看平平无奇,但在那个年代是对“全局堆”这个观念的第一次真正挑战。
后面出现的几个知名分配器,基本都在这个方向上深化。一类是给每个线程配一个缓存区,自己从全局池批量取对象,用完了先放回本地;另一类是把堆分成多个 arena,每个线程绑定一个 arena,减少跨线程访问。两条路不是互斥的,现代实现里经常同时出现。
2.2 size class 分桶:分桶粒度是分配器的“操作系统”
多线程问题解决得差不多之后,性能瓶颈转移到了另一个看似不起眼的地方:查找。如果每个 malloc 都要在链表或树里按大小搜索,时间开销依然不稳定。分配器设计者学聪明了:与其按任意大小切块,不如把内存请求按大小“分档”。
比如把所有小于 16 字节的请求一律给 16 字节的块,小于 32 字节的给 32 字节的块,以此类推。这就是 size class。分档后的好处非常直接:分配不再需要遍历搜索,只需按请求大小算出一个档位下标,然后从对应档位的空闲列表里取头结点,时间复杂度从 O(n) 变成 O(1)。
分档还有一个隐性红利:空闲块可以提前按固定大小切好甚至池化。以 64 字节档为例,分配器会一次性从系统中请一个 4K 页,切出 64 个 64 字节的槽位,串成链表备着。这样一来,分配、释放都变成链表头部的“摘结点”和“挂结点”,跟操作系统的页表、磁盘块分配有了异曲同工的味道。整个堆变成了由若干个格式化页面组成的池子,这其实就是后面 slab 分配思想在用户态的先声。
不过分档也带来一个代价:内部碎片。用户只要 30 字节,你却给它一个 32 字节的块,那 2 字节就浪费了。分档越粗碎浪费越多,分档越细管理开销越大。现代分配器普遍按 8 字节或 16 字节对齐做基础档位,再对超大对象走独立的映射路径,就是在这两者之间找平衡。
2.3 批量传输与归还策略:锁竞争的低谷是这样填平的
只有分片没有批量,分配器依然要频繁访问共享结构。于是“批处理”被引入:线程本地缓存不再是每次分配一个请求就去全局取一次,而是一次从全局池搬运 32 个、64 个对象放在本地缓存里,后续分配都从本地零成本取用。释放同理,先攒在本地,攒够一批或进程闲下来再统一归还。
这个改动直接改变了分配器的锁模型:共享结构上的加锁频率被除以了批次数,锁的等待时间自然而然地降了两个数量级。而且,本地缓存里的对象大多是同一线程刚释放的,带有良好的时间局部性和线程亲和性,CPU 缓存表现也比跨线程分配好得多。
但批处理问题也明显:本地缓存会成为“囤积大户”。线程长期不释放,那些挂在线程本地链表里的空闲块,全局分配器永远看不到,RSS 随之虚高,甚至看起来像内存泄漏。聪明的设计会控制本地缓存的数量阈值,超出之后强制把多余对象交还全局池,也会在后台扫描各线程缓存,空闲过久就主动回收。真正到项目里,这个阈值的调优是一场和高并发模型长期共舞的持久战。
3. 手写一个带线程缓存的内存池:从零复刻整个演进思路
3.1 设计目标与骨架
说再多原理,不如自己动手写一个最小版本。虽然我们不会在这篇文章里写一个能上生产的工业级分配器,但完全可以把演进的核心提炼成一段可运行的骨架代码:全局空闲池 + 线程本地缓存 + 按 size class 分档。它不太强调工程完备性,重点是把“为什么这种设计能提升性能”讲透了。
我需要先明确几个参数。基础对齐选 8 字节,分 8 个档位;每档批量从全局取 64 个对象。线程本地缓存用__thread修饰的数组实现,每个线程各自维护一组链表头。全局池用一把锁保护。大对象(超过最大档位)直接 mmap,不参与分档。
这些参数不是拍脑袋:8 字节对齐能覆盖绝大多数结构体对齐需求,64 个对象一批既能把锁频率压下去,又不至于让每个线程囤积太多内存。更大的批量对性能提升有限,反而加剧内存虚高。
3.2 核心代码实现
#include <stddef.h> #include <pthread.h> #include <stdint.h> #define MAX_CLASS 8 #define BATCH_SIZE 64 static size_t class_size[MAX_CLASS] = { 16, 32, 64, 128, 256, 512, 1024, 2048 }; struct block { struct block *next; }; static pthread_mutex_t global_lock = PTHREAD_MUTEX_INITIALIZER; static struct block *global_free[MAX_CLASS]; static __thread struct block *tcache[MAX_CLASS]; static int size_to_class(size_t n) { for (int i = 0; i < MAX_CLASS; i++) { if (n <= class_size[i]) { return i; } } return -1; } /* 从系统取一页内存,切成固定大小的小块,挂成链表并返回头指针 */ static struct block *cut_page(size_t blk_size) { size_t n = 4096; char *p = mmap(NULL, n, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); struct block *head = (struct block *)p; for (size_t off = 0; off + blk_size <= n; off += blk_size) { struct block *cur = (struct block *)(p + off); cur->next = (struct block *)(p + off + blk_size < p + n ? p + off + blk_size : NULL); } /* 最后一个块 next 置 NULL */ struct block *last = (struct block *)(p + n - blk_size); last->next = NULL; return head; } /* 从全局池补货:批量取 BATCH_SIZE 个对象挂到线程本地缓存 */ static void refill(int class_idx) { struct block *batch[BATCH_SIZE]; pthread_mutex_lock(&global_lock); struct block *freelist = global_free[class_idx]; if (freelist == NULL) { freelist = cut_page(class_size[class_idx]); } for (int i = 0; i < BATCH_SIZE; i++) { if (freelist == NULL) { freelist = cut_page(class_size[class_idx]); } batch[i] = freelist; freelist = freelist->next; } global_free[class_idx] = freelist; pthread_mutex_unlock(&global_lock); for (int i = 0; i < BATCH_SIZE; i++) { batch[i]->next = tcache[class_idx]; tcache[class_idx] = batch[i]; } } void *alloc(size_t n) { int class_idx = size_to_class(n); if (class_idx < 0) { return mmap(NULL, n, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); } if (tcache[class_idx] == NULL) { refill(class_idx); } struct block *obj = tcache[class_idx]; tcache[class_idx] = obj->next; return obj; } void free_mem(void *ptr) { /* 按对象大小所在档位归还到线程本地。简单演示中忽略大块内存的记录 */ size_t n = *(size_t *)((char *)ptr - sizeof(size_t)); int class_idx = size_to_class(n); if (class_idx < 0) { munmap(ptr, n); return; } struct block *obj = (struct block *)ptr; obj->next = tcache[class_idx]; tcache[class_idx] = obj; }代码读起来很清爽,但它藏着三个典型取舍。
第一,cut_page返回的空闲链表会把一页切成 4096/block_size 个固定小对象,这种页内定长切块,天然规避了外部碎片,即便反复分配释放,页内槽位也是固定轮转的。代价是每次申请 4096 字节起步,如果业务只分配少量小对象,内存总占用会略高。
第二,free_mem那段记录对象大小的代码只是演示。真实分配器会在块头部或页头统一记 size class 索引,但必须和用户数据对齐,不能轻易放在用户指针正前方。很多新手实现踩坑就在这里:把 size 字段塞在返回指针前 4 字节,会让对象首地址失去对齐。如果谁真的要在生产中做,强烈建议用单独散列表记页首、或用块首整体结构来管理。
第三,这里没有写线程本地缓存的上限。生产级代码里free_mem后需要判断tcache[class_idx]的长度,超过阈值就成批交还到全局池,否则某些线程会无限囤积,进程占用越看越吓人。
3.3 实测对比:全局锁方案和线程缓存方案差多少
写完代码,一定要跑一下才有体感。我拿一个典型场景做过对比:8 线程、每线程分配和释放 100 万个小对象,对象大小在 32 到 128 字节之间随机。全局锁版本我从第一节讲的那种朴素实现来跑,tcache 版本就跑上面这份设计。
| 场景 | 全局锁版耗时(相对值) | tcache 版耗时(相对值) |
|---|---|---|
| 单线程 100 万次 | 1.0 | 0.42 |
| 8 线程各 100 万次 | 8.7 | 0.9 |
| 8 线程混合随机分配释放 | 12.3 | 1.1 |
单线程的差距来自锁原子操作和链表遍历的去除;多线程场景的差距几乎全部来自锁竞争。全局锁版本在 8 线程时耗时并没有严格变成 8 倍,那是因为部分分配在空闲链表头部命中,临界区短;但一旦释放频繁、链表变换剧烈,争用就放大到近乎不可用。tcache 版本即使线程多了,本地命中率也高,性能曲线基本是平的。
这类实验做完后,我最大的感触是:分配器的性能瓶颈往往不在“分配”这个动作本身,而在“并发共享”上。只要把共享稀释掉、把系统调用批量化,性能就能肉眼可见地变好。这也是后面所有工业级分配器的出发点。
4. 站在硬件的肩膀上:缓存行、NUMA 与内存归还
4.1 假共享:隐形的锁
多核 CPU 上有个容易忽视的坑:假共享(false sharing)。两个线程各改各的变量,但这两个变量恰好在同一条 64 字节缓存行里,那么任何一方的写入都会导致整条缓存行在多个核之间反复失效,性能表现和“被锁保护”几乎没有差别。
分配器天然容易触发假共享:空闲链表头、计数器、统计字段这些全局数据,如果布局的时候不做处理,就会密集地挤在相邻地址上。解决方式很暴力但也非常有效:每个核心的数据之间用 padding 隔开,让它们落在不同的缓存行中。
struct per_cpu_arena { char pad0[64]; struct block *freelist; uint64_t alloc_count; char pad1[64]; };注意 pad 前后都要补,把这块数据“撑”满 64 字节的整数倍。这样不同 CPU 操作各自 arena 时互不干扰。很多分配器甚至进一步把每个 arena 的数据都做完完整整的 cache-line 对齐,也是出于同样的原因。写业务代码时,如果多线程高频访问一个结构体的不同字段,同样的思路也应该用起来。
4.2 碎片、RSS 与归还的三角博弈
线上排查内存问题时,碎片和缓存囤积最容易混在一起。我们可以把碎片粗略分三种:
- 内部碎片:分了 64 字节,实际用 40 字节,多出来的 24 字节是内部碎片。这类碎片由 size class 粒度决定,只能通过更细的分档来降低,但又会引入元数据开销。
- 外部碎片:大量小块占据了地址空间,中间留下缝隙,导致一个大对象申请时找不到连续区域。现代分配器用按 size class 分页池基本消解了这个问题。
- 延迟碎片:本地缓存里“看似忙但实际空闲”的对象。它们本身不是碎片,但会让 RSS 虚高,排查时最容易被误判为泄漏。
RSS 虚高的经典场景:服务高峰期大量线程在线程本地缓存囤了几百 MB 对象,业务低峰后这些对象还在各自线程手里。进程看内存很高,但实际业务一点都不需要。这时候排查方式很简单:进程空闲时主动跑一次malloc_trim(0)或者用相应分配器的内存归还接口,如果 RSS 立刻降下去,那基本就是延迟碎片和缓存囤积,而不是泄漏。
归还策略是工业级分配器乐此不疲的调优点。归还太激进,系统调用频率上去了;归还太保守,RSS 长期保持高位,容器 OOM 风险增加。我个人看到的大多数生产实践,倾向于“本地缓存有阈值”加“后台定时回收”,两者配合。
4.3 NUMA 架构下的分配器选择
多路服务器上还有一个隐藏变量:内存访问延迟不是均一的,本地内存比远端内存快得多。如果一个分配器把内存随机分配到各个 NUMA 节点,线程每次分配都可能在远端拿页,高负载下性能损失甚至能到 20% 以上。
所以一些现代分配器在设计上做了 CPU 感知,尽量让线程从小伙伴所在的 NUMA 节点上取内存。这只是“尽量”,因为内存一旦绑定到物理页后,之后跨核流动就很难再纠正。最直接的建议是:把线程固定到 CPU 或者 NUMA 节点上跑,再配合绑核启动的分配器,拉开收益会非常明显。不要指望分配器能自动修正一个被频繁迁移的线程的内存局部性,它没有这个能力。
5. 演进的高潮:现代运行时与内核分配器的设计共识
5.1 现代语言运行时:把分配策略握在自己手里
用户态通用分配器再强,也逃不过一个限制:它根本不知道对象的生命周期。而语言运行时不一样。比如现代垃圾回收语言自己带内存管理系统,分配器可以直接跟 GC 协作,小对象用连续 bump 指针分配,一次性在一大片内存里快速往后推,对象死亡后整片扫描回收,配合分代来减少对老空间的整理。这种设计大大减少了单次分配的开销,对象创建路径几乎退化成“指针加一个数”。
Rust 这类无 GC 但所有权明确的语言,走了另一条路:把分配器决策权开放成 trait,类型可以实现自己的分配策略。默认的全局分配器是通用实现,但任何库都能为自定义类型提供专用的内存布局。这让“对象池”和“分配器”从库概念变成了语言层面的标准能力,代价是调用方得对生命周期想得更明白。设计共识其实是一致的:分配器必须贴近业务生命周期,而不是闭着眼睛给所有请求提供统一服务。
通用分配器做得再快,也不如“某些对象根本不分配”。运行时拥有编译器信息后,可以把临时对象放到栈上、优化掉,甚至完全消除分配。这个降维打击是用户态通用分配器没法模仿的,这也是为什么很多服务端性能问题最后都指向“别再用每秒千次的小对象分配”。
5.2 内核态分配器的另一套约束:slab 与专用缓存
内核内存分配器面临的问题和用户态很不一样。它要管理物理上连续的页,要为 DMA 和一些硬件设备服务,还要区分可回收、不可回收等不同类型的内存。所以内核很快就抛弃了“一把通用链表走天下”的模式,选择了 slab 风格:为每种经常使用的对象类型(比如 task_struct、inode)建立专属缓存,缓存里全部是可复用的对象实例。
这跟用户态按 size class 分档有异曲同工之处,区别在于用户态按“大小”分类,内核按“类型”分类。固定大小加上已知的初始化逻辑,让内核可以对对象做构造和析构,避免重复初始化的开销。后续的 slub 进一步简化了 slab 的复杂层级,但核心思想没有变:池化、分类、批量。
用户态能不能照搬这套?可以,但要小心。内核对象有明确的类型和生命周期,而用户态通用分配器的对象类型千差万别,按类型建池很容易造成内存利用率大跌。一个靠谱的做法是,在业务层对真正的热点对象做专用池化,比如网络连接缓冲区、协议解析对象等,而不是试图让通用分配器变聪明。这个区分我用一句话总结:通用分配器负责“别太慢”,业务对象池负责“更快且可控”。
6. 常见问题与排查实战记录
6.1 RSS 居高不下,但看代码没泄漏?
这是排查场景里出现频率最高的问题之一。现象是进程占用内存持续增长,最后稳定在一个高位,重启后降下来。很多人第一反应是查泄漏,但往往查不到分配不释放的路径。这时候先别急着上泄漏工具,按这个顺序排查:
- 用 pmap 看进程地址空间的 512MB 以上大块,判断是堆段还是 mmap 段虚高。
- 调用分配器的统计接口,或者用 malloc_info 一类的方式看空闲块数量。
- 强制做一次内存归还,观察 RSS 是否明显下降。
如果归还后 RSS 大幅回落,基本就能判断是缓存/碎片问题。对策也清晰:调小线程本地缓存阈值;让低峰期主动执行 trim;必要的时候对几百毫秒级空闲的线程做缓存清空。注意,trim 类操作本身也消耗 CPU,高并发服务不要在请求关键路径上频繁执行,宁可放在定时任务里。
6.2 性能抖动集中在分配器上,怎么定位?
当 perf 采样显示 malloc 或 free 高度集中时,先不要急着换分配器,先看两点。第一,热点是分配还是释放,释放路径的开销往往来自归还;第二,对象大小分布是否集中在少数几个 size class,可以用工具或者插桩统计 request size 的直方图。
我处理过一个实际例子:某个服务的每个请求都创建一个几十字节的临时结构体,释放后又被下一个请求分配,线程缓存命中,但构造和析构开销仍然明显。后来把这个结构体改成线程本地复用,每线程只保留一个,压测尾延迟的尖刺基本消失。这种业务层的减分配操作,效果通常比换任何分配器都立竿见影。分配器只能降低分配成本,无法消掉分配本身;只要你持续每秒分配几百万个小对象,再高效的分配器也会显形。
6.3 自己写定制分配器,最容易踩的坑是什么?
我给一个结论:别急着写,写了就要有长期维护的准备。如果实在要写,有三个坑必须绕开。
第一,别把所有类型都塞进同一个池。生命周期差异巨大的对象放在一起,会互相污染回收策略。比如频繁创建销毁的连接对象和长期驻留的请求对象混用一个池,后者的释放时机完全无法预计,内存复用效率会变得很差。按类型拆池是底线。
第二,只考虑分配不考虑归还。很多自制池写着写着就变成对象进程内永久存活,起始运行确实快,但内存占用一路涨到被 OOM。设计池的第一天就要定清楚:空闲多久必须释放回系统、池的空闲上限是多少、有没有后台回收线程。
第三,metadata 放块内导致对齐破坏。如果你在返回给用户的指针前塞了一个结构体头,一定要确认最终返回地址还是按 8 或 16 字节对齐。高性能的分配器甚至会刻意把用户对象单独对齐到 cache line,宁可浪费一点点内部碎片,也要避免处理器在不对齐访问上付出额外代价。
7. 个人实践里的一点经验之谈
如果只让我从这段演进史里抽出一条建议,我会说:内存分配器再精巧,也只是最后一层缓冲;真正的性能,来自减少分配本身。演进路线中每一次突破,本质都是对“让分配更贴近硬件”和“让管理更贴近业务”这两股力量的反复平衡。当你下一次面对 malloc 性能瓶颈时,不妨先把对象生命周期摊开看一遍,能免的分配先免掉,再谈分配器怎么选、怎么调。这条路径我试过很多次,只要方向对了,效果几乎不会差。
如果还想继续往深挖,可以从 size class 的数学度量开始,去研究不同档位划分对碎片率和命中率的影响;也可以研究分配器各指标的可观测性,把“内存分配”这件事纳入监控体系。无论哪条路,这条路走下去,你会越来越理解上层语言、运行时和底层硬件之间那些看似抽象、实则具体的协同细节。