Contents

Go浅析-内存逃逸

[toc]

一、概述

Go 编译器会尽可能将变量分配到到栈上。但是,

  • 当编译器无法证明函数返回后,该变量没有被引用,那么编译器就必须在堆上分配该变量,以此避免悬挂指针(dangling pointer);
  • 另外,如果局部变量非常大,也会将其分配在堆上。

有如下规则可以参考:

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

二、案例

给出一些常见的内存逃逸情况。

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

1
2
go build -gcflags '-m -l' 01-type.go
go build -gcflags '-m -m -l' 01-type.go	// 更详细

变量类型不确定

1
2
3
4
5
// 变量类型不确定
func main() {
	a := 123
	fmt.Println(a)
}

分析结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
 go build -gcflags '-m -l' 01-type.go
# command-line-arguments
.\01-type.go:11:13: ... argument does not escape
.\01-type.go:11:13: a escapes to heap

 go build -gcflags '-m -m -l' 01-type.go
# command-line-arguments
.\01-type.go:11:13: a escapes to heap:
.\01-type.go:11:13:   flow: {storage for ... argument} = &{storage for a}:
.\01-type.go:11:13:     from a (spill) at .\01-type.go:11:13
.\01-type.go:11:13:     from ... argument (slice-literal-element) at .\01-type.go:11:13
.\01-type.go:11:13:   flow: {heap} = {storage for ... argument}:
.\01-type.go:11:13:     from ... argument (spill) at .\01-type.go:11:13
.\01-type.go:11:13:     from fmt.Println(... argument...) (call parameter) at .\01-type.go:11:13
.\01-type.go:11:13: ... argument does not escape
.\01-type.go:11:13: a escapes to heap

分析结果告诉我们变量a逃逸到了堆上,a逃逸是因为它被传入了fmt.Println的参数中,这个方法参数自己发生了逃逸。

1
func Println(a ...any) (n int, err error)

因为fmt.Println的函数参数为interface类型,编译期不能确定其参数的具体类型,也就不确定开辟多大的空间给它,所以将其分配于堆上。

暴露给外部指针

1
2
3
4
5
6
7
8
func foo() *int {
	a := 123
	return &a
}

func main() {
	_ = foo()
}

分析结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 go build -gcflags '-m -l' 02-expose.go
# command-line-arguments
.\02-expose.go:4:2: moved to heap: a

 go build -gcflags '-m -m -l' 02-expose.go
# command-line-arguments
.\02-expose.go:4:2: a escapes to heap:
.\02-expose.go:4:2:   flow: ~r0 = &a:
.\02-expose.go:4:2:     from &a (address-of) at .\02-expose.go:5:9
.\02-expose.go:4:2:     from return &a (return) at .\02-expose.go:5:2
.\02-expose.go:4:2: moved to heap: a

直接满足:变量在函数外部存在引用。这个很好理解,因为当函数执行完毕,对应的栈帧就被销毁,但是引用已经被返回到函数之外。如果这时外部从引用地址取值,虽然地址还在,但是这块内存已经被释放回收了,这就是非法内存,问题可就大了。所以,很明显,这种情况必须分配到堆上

变量所占内存较大

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// []int 太大, 导致逃逸
func foo() {
	s := make([]int, 8193, 8193)	// > 8192 就是大对象了
	for i := 0; i < len(s); i++ {
		s[i] = i
	}
}

// 外部引用, 导致逃逸
//func foo() []int {
//	s := make([]int, 100, 100)
//	for i := 0; i < len(s); i++ {
//		s[i] = i
//	}
//	return s
//}

func main() {
	foo()
}

分析结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 go build -gcflags '-m -l' 03-big.go
# command-line-arguments
.\03-big.go:5:11: make([]int, 10000, 10000) escapes to heap

 go build -gcflags '-m -m -l' 03-big.go
# command-line-arguments
.\03-big.go:5:11: make([]int, 10000, 10000) escapes to heap:
.\03-big.go:5:11:   flow: {heap} = &{storage for make([]int, 10000, 10000)}:
.\03-big.go:5:11:     from make([]int, 10000, 10000) (too large for stack) at .\03-big.go:5:11
.\03-big.go:5:11: make([]int, 10000, 10000) escapes to heap

逃逸信息too large for stack。当我们创建了一个容量为8193的int类型的底层数组对象时,由于对象过大,它也会被分配到堆上。

那为啥大对象需要分配到堆上?

goroutine 初始大小为2KB,其实说的是用户栈,它的最小和最大可以在runtime/stack.go中找到,分别是2KB1GB,而堆内存会大很多。因此,为了不造成栈溢出和频繁的扩缩容,大对象分配在堆上更加合理(大对象的范围:> 32KB),所以s :=make([]int, n, n)中,一旦n > 8192,就一定会逃逸。

变量大小不确定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func foo() {
	n := 1
	s := make([]int, n)
	for i := 0; i < len(s); i++ {
		s[i] = i
	}
}

func main() {
	foo()
}

分析结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 go build -gcflags '-m -l' 04-uncertain.go
# command-line-arguments
.\04-uncertain.go:5:11: make([]int, n) escapes to heap

 go build -gcflags '-m -m -l' 04-uncertain.go
# command-line-arguments
.\04-uncertain.go:5:11: make([]int, n) escapes to heap:
.\04-uncertain.go:5:11:   flow: {heap} = &{storage for make([]int, n)}:
.\04-uncertain.go:5:11:     from make([]int, n) (non-constant size) at .\04-uncertain.go:5:11
.\04-uncertain.go:5:11: make([]int, n) escapes to heap

逃逸信息non-constant size。在make方法中,没有直接指定大小,而是填入了变量n,这时也会将其分配到堆区去。

可见,为了保证内存的绝对安全,Go的编译器可能会将一些变量不合时宜地分配到堆上,但是因为这些对象最终也会被垃圾收集器处理,所以也能接受。

闭包

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func foo() func() int {
	i := 0
	return func() int {
		i++
		return i
	}
}

func main() {
	foo()()
}

分析结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
 go build -gcflags '-m -l' 05-close.go
# command-line-arguments
.\05-close.go:4:2: moved to heap: i
.\05-close.go:5:9: func literal escapes to heap

 go build -gcflags '-m -m -l' 05-close.go
# command-line-arguments
.\05-close.go:4:2: foo capturing by ref: i (addr=false assign=true width=8)
.\05-close.go:5:9: func literal escapes to heap:
.\05-close.go:5:9:   flow: ~r0 = &{storage for func literal}:
.\05-close.go:5:9:     from func literal (spill) at .\05-close.go:5:9
.\05-close.go:5:9:     from return func literal (return) at .\05-close.go:5:2
.\05-close.go:4:2: i escapes to heap:
.\05-close.go:4:2:   flow: {storage for func literal} = &i:
.\05-close.go:4:2:     from i (captured by a closure) at .\05-close.go:6:3
.\05-close.go:4:2:     from i (reference) at .\05-close.go:6:3
.\05-close.go:4:2: moved to heap: i
.\05-close.go:5:9: func literal escapes to heap

原本在函数运行栈空间上分配的内存,由于闭包的关系,变量在函数的作用域之外使用。

三、总结

问:进行逃逸分析有啥用?

理解逃逸分析一定能帮助我们写出更好的程序。

知道变量分配在栈堆之上的差别,那么就要尽量写出分配在栈上的代码,堆上的变量变少了,可以减轻内存分配的开销,减小gc的压力,提高程序的运行速度。

所以,有些Go的项目,它们在函数传参的时候并没有传递结构体指针而是直接传递的结构体。这个做法,虽然它需要值拷贝,但这是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。当然该做法不是绝对的,如果结构体较大,传递指针将更合适

因此,从GC的角度来看,指针传递是个双刃剑,需要谨慎使用,否则线上调优解决GC延时可能会让你崩溃。

参考文章: