Golang逃逸分析

116 阅读4分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

golang逃逸分析

1.栈和堆

在golang中,应用程序的内存载体,可以简单的分为栈和堆。 栈的内存是由编译器自动进行分配和释放,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,函数的退出而销毁。 与栈不同的是,应用程序在运行时只会存在一个堆。狭隘地说,内存管理只是针对堆内存而言的。程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配,并由垃圾收集器回收。

那么一个问题就来了,我们怎么知道一个对象是应该放在堆内存还是栈内存之上的呢?

其实golang的官网上已经给出了答案:

golang.org/doc/faq#sta…

在这里插入图片描述 中文版:

在这里插入图片描述 我们发现了一个名词:逃逸分析

2.逃逸分析

对于Golang程序,编译器是怎么判断一个变量到底是分配堆内存还是栈内存的呢?编译器决定内存分配位置的方式,就称之为逃逸分析(escape analysis)。逃逸分析由编译器完成,作用于编译阶段。

官网中虽然没有明确说明逃逸分析规则,但是有以下几点准则,是可以参考的:

  • 逃逸分析是在编译器完成的,这是不同于jvm的运行时逃逸分析;
  • 如果变量在函数外部没有引用,则优先放到栈中;
  • 如果变量在函数外部存在引用,则必定放在堆中;

3.几种常见的逃逸情况:

我们可通过go build -gcflags '-m -l'命令来查看逃逸分析结果,其中-m 打印逃逸分析信息,-l禁止内联优化。

3.1 指针逃逸

如果一个函数内部创建了一个对象(局部变量),但是在函数返回时是返回该对象的指针,那么该变量的生命周期就变了,即使当前函数执行结束了,但是变量的指针还在,并不是随着函数结束就被回收的,那么这个局部变量就会被分配在堆上,这就产生了指针逃逸。

package main

func foo(argVal int) *int {

	var fooVal1 int = 11
	var fooVal2 int = 12
	var fooVal3 int = 13
	var fooVal4 int = 14
	var fooVal5 int = 15

	println(&argVal, &fooVal1, &fooVal2, &fooVal3, &fooVal4, &fooVal5)

	return &fooVal3
}

func main() {
	mainVal := foo(666)

	println(*mainVal, mainVal)
}

运行结果:

.\main.go:8:6: moved to heap: fooVal3
0xc000045f58 0xc000045f38 0xc000045f30 0xc00000e040 0xc000045f28 0xc000045f20
13 0xc00000e040

结果显示,逃逸分析结果显示fooVal3逃逸到了堆中,通过打印出来的变量地址我们可以发现,fooVal3的地址是0xc00000e040,与其他地址是不连续的。

3.2 interface{} 动态类型逃逸

在golang中空接口interface{}可以是任意类型,因此编译器并不能确定其类型,所以也会被分配到堆上。

package main

import "fmt"

func main() {
	var valA interface{}
	valA = 666
	fmt.Println(&valA)
}

运行结果:

.\main.go:6:6: moved to heap: valA
.\main.go:7:7: 666 escapes to heap
.\main.go:8:13: ... argument does not escape
0xc00004a230

通过运行结果我们可以发现,interface{}变量varA逃逸到了堆中。

再来看下面这段程序:

package main

import "fmt"

func main() {
	varB := 666
	fmt.Println(varB)
}

运行结果:

.\main.go:7:13: ... argument does not escape
.\main.go:7:13: varB escapes to heap
666

可以看到,分析结果告诉我们变量varB逃逸到了堆上。但是,我们并没有外部引用啊,为什么也会有逃逸呢?为了看到更多细节,可以在语句中再添加一个-m参数:go build -gcflags '-m -m -l'。得到信息如下

.\main.go:7:13: varB escapes to heap:
.\main.go:7:13:   flow: {storage for ... argument} = &{storage for varB}:
.\main.go:7:13:     from varB (spill) at .\main.go:7:13
.\main.go:7:13:     from ... argument (slice-literal-element) at .\main.go:7:13
.\main.go:7:13:   flow: {heap} = {storage for ... argument}:
.\main.go:7:13:     from ... argument (spill) at .\main.go:7:13
.\main.go:7:13:     from fmt.Println(... argument...) (call parameter) at .\main.go:7:13
.\main.go:7:13: ... argument does not escape
.\main.go:7:13: varB escapes to heap

varB逃逸是因为它被传入了fmt.Println的参数中,这个方法参数自己发生了逃逸。

func Println(a ...interface{}) (n int, err error)

因为fmt.Println的函数参数为interface类型,编译期不能确定其参数的具体类型,所以将其分配于堆上。

3.3 栈空间不足

如果程序中需要分配一个空间比较大的局部变量,栈空间已经不够分配了,那么也会被分配到堆上。

package main

func foo()  {
	s := make([]int, 10000, 10000)
	for i := 0; i < len(s); i++ {
		s[i] = i
	}
}

func main() {
	foo()
}

运行结果:

.\main.go:4:11: make([]int, 10000, 10000) escapes to heap

3.4 变量大小不确定

package main

func foo()  {
	n := 1
	s := make([]int, n)
	for i := 0; i < len(s); i++ {
		s[i] = i
	}
}

func main() {
	foo()
}

运行结果:

.\main.go:5:11: make([]int, n) escapes to heap

3.5 闭包

package main

import "fmt"

func Add() func() int  {
	num := 0
	return func() int {
		num++
		return num
	}
}
func main() {
	fn := Add()
	fmt.Println(fn())
	fmt.Println(fn())
}

运行结果:

.\main.go:6:2: moved to heap: num
.\main.go:7:9: func literal escapes to heap
.\main.go:14:13: ... argument does not escape
.\main.go:14:16: fn() escapes to heap
.\main.go:15:13: ... argument does not escape
.\main.go:15:16: fn() escapes to heap
1
2

上述代码块中,Add() 返回的是一个闭包,并且该闭包访问了外部变量num,那么num将会被分配到堆上,因为num此时生命周期已经不会随着Add() 函数的结束而被回收,直到 fn 被销毁,num才会被回收。

4.传值还是传指针

传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收(GC)的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能。

一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能。