虚拟内存基础
操作系统给每个进程一个「独占的、连续的、从 0 开始」的假地址空间,再由硬件(MMU)和操作系统把它映射到真实的物理内存上,按需取用。这样每个进程都以为自己拥有一大块连续内存,实际物理内存可以比它小得多。
Go 的内存管理
堆和栈
Go 的内存分堆和栈。
栈其实有三层:内核栈(系统线程)、系统栈(g0)、用户栈(goroutine)。这里只讨论用户栈。
用户栈属于每个 goroutine,由 runtime 管理,初始 2KB,可动态伸缩。每个 goroutine 都有自己的用户态栈。
一个 goroutine 包含一条完整的函数调用链。函数调用是嵌套的,天然符合先进后出(LIFO),所以适合用栈保存调用关系。
一个最简单的例子
func main() {
a := 1
b := add(a, 2) // 调 add,等它算完
_ = b
}
func add(x, y int) int {
sum := x + y
return sum
}
关键问题:add 什么时候返回?
答案是:它必须比 main 先返回——因为 main 要用 add 的结果 b。add 没算完,main 就走不下去。
所以执行顺序一定是:
进入 main → 进入 add → 算出结果 → 退出 add → 回到 main → 退出 main
先进去的(main)后出来,后进去的(add)先出来——这就是 LIFO。
为什么栈上分配不需要 GC
压栈帧 = 移动一下栈指针 SP(减法),弹栈帧 = 移回 SP(加法)。
- 成本是 O(1),一个 CPU 指令的量级;
- 因为严格 LIFO,「最晚分配的一定最先释放」,不存在堆上那种「先分配的先释放、中间留洞」的碎片问题。
这才是栈的本质价值:它是用「调用嵌套结构」换来了几乎零成本的内存管理。
未逃逸变量能放栈,正是因为它的生命周期恰好和函数调用栈的 LIFO 规则匹配——函数返回,变量就没用了,跟着栈帧一起弹掉。
堆上放什么
堆上放的是逃逸出去的对象和大对象,由 GC 管理。
为什么这些对象要放堆上?
- 栈上逃逸出去的对象不符合栈严格的函数调用嵌套模式,会打乱栈的 LIFO 回收方式。一旦打破这个方式,回收时机就无法在编译期算出来,只能推迟到运行时追踪——也就是 GC。
- 大对象放栈上会导致栈疯狂翻倍、反复搬栈,成本远高于直接放堆。
为什么需要 GC
这些分配到堆上的对象,在运行前都无法知晓其回收时机,于是需要一个运行时追踪算法来判断什么时候该内存不再需要、可以回收——这个算法就是 GC。
C/C++ 是手动管理内存,程序员自己决定什么时候分配、什么时候回收;Go 是自动 GC,用的是三色标记法:运行时从根出发,能走到的对象标色,走不到的未标色,触发回收。细节可以自己去搜。
这里说下我自己的理解:堆不是更高级的内存。如果可以的话,其实根本不想有堆;但因为有些栈上的对象不符合栈严格嵌套的函数调用关系,或者出于其他考虑,才把这类对象放在堆上,由 GC 统一管理生命周期。
堆只是为了容纳不符合 LIFO 规则的对象,被迫付出的代价。
Go 的内存分配模型
在 Go runtime 内部,堆对象和 goroutine 栈不是两套独立的内存系统,而是同一套分配器(mheap + span)供应的两种用途。
我们讲下一次内存分配是什么样子、这个分配器长什么样、为什么这样设计。
一个很简单的 new(),runtime 要做的事情就是给你分配一块能放下对象的内存。这样的操作可能每秒发生几百万次,怎么保证又快又不浪费?
最笨的做法是每次 new 都向操作系统申请对应内存。那是系统调用,要走内核态转换,一次几微秒,很亏。
所以 runtime 一开始就向 OS 申请一大块内存(64MB 的 arena),然后自己在这块内存上做切分。后续所有分配都在这块内存上,不再进行系统调用,省下了系统调用的时间。
arena 切分成 8KB 一块,叫 page;一些连续的 page 组成 span。
一个 span 绑定一种尺寸。Go 把对象分成几十个大小档位(8B 到 32KB),一个 span 划分到某个档位,就会按档位切成固定大小的槽。比如某个 span 会切成 1024 个 8B 的槽。
这样设计的目的主要是空间换时间,免得分配内存时还要去找哪里有合适的内存块;代价就是有碎片。
内存分配结构
Go 切分出的一大块内存,不会让所有 goroutine 挤到一个全局结构上加锁抢内存——那样会有瓶颈。它分了三个层级。
第一层:mheap
开始时向 OS 申请的一大块内存,管理所有 span,全局唯一,对应 Go 进程,负责和 OS 交互。
第二层:mcentral
连接 mheap,向 mheap 申请一块内存,把这些内存按尺寸档位分类。全局共享,访问需要加锁。
第三层:mcache
每个 P(GMP 概念里的 P)有一个本地缓存,无锁。
一次分配流程
- 先去 mcache 里看有没有想要尺寸的 mspan——无锁,最快;
- 没有,就去 mcentral 对应尺寸获取 mspan,补到本地 cache;
- 还没有,就去 mheap 加锁获取内存;
- mheap 也没有,就系统调用向 OS 获取内存。
结合 GMP 看内存分配
枢纽只有一个:P 上面挂着 mcache。
type p struct {
// ...
mcache *mcache // 每个 P 一份,无锁内存缓存
// ...
}
一次内存分配在 GMP 中的流程:
G 执行 new(User)
│ G 此刻正被调度在某个 P/M 上
▼
【P.mcache】当前 P 私有,无锁,命中即返回
│ ← 99% 的情况到此结束,调度器完全不介入
│ 该 size class 空了
▼
【mcentral】全局,按尺寸分档,加锁(但很少走)
│ 拿一个新 span 补进 mcache
│ 还是不够
▼
【mheap】全局锁,从 arena(mmap 来的)里切
▼
分配完成,G 继续执行
为什么挂在 P 上,而不是 M 上?
借鉴 TCMalloc 时,不是把本地缓存挂在线程上吗?看下场景:
M1 跑着 G1,G1 分配了不少内存,M1.mcache 囤了一批 span
│
▼
G1 要做阻塞系统调用(读磁盘)
│
▼
调度器把 P 从 M1 上摘下来,交给 M2 继续跑别的 G
如果缓存挂在 M 上:
M1 抱着 mcache 一起阻塞了 —— 缓存被「锁死」在一条睡着的线程上
M2 接手 P 却没有缓存 —— 得重新去 mcentral 拿
→ 缓存白建了
而缓存挂在 P 上:
P 转移时,mcache 跟着 P 一起走
M2 接手 P 就立刻拥有完整缓存
→ 缓存永远服务于「正在跑 G 的那个上下文」,从不浪费
全景
G(goroutine)
│ 业务代码:new / make
▼
┌──────────────────────┐
│ P(Processor) │
│ 持有 mcache ★ │ ← 内存分配与 GMP 的枢纽
│ 无锁分配小对象 │
└──────────────────────┘
│ 由
▼
┌──────────────────────┐
│ M(OS 线程) │
│ 执行代码;g0 跑 runtime│
│ 阻塞 syscall 时 P 被摘走│
└──────────────────────┘
│ 底层供应
▼
mcentral → mheap → arena(mmap)
↑
GC 也在这里借 GMP(mark worker / assist / 扫栈根)
小结
- 栈靠 LIFO 零成本回收,适合生命周期与函数调用一致的对象;
- 堆容纳不符合 LIFO 的对象,代价是需要 GC 追踪;
- Go 的分配器用 arena → page → span → size class 做空间换时间;
- mcache 挂在 P 上而不是 M 上,是为了让缓存跟着调度上下文走;
- 理解这套结构,才能理解 GC 的压力从哪里来。