跳转至

入门:先建立 ptmalloc 的整体模型

Note

这页的目标不是展开源码细节,而是先建立一套稳定的整体模型:

  1. 什么是堆
  2. 什么是 chunk
  3. malloc 时一块内存大概会怎么被找到
  4. free 时一块内存大概会去哪里
  5. 后面读源码时,应该把哪些函数当成哪一步来看

总览

ptmalloc 本质上只做两件事:

  1. 尽量复用已经存在的空闲 chunk
  2. 如果现有空间不够,再向系统要新的内存

所以后面看到一堆 tcachefastbinsmallbinlargebintop chunkmmap chunk,不要先被名字吓住。
它们本质上都只是这两个目标下的不同“存放方式”和“处理策略”。

先回答最基础的问题:什么是堆

从程序运行的角度看,进程拿到的是一整片虚拟地址空间。
这片空间里会放很多东西,例如:

Text Only
低地址
+----------------------+
| 代码段 / 只读数据     |
+----------------------+
| 全局数据 / 静态数据   |
+----------------------+
|         堆           |
+----------------------+
|         ...          |
+----------------------+
|         栈           |
+----------------------+
高地址

这里先只关心两个最常见的区域:

  • 栈:函数调用时自动管理,通常随着函数进入和返回自动增长、回退
  • 堆:程序主动申请、主动释放,用来放生命周期更灵活的数据

例如:

  • 局部变量通常更接近“放在栈上”
  • malloc 出来的内存通常更接近“放在堆上”

堆最重要的特点不是“大”,而是:

  • 它的使用时机不固定
  • 它的大小需求不固定
  • 谁先申请、谁后释放也不固定

这就带来一个问题:

Text Only
1
2
3
4
5
6
7
程序不断 malloc / free
    |
    v
堆里会出现很多大小不同、状态不同的内存块
    |
    v
必须有人负责管理这些块

ptmalloc 做的就是这件事。
它并不是“堆本身”,而是“管理堆中这些内存块的一套分配器”。

所以后面读到 tcachebintop chunk 这些名词时,可以先把它们理解成:

  • 堆并不是一整块无差别的空间
  • 分配器会把堆里的空闲块分门别类地管理起来
  • malloc / free 就是在这些分类结构之间搬运和复用内存块

再换一个角度:ptmalloc 本身就是一套“多级缓存”

你可以把 ptmalloc 理解成一套分层缓存系统,而不是“每次都临时去系统要内存”的分配器。

它大概像这样:

Text Only
离用户最近、最快
    |
    +--> tcache
    +--> fastbin
    +--> unsorted bin
    +--> smallbin / largebin
    +--> top chunk
    +--> 系统调用(sbrk / mmap)
    |
离系统最近、最慢

这张图要表达的不是严格执行顺序,而是一个核心思想:

  • 越靠上,越像“已经准备好、希望立刻复用的缓存”
  • 越靠下,越像“必须付出更大成本才能拿到的新内存”

把这个视角记住之后,很多设计都会突然顺起来:

  • 为什么 free 不急着把普通 chunk 还给系统
  • 为什么 tcache / fastbin 这种“先挂起来再说”的结构会存在
  • 为什么 unsorted bin 不急着一开始就做最细分类
  • 为什么 top chunk 会被当成“最后的现成库存”

这套结构的核心可以概括成一句话:

ptmalloc 尽量把后续可能继续使用的 chunk 留在用户态维护的缓存和空闲结构里,而不是频繁重新进入系统调用。

第一层模型:什么是 chunk

我们平时写:

C
void *p = malloc(0x20);

你以为自己拿到的是“20h 字节内存”,但 ptmalloc 真正管理的是一个更大的对象,叫 chunk

可以先把它想成这样:

Text Only
1
2
3
4
5
6
7
低地址
+----------------------+--------------------------+
| chunk 头部信息        | 用户真正能用的数据区      |
+----------------------+--------------------------+
                        ^
                        malloc 返回给程序的是这里
高地址

也就是说:

  • ptmalloc 管理的是整个 chunk
  • 程序真正拿到的只是 chunk 里“用户数据区”的起点
  • free(p) 时,ptmalloc 会把这个用户指针再换算回所属的 chunk

Tip

后面读源码时一定要时刻分清两件事:

  • chunk 指针:分配器内部视角
  • 用户指针:程序拿到的 malloc 返回值

第二层模型:一个 chunk 在“使用中”和“空闲”时长得不一样

1. 使用中的 chunk

可以先粗暴地理解成:

Text Only
1
2
3
+----------------------+--------------------------+
| size / flag 等元数据  | 你的数据                  |
+----------------------+--------------------------+

这时 ptmalloc 只需要知道:

  • 这块多大
  • 前一块/当前块的一些状态信息
  • 这块现在是给用户用的,还是已经空闲

2. 空闲后的 chunk

一旦这块被 free,用户数据区就不再属于你了,ptmalloc 会把其中一部分拿来串链表、做缓存、记录下一个空闲块是谁。

可以把它理解成:

Text Only
1
2
3
+----------------------+--------------------------------------+
| chunk 头部信息        | 不再是“你的数据”,而是分配器的工作区    |
+----------------------+--------------------------------------+

这里通常会产生两个直接的问题:

  • 为什么 free 之后原来的内容会被“破坏”
  • 为什么利用里总说“free 后用户区会被写入指针”

原因很简单:
这块内存在 free 之后,已经从“你的缓冲区”变回了“分配器要管理的空闲块”。

第三层模型:先认识几个最重要的角色

读 ptmalloc,最先要认识的不是代码,而是“角色分工”。

名词 先把它想成什么 作用
arena 一个分配现场 / 一个管理区域 管着一批 heap 和 bin
chunk 一块被 ptmalloc 管理的内存单元 分配和释放的基本对象
tcache 线程本地的小缓存 为了更快地复用小块
fastbin 一组只追求快的小块空闲链 free 后先挂进去,通常不立刻合并
unsorted bin 普通空闲块的“中转站” 刚释放/合并后的块先来这里
smallbin 固定大小的小块仓库 同尺寸块放一起
largebin 较大块仓库 按大小组织,便于找合适块
top chunk 堆顶还没切出去的那一大块 现成仓库不够时,直接从这里切
mmap chunk 单独向系统映射的大块 通常不走普通 bin 体系

Note

从整体上看,最重要的不是先背出每个 bin 的精确定义,而是先区分它们各自承担的角色:

  • tcache / fastbin:偏“快”
  • unsorted bin:偏“中转”
  • smallbin / largebin:偏“正规仓库”
  • top chunk:偏“还没分出去的原料”
  • mmap chunk:偏“单独订单”

arenaheapchunk 到底是什么关系

这三个词特别容易混。

可以先把它们拆成三层:

Text Only
1
2
3
4
5
6
7
进程
  |
  +--> arena:管理者,负责维护 bins、top、状态信息
          |
          +--> heap:arena 管着的一片或几片实际内存区域
                  |
                  +--> chunk:heap 里被切出来的一块块管理单元

如果要更口语一点:

  • arena 更像“仓库管理员”
  • heap 更像“仓库的场地”
  • chunk 更像“场地上被切出来的一件件货物”

对主线程和其他线程,再粗分一次

按常见情况粗分,可以先写成:

Text Only
1
2
3
4
5
6
7
主线程常见:
main arena
  -> 更常和传统 brk/sbrk 那套堆空间打交道

其他线程常见:
non-main arena
  -> 更常通过 mmap 拿到自己的 heap 区域

这不是说主线程绝不会碰 mmap,也不是说其他线程完全不碰普通 chunk。
它只是先给出一个整体上的方向区分:

  • main arena:更像“主仓库”
  • non-main arena:更像“分仓”

Note

后面读源码时会反复看到两个判断:

  • 当前是不是 main_arena
  • 当前这块 chunk 是不是 NON_MAIN_ARENA

它背后其实就是在问:这块内存来自主仓库,还是分仓。

各空闲结构的大致定位

这几类结构不需要一开始就分别背细。
先知道它们各自负责什么,模型就已经够用了。

结构 可以先理解成什么 整体特点
tcache 线程本地的小缓存 最近释放的小块,优先快速复用
fastbin 分配器内部的小块快缓存 很快,但通常不立刻合并
unsorted bin 普通空闲块的中转站 先放这里,再看是否直接复用或继续分类
smallbin 小块正式仓库 同尺寸块集中管理
largebin 大块正式仓库 更强调按大小找合适块
top chunk 堆顶剩余原料 仓库不够时直接从这里切

如果只看顺序感,可以先记住下面这几个点:

  • tcachefastbin 更偏向“最近释放的先拿出来”,也就是更接近 LIFO
  • smallbin 更偏向稳定复用,同尺寸块整体更接近 FIFO/LRU
  • largebin 更关注“大小合不合适”
  • unsorted bin 不是最终归宿,而是一次过渡和再利用机会

从“仓库”视角看分配

可以把 ptmalloc 想成一个仓库管理员。
你来要一块空间,它会按下面这个大方向去找:

Text Only
malloc(request)
    |
    v
先把请求变成合适的 chunk 大小
    |
    v
先看看能不能直接复用现成空闲块
    |
    +--> tcache
    +--> fastbin / smallbin / unsorted bin / largebin
    |
    v
如果都不合适,再看看 top chunk 能不能直接切
    |
    v
如果 top chunk 也不够,再向系统申请更多内存
    |
    +--> 扩堆(main arena 常见)
    +--> mmap(大块常见)

第一步:先“规格化”请求

用户写的是:

C
malloc(0x23)

但 ptmalloc 不会真的按 0x23 这么离散的大小去管理。
它会先做几件事:

  1. 加上管理开销
  2. 按对齐规则向上取整
  3. 得到真正要处理的 chunk size

所以你申请 0x230x240x25,最后很可能都落到同一个大小档里。

这一步很重要,因为后面很多分支判断看的都不是“原始请求大小”,而是“规格化之后的 chunk 大小”。

第二步:优先复用最近释放过的块

如果有现成空闲块,最便宜的办法当然不是去问系统再要,而是直接复用。

最先被看的通常是这些地方:

  1. tcache
  2. fastbin
  3. smallbin
  4. unsorted bin
  5. largebin

Note

这里描述的是全局路径,不是说每一次 malloc 都会把这几站完整走一遍。
实际源码会根据请求大小、当前缓存状态、bin 命中情况走不同分支。

不要把它理解成“五个平等的容器”,更好的理解是:

  • tcache:离线程最近,拿起来最顺手
  • fastbin:很快,但管理比较粗糙
  • unsorted bin:刚回收的大多数普通块会先在这里停一下
  • smallbin / largebin:更正式、更稳定的仓库

按请求规模和命中情况拆开后,常见路径可以归纳为四类

路径 1:小块,而且刚好在线程本地缓存里

Text Only
1
2
3
malloc(small)
    -> tcache 命中
    -> 直接返回

这是最轻、最快、最适合高频小申请的一条路。

路径 2:小块,tcache 没命中,但仓库里有现成货

Text Only
1
2
3
4
5
malloc(small/medium)
    -> tcache miss
    -> fastbin / smallbin / unsorted / largebin 里找到合适 chunk
    -> 必要时切割
    -> 返回

这条路的关键词是“复用库存”。

路径 3:仓库没有现成货,但堆顶还有原料

Text Only
1
2
3
4
5
malloc(...)
    -> bins 里不合适
    -> top chunk 足够大
    -> 从 top 切一块
    -> 返回

这条路的关键词是“现切”。

路径 4:连 top chunk 都不够

Text Only
1
2
3
4
5
6
malloc(...)
    -> 现成块不够
    -> top chunk 也不够
    -> sysmalloc
    -> 扩堆 / 新 heap / mmap
    -> 返回

这条路的关键词是“进货”。

第三步:仓库都没有,就从 top chunk 现切

如果现成的空闲块都不合适,ptmalloc 还会看 top chunk

你可以把它想成:

Text Only
1
2
3
4
5
堆顶未切分区域

+--------------------------------------------------+
|                    top chunk                     |
+--------------------------------------------------+

当请求到来时,如果 top chunk 足够大,就直接从前面切一块出去:

Text Only
1
2
3
4
5
6
7
8
9
切分前
+--------------------------------------------------+
|                    top chunk                     |
+--------------------------------------------------+

切分后
+----------------------+---------------------------+
| 分给用户的 chunk      | 新的 top chunk            |
+----------------------+---------------------------+

这一步非常关键,因为它解释了很多源码里的核心判断:

  • “有没有现成 free chunk”
  • “没有的话 top chunk 够不够”
  • “还不够才去 sysmalloc

第四步:top chunk 也不够,就向系统申请

这时才轮到真正的“进货”。

大方向可以先理解成两种:

  1. 给普通堆继续扩空间
  2. 直接为某个大请求单独做一块映射

也就是:

Text Only
1
2
3
4
5
系统申请内存
    |
    +--> 扩大现有堆(后续继续作为普通 chunk 使用)
    |
    +--> 直接 mmap 一块大区域(常见于较大申请)

两个特殊 chunk

ptmalloc 里有几类块并不属于普通 bin 的稳定流转体系,其中最重要的是下面两个:

1. top chunk

它不是 bin 里的空闲块,而是“堆顶剩下的那一整块原料”。

你可以把它理解成:

  • 还没有正式放进 bin 分类体系
  • 但分配器知道它在这儿
  • 只要足够大,就可以直接切

2. mmap chunk

它也不是 bin 体系里的一员。
它更像“专门为某次较大需求单独开的一单”。

所以它的典型命运是:

Text Only
1
2
3
4
5
malloc(很大)
    -> 直接 mmap

free(这个大块)
    -> 直接 munmap

这也是为什么这里要单独把它提出来:

  • mmap chunk 通常不进入普通 bin
  • free 它时更像“直接退货给系统”

从“仓库”视角看 free

free 不是“把内存直接还给操作系统”。
更准确地说,free 做的是:

  1. 先把这块内存交还给 ptmalloc
  2. 看它应该先进缓存、先进 bin、还是直接释放给系统

它的大方向可以先记成下面这张图:

Text Only
free(ptr)
    |
    +--> 如果本来就是 mmap chunk
    |        |
    |        +--> 直接 munmap
    |
    +--> 如果是普通 chunk
            |
            +--> tcache 还能放?先放 tcache
            |
            +--> 否则如果属于 fastbin 范围?先放 fastbin
            |
            +--> 否则走普通 free 逻辑:
                     和前后空闲块尝试合并
                     -> 放入 unsorted bin
                     -> 后续再转 smallbin / largebin
                     -> 如果顶端太大,可能 trim

Note

这同样是一条抽象主线。真实执行时,某些块会很早就在 tcachefastbin 停住,某些块则会直接落入合并与 unsorted bin 逻辑,不是每次都把所有步骤走完。

按来源和大小拆开后,free 的典型路径可以归纳为四类

路径 1:小块,先回 tcache

Text Only
1
2
3
free(small)
    -> tcache 还有空位
    -> 先进 tcache

这条路径的重点是线程局部缓存优先。

路径 2:再往下一层,先回 fastbin

Text Only
1
2
3
4
free(small)
    -> tcache 不走 / 放不下
    -> 属于 fastbin 范围
    -> 先挂 fastbin

这条路径仍然偏向“先快速挂起”,而不是立刻彻底整理。

路径 3:普通块,走合并与整理

Text Only
1
2
3
4
5
free(normal)
    -> 看前后相邻 chunk
    -> 能合并就合并
    -> 先挂 unsorted bin
    -> 之后再分流到 smallbin / largebin

这条路径会进入更完整的空闲管理流程。

路径 4:本来就是大块单独映射

Text Only
free(mmapped chunk)
    -> 直接 munmap

这条路径不进入普通 bin 体系。

普通 free 的主线其实只要先把下面三件事想清楚就够了:

  1. 小块可能先进入 tcachefastbin
  2. 普通块通常会看能不能和前后空闲块合并
  3. 合并后的块常先去 unsorted bin,再决定是否继续分类;如果一路并到顶端,还可能触发 trim

这三件事基本就覆盖了后面源码里大多数关键判断的出发点。

一张图把分配和释放串起来

把分配和释放放在同一张总图里,可以更容易看出它们之间的闭环关系:

Text Only
                 +----------------------+
                 |    用户调用 malloc    |
                 +----------+-----------+
                            |
                            v
                  [ 规格化请求大小 ]
                            |
                            v
         +------------------+------------------+
         |                                     |
         v                                     v
 [ 复用现成空闲块 ]                      [ 现成块不够 ]
         |                                     |
         |                         +-----------+-----------+
         |                         |                       |
         v                         v                       v
 tcache / fastbin / bins      top chunk 切分         sysmalloc 向系统申请
                                                         |
                                            +------------+------------+
                                            |                         |
                                            v                         v
                                        扩堆 / 新 heap             mmap 大块


                 +----------------------+
                 |    用户调用 free      |
                 +----------+-----------+
                            |
                            v
                   [ 找回所属的 chunk ]
                            |
          +-----------------+------------------+
          |                                    |
          v                                    v
    [ mmap chunk ]                        [ 普通 chunk ]
          |                                    |
          v                                    v
      munmap                        tcache / fastbin / 合并
                                                     |
                                                     v
                                                unsorted bin
                                                     |
                                +--------------------+--------------------+
                                |                                         |
                                v                                         v
                           smallbin / largebin                       top chunk / trim

再加一张“单个 chunk 的生命周期图”

如果上面那张是“整个仓库视角”,这张可以帮助你从“单个 chunk 的命运”去理解。

Text Only
向系统申请到一大片内存
        |
        v
    形成可管理区域
        |
        v
    某一块被切成 chunk
        |
        v
    malloc 返回给用户
        |
        v
    用户使用中
        |
        v
      free
        |
    +----+----+----------------------+------------------+
    |         |                      |                  |
    v         v                      v                  v
 tcache   fastbin             unsorted/bin         munmap
    |         |                      |
    +---------+----------+-----------+
                        |
                        v
                   再次被 malloc 复用

这张图最想表达的是:

  • chunk 的命运不是“一次性”的
  • 一块 chunk 可能在“使用中”和“空闲体系”之间来回流转很多次
  • ptmalloc 的工作重点就是让这种流转尽量高效

不同类型 chunk 的典型命运

类型 分配时怎么来 free 后通常去哪里 整体定位
tcache chunk 常从线程本地缓存直接拿 优先回线程本地缓存 最近刚用过的小块
fastbin chunk 快速复用小块 先进 fastbin,稍后再整理 为了快,先不急着合并
smallbin chunk 从固定大小仓库里拿 合适时会进入 smallbin 同尺寸块整齐摆放
largebin chunk 从较大空闲块里找 合适时会进入 largebin 需要按大小挑选
unsorted chunk 常作为中转被重新利用 普通 free/合并后先到这里 临时中转站
top chunk 不够时直接从堆顶切 和顶端合并后可能变大 还没分出去的原料
mmap chunk 对较大申请单独映射 通常直接 munmap 不走普通 bin 体系

几个容易混淆的问题

1. 为什么 free 不等于“立刻还给系统”?

因为 ptmalloc 更希望复用。
频繁“还给系统再重新申请”通常更慢,碎片控制也未必更好。

2. 为什么有的 free 会合并,有的不会?

因为不同路径目标不同:

  • tcache / fastbin 更偏“快”
  • 普通 free 更偏“整理与合并”

3. 为什么会先有 unsorted bin,再有 smallbin / largebin

因为 ptmalloc 想先给这些新回收的块一个“快速被再次利用”的机会,而不是一上来就做最重的分类工作。

4. 为什么读源码时总觉得一个 chunk 一会儿是数据,一会儿又变成链表节点?

因为“用户数据区”在 free 之后已经不再是用户数据区,而是分配器拿来管理空闲块的工作空间。

读源码时,怎么把这个模型对上函数

下面这张表可以作为后续精读时的“函数地图”:

你脑子里的步骤 源码里常看到的函数
进入 malloc __libc_malloc
真正的主分配流程 _int_malloc
现有空间不够,向系统申请 sysmalloc
直接走大块 mmap 分配 sysmalloc_mmap
进入 free __libc_free
普通释放主流程 _int_free
fastbin 后续统一整理 malloc_consolidate
释放 mmap 大块 munmap_chunk
main arena 尝试缩堆 systrim
线程 heap 尝试收缩 heap_trim

Tip

后面读源码时,不要要求自己“每个分支第一次就全懂”。
更有效的方法是:

  1. 先判断当前在做的是“分配”还是“释放”
  2. 再判断它是在“复用空闲块”还是“向系统申请/归还”
  3. 最后再看这一条具体落在了 tcachefastbinunsortedtop chunk 还是 mmap

归纳

  1. ptmalloc 不是直接管理“用户指针”,而是管理 chunk
  2. malloc 的核心是“先复用,不够再申请”。
  3. free 的核心是“先交还给分配器,再决定缓存、合并还是归还系统”。
  4. tcache / fastbin 偏快,unsorted 偏中转,smallbin / largebin 偏正式管理。
  5. top chunk 是还没切出去的堆顶原料,mmap chunk 是单独订单。
  6. 读源码时,先认清“这块 chunk 现在处于哪一种命运里”,很多分支就不会乱。

下一步怎么读

如果这页已经看顺了,接下来比较自然的顺序是:

  1. 先看 精读 1:malloc 入口、初始化与 tcache 建立
  2. 再看 精读 2:_int_malloc 主分配路径
  3. 最后看 精读 3:free、合并与 trim 路径

这样读时,你会更容易知道每一段代码在整个模型里的位置,而不是一开始就陷在宏、结构体和分支里。