虚拟内存基础
它给每个进程一个"独占的、连续的、从 0 开始的"假地址空间,再由硬件(MMU)+ 操作系统把它映射到真实的物理内存上,按需取用,从而提供了一个比实际物理内存大的多的内存空间
go的内存管理
堆栈
内存分堆和栈
栈有三层,内核栈(系统线程)、系统栈(g0)、用户栈(goroutine),这里我们只讨论用户栈
用户栈属于每个goroutine,由runtime管理,初始2kb可动态伸缩,每个goroutine有自己的用户态栈。
一个goroutine包含一条完整的函数调用链,因为函数调用是嵌套的,天然符合先进后出(lifo),所以适合用栈来保存goroutine的函数调用关系
先看一个最简单的例子
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管理。
为什么堆上需要放这些对象,为什么需要gc管理?
栈上逃逸出去的对象不符合栈上严格的函数调用嵌套模式,所以会打乱栈的简单lifo回收方式,一旦打破这个方式回收时间就无法在编译器算出来们只能推迟到运行时追踪(即gc)
于是栈上逃逸出去的对象需要分配到堆上统一gc。
大对象放堆上会导致栈疯狂翻倍反复搬栈,成本远高于直接放堆。
为什么需要gc
因为这些分配到堆上的对象在运行前都无法知晓其回收时机,于是需要一个运行时追踪算法来判断什么时候该内存不再需要从而可以回收,这个运行时追踪算法就是gc。
c++和c都是手动管理内存的,就是程序员自己写代码管理内存什么时候分配什么时候回收;go的是自动gc,用的三色标记法,简单说就是运行时从根出发,能走到的对象标色,走不到的未标色触发回收。详细可以自己去搜下。
这里我说下自己的理解,堆不是更高级的内存,如果可以的话其实根本不想有堆存在的,但是因为有些栈上的对象不符合栈上严格嵌套的函数调用关系或者出于其他考虑,所以将这类对象放在堆上由gc统一管理生命周期。堆只是为了容纳不符合lifo规则的对象被迫付出的代价。
golang的内存分配模型
在 Go runtime 内部,堆对象和 goroutine 栈不是两套独立的内存系统,而是同一套分配器(mheap + span)供应的两种用途。
我们讲下go里面一次内存分配时什么样子的,这个内存分配器长什么样子,为什么这样设计
很简单的一个new(),runtime要做的事情就是给你分配一块能放下new对象的内存,这样的操作可能每秒发生几百万次,怎么保证又快又不浪费呢?
最笨的做法就是每次new的时候runtime去向操作系统申请对应内存,那是系统调用,要走内核态转换,一次几微妙,很亏。
所以runtime做的事情时一开始就向os申请一大块内存(64mc的arena),然后自己在这块内存上面做切分,后续所有的分配都在此内存块上不再进行系统调用,省下了系统调用的时间。
arena切分成8kb一块,这个叫page,一些连续的page组成span。
一个span绑定一种尺寸。go把对象分成几十个大小挡位(8b到32kb),一个span划分到某个挡位就会按挡位切成固定大小的槽,比如某个span会切成1024个8b的槽。这样设计的目的主要就是空间换时间,免得分配内存的时候还要去找下哪里有合适的内存块,代价就是有碎片。
内存分配结构以及过程:
go切分出的一大块内存,不会让goroutine挤到一个全局结构上加锁抢内存,这样会有瓶颈,它分了三个层级。
第一层(mheap)就是开始时向os申请的一大块内存,管理所有span,全局唯一,对应go进程,向os交互
第二层(mcentral)交互mheap,交互时申请一块内存,将这些内存按尺寸挡位分类,全局有锁。
第三层(mcache),每个p(gmp概念)有一个本地缓存,无锁
一次内存分配过程:先去mcache里面看看有没有想要尺寸的mspan,无锁,最快;没有就去mcentral对应尺寸获取mspan到本地cache;还是没有就去mheap加锁获取内存;如没有则mheap系统调用获取内存
同时这里另外补充:栈实际上也是从mheap里面获取内存的,栈的动态扩缩和搬栈也是来自于mheap对内存的分级。
结合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 继续执行
为什么挂在g上而不是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 / 扫栈根)