Go 内存分配与 GC

Go 的内存管理设计,核心思路就一个:用空间换时间,用复杂度换低延迟 ——运行时 GC,但拼命压低 STW 时间。这个选择带来的后果是:GC 本身的吞吐量不算高,但延迟确实低,p99 通常在毫秒级以内。

对于大多数业务服务来说,这个 trade-off 是合理的。但如果你在写高吞吐的数据管道或者内存敏感的基础设施,就得深入了解底层机制了。


一、内存分配:不只是 malloc

1.1 整体架构

Go 的内存分配器脱胎于 TCMalloc(Thread-Caching Malloc),但做了大量改造。整体分三层:

goroutine
    ↓
 mcache    (每个 P 一个,无锁分配)
    ↓
 mcentral  (全局,按 size class 分组,需要锁)
    ↓
  mheap    (全局堆,管理 arena,向 OS 申请内存)

关键点在第一层 mcache。Go 给每个 P(不是每个 goroutine,是每个 P)绑定了一个本地缓存。分配小对象时直接从 mcache 拿,完全不需要锁。这是快的核心原因。

最底层的 mheap 管理的内存来自 arena。arena 是 Go runtime 向操作系统预留的大块连续虚拟地址空间(不是物理内存,物理内存由 OS 按需分配):

  • 在 64 位系统上,每个 arena 大小为 64MB,包含 8192 个 page(每页 8KB),整个堆由多个 arena 组成
  • arena 只是虚拟地址预留(mmap),不会立即占用物理内存,实际使用时操作系统才会按页分配物理内存
  • mheap 从 arena 中切出连续的 page,组成 mspan,再交给 mcentral 和 mcache 使用

从大到小的层级关系:

Arena (64MB) → 包含 8192 个 Page (8KB) → 若干连续 Page 组合成 mspan → mspan 内部切分成 slot

Arena 与其他组件的关系

  • mheap 拥有所有 arena:mheap 内部维护了一个 arenas 数组,记录所有已向操作系统申请的 arena 的元数据指针。这是整个堆内存的总账本
  • mspan 寄生在 arena 内部:当 mcentral 或 mheap 需要创建新的 mspan 时,就是从某个 arena 里取出若干连续 page,打包成 mspan。一个 arena 内部可以同时存在大量不同 size class 的 mspan
  • 大对象独占 mspan,不是独占 arena:比如申请 100KB 的大 slice,会在某个 arena 里取出 13 个连续 page 组成一个专属 mspan,但这个 arena 的其余 page 仍然可以分给其他 mspan 使用。只有对象超过 64MB 才会跨多个 arena

Go 还为每个 arena 维护了一个 heapArena 元数据结构,记录两个关键信息:

  • bitmap:标记哪些地址上有指针,GC 扫描时据此决定是否要追踪某个 word
  • spans 映射:从页号快速查到它属于哪个 mspan,GC 回收时需要这个反查

这个设计的好处是:arena 提供了连续的地址空间,使得从任意对象地址反查它属于哪个 mspan、哪个 arena 只需要简单的位运算(地址右移即可),不需要遍历或哈希查找。

1.2 Size Class 和 Span

Go 把对象按大小分成了 67 个 size class(从 8B 到 32KB),加上一个 size class 0 给大对象用。

runtime/sizeclasses.go 里的部分定义(简化):

class bytes/obj (Slot大小) bytes/span objects (Slot数量) tail waste max waste%
1 8 8192 1024 0 87.50%
2 16 8192 512 0 43.75%
3 24 8192 341 8 29.24%
4 32 8192 256 0 21.88%
... ... ... ... ... ...
67 32768 32768 1 0 12.50%

注意 tail waste 和 max waste 这两列。Go 的 size class 设计是故意多给一点内存来减少碎片。比如你申请 17 字节,实际会分配到 class 3(24 字节),浪费 7 字节。这是典型的空间换时间。

span(mspan)是内存管理的基本单位,通常是 8KB 的整数倍。每个 span 只服务一种 size class。

1.3 mcache 如何使用 mspan

这是分配器里最关键的一环。mcache 内部维护了一个 mspan 指针数组,按 spanClass 索引:

// runtime/mcache.go(简化)
type mcache struct {
    alloc [numSpanClasses]*mspan  // numSpanClasses = 67×2 = 134
}

为什么是 134 而不是 67?因为每个 size class 分了两种 span:scan(包含指针,GC 需要扫描)和 noscan(不含指针,GC 可以跳过)。这个区分直接减少了 GC 的扫描量。

分配一个小对象时,mcache 内部的查找过程:

1. 根据对象大小 → 查表得到 size class(比如 17B → class 3,24B)
2. 根据类型是否含指针 → 确定 spanClass(class 3 + noscan = spanClass 7)
3. 从 alloc[spanClass] 拿到当前 mspan
4. 在 mspan 内部找一个空闲 slot → 返回地址

第 4 步是重点。每个 mspan 维护了三个关键成员来管理空闲 slot:

// runtime/mheap.go(简化)
type mspan struct {
    startAddr  uintptr     // span 起始地址
    npages     uintptr     // span 占多少页(每页 8KB)
    spanclass  spanClass   // 这个 span 服务哪个 size class

    freeindex  uintptr     // 从这里开始找空闲 slot
    allocBits  *gcBits     // 持久位图:1 = 已分配,0 = 空闲(GC 周期间保留)
    allocCache uint64      // allocBits 当前 64 位窗口的取反缓存(1 = 空闲)
    nelems     uintptr     // span 里总共能放多少个对象
    // ...
}

这三者的关系:

  • allocBits 是完整的持久位图,记录所有 slot 的占用状态,GC 会更新它
  • allocCacheallocBits 从 freeindex 开始的 64 位窗口,取反后缓存到这里(1 = 空闲)。为什么要取反?因为 TrailingZeros64 的语义是"找最低位的 1",而 allocBits 里 1 代表已占用、0 代表空闲。如果不取反,每次分配都得先 ^allocBits 再调用——取反一次缓存起来,后续 64 次分配直接一条指令出结果
  • freeindex 是游标,标记 allocCache 对应的起始 slot

快速路径直接操作 allocCache,不碰 allocBits。只有当 allocCache 的 64 位用完了(全为 0,没有空闲位了),才从 allocBits 加载下一个 64 位 word 并取反填入 allocCache

查找空闲 slot 的过程(以 class 3 为例,span 8KB,对象 24B,共 341 个 slot):

mspan 内存布局——每个 slot 大小相同,连续排列:

slot 0 slot 1 slot 2 slot 3 slot 4 slot 5 slot 6 slot 7 slot 8 ...
24B 24B 24B 24B 24B 24B 24B 24B 24B ...

allocBits(持久位图)allocCache(取反缓存) 的关系,freeindex = 5:

slot 0 1 2 3 4 5 6 7 8 ...
allocBits 1 1 1 1 1 0 1 0 0 ...
↑ freeindex=5,从这里开始取 64 位
allocCache(取反) 1 0 1 1 ...
含义 空闲 ✓ 占用 空闲 空闲 ...

分配步骤

步骤 操作 结果
1 读取 allocCache 1,0,1,1,...(1 = 空闲,已经是取反的了)
2 调用 TrailingZeros64(allocCache)(CPU 指令 TZCNT 返回 0 → 第 0 位就是 1,即 freeindex+0 = slot 5 空闲
3 右移 allocCache >>= (0+1) 消耗掉已分配的位,allocCache 变为 0,1,1,...
4 计算地址:startAddr + 5 × 24 返回该 slot 的内存地址给调用方
5 freeindex 推进到 6 下次从 slot 6 开始

当 allocCache 耗尽时(64 个 slot 都检查完了):

步骤 操作 结果
1 allocCache == 0,当前 64 位窗口没有空闲 slot 了 需要加载下一批
2 freeindex 推进 64 位 跳到下一个 64-slot 窗口
3 allocBits 加载 freeindex 开始的下一个 64 位 word 得到新的占用位图
4 取反后写入 allocCache 1 = 空闲,继续用 TrailingZeros64 快速查找

所以实际的快速路径是:TrailingZeros64(allocCache) 一条指令定位空闲 slot,右移消耗掉,完全不碰 allocBitsallocBits 只在 allocCache 耗尽时批量加载一次,以及 GC 标记阶段更新。这就是为什么有些文章说 allocCache、有些说 allocBits——前者是快速路径的主角,后者是持久化的数据源。

当 mspan 满了怎么办? 触发 refill:

mcache 的 mspan 满了
    ↓
向 mcentral 要一个新的 mspan(需要加锁)
    ↓
mcentral 维护两个 mspan 链表:
  - partial:还有空闲 slot 的 mspan
  - full:已满的 mspan
    ↓
从 partial 取一个给 mcache,把满的旧 mspan 还到 full
    ↓
如果 partial 也空了 → mcentral 向 mheap 申请新的 span(先查 pageCache,无锁;不够再加锁走 pageAlloc)
    ↓
mheap 从 arena 切一块出来,初始化成 mspan 返回

上面提到的 pageCache 是 Go 1.14 引入的优化,思路和 mcache 一样——给每个 P 一个本地缓存来避免加锁:

// runtime/mpagecache.go(简化)
type pageCache struct {
    base  uintptr // 这批页的起始地址
    cache uint64  // 64 位位图,1 = 空闲页
    scav  uint64  // 哪些页已经归还过物理内存
}

每个 P 持有一个 pageCache,缓存 64 个连续页(64 × 8KB = 512KB)。mheap 需要分配页时,先从当前 P 的 pageCache 里找(位图扫描,无锁),找不到才加锁走全局的 pageAlloc。这让 mcentral 向 mheap 要新 span、以及大对象(≤512KB)的分配都有机会避免加锁。

整个设计的精髓就是:绝大多数分配在 mcache 这一层就完成了——查数组、扫位图、推游标,全程无锁。只有 mspan 耗尽时才会往上走,而一个 mspan(比如 class 3,8KB / 24B = 341 个 slot)能服务几百次分配,所以 refill 的频率很低。

1.5 分配路径

对象分配走三条路径,按大小区分:

类型 大小 路径 说明
微小对象(tiny) ≤ 16B 且无指针 tiny allocator 多个小对象合并到一个 16B 的块里
小对象(small) ≤ 32KB mcache → mcentral → mheap 按 size class 从 mcache 分配
大对象(large) > 32KB 直接从 mheap 大对象单独分配 span

tiny allocator 是个很巧妙的设计,它和小对象走的完全不是同一条路。小对象是"每个对象独占一个 slot",微小对象是"多个对象挤进同一个 16B 块"。划分微小对象的主要目的是处理极小的字符串和独立的转义变量。

mcache 里为 tiny 分配单独维护了三个字段:

// runtime/mcache.go(简化)
type mcache struct {
    tiny       uintptr  // 当前 tiny 块的起始地址
    tinyoffset uintptr  // 当前已用到哪里(偏移量)
    tinyAllocs uintptr  // 统计:从这个块里分配了多少个对象
    // ...
}

分配过程(以连续分配 3B、2B、1B 三个对象为例):

初始状态——mcache.tiny 指向一个 16B 的块,tinyoffset = 0:

偏移 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
内容 · · · · · · · · · · · · · · · ·
↑ tinyoffset=0

逐步分配

步骤 申请 对齐后大小 对齐要求 当前 tinyoffset 分配位置 返回地址 新 tinyoffset
1 3B 4B 4B 对齐 0 offset 0~3 tiny+0 4
2 2B 2B 2B 对齐 4 offset 4~5 tiny+4 6
3 1B 1B 无要求 6 offset 6 tiny+6 7

分配完 3 个对象后的块内布局

偏移 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
内容 A A A pad B B C · · · · · · · · ·
归属 obj A(3B) 对齐填充 obj B(2B) obj C(1B) ↑ tinyoffset=7 空闲(9B)

三个对象共用一个 16B 块,只消耗了 7B。如果走普通 size class 1 需要 3 × 8B = 24B。

当 16B 块用满了怎么办? 从 mcache 的 size class 2(16B)的 mspan 里取一个新 slot 作为新的 tiny 块——所以 tiny allocator 本质上是在 mspan 机制之上又加了一层"块内打包"。

还有一个重要限制:tiny allocator 只服务无指针的对象。因为多个对象共用同一块内存,GC 无法单独回收其中某一个——必须等这个 16B 块里所有对象都不可达了,整个块才能被回收。如果里面有指针,GC 就得追踪每个对象的引用关系,共用就没意义了。

来看实际效果:

package main

// 定义一个全局切片,用来存放指针,强行触发堆逃逸
var escapeSink []*int8

func main() {
    // 预分配空间,排除切片自身扩容带来的堆分配干扰
    escapeSink = make([]*int8, 1000000)

    for i := 0; i < 1000000; i++ {
        x := new(int8) // 1 字节
        escapeSink[i] = x // 赋值给全局变量,触发 Tiny allocator 的合并机制
    }
}

如果你用 go tool trace 看这段代码,会发现实际的堆分配次数远小于 100 万——理论上 1 个 16B 块能装 16 个 int8,所以分配次数大约只有 1/16。

大对象的分配路径则完全绕过了 mcache 和 mcentral,直接跟 mheap 打交道:

大对象(> 32KB)分配路径:

mallocgc(size > 32KB)
    ↓
直接调用 mheap.alloc
    ↓
计算需要多少个页:npages = size / 8KB(向上取整)
比如 100KB 的对象 → 需要 13 个页
    ↓
先查 pageCache(每个 P 一个,缓存 64 页 = 512KB,无锁)
如果 pageCache 能满足 → 直接分配,不加锁
    ↓
pageCache 不够 → 加锁,走 pageAlloc 在 arena 中寻找连续的空闲页
    ↓
找到后,创建一个专属 mspan(spanClass = 0,即 size class 0)
这个 span 只服务这一个对象
    ↓
返回 span 的起始地址

和小对象对比,差异很明显:

小对象 大对象
入口 mcache(无锁) mheap(先 pageCache 无锁,再 pageAlloc 加锁)
span 复用 一个 span 装几百个对象 一个 span 只装一个对象
size class 67 种,有内部碎片 class 0,按需分配页数,几乎无碎片
分配开销 极低(位图 + 游标) 较高(可能加锁 + 找连续页)
回收 span 内 slot 逐个释放 对象回收 = 整个 span 释放

大对象跳过 mcache/mcentral 的原因很直接:mcache 的 mspan 最大只服务 32KB(size class 67),更大的东西塞不进去。不过 Go 1.14 引入了 pageCache(每个 P 缓存 64 页 = 512KB),大对象如果需要的页数不超过 64 页(512KB),可以无锁从 pageCache 分配。只有超过 512KB 或 pageCache 耗尽时才真正加锁走 pageAlloc。所以大对象也不是一定会加锁,只是概率比小对象高得多。

1.6 逃逸分析——真正决定分配在哪

说到这里必须提一下逃逸分析,因为 Go 的编译器会尽量把对象分配在栈上,只有逃逸到函数外部的对象才会堆分配。

func noEscape() int {
    x := 42       // 栈上,不触发 GC
    return x
}

func escape() *int {
    x := 42       // 逃逸!分配到堆上
    return &x
}

查看逃逸分析结果:

go build -gcflags='-m -l' main.go
# main.go:8:2: moved to heap: x

工程上有几个常见的逃逸场景容易被忽略:

// 1. interface{} 参数会导致逃逸
fmt.Println(x)  // x 会逃逸,因为 Println 接收 interface{}

// 2. 闭包引用外部变量
func foo() func() int {
    x := 0
    return func() int {
        x++      // x 逃逸
        return x
    }
}

// 3. slice append 可能导致底层数组逃逸
// 如果编译器无法确定 append 后的容量,就会逃逸
func bar() []int {
    s := make([]int, 0, 10)
    s = append(s, 1)
    return s   // s 的底层数组逃逸
}

减少堆分配是优化 Go 程序性能最有效的手段,没有之一。堆分配少了,GC 压力自然小。

1.7 栈内存 vs 堆内存:两条独立的供应链

前面讲逃逸分析时提到"不逃逸的对象分配在栈上"。这里的栈和堆是两套完全独立的内存管理体系,共用 mheap 这个底层页分配器,但上层的缓存和分配逻辑完全不同:

mheap(全局页分配器)
                        │
        ┌───────────────┴───────────────┐
        ▼                               ▼
    【堆供应链】                      【栈供应链】
  mcentral(按规格加锁)         stackpool / stackLarge
        ↓                               ↓
  mcache(P 本地无锁)           stackcache(P 本地无锁)
        ↓                               ↓
  mspan → slot                  Goroutine 栈空间
 (逃逸对象、全局变量)           (函数栈帧、局部变量)

栈内存的管理方式和堆完全不同

对比 堆内存(mspan 体系) 栈内存(Goroutine Stack 体系)
分配什么 逃逸对象、全局变量、大对象 函数栈帧、非逃逸局部变量、函数参数
分配方式 位图搜索空闲 slot SP 寄存器加减偏移,纳秒级
初始大小 按 size class 分配 每个 goroutine 初始 2KB,按需扩容
扩容 不扩容,满了换新 span 栈空间不够时分配 2 倍大小的新栈,拷贝旧栈内容过去(连续栈,Go 1.4+)
隔离性 同一 span 被多个 goroutine 共用 每个 goroutine 独占自己的栈,天然无锁
回收 GC 标记清扫 goroutine 结束时整块归还

SP 寄存器加减偏移的例子

func main() {
    a := 1    // 8 字节
    b := true // 1 字节,对齐后占 8 字节
    foo(a)
    _ = b
}

func foo(x int) {
    c := 2    // 8 字节
    _ = c
}

调用 foo 前后,栈的变化(栈从高地址向低地址增长):

时刻 SP 操作 栈内布局(高地址 → 低地址) SP 值
main 入口 SP -= 16(为 a、b 预留空间) [a=1] [b=true] ← SP 原始 - 16
调用 foo SP -= 8(为 c 预留空间) [a=1] [b=true] [返回地址] [c=2] ← SP 原始 - 32
foo 返回 SP += 8(回收 c 的空间) [a=1] [b=true] ← SP(c 的空间自动失效) 原始 - 16
main 返回 SP += 16 整个栈帧回收 原始

核心区别:堆分配要搜索位图找空闲 slot,栈分配就是 SP -= size 一条指令,回收就是 SP += size,不需要任何搜索、标记或 GC 介入。这就是为什么"减少逃逸 = 减少堆分配 = 减少 GC 压力"。

栈的供应链(goroutine 需要新栈或栈扩容时):

层级 组件 说明
1 p.stackcache 每个 P 的本地栈缓存,存放固定大小的栈块(2KB、4KB、8KB、16KB),无锁获取
2 stackpool / stackLarge 全局栈池。≤32KB 走 stackpool,>32KB 走 stackLarge,需要加锁
3 mheap 全局栈池也空了,直接向 mheap 申请原始 page,跳过 mcentral,不走 mspan 规格体系

这套设计解释了为什么 GC 扫描要从栈开始:栈帧里存放着当前函数正在使用的局部变量指针,如果某个栈上的指针指向了堆中某个 mspan 的 slot,GC 就会顺着这个指针把对应的堆对象标记为灰色——栈是 GC 根对象的主要来源。

根对象分布在整个程序的各个角落,主要包括以下三类:
1. Goroutine 的栈(Stack):
全系统所有存活的 Goroutine(无论它当前在哪个 P 上运行),其栈帧上正在使用的局部变量、函数参数、返回地址中的指针。
2. 全局变量(Global / DATA / BSS 段):
程序在编译期就固定下来的全局变量、常量、或者是 package 级别的单例指针。
3. 运行时内部的特殊指针:
例如延迟释放的内部数据结构、被 finalizer 绑定的对象等。

扫描时,Go 会把这些根对象第一批捞出来,全部染成灰色,塞进全局和本地的“待扫描队列”(gcWork)中。


二、垃圾回收:三色标记的工程实现

2.1 从 STW 到并发 GC

Go 的 GC 演进历史:

  • Go 1.0:STW 标记清除,简单粗暴,暂停时间几百毫秒
  • Go 1.5:并发三色标记清除,STW 降到 10ms 级别
  • Go 1.8:混合写屏障,STW 降到 sub-ms 级别
  • Go 1.12+:持续优化,大多数场景 STW < 500μs

现在的 GC 一个完整周期分为四个阶段:

Mark Setup → Concurrent Mark → Mark Termination → Concurrent Sweep

阶段 是否 STW 做什么 耗时
1. Mark Setup ✅ STW 开启写屏障;初始化 GC 状态(标记所有 span 为"待扫描"、重置 GC 计数器等);启动 GC worker goroutine 几十微秒
2. Concurrent Mark ❌ 并发 GC worker 和业务 goroutine 同时运行。主要做两件事:①扫描根对象——逐个 goroutine 在安全点短暂暂停,扫描其栈上引用的指针并置灰(注意:不是 STW 全部暂停,而是逐个单独暂停);②三色标记推进——从灰色队列取对象,扫描其引用。如果某个 goroutine 分配堆内存的速度超过了 GC 标记的推进速度(堆增长快于标记进度,有 OOM 风险),该 goroutine 会被 mark assist 征用——暂停业务逻辑,先帮 GC 做标记工作才能继续分配 占 GC 周期的绝大部分时间
3. Mark Termination ✅ STW 关闭写屏障;完成最后的标记收尾(写屏障队列中残留的灰色对象扫描置黑);计算下一次 GC 的触发阈值(基于 GOGC 和 GOMEMLIMIT) 几十微秒
4. Concurrent Sweep ❌ 并发 不会一次扫完所有 span,而是懒惰清扫——在分配新对象需要获取 mspan 时才顺便清扫;将未标记的白色对象所占的 slot 释放 穿插在后续的内存分配中

真正的 STW 只发生在第 1 和第 3 阶段,加起来通常不到 1 毫秒。

补充:GC 标记的是 mspan,不是 mcache

一个常见的困惑是:GC 标记操作作用在哪一层?答案是 mspan 内部的 gcmarkBits 位图,而不是 mcache。mcache 只是每个 P 的"本地无锁临时柜台",存放 mspan 的指针。Mark Setup 阶段会刷新(flush)所有 P 的 mcache——调用 mcache.releaseAll(),将每个 P 持有的所有 mspan 归还给 mcentral,同时同步分配统计信息到全局。归还后,这些 mspan 才能在 Concurrent Mark 阶段被 GC worker 统一扫描——GC worker 直接操作 mspan 的 gcmarkBits,把存活对象对应的位置 1。mcache 在标记期间处于"空柜台"状态,后续分配时会重新从 mcentral 获取 mspan。

补充:扫描 goroutine 栈时会暂停该 goroutine

上面第 2 阶段提到"逐个 goroutine 在安全点短暂暂停",具体过程如下:

步骤 动作 说明
1 发起抢占 GC worker 准备扫描某个 goroutine(g)时,向它发送抢占信号(协作式抢占或信号抢占)
2 安全点挂起 g 运行到下一个安全点(函数调用、循环边界或收到 OS 信号)时暂停自己,状态从 _Grunning_Gwaiting
3 快速扫描 g 的栈彻底静止,GC worker 扫描其所有栈帧中的指针。栈通常只有 2KB~几百KB,扫描在几微秒内完成
4 唤醒恢复 扫描完毕,g 的状态改回 _Grunnable,重新进入调度队列继续执行

关键点:只暂停被扫描的那一个 goroutine,其他 goroutine 正常运行。这就是为什么 Go 的并发标记阶段不需要 STW——几千个 goroutine 逐个暂停几微秒,对整体延迟的影响远小于一次性全部暂停。

补充:写屏障队列中残留的灰色对象扫描置黑

以下是 Mark Termination 阶段处理这些残留灰色对象的精确底层流程:

1. 第一步:触发全局 STW,封锁指针变更

当并发标记的后台 Worker 发现堆上的灰色对象基本被消耗完时,运行时会发起 gcMarkDone 并触发 STW。这意味着写屏障将不再产生任何新的灰色对象。写屏障队列里的残留物被彻底“锁死”在这一瞬间。

2. 第二步:强行 Flush 所有 P 的本地缓存

在并发标记期间,每个 P 为了高频无锁运行,内部都有一个专门存放灰色对象的本地队列以及写屏障专属的内存缓冲区(wbBuf)。强行Flush:调用 wbBufFlush 相关的底层函数,将每个 P 的 wbBuf 缓冲区中残留的灰色对象指针,强行Flush、合并到全局的 GC 待扫描队列(gcWork)中。

3. 第三步:进入终极“收尾扫描”循环(Drain)

Flush完成后,所有的残留灰色对象都集中到了全局和本地的 gcWork 队列中。此时,GC 的核心执行器(通常由主线程或专职的标记 Worker 执行)会进入一个高优先级的 gcDrain 终极循环:

[ 进入 Mark Termination (STW) ]
                │
                ▼
 [ 强行冲刷所有 P 的写屏障缓冲区 (wbBuf) ]
                │
                ▼
   【 残留灰色对象全部汇集到 gcWork (全局) 】
                │
                ▼
┌──►【 1. 从 gcWork 中弹出一个灰色对象 】
│               │
│               ▼
│   【 2. 顺着它的指针摸过去 (不扫栈,仅扫堆) 】
│               │
│               ├─────────────────────────┐
│               ▼ (如果指向的对象是白色)      ▼ (如果没有下级指针了)
│     [ 将下级对象染灰,塞回 gcWork ]           [ 该对象正式染黑 ]
│               │                                 │
└───────────────┴─────────────────────────────────┘
                │
                ▼ (当 gcWork 彻底变成全空 0)
   [ 灰色对象全部清空 ──► 标记终止 ──► 关闭写屏障 ]

2.2 三色标记

先说模型。三色标记把对象分为三种状态:

  • 白色:未被扫描,GC 结束后白色对象会被回收
  • 灰色:已被发现,但其引用的对象还没扫描完
  • 黑色:已扫描完毕,它引用的所有对象都已经是灰色或黑色

标记过程用一个具体的指针链 A → B → C 来还原(假设 A 是根对象):

阶段 动作 A B C 说明
1 初始状态 所有对象默认白色
2 扫描根对象 A 是全局变量,被 GC 发现并置灰,但 A 指向谁还没看
3 扫描 A 顺着 A 的指针发现 B → B 置灰;A 的所有引用都找完了 → A 置黑。此时黑色指向灰色是正常状态
4 扫描 B 顺着 B 的指针发现 C → C 置灰;B 置黑。灰色像波浪一样向前推进
5 扫描 C C 没有引用了 → C 置黑。灰色队列为空,标记结束

核心规律:灰色是标记的"波前"——它像波浪一样沿着引用链向前推进。黑色在身后(已扫描完),白色在前方(尚未被发现)。当灰色队列为空时,所有可达对象已经变黑,剩下的白色对象就是垃圾。

这个模型本身很简单。难的是:标记和用户代码并发执行时,怎么保证正确性?

2.3 并发标记的问题:丢失对象

考虑这个场景:

初始状态:A(黑) → B(灰) → C(白)

用户代码执行:
  1. A.ref = C    (黑色对象 A 新增了对白色对象 C 的引用)
  2. B.ref = nil  (灰色对象 B 删除了对 C 的引用)

标记继续:
  扫描 B → B 没有引用了 → B 变黑
  没有灰色对象了,结束

结果:C 是白色 → 被回收!但 A 还引用着 C!程序崩溃。

这就是经典的"丢失对象"问题。对象被误杀需要同时满足两个条件

  1. 黑色对象指向了白色对象(新增了一条黑→白引用)
  2. 灰色对象到白色对象的路径被删除(唯一能发现白色对象的线索断了)

只要破坏其中任何一个条件,就能保证安全:

  • 插入屏障破坏条件 1:新增引用时把目标对象置灰
  • 删除屏障破坏条件 2:删除引用时把旧目标对象置灰

2.4 Go 的选择:混合写屏障

Go 1.8 之前用的是 Dijkstra 插入屏障,但有个问题:栈上的指针写入不会触发写屏障(性能原因,每次栈操作都触发屏障太贵了)。所以 GC 结束前需要重新扫描所有 goroutine 的栈,那个 STW rescan 很痛。

Go 1.8 引入了混合写屏障(Hybrid Write Barrier),同时破坏两个条件。它的底层实现是编译器在堆指针修改前自动插入的一段检测代码:

// 伪代码:业务代码 *slot = new_obj 会被编译器展开为
func hybridWriteBarrier(slot *unsafe.Pointer, new_obj unsafe.Pointer) {
    old_obj := *slot       // 1. 提取即将被覆盖的旧指针

    shade(old_obj)         // 2. 删除屏障:旧对象置灰,加入灰色队列
    shade(new_obj)         // 3. 插入屏障:新对象置灰,加入灰色队列

    *slot = new_obj        // 4. 执行真正的指针修改
}

混合写屏障的四条规则:

规则 内容 原因
1. 栈上新生直接黑 GC 期间,任何在栈上新分配的对象,跳过灰色,直接染成黑色 杜绝新栈对象在 GC 期间发生状态反复,彻底省掉 GC 结束时的 STW 栈二次扫描(Rescan)
2. 栈上操作无屏障 函数调用、局部变量赋值等所有栈内部的指针修改,完全不插入任何屏障代码。 栈操作极其高频(纳秒级),加屏障代价太大
3. 堆上旧引用被删除 → 旧对象置灰 *slot 原来指向 old_obj,现在要改成指向别人,先把 old_obj 染灰 防止 old_obj 被从堆中拔出后藏进已扫描完的黑色栈中,导致 GC 找不到它
4. 堆上新引用被插入 → 新对象置灰 *slot 要指向 new_obj,先把 new_obj 染灰 防止白色对象被直接挂到已扫描完的黑色堆对象上

规则 1 和 2 保护栈的高性能;规则 3 和 4 保护堆上的引用变更不丢对象。

来看一个最极端的场景——对象在堆和栈之间"密谋转移":

初始状态:
  Stack_A(黑,栈上,已扫描完) 
  Heap_B(灰,堆上) → Heap_C(白,堆上)
步骤 谁做的 动作 发生了什么
1 业务代码 Stack_A.ref = Heap_C 黑色栈对象引用了白色堆对象(栈上修改,规则 2:不触发屏障
2 业务代码 Heap_B.ref = nil 灰色堆对象删除了对 Heap_C 的引用(堆上修改,触发屏障!
3 写屏障 shade(Heap_C) 规则 3 生效:旧对象 Heap_C 被染灰,加入灰色队列
4 GC worker 从灰色队列取出 Heap_C 并扫描 Heap_C 变黑,它的子对象也会被继续追踪

如果没有混合写屏障:第 2 步不会触发任何保护,Stack_A 是黑色不会被重新扫描,Heap_B 砍断引用后也找不到 Heap_C → Heap_C 保持白色 → 被误杀。

有了混合写屏障:第 2 步触发规则 3,Heap_C 被染灰兜住。它不仅不会死,还会作为新的灰色源头,让它后面引用的所有对象都被正确标记。

这套设计的代价是标记会更保守一些——有些实际已经是垃圾的对象可能被多余地染灰(浮动垃圾),活过这一轮 GC,下一轮才被回收。但这换来的是:不需要在 GC 结束时 STW 重新扫描所有 goroutine 的栈,延迟改善巨大(sub-ms 级别)。

2.5 Mark Assist——背压机制

并发 GC 有个隐患:如果用户代码分配内存的速度比 GC 标记的速度还快怎么办?

Go 的做法是 mark assist:当 goroutine 试图分配新内存时,先检查当前 GC 的标记进度是否落后。如果落后了,这个 goroutine 会被"征用"去做一些标记工作,做完才能拿到内存。

Assist 量的计算:

assist 的工作量 ∝ 这个 goroutine 已分配的内存

意思就是:你分配得越多,被征用去做 GC 的概率越大。这是一种很公平的背压设计,谁制造的垃圾多,谁就多干活。

实际影响:如果你发现某些 goroutine 的延迟偶尔会突然抖一下,大概率是被 mark assist 了。用 go tool trace 可以看到:

goroutine 12 [GC assist marking]:
runtime.gcAssistAlloc(...)

2.6 清扫(Sweep)

标记之后是清扫。清扫通过三种机制协作完成,确保所有 span 最终都会被清扫:

机制 触发条件 说明
懒惰清扫(sweep on demand) mcache 需要从 mcentral 获取新 span 时 最常见的路径:mcentral 先把待清扫的 span 清扫完再给出去,把清扫开销分摊到每次分配中
后台清扫(bgsweep) runtime 常驻后台 goroutine 主动执行 空闲时(没有分配触发懒惰清扫)由 bgsweep goroutine 主动遍历并清扫剩余 span,防止"分配频率低导致清扫停滞"
强制清扫 下一轮 GC 启动前 新一轮 GC 的 Mark Setup 阶段会检查:如果还有上一轮未清扫的 span,必须全部清扫完才能开始新的标记。这是最终的兜底保证

懒惰清扫的代码路径:

// 简化的分配路径
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // ... 
    // sweep 发生在获取新 span 时
    s = c.alloc[spc]
    if s.sweepgen != swgen {
        s = c.refill(spc)  // refill 过程中会触发 sweep
    }
    // ...
}

所以即使程序分配频率很低,后台清扫和下轮 GC 前的强制清扫也会兜底。懒惰清扫只是"优先路径"(把开销分摊到分配中,避免一次性清扫的延迟尖刺),不是唯一路径。

2.7 GC 与内存分配的结合:gcmarkBits 翻转

GC 标记和内存分配之间的衔接,靠的是 mspan 上的两套位图配合完成。每个 mspan 同时维护两个位图:

位图 谁写 含义
allocBits 分配器 1 = 已分配,0 = 空闲。分配器据此找空闲 slot
gcmarkBits GC 标记阶段 1 = 存活(被标记),0 = 垃圾或未使用

一轮 GC 的完整交互过程:

阶段 allocBits gcmarkBits 说明
GC 前 1,1,1,0,1,0,1,0 0,0,0,0,0,0,0,0 allocBits 反映当前分配状态,gcmarkBits 全部清零
标记中 不变 GC 逐步置 1 GC worker 扫描到存活对象就在 gcmarkBits 对应位置 1
标记完成 1,1,1,0,1,0,1,0 1,0,1,0,1,0,0,0 allocBits[1]=1 但 gcmarkBits[1]=0 → slot 1 是垃圾
清扫(翻转) 1,0,1,0,1,0,0,0 清零,等下轮 GC 关键一步:gcmarkBits 直接变成新的 allocBits,旧 allocBits 丢弃

翻转后的效果:

清扫前 allocBits:  1  1  1  0  1  0  1  0   (分配器视角:6  slot 被占用)
gcmarkBits:        1  0  1  0  1  0  0  0   (GC 视角:3  slot 存活)
                                    
                   slot 1          slot 6   这2个是垃圾:分配了但没被标记

清扫后 allocBits:  1  0  1  0  1  0  0  0   (gcmarkBits 直接接管)
                                    
                   slot 1,6 变成空闲,可以被重新分配

这个设计非常精巧——清扫不需要逐个释放对象,只需要把指针一换allocBits = gcmarkBits),O(1) 完成。之后分配器从新的 allocBits 里找空闲位,自然就会复用那些垃圾对象的 slot。

同时 freeindex 会被重置到 0,allocCache 从新的 allocBits 重新加载。这样分配器和 GC 就通过这两套位图的翻转完成了无缝衔接,不需要额外的"释放"操作。


三、GC 调优:GOGC 和 Memory Limit

3.1 GOGC 到底控制什么

GOGC 控制的是 GC 触发的堆增长比例,默认 100,意思是:

下次 GC 触发时的堆大小 = 上次 GC 后的存活堆大小 × (1 + GOGC/100)

举个例子:GC 后存活堆 100MB,GOGC=100,那下次堆增长到 200MB 时触发 GC。

GOGC=50   → 堆增长到 150MB 触发(GC 更频繁,内存用得少)
GOGC=200  → 堆增长到 300MB 触发(GC 不频繁,内存用得多)
GOGC=off  → 关闭 GC(别在生产环境干这事)

常见误区:很多人以为调高 GOGC 就能提升性能。没那么简单。GOGC 调高,GC 频率降低了,但每次 GC 要扫描的对象更多,单次 GC 的暂停和 CPU 开销也更大。

实际调优的经验法则:

  • 内存充裕 + 对延迟敏感:适当调高 GOGC(200-400),减少 GC 频率
  • 内存紧张:保持默认或调低
  • 大量临时对象:优先优化代码减少分配,而不是调 GOGC

3.2 Ballast 技巧(Go 1.19 之前)

Go 1.19 之前有个广泛使用的技巧——内存压舱石(ballast):

func main() {
    // 分配一个大的 byte slice 作为压舱石
    ballast := make([]byte, 1<<30) // 1GB
    _ = ballast
    // ... 启动服务
}

原理:make([]byte, 1<<30) 分配了 1GB,但因为没有写入,Linux 的延迟分配(lazy allocation)不会真正分配物理内存。但对 Go runtime 来说,存活堆大小变成了 1GB+。如果 GOGC=100,下次 GC 要等堆增长到 2GB+ 才触发,相当于间接提高了 GC 触发阈值。

Twitch 用这个技巧把 GC 频率从每秒十几次降到了几十秒一次,效果很明显。

但这是个 hack,不优雅。

3.3 Go 1.19:GOMEMLIMIT

Go 1.19 引入了 GOMEMLIMIT,正式解决了这个问题:

GOMEMLIMIT=4GiB ./myapp

有了 GOMEMLIMIT,你可以做一件之前做不到的事:把 GOGC 设成很高甚至 off,同时用 GOMEMLIMIT 兜底防止 OOM

# 激进配置:几乎不做 GC,除非接近内存上限
GOGC=off GOMEMLIMIT=3GiB ./myapp

runtime 会在接近 GOMEMLIMIT 时自动加大 GC 力度。这比 ballast 干净太多了。

但要注意一个坑:GOMEMLIMIT 是软限制。如果 GC 跟不上分配速度,堆可能会短暂超过 GOMEMLIMIT。它不是 cgroup 那种硬限制。生产环境建议设置为容器内存限制的 70-80%:

# 容器 4GB 内存
GOMEMLIMIT=3GiB  # 留 1GB 给栈、非 Go 内存和缓冲

3.4 runtime/debug 动态调整

Go 1.19 还引入了 runtime/debug.SetMemoryLimit,可以在运行时动态调整:

import "runtime/debug"

func adjustGC(liveBytes int64) {
    // 根据实际存活堆大小动态调整
    if liveBytes > 2<<30 { // > 2GB
        debug.SetGCPercent(50)  // 更积极地回收
    } else {
        debug.SetGCPercent(200) // 放宽
    }
}

四、实战排查

4.1 pprof 常用操作

# 查看堆内存分配
go tool pprof http://localhost:6060/debug/pprof/heap

# 查看分配次数(而非大小)
go tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap

# 查看 goroutine
go tool pprof http://localhost:6060/debug/pprof/goroutine

在 pprof 交互界面:

(pprof) top 20           # 看哪些函数分配最多
(pprof) list funcName    # 看具体哪一行
(pprof) web              # 生成火焰图(需要 graphviz)

实际经验:排查内存问题时,先看 -alloc_objects(分配次数),不要只看 -inuse_space(当前占用)。很多性能问题是"频繁分配小对象"导致的 GC 压力,而不是"大对象占了很多内存"。

4.2 trace 看 GC 行为

curl -o trace.out http://localhost:6060/debug/pprof/trace?seconds=5
go tool trace trace.out

在 trace viewer 里你能看到:

  • 每次 GC 的 STW 时间
  • mark assist 发生在哪些 goroutine 上
  • GC 占用了多少 CPU

4.3 runtime.ReadMemStats

程序里打点监控:

var m runtime.MemStats
runtime.ReadMemStats(&m)

fmt.Printf("HeapAlloc = %d MiB\n", m.HeapAlloc/1024/1024)   // 当前堆使用
fmt.Printf("HeapSys   = %d MiB\n", m.HeapSys/1024/1024)     // 从 OS 拿的堆内存
fmt.Printf("NumGC     = %d\n", m.NumGC)                       // GC 次数
fmt.Printf("PauseTotalNs = %d ms\n", m.PauseTotalNs/1e6)     // 总 GC 暂停时间
fmt.Printf("GCCPUFraction = %f\n", m.GCCPUFraction)          // GC 占 CPU 比例

注意ReadMemStats 本身会触发一次 STW,高频调用会影响性能。生产环境建议 10-30 秒采集一次。

4.4 一个真实案例

分享一个遇到过的问题。一个 HTTP 服务,QPS 大概 5000,Go 1.20,容器 4GB 内存。上线后内存持续增长,几小时就 OOM。

排查过程:

# 1. 先看 pprof heap
go tool pprof http://host:6060/debug/pprof/heap
(pprof) top
# 发现 json.Unmarshal 相关的分配最多,但这很正常

# 2. 对比两次 heap profile
go tool pprof -base old.prof new.prof
(pprof) top
# 发现某个 map 持续增长

最终发现是一个用于缓存的 map[string]*SomeStruct,key 是请求 ID,但忘了设置过期清理。map 只增不减,经典内存泄漏。

Go 的 GC 能回收不可达的对象,但它无法帮你回收还能达到但不再需要的对象。这不是 GC 的问题,是代码逻辑的问题。


五、一些优化原则

总结几条我觉得实用的原则:

1. 减少分配 > 调 GC 参数

// Bad:每次都分配新的 buffer
func process(data []byte) []byte {
    buf := make([]byte, 0, 1024)
    // ...
    return buf
}

// Good:用 sync.Pool 复用
var bufPool = sync.Pool{
    New: func() interface{} {
        b := make([]byte, 0, 1024)
        return &b
    },
}

func process(data []byte) []byte {
    bp := bufPool.Get().(*[]byte)
    buf := (*bp)[:0]
    defer func() {
        *bp = buf
        bufPool.Put(bp)
    }()
    // ...
}

2. 预分配 slice 和 map

// Bad
m := make(map[string]int)
for _, item := range items {
    m[item.Key] = item.Value  // 多次扩容
}

// Good
m := make(map[string]int, len(items))  // 预分配

3. 避免不必要的指针

// 指针多 → GC 扫描多
type Bad struct {
    Name *string
    Tags []*string
}

// 值类型 → GC 扫描少
type Good struct {
    Name string
    Tags []string
}

4. 小心 string 和 []byte 转换

// 每次转换都会分配新内存
s := string(byteSlice)  // 分配
b := []byte(str)         // 分配

// Go 1.22+ 加了一些优化,但在热路径上还是要注意

5. structtag 和对齐

// Bad:由于对齐,占 24 字节
type Bad struct {
    a bool   // 1 byte + 7 padding
    b int64  // 8 bytes
    c bool   // 1 byte + 7 padding
}

// Good:重排字段,占 16 字节
type Good struct {
    b int64  // 8 bytes
    a bool   // 1 byte
    c bool   // 1 byte + 6 padding
}

Comments

No Data
Total 0
  • 1