文章目录
- 浅析 Golang 内存管理
- 栈(Stack)
- 堆(Heap)
- 堆 vs. 栈
- 内存逃逸分析
- 内存逃逸产生的原因
- 避免内存逃逸的手段
- 内存泄露
- 常见的内存泄露场景
- 如何避免内存泄露?
- 总结
浅析 Golang 内存管理
在 Golang 当中,堆(Heap)和栈(Stack)是内存管理的两个核心区域,它们的用途、生命周期和管理方式有显著区别。
栈(Stack)
特点
- 自动分配/释放:由编译器管理,无需开发者干预;
- 高效:内存分配仅需移动栈指针;
- 线程/协程私有:每一个 Goroutine 有自己独立的栈,可动态扩缩容;
- 生命周期:与函数调用相绑定,函数返回时自动回收。
栈上保存的对象
- 局部变量:函数内定义的普通变量(非指针、接口、逃逸对象);
- 函数参数和返回值;
- 值类型的临时变量;
逃逸分析
如果对象的引用逃逸到函数外部(如返回值是一个指针,或是函数当中的值被全局对象引用等),编译器会将其分配到堆上。
func escape() *int {
x := 42 // x 逃逸到堆上(因为返回了指针)
return &x
}
可通过 go build -gcflags="-m"
查看分析逃逸结果。
堆(Heap)
特点
- 动态分配:运行时通过 GC 管理,存在分配和回收开销;
- 共享访问:堆上的对象可以被多个 Goroutine 或函数引用;
- 生命周期:由 GC 决定,当对象不可达时被回收。
堆上保存的对象
1)显式分配内存的对象:
p := new(int) // 通过 new 分配在堆上
s := make([]int, 10) // 动态切片(底层数组在堆上)
m := make(map[string]int) // 映射(堆上)
2)逃逸对象:局部函数的返回值是指针或局部函数当中的局部变量被全局变量所引用。
func createUser() *User {
u := User{Name: "Alice"} // 结构体逃逸到堆上
return &u
}
3)大对象:超过栈容量限制的变量,比如大数组。
4)闭包捕获的变量:
func closure() func() {
x := 42 // x 被闭包捕获,分配在堆上
return func() { fmt.Println(x) }
}
5)接口和指针的动态类型:
var i interface{} = 42 // 接口背后的动态值可能分配在堆上
堆 vs. 栈
特性 | 栈 | 堆 |
---|---|---|
管理方式 | 由编译器管理 | 运行时 GC 管理 |
速度 | 极快(直接操作栈指针) | 较慢 |
线程安全 | 每个 Goroutine 的栈空间独立 | 全局共享 |
生命周期 | 随函数调用结束自动回收 | 由 GC 决定 |
适用对象 | 小对象、短生命周期、未逃逸的局部变量 | 大对象、逃逸对象(局部函数返回值为指针)、共享对象(局部函数创建的局部变量被全局变量引用) |
内存逃逸分析
Golang 当中的内存逃逸指的主要是本应该被分配在栈上的对象,由于外部引用或生命周期延长,而不得不被分配到堆上。内存逃逸会增加 GC 的压力,进而影响到程序的性能。
内存逃逸产生的原因
1)对象被外部引用:
- 函数返回局部变量的指针:由于 Golang 当中每个函数有自己的栈空间,函数当中新建的局部变量会优先分配到栈上,而当函数返回时,栈也会随之被销毁。如果函数返回的是局部变量的指针,为了确保局部变量不随着栈的销毁而被清除,它会被分配到堆上,从而产生了内存逃逸。
- 被局部变量或包外引用:产生内存逃逸的原因同上,由于局部函数当中的局部变量被包外引用,那么为了确保该变量不随着函数返回时栈空间的销毁而被清除,需要被分配在堆上。
2)对象被闭包捕获:
- 函数闭包捕获的变量会逃逸到堆上,原因是函数闭包指的是局部函数的返回值是另一个函数,函数返回后主函数的栈会被销毁,为了确保闭包函数仍然能使用主函数当中的变量,需要把被闭包捕获的变量分配到堆上。
3)动态类型或接口赋值:
- 接口背后的动态值可能逃逸,原因在于接口类型在运行时的具体类型是不确定的。如果一个函数的返回值是接口值,由于返回值可能是指针,因此接口类型的变量应该分配在堆上。
4)栈空间不足时:
- 超过栈容量的对象(比如大数组)会直接被分配到堆上。
5)反射或底层操作:
- 使用
reflect
或unsafe
包可能导致逃逸分析失效。
避免内存逃逸的手段
- 减少指针暴露,比如将局部函数的返回值变为值类型;
- 避免返回局部变量的引用;
- 控制变量的作用域;
- 避免不必要的接口或反射;
- 利用编译器优化:通过
-gcflags="-m"
分析逃逸。
在 Geektutu 的博客上给出了一个「传值 vs. 传指针」的建议,在此也一并学习一下。
传值会拷贝整个对象,而传指针只传递对象的地址。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加 GC
的负担。在对象频繁创建和删除的场景下,传指针会导致 GC 开销变大从而影响性能。一般情况下,对于需要修改原对象的值,或是占用内存较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值会获得更好的性能。
内存泄露
此处需要区分一个与「内存逃逸」名字非常像的概念,也就是「内存泄露」。二者有一定的关系,但本质上是不同的概念。内存泄露指的是程序在运行过程中未能正确释放不再使用的内存,导致堆内存持续增长(堆内存全局共享),最终可能耗尽系统资源。而内存逃逸指的是本应该分配在栈上的内存因为某种原因(比如被全局对象所引用,或局部变量被函数闭包捕获,或是返回值是地址)逃逸到了堆上。
常见的内存泄露场景
1)Goroutine 泄漏:指的是协程无法退出(如未关闭的 channel、死循环、阻塞 I/O 等)。
func leaky_goroutine_example() {
ch := make(chan int)
go func() {
val := <- ch // goroutine 阻塞地等待外部调用者传入一个值到 channel 当中
fmt.Println(val)
}()
// ... ... ...
// ⬆️ 如果最终没有值传入到 ch 当中, 那么之前启动的 goroutine 会一直阻塞, 导致内存泄漏
}
2)全局 map
、slice
或缓存未清理旧的数据,导致旧数据一直占据着堆内存。
3)未关闭的资源:比如文件、数据库连接、HTTP 响应体未关闭。一个比较好的习惯是在打开这些资源之后紧跟一个 defer,确保资源被关闭。
4)循环引用。
如何避免内存泄露?
1)避免 Goroutine 泄漏:
- 使用
context
控制 Goroutine 退出,通常与 select 相结合; - 确保 channel 被正确关闭,或有值写入。
2)使用 defer 及时释放资源。
3)HTTP 响应体必须关闭。
4)定期清理全局的 map
或使用 sync.Map
。
5)使用工具检测内存泄露:
pprof
监控内存使用;runtime.ReadMemStats
检查内存增长。
总结
- 内存逃逸是编译器行为,具体指的就是因为某些原因,本应该分配在栈上的内存逃逸到了堆上,内存逃逸会增加 GC 压力,但是没有内存泄露的风险;
- 内存泄露是代码逻辑问题,需要手动优化(如积极使用 defer 关闭资源、使用 context 控制 goroutine 生命周期,使用 pprof 监控内存以避免长期运行的服务耗尽系统资源)。