跳到主要内容

内存模型与分配器

问题

Go 的栈和堆有什么区别?内存是如何分配和管理的?

答案

栈(Stack)

每个 goroutine 拥有独立的栈空间:

  • 初始大小:Go 1.4+ 为 2KB(早期版本更大)
  • 动态增长:不够时自动扩容(连续栈,Go 1.4+)
  • 最大大小:默认 1GB(runtime/debug.SetMaxStack
  • 回收:函数返回时自动释放,不需要 GC

栈分配极快——只需移动栈指针(SP),没有锁、没有 GC 压力。

堆(Heap)

所有 goroutine 共享的內存区域,由 GC 管理。

Go 内存分配器

Go 的内存分配器基于 TCMalloc(Thread-Caching Malloc),采用多级缓存结构:

三级分配结构

层级说明
mcache每个 P(处理器)私有,缓存小对象的 mspan无锁(P 本地)
mcentral全局共享,按 size class 管理 mspan有锁(每个 size class 一把)
mheap全局堆,管理所有 arena(64MB 块)有锁

对象大小分类

大小分类分配方式
< 16BTiny 对象mcache 的 tiny allocator(多个小对象合并分配)
16B ~ 32KB小对象mcache → mcentral → mheap,按 size class 分配
> 32KB大对象直接从 mheap 分配

小对象分配有 68 个 size class(8B、16B、32B、48B、...、32KB),避免内存碎片。

栈增长与收缩

Go 使用连续栈(contiguous stack):

  • 扩容:栈空间不足时,分配 2 倍新栈,复制数据
  • 收缩:GC 时检查栈使用率,如果 < 1/4 则缩小栈
栈复制的影响

栈增长时涉及内存复制和指针更新,这就是为什么 Go 不能直接使用对象指针做 C 互操作——Go 指针可能在栈增长时改变地址。

内存对齐

Go 结构体字段按照内存对齐规则分配,对齐到字段类型大小的倍数:

// ❌ 未优化的结构体(占 24 字节)
type Bad struct {
a bool // 1 byte + 7 padding
b int64 // 8 byte
c bool // 1 byte + 7 padding
}

// ✅ 优化后的结构体(占 16 字节)
type Good struct {
b int64 // 8 byte
a bool // 1 byte
c bool // 1 byte + 6 padding
}

查看结构体大小:

fmt.Println(unsafe.Sizeof(Bad{}))  // 24
fmt.Println(unsafe.Sizeof(Good{})) // 16
结构体字段排列规则

按字段大小从大到小排列,可以减少 padding 浪费。工具 fieldalignment 可以自动检测:

go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment -fix ./...

常见面试问题

Q1: Go 的变量分配在栈上还是堆上?

答案

由编译器的逃逸分析决定:

  • 如果变量的生命周期不超过函数,分配在栈上
  • 如果变量可能被函数外部引用(逃逸),分配在堆上

开发者不需要(也不能)手动指定。

Q2: Goroutine 的栈为什么这么小?

答案

Go goroutine 栈初始只有 2KB(对比 OS 线程通常 1~8MB),原因:

  1. goroutine 数量可能很多(百万级),如果每个都 1MB 则内存不够
  2. Go 栈可以动态增长,按需扩容
  3. 大多数函数实际栈使用量很小

Q3: mcache 为什么不需要锁?

答案

mcache 是 P(processor)本地的缓存。Go 的 GMP 调度模型中,每个 P 同一时间只有一个 goroutine 在运行,所以 mcache 天然没有并发访问,不需要锁。

这使得小对象分配极快——大多数时候只需要从 mcache 中取内存。

Q4: 什么是内存碎片?Go 如何处理?

答案

内存碎片分为:

  • 外部碎片:空闲内存不连续,无法分配大块。Go 通过 size class 分级管理缓解
  • 内部碎片:分配的块比实际需要的大(padding)。Go 的 68 个 size class 尽量减小浪费

相关链接