首页 Go 内存逃逸
文章
取消

Go 内存逃逸

说起内存逃逸之前,我们需要先了解下Go 的堆和栈。

Go的堆和栈

堆和栈是 Go 内存中不同的内存区域,作用不同。

堆(Heap):一般来讲是人为手动进行管理,手动申请、分配、释放。一般硬件内存有多大堆内存就有多大。适合不可预知大小的内存分配,分配速度较慢,而且会形成内存碎片。Go 的垃圾回收器就是回收这里的内存。

栈(Stack):是一种拥有特殊规则的线性表数据结构。由编译器进行管理,自动申请、分配、释放。大小一般是固定的。

题外话: Go 中的堆和栈与传统意义上的(系统)堆和(系统)栈不同。 传统意义上的(系统)栈内存被 Go 的运行时霸占,不开放给用户态代码。 传统意义上的(系统)堆内存又被 Go 的运行时划分为了两个部分:

  • Go 运行时自身所需的堆内存,即非托管内存(或堆外内存,这部分内存会被 //go:notinheap 进行标记,用于显示区分分配在 mheap 上的内存);
  • 另一部分则用于 Go 用户态代码所使用的堆内存,也称为 Go 堆),Go 堆负责用户态对象的存放以及 goroutine 的执行栈(从用户程序角度所理解的栈的概念)。

zgAIWF.png

什么是内存逃逸?

对象本应该存储在栈上,但最终存储到了堆上。

内存逃逸的根本原因是什么?

编译器在编译的时候无法确定确定变量的生命周期,只能放到堆上,在运行时控制其生命周期。

我们来看下官方的说明:

How do I know whether a variable is allocated on the heap or the stack?

From a correctness standpoint, you don’t need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.

The storage location does have an effect on writing efficient programs. When possible, the Go compilers will allocate variables that are local to a function in that function’s stack frame. However, if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors. Also, if a local variable is very large, it might make more sense to store it on the heap rather than the stack.

In the current compilers, if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack.

意思是说:

怎样知道一个变量是分配到了堆上还是栈上?

从一个正确的立场来说,你不必知道。Go 中每个变量只要被引用就会存在。编程语言的实现选择的存储位置与语言的语义无关。

内存存储位置确实对写一个高效地程序有影响。可能的话,Go 编译器会把局部变量存储在函数的栈空间中。然而,如果编译器不能证明变量在函数返回结束后不再被引用 ,那么编译器必须把变量分配在会垃圾回收的堆空间上,以避免出现悬空指针。另外,如果一个局部变量非常大,存储在堆上比存储在栈上更有实际意义。

在当前的编译器中,如果一个变量被引用,那么变量会是在堆上分配的候选。然而,一个基本的逃逸分析能够识别出一些变量不在函数返回后存在且在栈上存在的情况。

如何检测内存逃逸?

执行 go rungo build 时,使用 -gcflags '-m',如

1
$ go build -gcflags '-m -l'  xxx.go

其中参数:

  • m,打印出逃逸分析的优化策略
  • l,禁用函数内联,减少干扰,利于观察

示例如下:

1
2
3
4
5
6
7
8
9
10
package main

func f() *string {
	var s = "testing memory escape"
	return &s
}

func main() {
	f()
}

检测内存逃逸:

1
2
3
$ go run -gcflags='-l -m' escape.go
# command-line-arguments
./escape.go:4:6: moved to heap: s

可以看到变量 s 逃逸到了堆上。

发生场景有哪些?

函数返回了局部变量的指针

当函数返回了局部变量的指针,或者返回了引用类型的对象,如 slice,Go 编译器会不得不将其内存分配到堆上,因为如果分配到栈上,当函数调用结束时,函数中的所申请的栈内存就会被释放,返回的指针就成了悬空指针,指向不到正确的内存地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package main

func f1() *string {
	var text = "testing memory escape"  // text 会逃逸
	return &text
}

type S struct {
	A int
	B *T
}

type T struct {
}

func f2() *S {
	var s = &S{}  // s 会逃逸
	return s
}

func f3() int {
	var s = &S{}  // s 不会逃逸
	return s.A
}

func f4() *T {
	var s = &S{A: 1, B: &T{}} // s 不会逃逸,但 s.B 会逃逸
	return s.B
}

func main() {
	f1()
	f2()
	f3()
	f4()
}

逃逸分析:

1
2
3
4
5
6
# command-line-arguments
./escape.go:4:6: moved to heap: text
./escape.go:17:10: &S{} escapes to heap
./escape.go:22:10: &S{} does not escape
./escape.go:27:10: &S{...} does not escape
./escape.go:27:22: &T{} escapes to heap

题外话:在 C 语言中函数不能返回栈内存地址,

// demo.c

#include <stdio.h>

int *demoFunction() {
    int data = 11;

    return &data;
}

int main()
{
    int *resultData = demoFunction();

    printf("%d\n", *resultData);
}

在执行 gcc gemo.c 时会发生:

1
2
3
demo.c:6:13: warning: address of stack memory associated with local variable 'data' returned [-Wreturn-stack-address]
return &data;
1 warning generated.

容器中存储指针、带有指针的值、引用类型的对象

容器中的指针或引用类型对象会发生逃逸

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

type S struct {
	Data int
}

type T struct {
	Data *S
}

func f1() {
	var a []*S
	for i := 0; i < 10; i++ {
		a = append(a, &S{}) // &S{} 发生逃逸
	}

	var b = make(map[int]*S) // b 不会发生逃逸
	for i := 0; i < 10; i++ {
		b[i] = &S{} // &S{} 发生逃逸
	}

	var c = make(map[int][]S) // c 不会发生逃逸
	for i := 0; i < 10; i++ {
		c[i] = make([]S, 1) // make([]S, 1) 发生逃逸
	}

	var d []T
	for i := 0; i < 10; i++ {
		d = append(d, T{Data: &S{Data: i}}) // &S{Data: i} 发生逃逸
	}

	var e = make(chan *S, 1)
	e <- &S{} // &S{} 发生逃逸

	var f = make(chan S, 1)
	f <- S{} // S{} 不会发生逃逸
}

func main() {
	f1()
}

逃逸分析:

1
2
3
4
5
6
7
8
9
$ go run -gcflags='-l -m' escape.go
# command-line-arguments
./escape.go:14:17: &S{} escapes to heap
./escape.go:17:14: make(map[int]*S) does not escape
./escape.go:19:10: &S{} escapes to heap
./escape.go:22:14: make(map[int][]S) does not escape
./escape.go:24:14: make([]S, 1) escapes to heap
./escape.go:29:25: &S{...} escapes to heap
./escape.go:33:7: &S{} escapes to heap

局部变量的内存占用超过栈的限制

笔者笔记本的栈空间大小限制为 8192KB,即 8M 大小:

1
2
$ ulimit -a | grep stack 
-s: stack size (kbytes)             8192

函数内局部变量本应该存储在栈空间,但栈的空间不够,只能存储在堆上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

func f1() {
	// 占用大小:8*100+8+8+8=824bytes
	_ = make([]int64, 100)
}

func f2() {
	// 占用内存大小:8*10000000+8+8+8=80000024bytes=76.29M,会发生内存逃逸
	_ = make([]int64, 10000000)
}

func main() {
	f1()
	f2()
}

逃逸分析:

1
2
3
4
$ go run -gcflags='-l -m' escape.go
# command-line-arguments
./escape.go:5:10: make([]int64, 100) does not escape
./escape.go:10:10: make([]int64, 10000000) escapes to heap

动态类型

通俗地来说,就是函数调用的入参是 interface{} 或不定参数,这种情况下编译器无法确定入参的类型,也就无法计算内存大小,只能在运行期间才能确定,于是放到堆上存储。

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func f1() {
	fmt.Println("testing", "memory", "escape")
}

func main() {
	f1()
}

逃逸分析:

1
2
3
4
5
6
7
$ go run -gcflags='-l -m' escape.go
# command-line-arguments
./escape.go:6:13: ... argument does not escape
./escape.go:6:14: "testing" escapes to heap
./escape.go:6:25: "memory" escapes to heap
./escape.go:6:35: "escape" escapes to heap
testing memory escape

闭包调用

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func f1() func() string {
	return func() string {
		return "testing memory escape"
	}
}

func main() {
	fmt.Println(f1())
}

逃逸分析:

1
2
3
4
5
$ go run -gcflags='-l -m' escape.go
# command-line-arguments
./escape.go:6:9: func literal escapes to heap
./escape.go:12:13: ... argument does not escape
0x108d1c0

内存逃逸的影响

内存逃逸是解决对象内存存储问题而出现的,对于不能存储到栈上的会改为存储到堆上。对于 GC 来说,GC只负责堆上的垃圾回收,如果内存逃逸出现太多的话会增加 GC 的压力,虽然 GC 已经很优秀了,但频繁垃圾回收多少会影响程序的执行效率。对于开发者来说,尽量将对象存储到栈上任其内存自动释放是最佳选择,但也不必过分苛责。

本文由作者按照 CC BY 4.0 进行授权