Go 内存模型与 GC
1. 内存分配基础
1.1 栈(Stack)与堆(Heap)
┌─────────────────────────────┐
│ 堆 (Heap) │ ← 动态分配,GC 管理
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ obj │ │ obj │ │ obj │ │
│ └─────┘ └─────┘ └─────┘ │
├─────────────────────────────┤
│ goroutine 栈 1 │ ← 2KB 起始,自动增长
│ ┌─────────────────────┐ │
│ │ 局部变量、函数参数 │ │
│ └─────────────────────┘ │
├─────────────────────────────┤
│ goroutine 栈 2 │
│ ┌─────────────────────┐ │
│ │ 局部变量、函数参数 │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
- 栈分配:速度极快(只需移动栈指针),函数返回自动回收
- 堆分配:需要 GC 回收,有额外开销
- Go 编译器通过逃逸分析决定变量分配在栈还是堆
1.2 逃逸分析(Escape Analysis)
# 查看逃逸分析结果
go build -gcflags="-m" main.go
go build -gcflags="-m -m" main.go # 更详细
// 不逃逸:局部变量,分配在栈上
func noEscape() int {
x := 42
return x // 值拷贝,x 不逃逸
}
// 逃逸:返回指针,分配在堆上
func escape() *int {
x := 42
return &x // x 逃逸到堆,因为函数返回后还要用
}
// go build -gcflags="-m" 输出: moved to heap: x
// 逃逸:接口类型参数
func printVal(v interface{}) {
fmt.Println(v) // v 逃逸(interface{} 导致)
}
// 逃逸:闭包引用
func closure() func() int {
x := 0
return func() int {
x++ // x 被闭包捕获,逃逸到堆
return x
}
}
// 逃逸:发送到 channel
func chanEscape(ch chan *int) {
x := 42
ch <- &x // x 逃逸
}
// 不逃逸:已知大小的切片
func noEscapeSlice() {
s := make([]int, 10) // 不逃逸(编译期已知大小)
_ = s
}
// 逃逸:运行时大小的切片
func escapeSlice(n int) {
s := make([]int, n) // 逃逸(编译期不知道大小)
_ = s
}
1.3 变量何时分配到堆上
| 场景 | 是否逃逸 |
|---|---|
| 返回局部变量的指针 | 逃逸 |
| 赋值给 interface{} | 逃逸 |
| 闭包捕获局部变量 | 逃逸 |
| 发送指针到 channel | 逃逸 |
| slice/map 运行时大小 | 可能逃逸 |
| 超过一定大小的对象 | 逃逸 |
| 纯值类型局部变量 | 不逃逸 |
| 编译期已知大小的 slice | 不逃逸 |
2. Go 内存分配器
Go 的内存分配器基于 tcmalloc(Thread-Caching Malloc)思想。
2.1 三级结构
goroutine ──► mcache (每个P一个,无锁)
│
▼
mcentral (全局,有锁,按 size class)
│
▼
mheap (全局,向 OS 申请)
│
▼
操作系统 (mmap/sbrk)
- mcache:每个 P 拥有一个,分配小对象时无需加锁
- mcentral:当 mcache 用完时,从 mcentral 获取,需要加锁
- mheap:当 mcentral 也不够时,从 mheap 申请,mheap 向 OS 要内存
2.2 mspan 与 size class
Go 将对象按大小分为约 70 个 size class(8B, 16B, 32B, 48B, ...32KB)。
- 微小对象(<16B):使用 tiny allocator,多个小对象可共享一个内存块
- 小对象(16B-32KB):按 size class 从 mcache 分配
- 大对象(>32KB):直接从 mheap 分配
// 观察分配行为
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("堆分配次数: %d\n", m.Mallocs)
fmt.Printf("堆释放次数: %d\n", m.Frees)
fmt.Printf("堆使用字节: %d\n", m.HeapAlloc)
fmt.Printf("系统内存: %d\n", m.Sys)
3. 垃圾回收(GC)
3.1 三色标记法
Go 使用并发三色标记-清除算法。
白色(White): 未被标记,GC 结束后回收
灰色(Gray): 已标记但子对象未扫描
黑色(Black): 已标记且子对象已扫描,保留
标记过程:
1. 所有对象初始为白色
2. 从根对象(栈、全局变量)出发,标记为灰色
3. 取出一个灰色对象,扫描它引用的所有对象:
- 白色 → 标记为灰色
- 已经是灰色或黑色 → 跳过
- 当前对象标记为黑色
4. 重复步骤3,直到没有灰色对象
5. 剩余的白色对象就是垃圾,回收
初始: [白A] → [白B] → [白C]
↗
[白D] → [白E]
Step1: [灰A] → [白B] → [白C] (A 是根对象)
↗
[灰D] → [白E] (D 是根对象)
Step2: [黑A] → [灰B] → [白C] (扫描A,B变灰)
↗
[黑D] → [灰E] (扫描D,E变灰)
Step3: [黑A] → [黑B] → [灰C] (扫描B,C变灰)
↗
[黑D] → [黑E] (扫描E,C已灰)
Step4: [黑A] → [黑B] → [黑C] (扫描C,完成)
[黑D] → [黑E]
→ 没有白色对象,全部保留
3.2 写屏障(Write Barrier)
GC 与用户代码并发执行时,用户可能修改引用关系。写屏障确保不会遗漏存活对象。
Go 1.8+ 使用混合写屏障(Hybrid Write Barrier):
- 标记开始时,栈上的对象全部标为黑色
- 堆上新创建的对象标为黑色
- 被覆盖的指针指向的对象标为灰色
3.3 GC 触发条件
// 1. 堆内存增长到上次 GC 后的 GOGC% 时触发(默认 100%,即翻倍)
// 环境变量控制
GOGC=100 // 默认,堆增长 100% 时触发
GOGC=200 // 堆增长 200% 时触发(GC 频率降低,内存占用增加)
GOGC=50 // 堆增长 50% 时触发(GC 更频繁,内存占用降低)
GOGC=off // 关闭 GC
// 2. 定时触发:距离上次 GC 超过 2 分钟
// 3. 手动触发
runtime.GC()
3.4 GOMEMLIMIT(Go 1.19+)
软内存上限,当接近上限时 GC 会更积极地回收。
// 环境变量
GOMEMLIMIT=1GiB // 软限制 1GB
GOMEMLIMIT=512MiB
// 代码中设置
import "runtime/debug"
debug.SetMemoryLimit(1 << 30) // 1GB
// 典型用法:容器环境
// 容器限制 2GB,设置 GOMEMLIMIT=1.5GiB
// 留 0.5GB 给非堆内存(栈、goroutine等)
3.5 GC 阶段
用户代码运行 │ STW │ 并发标记 │ STW │ 并发清除 │ 用户代码运行
│Mark │ │Mark │ │
│Setup│ │Term │ │
│ │ │ │ │
<1ms <1ms
- Mark Setup (STW):开启写屏障,通常 <1ms
- 并发标记:与用户代码并行运行
- Mark Termination (STW):关闭写屏障,通常 <1ms
- 并发清除:回收白色对象
4. 内存泄漏常见场景
4.1 goroutine 泄漏
// 泄漏:goroutine 永远阻塞在 channel
func leak() {
ch := make(chan int)
go func() {
val := <-ch // 永远收不到数据,goroutine 泄漏
fmt.Println(val)
}()
// 函数返回,ch 没人发数据,goroutine 永远不会结束
}
// 修复:使用 context 取消
func noLeak(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
return // 可以退出
}
}()
}
4.2 time.After 在循环中使用
// 泄漏:每次循环创建一个 Timer,直到触发才会被 GC
func leak() {
ch := make(chan int)
for {
select {
case v := <-ch:
fmt.Println(v)
case <-time.After(5 * time.Second): // 每次循环都新建 Timer
fmt.Println("timeout")
}
}
}
// 修复:使用 time.NewTimer 并手动 Reset
func noLeak() {
ch := make(chan int)
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
timer.Reset(5 * time.Second)
select {
case v := <-ch:
fmt.Println(v)
case <-timer.C:
fmt.Println("timeout")
}
}
}
4.3 slice 底层数组未释放
// 泄漏:切片引用大数组的一小部分
func leak() []byte {
data := make([]byte, 1<<20) // 1MB
// ... 填充数据
return data[:10] // 返回 10 字节,但底层 1MB 数组不会被回收
}
// 修复:拷贝需要的数据
func noLeak() []byte {
data := make([]byte, 1<<20)
result := make([]byte, 10)
copy(result, data[:10])
return result
}
4.4 全局变量持有引用
// 泄漏:全局 map 不断增长
var cache = make(map[string][]byte)
func processRequest(key string, data []byte) {
cache[key] = data // 只增不删,内存不断增长
}
// 修复:设置过期策略或大小限制
// 使用 LRU cache 或定期清理
5. runtime 包常用函数
package main
import (
"fmt"
"runtime"
"runtime/debug"
)
func main() {
// CPU 和 Goroutine 信息
fmt.Println("CPU 核数:", runtime.NumCPU())
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
fmt.Println("goroutine 数:", runtime.NumGoroutine())
fmt.Println("Go 版本:", runtime.Version())
// 内存统计
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("堆分配: %d MB\n", m.HeapAlloc/1024/1024)
fmt.Printf("系统内存: %d MB\n", m.Sys/1024/1024)
fmt.Printf("GC 次数: %d\n", m.NumGC)
fmt.Printf("上次 GC 暂停: %d μs\n", m.PauseNs[(m.NumGC+255)%256]/1000)
// 手动触发 GC
runtime.GC()
// 设置 GC 百分比(等价于 GOGC 环境变量)
debug.SetGCPercent(200) // 堆增长 200% 时触发 GC
// 设置内存限制(Go 1.19+)
debug.SetMemoryLimit(512 << 20) // 512MB
// 释放未使用的内存给 OS
debug.FreeOSMemory()
// SetFinalizer: 对象被 GC 回收前执行清理
// 注意:不保证一定执行,不要用于关键清理
type Resource struct{ Name string }
r := &Resource{"test"}
runtime.SetFinalizer(r, func(r *Resource) {
fmt.Println("清理资源:", r.Name)
})
}
主题测试文章,只做测试使用。发布者:Walker,转转请注明出处:https://walker-learn.xyz/archives/6785