内存分配原理与全过程

从最底层的物理内存芯片,到操作系统内核,再到 C 和 Go 语言运行时的分配器 —— 一次内存申请究竟经历了什么?下面用可交互的动画带你逐层拆解。

① 全景 & 端到端动画
② 物理内存层
③ 操作系统层
④ C 语言层
⑤ Go 语言层
⑥ C vs Go 对比

🧭 三层模型总览

一次“我要 256 字节内存”的请求,会自上而下穿过三个抽象层。越往下越接近硬件,越往上越接近程序员。点下面的按钮,看一个请求如何从头走到物理内存,再拿到一个可用的地址。

应用代码(C)
int *p = malloc(256);
程序员发起申请,语言运行时接管
C 运行时分配器(glibc ptmalloc)
bin 查找 / 分割 top chunk
在已向 OS 要来的堆里找空闲块,优先复用
操作系统(虚拟内存 / 系统调用)
brk() / mmap() 系统调用
扩大进程堆或映射匿名页,建立虚拟→物理映射
物理内存(DRAM 芯片)
页表项指向的物理页框(page frame)
真正存放比特的电容阵列,由内存控制器访问

📍 为什么需要这么多层?

  • 物理层只懂“地址总线上的电信号 + 电容里的电荷”,它不知道什么是“进程”“对象”“变量”。直接让程序操作物理地址既不安全也难用。
  • 操作系统层在物理内存之上加了一层虚拟内存:每个进程都以为自己独占一整片连续地址空间,OS 负责把它映射到零碎的物理页框上,并提供隔离、共享与按需分配。
  • 语言层则在 OS 给的“大块地”(堆)上做精细化二次分配:因为每次都找 OS 要太慢,所以运行时把 OS 给的内存切成小块、自己管理空闲链表,让 malloc/free 快到微秒级。
  • 关键事实:你写的 malloc / new 不会每次都进入内核。绝大多数分配在用户态就完成了,只有“地不够了”才触发系统调用。

💾 物理内存层:内存到底是什么?

拆开任何一根内存条(DIMM),里面是成片的 DRAM 芯片。每个存储单元(1T1C)由一个晶体管 + 一个电容组成:电容带电 = 1,不带电 = 0。因为电容会漏电,所以内存控制器必须定期刷新(refresh)才能保住数据 —— 这也正是“动态(Dynamic)”内存的由来。

物理地址 = 地址总线上的电信号

CPU 把“想访问哪个字节”编码成一组电信号送到地址总线。比如 6 根地址线就能编出 2⁶ = 64 个地址。内存芯片内部的行列译码器(row/col decoder)把地址拆成“第几行、第几列”,从而选中唯一一个存储单元。下面用 8×8 = 64 个单元演示这个过程。

输入地址后,这里会显示“行 / 列”译码过程。

几个常被忽略的点

  • 物理地址空间大小受地址线位数限制。x86-64 芯片通常实现 40~52 根物理地址线(可寻址数十 GB ~ 数 TB),并非理论的 2⁶⁴。
  • 物理内存是字节寻址、按字/缓存行访问的。CPU 实际以 64 字节缓存行(cache line)为单位与内存交换。
  • “物理地址 → 哪个颗粒、哪个 bank、哪一行”由内存控制器负责,对 OS 和应用程序完全透明。
  • 硬件层面没有“释放”概念,只有“覆盖”。所谓释放,是上层(OS/运行时)标记这块地址“可以 reuse”。

🛡️ 操作系统层:虚拟内存与分页

如果程序直接操作物理地址,两个进程很容易互相踩内存,且地址空间碎片化严重。OS 的解法是虚拟内存:每个进程拥有独立的虚拟地址空间,由页表(page table)把虚拟页(page)映射到物理页框(frame)。每次访存,MMU 硬件借助 TLB 缓存做地址翻译。

动手走一次地址翻译

下面把虚拟地址拆成 4 位虚拟页号(VPN)+ 4 位页内偏移(offset),共 256 字节虚拟空间、16 个虚拟页。点“翻译”看 MMU 如何查页表、命中/缺失,并最终落到物理页框。

VPN4 位 · 选页
Offset4 位 · 页内位置
输入 0-255 的虚拟地址开始。

页表(VPN → PFN)

物理内存(页框)

📦 OS 如何把内存“给”进程?

  • brk / sbrk 系统调用:移动“程序断点(program break)”,把堆区向高地址扩张。这是 malloc 在“小对象”时向内核批量要地的主要方式。注意:刚 brk 出来的只是虚拟地址范围,物理页要等真正写入(page fault)才分配 —— 这叫按需分页(demand paging)
  • mmap(MAP_ANONYMOUS):直接映射一段匿名虚拟内存,适合大块分配或想单独释放的场景(C 里 >128KB 的对象就用它,释放时 munmap 直接还给 OS)。
  • 内核自己的分配器:OS 收到“给我 N 个物理页”的请求后,用 伙伴系统(buddy) 管理空闲页框(按 2 的幂合并/拆分),用 slab 分配器 为内核对象(如 task_struct)做定长缓存。

一句话:OS 给进程的是“虚拟地址 + 一套映射”,物理内存是延后、按需、按页补给的。真正“按字节切分、快速复用”的脏活,交给了下一层的语言运行时。

🔧 C 语言层:glibc ptmalloc 分配器

当你调用 malloc(size),glibc 的 ptmalloc 不会每次都进内核。它维护一个堆(heap)(从 OS 用 brk/mmap 批发来的大块)并切成许多 chunk,再用若干条“空闲链表(bin)”管理。点下面的节点,看清一次分配走哪条路径。

malloc(size)
入口:要 size 字节
size > 128KB ?
默认 MMAP_THRESHOLD
▼ 否,走堆
size ≤ 128B ?
fast bin:LIFO,释放时不立即合并
▼ 否
≤ ~1024B → small bin
按大小分桶,FIFO
> ~1024B → large bin
按大小排序,最佳适配
▼ 都找不到合适空闲块时
切分 top chunk / 向 OS 要地
用 sbrk 或 mmap 扩张堆
点上方任意节点查看说明。

🧪 小堆模拟器(概念版)

点“分配”在堆上放一个块,点“释放”回收最后用的块。空闲块用斜纹表示,运行时不会立即把它还给 OS,而是留在 bin 里等下次复用 —— 这正是“为什么不 free 就内存泄漏”的原因。

已分配(C) 空闲(可复用)
堆初始为空,全部是可用空间(top chunk)。

🐹 Go 语言层:受 tcmalloc 启发的三级分配器

Go 运行时也向 OS(Linux 上用 mmap)批发大块内存,但它在用户态设计了一套无锁快速路径:每个 P(处理器)都有自己的缓存。点节点看一个对象从哪来。

分配对象(如 make([]byte, n))
先经逃逸分析:能放栈就放栈
tiny(≤16B 无指针)?
mcache.tiny 微分配,减少碎片
▼ 否则看大小
small(< 32KB)
定 size class → mcache → mcentral → mheap
large(≥ 32KB)
直接找 mheap 要一整段 span
▼ 任一级缺货时
向 OS 要(sysAlloc / mmap)
mheap 向内核申请新 span
点上方任意节点查看说明。

🧩 三级缓存:mcache / mcentral / mheap

  • mcache:绑定到每个 P,无锁。日常分配 99% 在这里命中,直接拿一个同 size class 的空闲对象,速度极快。
  • mcentral:全局按 size class 分桶的中央空闲链表,带锁。mcache 空了就向它“批发”一批对象。
  • mheap:管理所有 span(连续的若干页)。mcentral 不够时从这里切 span;mheap 没有空闲 span 才向 OS 申请。
  • size class:Go 把对象按约 70 个尺寸等级归类(如 8/16/24/32…字节),同类对象共用一种块大小,既快又省碎片。

和 C 最大的不同:没有 free,有 GC

Go 没有 free。对象不可达后,由并发标记-清除 GC 回收,分配器与 GC 协作标记 span。另外,逃逸分析会让很多局部变量直接分配在 goroutine 栈上——栈随函数返回自动回收,连 GC 都不用参与。这就是为什么 Go “不容易内存泄漏”,但代价是 GC 的少量开销。

⚖️ C vs Go 内存分配对比

维度C (glibc ptmalloc)Go (runtime allocator)
谁管理生命周期程序员手动 malloc / free运行时 + 垃圾回收(GC)自动
分配入口malloc / calloc / reallocnew / make,逃逸分析决定栈或堆
快速路径线程 arena + bin 链表(有锁竞争)每 P 的 mcache,无锁
小块策略fast/small/large bin + top chunksize class + mcache/mcentral/mheap + span
大块(>~128KB)mmap 匿名映射,free 即 munmap 还给 OS≥32KB 直接 mheap 拿 span
还给 OS 的时机仅 mmap 大块可还;堆顶碎片常驻GC 回收后空闲 span 可被 mheap 归还(较积极)
典型风险泄漏 / 悬垂指针 / 双重释放 / 碎片GC 停顿(已极小) / 临时对象 GC 压力
“每次都进内核?”否,bin 命中在用户态否,mcache 命中在用户态

两者殊途同归:都在“OS 批发来的大块虚拟内存”之上做用户态二次分配,避免频繁系统调用;区别只在谁来释放、以及如何减少锁竞争。

🚀 一句话串起全过程

当你写下 malloc(256)make([]byte,256)

  • ① 语言运行时先在用户态的空闲链表里找 256 字节左右的块 —— 命中就直接返回指针(微秒级,不进内核)。
  • ② 找不到时,运行时用 brk/mmap 系统调用向 OS 要更大一块虚拟内存。
  • ③ OS 更新页表、建立虚拟→物理映射,但物理页框往往延迟到首次写入才由 page fault 真正分配。
  • ④ 最终数据落到 DRAM 的电容阵列里,由内存控制器按物理地址读写。

回到第①个标签页,点 C 或 Go 按钮,把这条链路再动态走一遍吧。