K07 —— malloc 背后的故事:brk、mmap 和 ptmalloc 分配器

金句:你调用的 malloc,背后藏着一整套分配器体系——它比你想的更复杂。


malloc 不是系统调用

很多人以为 malloc 是系统调用,实际上 malloc 是 glibc 的库函数,它最终可能调用 brk 或 mmap,也可能根本不调用系统。

Bash
malloc 的调用路径:

用户代码:
  char *p = malloc(1024);

glibc 实现(ptmalloc):
  if (size <= 128KB) {
      // 小于 128KB:用 brk/sbrk 扩展堆
      // 从 arena 的 free list 分配
  } else {
      // 大于 128KB:用 mmap 直接映射
      // 直接从内核分配整块
  }

系统调用(可能触发):
  brk():调整堆顶(heap top)
  mmap():创建匿名映射
  munmap():释放映射

关键点:
  - 小分配:brk + 用户态分配器(无系统调用)
  - 大分配:mmap + munmap(系统调用)

ptmalloc:glibc 的内存分配器

Bash
ptmalloc 的核心概念:

Arena(内存竞技场):
  - 每个线程有自己的 arena(避免锁竞争)
  - arena 管理一堆 bin(空闲链表)
  - 主线程用 main_arena,其他线程用 per-thread arena

Bin(空闲链表):
  - fast bin:< 64 bytes,先进后出(不能合并)
  - small bin:< 1024 bytes,先进先出
  - large bin:>= 1024 bytes,按大小排序
  - unsorted bin:刚释放的块(可能合并)

分配策略:
  1. 检查 thread local cache(tcache)
  2. 从对应 size 的 bin 取
  3. 如果 bin 空,从 arena 的 top chunk 取
  4. 如果 top chunk 不够,调用 brk/mmap

Free 策略:
  1. 放入 thread local cache(tcache)
  2. 如果 cache 太大,批量放回 bin
  3. 相邻的 free chunk 可以合并(top chunk 不合并)

malloc_chunk 的真实布局(64-bit)

Bash
64-bit 系统上,一个已分配的 malloc_chunk 长这样:

+--------+───────────────+
│ prev_size (8B)  │  前一个 chunk 的大小(如果前一个 chunk 空闲)       │
+----------------+----------------+
│ size (8B)      │  本 chunk 大小(低 3 bit 是标志位)                 │
+----------------+----------------+
│ fd (8B)        │  下一个同 bin 的 chunk 指针(已分配时用户可用)     │
+----------------+----------------+
│ bk (8B)        │  上一个同 bin 的 chunk 指针(已分配时用户可用)      │
+----------------+----------------+
│        用户数据区域              │
│        (实际可用的内存)        │
+---------------------------------+

size 的低 3 bit 标志位(flag bits):
  bit 0 = PREV_INUSE     :前一个 chunk 处于已分配状态
  bit 1 = IS_MMAPPED    :本 chunk 由 mmap 分配
  bit 2 = NONMAIN_ARENA  :不属于主 arena

注意:
  - 最小 chunk:32 bytes(32-bit)/ 48 bytes(64-bit,含头部)
  - 如果前一个 chunk 已分配,prev_size 区域可以被当前 chunk 用来存数据
  - 这就是为什么 malloc(16) 实际可能占用 32+ bytes

空闲 chunk 和已分配 chunk 的布局是一样的,
区别是空闲时 fd/bk 用来串链表,已分配时 fd/bk 是用户的"免费"存储空间。

fastbin 的 LIFO 行为

Bash
fastbin 为什么不能合并?

fastbin 是 small bin 的一种,专门处理 <= 64 bytes 的小块。

设计决策:性能优先,安全次之。

  - 正常 free chunk 合并:检查相邻 chunk,更新 size,插入 unsorted bin
  - fastbin 不合并:直接插入对应大小的链表头部

LIFO(后进先出)示例:

  malloc(32) -> chunk A
  malloc(32) -> chunk B
  free(B)    -> fastbin[32]: B
  free(A)    -> fastbin[32]: A -> B   (A 在头部)

  再分配 32 bytes 时,先取 A(最新的)。

为什么不合并?
  - 合并需要遍历相邻块,开销大
  - fastbin 设计的本意是"小对象用完就还",合并反而慢

危险:
  - fastbin 可以被 double-free 攻击
  - glibc 对 fastbin 做了检测,但逻辑复杂时仍可能绕过的

tcache(thread local cache,glibc 2.26+):

  tcache 是 per-thread 的缓存,比 fastbin 更激进:
  - 每个线程有自己的 tcache(类似 tcmalloc 的 thread local area)
  - 分配:先查 tcache,有则直接返回(无锁,O(1))
  - tcache 满了才走 arena/bin
  - free:先放 tcache,tcache 满了才放回 bin

brk 和 mmap 的分界线

Bash
为什么是 128KB?

临界值(MMAP_THRESHOLD):
  - 默认 128KB(可通过 mallopt(M_MMAP_THRESHOLD, N) 调整)
  - <= 128KB:用 brk 扩展堆
  - > 128KB:用 mmap 匿名映射

brk 的优点:
  - 分配/释放快(用户态操作,不需要系统调用)
  - 内存连续(相对于 mmap)

mmap 的优点:
  - 释放容易(munmap 整个块)
  - 不产生碎片(相对于 brk 扩展堆)

性能对比(分配 + 释放):
  1. malloc(100) + free():~10 ns(brk,用户态)
  2. mmap(2MB) + munmap():~500 ns(系统调用)

这就是为什么大对象用 mmap 更好:
  - 大对象释放不需要合并到堆
  - 整块 munmap 效率高

mallopt 实战:调整 mmap 阈值

C
#include <malloc.h>
#include <stdio.h>

int main() {
    // 默认 128KB,改成 4KB
    // 所有 > 4KB 的分配都走 mmap
    mallopt(M_MMAP_THRESHOLD, 4096);

    char *p = malloc(8192);  // 8KB > 4KB,走 mmap
    printf("mmap'd: %pn", p);
    free(p);  // munmap 整块,无碎片

    // 改回默认
    mallopt(M_MMAP_THRESHOLD, 128 * 1024);

    return 0;
}

Bash
Mallopt 可调参数:

M_MMAP_THRESHOLD    :mmap 阈值(默认 128KB)
M_MMAP_MAX          :最大 mmap 次数(默认 65536)
M_TRIM_THRESHOLD    :调用 munmap 收缩堆的阈值(默认 128KB)
M_TOP_PAD           :brk 扩展时多要的"余量"(默认 0)

典型调优场景:
  - 高频分配大缓存(>1MB):设置 M_MMAP_THRESHOLD=1MB 减少碎片
  - 内存敏感服务:设 M_TRIM_THRESHOLD=0 禁止收缩(减少 munmap 系统调用)

为什么频繁 malloc/free 会变慢

Bash
内存分配器的性能问题:

1. 碎片化:
   - 多次分配释放后,堆碎片化
   - 大块请求可能找不到连续空间
   - 需要调用 brk 调整堆顶

2. 锁竞争(多线程):
   - 每个 arena 有一把锁
   - 线程 A 释放时,线程 B 可能正在分配
   - 锁竞争导致上下文切换

3. Top chunk 合并问题:
   - free 的 chunk 放入 bin,不会和 top chunk 合并
   - 除非调用 malloc_consolidate,否则碎片不会消失

4. 缓存失效:
   - free 后,chunk 放入 bin
   - 再次分配同一大小,可能不在 CPU cache 中

解决:tcmalloc / jemalloc / mimalloc

tcmalloc(Google):
  - 每个线程有自己的 thread cache
  - 无锁分配(thread local)
  - 分配 O(1)
  - 适合多线程程序

jemalloc(Facebook):
  - 类似的 thread cache 设计
  - 更好的碎片管理
  - 被 Firefox 等使用

mimalloc(Microsoft):
  - 更紧凑的布局
  - 更好的安全属性(隔离)
  - 微软云服务使用

多线程 malloc 锁竞争实测(定性):

  2 线程:~5% 时间在等锁
  8 线程:~30% 时间在等锁(取决于分配频率)
  32 线程:锁竞争成为主要瓶颈

  切到 tcmalloc/jemalloc 后,8 线程 QPS 可提升 3~5 倍。

总结

  • malloc 是库函数:不是系统调用,内部可能调用 brk/mmap 或只操作用户态分配器
  • ptmalloc:glibc 的分配器,arena(竞技场)+ bin(空闲链表)+ cache
  • malloc_chunk:64-bit 下 prev_size(8B) + size(8B) + fd/bk 指针,size 低 3 bit 是标志
  • fastbin:LIFO 不合并,专为小对象优化;tcache 是更激进的 per-thread 缓存
  • 分配策略:小对象(≤128KB)用 brk + bin,大对象(>128KB)用 mmap
  • mallopt:可调 mmap 阈值/次数/trim 参数,实战调优工具
  • 碎片化:多次分配释放后堆碎片化,导致性能下降
  • 替代方案:tcmalloc/jemalloc/mimalloc,用 thread-local 缓存减少锁竞争

下篇预告(K08):进程间是怎么共享内存的?shmget/shmat、POSIX shm_open、以及 mmap(MAP_SHARED) 的不同实现。


关注公众号「AI不着急」,回复”资料”获取内存学习路线图。

最后修改: 2024年4月20日

作者

评论

发表评论

您的邮箱地址不会被公开。