从最底层的物理内存芯片,到操作系统内核,再到 C 和 Go 语言运行时的分配器 —— 一次内存申请究竟经历了什么?下面用可交互的动画带你逐层拆解。
一次“我要 256 字节内存”的请求,会自上而下穿过三个抽象层。越往下越接近硬件,越往上越接近程序员。点下面的按钮,看一个请求如何从头走到物理内存,再拿到一个可用的地址。
拆开任何一根内存条(DIMM),里面是成片的 DRAM 芯片。每个存储单元(1T1C)由一个晶体管 + 一个电容组成:电容带电 = 1,不带电 = 0。因为电容会漏电,所以内存控制器必须定期刷新(refresh)才能保住数据 —— 这也正是“动态(Dynamic)”内存的由来。
CPU 把“想访问哪个字节”编码成一组电信号送到地址总线。比如 6 根地址线就能编出 2⁶ = 64 个地址。内存芯片内部的行列译码器(row/col decoder)把地址拆成“第几行、第几列”,从而选中唯一一个存储单元。下面用 8×8 = 64 个单元演示这个过程。
如果程序直接操作物理地址,两个进程很容易互相踩内存,且地址空间碎片化严重。OS 的解法是虚拟内存:每个进程拥有独立的虚拟地址空间,由页表(page table)把虚拟页(page)映射到物理页框(frame)。每次访存,MMU 硬件借助 TLB 缓存做地址翻译。
下面把虚拟地址拆成 4 位虚拟页号(VPN)+ 4 位页内偏移(offset),共 256 字节虚拟空间、16 个虚拟页。点“翻译”看 MMU 如何查页表、命中/缺失,并最终落到物理页框。
一句话:OS 给进程的是“虚拟地址 + 一套映射”,物理内存是延后、按需、按页补给的。真正“按字节切分、快速复用”的脏活,交给了下一层的语言运行时。
当你调用 malloc(size),glibc 的 ptmalloc 不会每次都进内核。它维护一个堆(heap)(从 OS 用 brk/mmap 批发来的大块)并切成许多 chunk,再用若干条“空闲链表(bin)”管理。点下面的节点,看清一次分配走哪条路径。
点“分配”在堆上放一个块,点“释放”回收最后用的块。空闲块用斜纹表示,运行时不会立即把它还给 OS,而是留在 bin 里等下次复用 —— 这正是“为什么不 free 就内存泄漏”的原因。
Go 运行时也向 OS(Linux 上用 mmap)批发大块内存,但它在用户态设计了一套无锁快速路径:每个 P(处理器)都有自己的缓存。点节点看一个对象从哪来。
Go 没有 free。对象不可达后,由并发标记-清除 GC 回收,分配器与 GC 协作标记 span。另外,逃逸分析会让很多局部变量直接分配在 goroutine 栈上——栈随函数返回自动回收,连 GC 都不用参与。这就是为什么 Go “不容易内存泄漏”,但代价是 GC 的少量开销。
| 维度 | C (glibc ptmalloc) | Go (runtime allocator) |
|---|---|---|
| 谁管理生命周期 | 程序员手动 malloc / free | 运行时 + 垃圾回收(GC)自动 |
| 分配入口 | malloc / calloc / realloc | new / make,逃逸分析决定栈或堆 |
| 快速路径 | 线程 arena + bin 链表(有锁竞争) | 每 P 的 mcache,无锁 |
| 小块策略 | fast/small/large bin + top chunk | size 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):
回到第①个标签页,点 C 或 Go 按钮,把这条链路再动态走一遍吧。