“上层”到底在内存分配里干了啥?

最大的误会:以为 malloc 是操作系统给的函数,上层只是转发调用。真相恰恰相反——本页用图和可交互演示讲清:内核只给粗粒度的 brk/mmapmalloc 你自己进程的库函数,而它(以及更上层的专用分配器)做的事远比你想象的多。

① 核心困惑与真相
② 操作系统到底给了啥
③ 分配器在上面干了啥
④ 分配器动物园
⑤ 速查总结

🤔 先说清楚:malloc 在哪一“层”?

点下方每一层,看它到底归谁、干什么。重点看第二层——malloc / free 是跑在你进程里的用户态库函数,不在内核里。

① 应用程序
int *p = malloc(17);
▲ 普通函数调用,不进内核
② C 运行库 malloc / free(用户态)
glibc ptmalloc / jemalloc / tcmalloc …
这才是“分配器”本身,跑在你的进程里
▲ 偶尔才进内核(地不够时)
③ 操作系统内核
brk() / sbrk() / mmap() / munmap()
只给“整页(4KB)”的虚拟内存,且要系统调用
▲ 操作物理页框
④ 物理内存 DRAM
电容阵列 / 页框(page frame)
点任意一层查看说明。

💡 一句话破题

你以为:程序 → 调 OS 的 malloc → 拿到内存。
实际是:程序 → 调 libc 里的 malloc(用户态,自己做切分/记账/回收)→ 只有“手里地不够了”才偶尔 → 调 OS 的 brk/mmap 批一大块回来 → 再切成小块给你。

所以你问的「上层自己还能做啥」——那个“上层”(libc 里的 malloc)本身就是分配器,它才是干绝大多数活的角色。内核只负责“批发整页的虚拟地址”,完全不知道你那个 17 字节的对象长什么样、什么时候该释放。

更往上,应用还能在 libc malloc 之上再写专用分配器(对象池、临时区分配器、GC 堆等)。下面几节逐一拆解。

🧱 操作系统到底“给”了你什么?

内核提供给用户程序的,只有两类非常粗粒度、且昂贵的原语:

  • brk / sbrk:把进程的“堆顶指针(program break)”往上挪,从而“圈出”一块连续的虚拟地址。一次移动可能拿到好几页,但没法只拿 17 字节。
  • mmap / munmap:把一段匿名或文件内存映射进进程地址空间,按页(通常 4KB)对齐。适合大块或想单独释放的区域。
两个致命限制:
1️⃣ 粒度是页:你不能向内核要“17 字节”,最小也是一整页 4096 字节,剩下 4079 字节白白浪费。
2️⃣ 要进内核:每次都是系统调用(上下文切换),开销约几百纳秒到微秒级,远慢于一次普通函数调用。

那如果“每次分配都直接问 OS”会怎样?点下面按钮看看 1000 次 20 字节分配的下场👇

naive:每次 mmap 一页

已用
浪费
点上方按钮运行

allocator:批发后切分

已用
浪费/元数据
点上方按钮运行

注:真实 malloc 还有 chunk 头(几十字节)等元数据开销,这里为对比做了简化,但“naive 浪费 ~99% 内存 + 1000 次系统调用”的结论是成立的。

🔧 分配器在 OS 之上,到底干了哪些活?

拿到 OS 批发来的几页虚拟内存后,分配器要在用户态扮演一个“迷你操作系统”,把大块切成任意大小的碎片并高效管理。它的核心职责:

  • ① 任意尺寸切分:把 4KB 页切成 17、100、1000 字节的块——内核做不到。
  • ② 记账(bookkeeping):每个块前面藏一个“头”,记下它有多大、是否空闲,这样 free(p) 才知道回收多少。
  • ③ 空闲管理:用空闲链表 / bin 数组记住哪些块空着,优先复用,而不是每次找 OS。
  • ④ 对抗碎片:外部碎片靠“拆分 + 相邻合并(coalescing)”;内部碎片靠“尺寸分级(size class)”减少浪费。
  • ⑤ 对齐:对象常需 8/16 字节对齐,分配器负责垫齐。
  • ⑥ 并发性能:多核下用每线程缓存、无锁快路径,避免所有人都抢同一把大锁。

🕳️ 交互演示:外部碎片 vs 尺寸分级

下面是一段堆。先分配 A/B/C,再释放中间的 B 形成空洞,然后分配一个稍大的 D——看普通分配器会“塞不进空洞”而浪费空间;尺寸分级怎么解决这个问题。

已分配 空闲 碎片空洞(放不下)
点上面的按钮逐步操作,看碎片如何产生与被解决。

📏 交互演示:内部碎片与 size class

你申请的空间会被向上取整到某个“尺寸等级”,多出来的就是内部碎片。输入想要的字节数,看它实际分到哪一级。

申请字节:

🦁 分配器“动物园”:为什么有这么多?

既然都要“切分+记账+回收”,为什么还有 ptmalloc、jemalloc、tcmalloc、mimalloc… 以及应用层的对象池、区域分配器?因为它们针对的瓶颈不同。点卡片看各自解决什么问题。

ptmalloc 通用·glibc默认

glibc 自带。单堆 + 多线程 arena + bin。够用但多核下全局锁易成瓶颈。

jemalloc 低碎片·多核

Facebook 出品。arena + size class + 着色,碎片的长期占用极低,多核扩展好。

tcmalloc 无锁快路径

Google 出品。每线程缓存(mcache)让小对象分配基本无锁,Go 分配器受其启发。

mimalloc 极简·快

微软出品。用“自由表”设计,元数据极轻,单线程/多线程都很快,代码量小。

区域/凹凸分配器 应用层·临时

Arena / bump allocator:只进不退,最后整体一次性释放。解析器、编译器、帧内存超快。

对象池 应用层·定长

固定大小对象反复创建销毁(游戏实体、网络连接)。零碎片、零搜索,O(1) 取放。

GC 堆 应用层·自动

Go/Java/V8:分配器与垃圾回收协作,程序员不写 free,由运行时判断对象死活后回收。

slab 分配器 内核层

Linux 内核用:为内核对象(如 task_struct)做定长缓存,避免内核自己频繁切分。

📌 速查:谁干了什么

角色在哪给你的原语/接口粒度要不要进内核
物理内存硬件电容/页框字节(硬件)
操作系统内核内核态brk/sbrk/mmap/munmap整页(4KB)是(系统调用)
C 运行库 malloc用户态(libc)malloc/free/calloc任意字节否(偶尔补货)
应用专用分配器用户态(你的代码)自定义 API任意/定长

🎯 回到你最初的疑问

  • “内存分配不就是调 OS 的吗?” —— 不准确。你直接调用的 malloc 是 libc 的用户态函数;它只在“存货不够”时才去调 OS 的 brk/mmap 批发。
  • “上层自己还能做啥?” —— 正是那个“上层”在做绝大多数活:把页切成任意小对象、记账、维护空闲表、复用、对抗碎片、对齐、并发无锁化。
  • “还有啥能做的?” —— 通用分配器之上还能做专用分配器:区域/凹凸分配(一次性释放)、对象池(定长零碎片)、GC 堆(自动回收),各自针对特定瓶颈把性能再榨一遍。
  • 一句话:内核负责“按页批发虚拟地址”,分配器负责“按字节零售 + 全生命周期管理”。没有这层,你的 17 字节对象根本无从谈起。