题目:什么是内存逃逸?简述原因。
Go内存分配方式:
-
堆(heap)分配方式。堆中分配的空间,在结束使用之后需要垃圾回收器进行闲置空间回收,属于动态资源分配,代价比较昂贵。方法内部对外开放的局部变量以及所需内存超出栈所能提供最大容量的变量还有一切使用指针指向的数据都是在堆上进行分配的。
-
栈(stack)分配方式。对于栈的操作只有入栈和出栈两种分配方式,属于静态资源分配,代价比较廉价。方法内部不对外开放的局部变量,只作用于方法中的这一部分变量都在栈上进行分配的。
内存逃逸:Go中程序变量会携带一组校验数据,用来证明它的整个生命周期在程序运行时是否完全可知。如果变量通过了这些校验,它就可以在栈上分配,反之就可以说它逃逸了,这时就必须在堆上分配。
来看一些能引起变量逃逸到堆上的典型情况:
-
在方法内把局部变量指针返回时,会出现上述情况。因为局部变量原本应该在栈上分配,并且在栈中回收,但是由于在返回时被外部引用,因此该变量的生命周期大于栈,这时就会发生内存溢出。
-
发送指针或是带有指针的值到channel中时,会出现上述情况。因为在代码编译的时候,是没有办法知道是哪个goroutine会在channel上接收数据,所以编译器没办法知道变量什么时候才会被释放。
-
在一个切片中存储指针或是带有指针的值时,会出现上述情况。典型的例子就是[]*string,这会导致内存逃逸。尽管其底层的数组可能是在栈上分配,但是其引用的值一定是在堆上。
-
slice背后的数组被重新分配了的时候,会出现上述情况。因为append的时候可能会超出期容量【cap】。slice初始化的地方在代码编译的时候是可以知道的,它最开始是在栈上分配的,如果切片底层的数据是要基于运行时的数据进行扩充的话,它就会在堆上进行分配。
-
在interface类型上调用方法时,会出现上述情况。在interface类型上调用方法都是动态调度的,方法的真正实现只有在运行时才能知道。假如有一个io.Reader类型的变量r,调用r.Read[b]会是的r的值和切片b的背后存储都逃逸掉,所以都会是在堆上进行分配。
接下来看个例子,之后通过go build -gcflags=-m main.go进行编译感受下:
package main
import "fmt"
type A struct {
s string
}
// 这是上面提到的 "在方法内把局部变量指针返回" 的情况
func foo(s string) *A {
a := new(A)
a.s = s
return a //返回局部变量a,在C语言中妥妥指针,但在go则ok,但a会逃逸到堆
}
func main() {
a := foo("hello")
b := a.s + " world"
c := b + "!"
fmt.Println(c)
}
运行go build -gcflags=-m main.go结果如下:
由上图可看出:
-
.\main.go:11:10: new(A) escapes to heap 说明new(A)逃逸了,符合之前说明的第一种情况。
-
.\main.go:18:11: a.s + " world" does not escape 说明b变量没有逃逸,因为它只在方法内存在,在方法结束时就会被回收。
-
.\main.go:19:9: b + "!" escapes to heap 说明变量c逃逸了,通过fmt.Println(a ...interface{}) 打印的变量都会发生逃逸。
避免内存逃逸可参考以下几点:
-
Go接口类型方法调用是动态的,因此不能在编译阶段确定那个类型结构转换成接口的过程会涉及到内存逃逸,所以在频次访问较高的方法中尽量调用接口。
-
不要过度使用变量指针作为参数,此举虽减少复制,但如果发生变量逃逸,那么内存开销就会变得更大。
-
预先确认好slice长度,避免频繁超出扩容,导致内存重新分配。
扫码关注公众号,获取更多优质内容。