介绍
我们要聊的就是“内存逃逸”——这个看起来很恐怖的名字其实说白了就是,某个变量的生命周期可能被 Go 的编译器不小心“推”到了堆上,而不是栈上,导致了一些不必要的内存消耗。那么,内存逃逸到底是怎么回事,怎么避免它呢?
面试应该从以下角度回答
- 什么是逃逸?
- 导致内存逃逸的原因是什么
- 常见的发生逃逸的情况与逃逸分析
- 如何避免
栈和堆
- 栈(Stack):栈内存是程序运行时的一个局部区域,用于存储局部变量和函数调用的上下文。栈上的内存分配和释放速度非常快,因为栈是一个先进后出的结构,函数调用结束后,栈上的内存可以立即回收。
- 堆(Heap):堆内存是动态分配的内存区域,通常用于存储生命周期不确定的对象,例如使用 new 或 make 创建的对象。堆内存的管理相对复杂,需要垃圾回收(GC)来进行清理,回收速度不如栈快。
什么是内存逃逸?
内存逃逸,顾名思义,就是程序在执行过程中,某些本应在栈上分配的变量,被“逃逸”到了堆上。为什么会发生这种情况呢?
在 Go 语言中,如果一个变量的生命周期不再局限于当前函数内,或者说,它的作用域扩展到了函数外面,Go 的编译器就会认为这个变量需要存储在堆上,而不是栈上。这就是所谓的“内存逃逸”。
为什么内存逃逸有问题?
栈内存的分配和释放是非常高效的,因此,程序员通常希望将变量保存在栈上,因为栈上的内存会随着函数的执行自动释放。然而,如果 Go 编译器无法优化内存分配,导致变量被错误地分配到了堆上,就会产生以下问题:
-
内存开销增加:堆内存的分配和释放比栈慢,因为它依赖垃圾回收机制。而且堆内存的管理成本较高,会带来性能上的浪费。
-
GC负担加重:内存逃逸意味着更多的内存需要由垃圾回收(GC)来管理。如果大量的变量逃逸到堆上,GC 会变得更加繁重,可能会导致频繁的垃圾回收,影响程序的响应时间。
-
不必要的内存消耗:有些变量本来只是局部变量,完全可以在栈上分配,结果却因为逃逸而浪费了堆内存。
-
指针引用和内存安全
-
内存泄漏风险
变量逃逸的常见原因
指针传递:
-
分配在栈上:当原生类型被取地址且地址被赋值给了一个指针变量,当这个指针变量只是在函数内部使用,则这个原生类型会被分配在栈上(即使是通过new方法分配的)
-
变量逃逸到堆上的情况:如果这个指针变量被以某种形式作为了函数返回值(例如,指针变量是struct中的变量,struct是函数返回值),则这个原生类型被分配在堆上(原因很简单,如果分配在栈上,函数返回后栈中的数据失效,这个指针指向的地址就是无效的)
如果你在函数内部定义了一个局部变量,并返回了这个变量的指针,那么这个变量就会逃逸到堆上,因为在函数返回后,这个指针可能会被外部代码访问。
func createSlice() *[]int {s := make([]int, 10)return &s // 错误:返回局部变量的地址
}正确的做法是直接返回切片本身,而不是其地址:func createSlice() []int {s := make([]int, 10)return s // 正确:返回切片的副本
}
如果你将一个局部变量的指针传递给了其他函数,Go 编译器会推测这个变量的生命周期已经超出了当前作用域,从而将它分配到堆上。
package mainimport "fmt"func foo(ptr *int) {fmt.Println(*ptr)
}func main() {x := 42foo(&x) // 这里传递了 x 的地址,x 会被分配到堆上
}
这个例子中,x 被传递给了 foo 函数,而 foo 是通过指针来访问 x 的。因为 Go
编译器无法确定 x 在 main 函数外部是否会继续使用,所以 x 被分配到了堆上
数组或切片的引用:
切片是一个动态数组,它分为
- slice本身(即SliceHeader)
- slice中的元素(即SliceHeader中Data)
与指针传递类似
- 当返回指向slice的指针时,slice逃逸;
- 当返回slice时,只有slice中的数据(Data)可能会逃逸(Data可能为地址)
具体
-
SliceHeader分配在栈上、Data分配在堆上
当SliceHeader分配在栈上,Data既可以分配在栈上也可以分配在堆上 -
当Data的空间不足、需要动态扩容时,Data会被分配在堆上
当初始化slice时,Data所占空间达到64K时,SliceHeader和Data都会被分配在堆上(注意这里的64K边界是在自己的windows和linux机上测试到的,没有找go源码的出处,有可能不准确,理解为Data比较大时会直接分配在堆上比较好。另外除了slice,其他的数据类型如果初始化大小超过某个阈值时,应该也会直接分配在堆上) -
当SliceHeader分配在堆上,SliceHeader和Data都分配在堆上
代码示例:
package mainimport "fmt"func createSlice() []int {arr := [3]int{1, 2, 3} // arr 会逃逸到堆上return arr[:]
}func main() {slice := createSlice()fmt.Println(slice)
}
在上面的代码中,arr 是一个局部变量,而它被切片的返回值所引用。因为返回的是切片,而不是数组,所以 arr 会被分配到堆上。
map
1)不作为函数返回值时,分配在栈上
2)作为函数返回值且返回的不是指针时,map的元素分配在堆上,map本身分配在栈上
3)作为函数返回值且返回的是指针时,map的元素分配在堆上,map本身也分配在堆上
闭包(Closure):
闭包是导致内存逃逸的一个典型场景。因为闭包会持有外部函数的引用,所以即使外部函数已经返回,闭包内部的变量依然可能在堆上存活。
代码示例:
package mainimport "fmt"func main() {f := func() int {x := 42 // 这个变量 x 会逃逸到堆上return x}fmt.Println(f())
}
这里,x 是一个局部变量,但因为 f 是一个闭包,Go 编译器不能确定 x 的生命周期是否结束,所以 x 被分配到了堆上。
将局部变量的地址存储在全局变量或外部包中:
如果你将局部变量的地址存储在全局变量或外部包中,那么这个局部变量也会逃逸到堆上。
var globalPtr *intfunc setGlobalPtr() {local := 10globalPtr = &local // 错误:逃逸到堆上
}
正确的做法是使用指针指向一个新的堆分配的内存:
var globalPtr *intfunc setGlobalPtr() {local := 10localPtr := new(int) // 在堆上分配内存*localPtr = local // 赋值给堆上的内存globalPtr = localPtr // 正确:globalPtr 指向堆内存
}
传递给包含指针的接口:
如果你将一个局部变量的地址传递给一个接口(特别是包含指针方法的接口),那么这个变量可能会逃逸。
type MyInterface interface {DoSomething()
}type MyStruct struct {value int
}func (m *MyStruct) DoSomething() {}func passToInterface(i MyInterface) {// i 可能持有对局部变量的引用,导致逃逸
}
如何避免内存逃逸?
为了提高程序的性能,减少内存消耗,避免内存逃逸是非常重要的。下面是一些常见的避免内存逃逸的技巧:
-
尽量避免不必要的闭包:如果不是特别必要,尽量避免在函数内创建闭包,尤其是当闭包引用了大量外部变量时。闭包会把外部变量的生命周期拉长,导致这些变量被分配到堆上。
-
避免指针传递:当传递变量的指针时,要尽量确保该变量的生命周期不会超出当前函数。如果能直接传值,而不是传指针,那就直接传值,避免不必要的堆分配。
-
使用值传递而不是引用传递:对于小的、临时的结构体或数组类型,尽量采用值传递的方式,而不是传递其指针。这样,Go 编译器就可以安全地在栈上分配内存。
-
避免返回切片或数组的引用:如果你只需要使用局部数据,尽量不要返回指向该数据的切片或数组。否则,它们的底层数组可能会逃逸到堆上。
如何检测内存逃逸?
Go 提供了 go run -gcflags=“-m” 命令来帮助我们检测内存逃逸问题。通过这个命令,我们可以查看编译器是否将某个变量分配到了堆上。
命令示例:
go run -gcflags="-m" main.go
当你运行这个命令时,Go 编译器会输出类似这样的信息:
/tmp/main.go:10:6: x escapes to heap
这表明变量 x 被分配到了堆上。
内存逃逸的优化
为了减少内存逃逸,可以通过优化代码来改善性能:
1)使用指针传递:将大的结构体或数组通过指针传递,避免复制数据。【需要修改原对象值,或占用内存比较大的结构体】
2)使用值传递:对于小的结构体或基本类型,可以使用值传递,避免指针的额外开销。【对于只读的占用内存较小的结构体】