ptmalloc2和STL 内存池

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中"两种状态下,这四个字段的用途截然不同。

  1. mchunk_prev_size(前一个块的大小)
    空闲时(作为空闲块):记录前一个相邻物理地址的Chunk的大小(如果前一个Chunk是空闲状态)。它的存在是为了内存合并。当你释放当前Chunk时,分配器会通过这个字段往前找,看看物理地址紧挨着的前一个Chunk是否空闲,如果是,就把它俩合并成一个大块,防止内存碎片。

已分配时(被用户使用):这个字段对当前Chunk无效。因为当前Chunk已被占用,不需要合并操作了。这块内存空间(4或8字节)会被"借"给前一个空闲Chunk使用。如果前一个Chunk是空闲的,它会把它的用户数据区尾部延伸到这里,以节省内存开销。

  1. 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(子线程堆)。

  1. fd(Forward Pointer,前向指针)
    空闲时(在Bin链表中):指向当前Bin链表中下一个空闲Chunk的起始地址。用来把多个空闲块串成双向链表,方便遍历查找。

已分配时(被用户使用):该字段被覆盖!这块内存(8字节)直接划入用户数据区,用户malloc返回的地址就指向这里。此时它不再是指针,而是用户可以任意写入的数据。

  1. 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 bin

    remainder 被放在 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。