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 拿,完全不需要锁。这是快的核心原因。

1.2 Size Class 和 Span

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

// runtime/sizeclasses.go 里的部分定义(简化)
// class  bytes/obj  bytes/span  objects  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 分配路径

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

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

tiny allocator 是个很巧妙的设计。想象你有大量的小 int、bool 之类的值需要在堆上分配(比如逃逸分析判定它逃逸了),如果每个都独占一个 size class 1(8B)的 slot,太浪费。tiny allocator 会把几个小对象"挤"进同一个 16B 的块里。

来看实际效果:

func main() {
    for i := 0; i < 1000000; i++ {
        x := new(int8) // 1 字节,走 tiny allocator
        _ = x
    }
}

如果你用 go tool trace 看这段代码,会发现实际的堆分配次数远小于 100 万,因为 tiny allocator 在合并。

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

说到这里必须提一下逃逸分析,因为 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 压力自然小。


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

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 只有两个很短的 STW 阶段:

  1. Mark Setup(开启写屏障,几十微秒)
  2. Mark Termination(关闭写屏障,做最终清理,几十微秒)

真正的标记工作是并发做的,和你的业务 goroutine 同时跑。

2.2 三色标记

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

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

标记过程:

1. 初始:所有对象白色,根对象(栈、全局变量)置灰
2. 循环:取一个灰色对象,扫描它引用的所有白色对象并置灰,然后把自己置黑
3. 结束:没有灰色对象了,剩下的白色对象就是垃圾

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

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. 灰色对象到白色对象的路径被删除(删除屏障破坏这条)

2.4 Go 的选择:混合写屏障

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

Go 1.8 引入了混合写屏障(Hybrid Write Barrier),结合了插入屏障和删除屏障的思想:

// 伪代码
writePointer(slot, ptr):
    shade(*slot)  // 删除屏障:被覆盖的旧指针指向的对象置灰
    shade(ptr)    // 插入屏障:新指针指向的对象置灰
    *slot = ptr

加上一个重要约束:GC 开始时,所有栈上的对象都被当作黑色

这样做的好处是:不需要在 GC 结束时重新扫描栈了。那个令人头疼的 STW rescan 直接省掉。代价是标记会更保守一些(可能有一些浮动垃圾活过一轮 GC),但对延迟的改善是巨大的。

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,而是在分配新对象时顺便清扫。

具体来说,当 mcache 需要从 mcentral 获取新 span 时,mcentral 会先把待清扫的 span 清扫完再给出去。这就是 "sweep on demand"。

// 简化的分配路径
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 调优: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
}

写在最后

Go 的内存管理设计哲学很务实:不追求理论上的最优,而是在"对大多数场景够好"和"实现复杂度可控"之间找平衡。

如果让我给还没深入了解过 GC 的同学一个建议:先学会用 pprof 和 trace,看得见问题才能谈优化。过早优化 GC 参数纯粹是浪费时间,大多数性能问题的根源是分配太多、对象太大、缓存没过期,而不是 GOGC 设得不对。

Comments

No Data
Total 0
  • 1