内存模型与分配器
问题
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 块) | 有锁 |
对象大小分类
| 大小 | 分类 | 分配方式 |
|---|---|---|
| < 16B | Tiny 对象 | 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),原因:
- goroutine 数量可能很多(百万级),如果每个都 1MB 则内存不够
- Go 栈可以动态增长,按需扩容
- 大多数函数实际栈使用量很小
Q3: mcache 为什么不需要锁?
答案:
mcache 是 P(processor)本地的缓存。Go 的 GMP 调度模型中,每个 P 同一时间只有一个 goroutine 在运行,所以 mcache 天然没有并发访问,不需要锁。
这使得小对象分配极快——大多数时候只需要从 mcache 中取内存。
Q4: 什么是内存碎片?Go 如何处理?
答案:
内存碎片分为:
- 外部碎片:空闲内存不连续,无法分配大块。Go 通过 size class 分级管理缓解
- 内部碎片:分配的块比实际需要的大(padding)。Go 的 68 个 size class 尽量减小浪费