glibc 在用户态维护了一个内存池,malloc/free 大多数时候只是在池子里切蛋糕 / 还蛋糕,而不是每次都找内核要。整套设计围绕"按大小分级管理"和"多线程低锁竞争"两个目标展开。
一、内存管理的基本单位:chunk、 bin 与 arena
chunk 是 ptmalloc2 管理内存的最小单位。用户拿到 malloc 返回的指针,只是 chunk 中"用户数据区"的起始地址;在它前面还有一段元数据头部,存放了本块大小、前一 chunk 是否在使用中(PREV_INUSE)、是否 mmap 而来(IS_MMAPPED)、是否属于非主 arena(NON_MAIN_ARENA)等标志。
chunk 的大小在 64 位机上按 16 字节对齐,最小 chunk 通常为 32 字节。这些标志位藏在 size 字段的低 3 比特里,所以整个分配器对元数据的操作都是"就地(in-place)"的------这也是为什么堆溢出能直接覆写相邻 chunk 的元数据。
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* 双向链表 ------ 前向指针 */
struct malloc_chunk* bk; /* 双向链表 ------ 后向指针 */
};
这些字段是"复用的"。一个Chunk在"已被分配给用户"和"空闲在Bin中"两种状态下,这四个字段的用途截然不同。
- mchunk_prev_size(前一个块的大小)
空闲时(作为空闲块):记录前一个相邻物理地址的Chunk的大小(如果前一个Chunk是空闲状态)。它的存在是为了内存合并。当你释放当前Chunk时,分配器会通过这个字段往前找,看看物理地址紧挨着的前一个Chunk是否空闲,如果是,就把它俩合并成一个大块,防止内存碎片。
已分配时(被用户使用):这个字段对当前Chunk无效。因为当前Chunk已被占用,不需要合并操作了。这块内存空间(4或8字节)会被"借"给前一个空闲Chunk使用。如果前一个Chunk是空闲的,它会把它的用户数据区尾部延伸到这里,以节省内存开销。
- mchunk_size(当前块的大小)
这是最核心的字段,功能固定,永不复用。
功能:记录当前Chunk的总大小(包括元数据头部 + 用户数据区),且必须是8字节(32位)或16字节(64位)的整数倍(内存对齐)。
隐藏的"标志位":由于大小总是对齐的,所以低3位(二进制末尾)永远为0。为了省空间,Glibc把这3位用作状态标志:
PREV_INUSE (P)(第0位):1表示前一个Chunk正在被用户使用(不可合并),0表示前一个Chunk是空闲的(可以合并)。这是合并操作的"开关"。
IS_MMAPPED (M)(第1位):1表示这块内存是通过mmap系统调用直接分配的(通常用于超大块),不归Arena管理。
NON_MAIN_ARENA (N)(第2位):1表示该Chunk属于非主线程的Arena(子线程堆)。
- fd(Forward Pointer,前向指针)
空闲时(在Bin链表中):指向当前Bin链表中下一个空闲Chunk的起始地址。用来把多个空闲块串成双向链表,方便遍历查找。
已分配时(被用户使用):该字段被覆盖!这块内存(8字节)直接划入用户数据区,用户malloc返回的地址就指向这里。此时它不再是指针,而是用户可以任意写入的数据。
- bk(Backward Pointer,后向指针)
空闲时(在Bin链表中):指向当前Bin链表中上一个空闲Chunk的起始地址。与fd配合,组成完整的双向链表,支持快速的插入和删除(比如从链表中摘取一个Chunk)。
已分配时(被用户使用):同样被覆盖,成为用户数据区的一部分。
Bin(空闲链表索引 ):中间层,是 Arena 内部用于组织空闲 Chunk 的"索引"或"收纳盒"。Bin 本身是一个指针数组,数组的每个元素都指向一个空闲 Chunk 链表的头部。Arena 的结构体 (malloc_state) 中,通过 fastbinsY 和 bins 两个数组来管理所有的 Bin。small bins 和 large bins 是普通 Bin (bins 数组) 中的两类;
| 特性 | Small Bins | Large Bins |
| ---- | --------------------------- | -------------------------------- |
| 管理方式 | 每个 bin 管理固定大小的空闲 Chunk | 每个 bin 管理一个大小范围内的空闲 Chunk |
| 查找效率 | 高 (O(1)),可直接定位到对应大小的 bin | 相对低 (O(N)),需要在链表或树中遍历查找最合适的块 |
| 数据结构 | 双向链表,先进先出 (FIFO) | 双向链表 + 大小树 (size tree),按大小排序 |
| 分配策略 | 精确匹配,找到大小完全一致的 Chunk | 最佳匹配 (best-fit),找到能满足需求的最小 Chunk |
| 大小范围 | 小于 512B (32位) 或 1024B (64位) | 大于等于 512B (32位) 或 1024B (64位) |
| 数量 | 共 62 个 (bin 2 到 63) | 共 63 个 (bin 64 到 126) |
arena 则是一个独立的堆内存区域,拥有自己的 bins 数组、top chunk 和一把锁(struct malloc_state)。进程启动时只有一个主 arena(main_arena),它通过 sbrk() 扩展堆区;后续为其他线程创建的非主 arena(thread_arena),则用 mmap() 向内核批量申请大块内存自行切割。一个 Arena 包含多个 Bin,以及一个特殊的 top chunk。
💡 关键认知:一个 arena = 一个独立的内存池 + 一把锁。多线程的并发能力,本质上来自于"线程分散到不同 arena 上操作",从而避开同一把全局锁的争用
二、按大小分级的分配策略
ptmalloc2 的典型尺寸阈值如下(具体值会随架构与 mallopt 配置变化):
| 请求大小 | 所属分级 | 数据结构 | 特点 |
| ----------------- | ---- | ------------------- | -------------------------------------- |
| ≤ 64B(默认) | 极小块 | tcache / fast bins | 单链表 LIFO,释放时不合并,追求极速 |
| ≤ 512B | 小块 | small bins | 62 个精确尺寸的双向链表,FIFO,释放时会做相邻合并 |
| > 512B 且 < 128KB | 大块 | large bins | 63 个按尺寸区间划分的双向链表,区间内按大小排序,best-fit 查找 |
| ≥ 128KB(默认) | 超大块 | 直接 mmap() | 绕过堆池,直接向内核申请页对齐内存,free 时直接 munmap() 归还 |
此外还有一个 unsorted bin------刚释放、还没归位的 chunk 的中转站,作为"最近释放"的缓存,下一次 malloc 会先来这里碰碰运气,命中就直接复用, miss 才正式进入 small/large bin。
tcache(Thread-Local Cache) 是现代 ptmalloc2(glibc 2.26 起)加入的关键优化:每个线程私有一份最近释放的 chunk 缓存(默认 64 个 size class,每类最多 7 个),完全无锁------这是当前小内存分配能达到极速的根本原因。
三、malloc 的整体分配流程
-
malloc 总的分为三步1、初始化arena;2、从bins回收站体系或top_chunk中,尝试获取指定大小内存块; 3、上述失败,从系统调用mmap获取(brk mmap);
-
chunk是arena中管理内存块的基础组件,bin是管理空闲内存块的链表,bin回收站体系,包括fast bins/small bins/ large bins/ unsorted bins,
它们存储从核心空闲内存块top_chunk中切片出后回收的块,并提供给下次malloc使用。
-
fast bins small bins large bins链表存储这不同大小范围的内存块;如果有大小完全匹配的;直接从链表中取用;如果没有大小完全一致的,遍历中转站 unsorted bin时会将不匹配的chunk 按大小归类到small/large bin中;
-
对于larger bin中找到的更大chunk,通过切分(split)取走所需大小,剩余部分(remainder)放回 unsorted bin 供下次使用。
-
如果这样也不行,被迫从top chunk切取。
合并(consolidation) 发生在:
_int_free 中释放时:前后相邻 chunk 如果是空闲的,合并成一个malloc_consolidate:
集中合并所有 fastbin,由几个时机触发(large 请求进入、small 请求 top 不够、大块 free 超过 64KB)遍历 unsorted bin 中的每个 chunk:
├─ exact fit? → 直接拿走,不切分
├─ last_remainder 命中? → 切分(split),remainder 留在 unsorted bin
└─ 都不是? → ★ 归类(不是合并!)到 small/large binremainder 被放在 unsorted bin 里"等一等",如果下一次 malloc 请求大小匹配,
就能直接命中,不需要先入small/large bin 再取出。只有当下次 malloc 遍历 unsorted bin 且不匹配时,才会被归类到对应 bin。fastbin 为了速度"隐藏"了空闲内存的存在,分配器因此无法判断真实可归还量。
如果每次 free 都做全面检查代价太大,折中就是只在释放大块内存时(这是最可能触发 trim 的时机)
做一次全面的 fastbin 合并和 trim 评估。
把上面的分级串起来,malloc(size) 的典型路径是:
request2size 把用户请求字节数换算成对齐后的 chunk 大小;
先查 tcache:本线程私有缓存里有没有同尺寸的空闲 chunk?有就直接返回,全程无锁、无系统调用;
再查 fast bins:极小块的单链 LIFO 缓存;
查 small bins:精确尺寸匹配;
查 unsorted bin:从中尝试复用或将其正式分拣进 small/large bin;
查 large bins:best-fit 查找合适尺寸;
从 top chunk 切:以上都没命中,就从该 arena 的 top chunk( wilderness chunk )里切一块出来;
top chunk 不够:主 arena 走 sbrk() 扩容,非主 arena 走 mmap() 新映射一块 heap segment;
超大请求:直接 mmap() 匿名映射,绕过整个堆池。
四、free 的核心逻辑:延迟归还 + 相邻合并
找不到才从原料切新的。free 则把不用的 chunk 放回回收站(或直接还给原料 top),而不是立即归还 OS。整个系统就是一个用户态的内存复用引擎。
只有合并后块非常大、或top chunk过大时,才会触发 munmap() 真正归还给内核。这套"延迟归还"机制让 free 在大多数情况下零系统调用,下次 malloc 直接从 bin 里复用即可。
-
如果free的是mmap块, 那系统调用munmap
-
进入bins回收体系,小块内存 挂载到fast chunk
-
其他chunk内存,先进行前后块合并, 放入unsorted bins共下次malloc切片调度, 下次malloc会将不匹配的块归类到samll large。
-
特别的,如果合并是的下一个块恰好是top chunk ,直接合并成top chunk,而不是进行unsorted bins.
遗漏点 说明 last_remainder 连续 small 请求时,从同一块 remainder 反复切分,具有极好的缓存局部性 binmap 128个bin用bitmap加速扫描,O(1)跳过空bin,不是逐个遍历 Fastbin 不合并 fastbin chunk 保持 PREV_INUSE=1,假装还在使用,这是 fastbin 快的核心原因 Large 请求主动合并 fastbin large 请求在第(3)步就调用 malloc_consolidate,small 请求则是到 top 不够才合并 大块 free 触发 trim ≥64KB 的 chunk 释放后,会检查 top 是否超过 trim_threshold,若超过则归还 OS Unsorted bin "只看一次" 每个 chunk 在 unsorted bin 中只被遍历一次------要么被分配,要么被归类
五、多线程模型下arena 的锁
- 一个 arena 是一个被多个线程共享的内存池。权衡并发性能 vs 内存开销,arena 数量有上限(8 × 核数)。
- main_arena 与 thread_arena*是同一个结构体 malloc_state 的两个实例。main_arena 是 glibc 中唯一的全局变量实例;thread arena 是动态创建的 malloc_state,其内存本身就在它管理的 mmap 区域开头。所有 arena 通过 next 指针串成环形链表。
多线程模型:arena 如何降低锁竞争?
- 每个线程通过线程局部存储(Thread-Local Storage, TLS)thread_arena 绑定到一个 arena。让每个线程能快速找到自己专属或常用的 arena。
- 锁竞争与切换:如果该 arena 正被其他线程占用(锁竞争失败),malloc 可能会尝试寻找其他空闲的 arena。如果找到了,它甚至可能会更新 thread_arena 指向这个新的 arena。
- 失败则新建一个非主 arena(用 mmap),不超过上限;
所以 ptmalloc2 的并发性能 ≈ "线程数 × 单 arena 无争用时的分配速度",直到 arena 数量触顶。这也是为什么高并发程序有时会通过 MALLOC_ARENA_MAX 调小 arena 数量来换内存开销的确定性。
六、STL的内存池
STL 的"内存池"指二级空间配置器(allocator)所采用的一种小块内存管理技术。------它并非 C++ 标准强制要求的统一组件,而是经典 SGI STL里默认分配器的实现策略:用"内存池 + 自由链表(free-list)"来优化容器中小对象(如 list 节点、vector 元素等)的频繁分配与释放。
两级配置器结构:
🎯 第一级配置器(__malloc_alloc_template)
处理 > 128 字节 的大块内存
直接调用 malloc / free / realloc
模拟 C++ 的 new-handler 机制:内存不足时调用用户注册的回调尝试释放内存,仍失败则抛 std::bad_alloc
🎯 第二级配置器(__default_alloc_template)------ 这才是内存池的核心
处理 ≤ 128 字节 的小块内存
维护 16 个 free-list,分别对应 8、16、24、...、128 字节(即按 8 字节对齐)
每个 free-list 是一个单向链表,串起来的节点大小固定
分配流程(allocate)
申请大小 > 128 字节 → 转第一级配置器,直接 malloc
申请大小 ≤ 128 字节 → 字节对齐,找到对应的 free-list
该 free-list 非空 → 直接从链表头摘一个节点返回(几乎是纯指针操作,极快)
该 free-list 为空 → 调用 refill(),再通过 chunk_alloc() 从内存池一次性切 20 个该大小的块:
内存池水量充足 → 直接拨 20 个 (pool 是由 allocator 自己维护的、独立于 malloc 内部结构的应用层缓冲区。减少了malloc调用)
内存池水量不够但至少够 1 个 → 返回能凑出的数量
内存池完全不够 → 用 malloc 扩容内存池,新申请大小为 2 × 需求量 + 随分配次数递增的附加量
malloc 也失败 → 去更大的 free-list 里"借"块;再不行就求助第一级配置器的 OOM 机制
释放流程(deallocate)
块 > 128 字节 → 直接 free 还给系统
块 ≤ 128 字节 → 不还给系统,而是按大小挂回对应的 free-list,供下次复用
这就是内存池"只进不出"的特点:小块内存释放后留在进程里复用,所以即使容器销毁,那块内存也不会立刻归还 OS。