Golang深入学习8-堆栈与逃逸分析

警告
本文最后更新于 2020-08-15,文中内容可能已过时。

本篇介绍 Go 的堆栈和逃逸分析。

1. 堆栈

栈是一种后入先出的结构,计算机中也有一块满足该特性的动态内存区域,程序可以将数据压入栈中或者将数据从栈中弹出,从而使栈动态的增大或减小。

除此之外,堆也是计算机中的一中内存区域。堆与栈的区别如下

  1. 栈一般由操作系统来分配和释放,堆由程序员通过编程语言来申请创建与释放。
  2. 栈用来存放函数的参数、返回值、局部变量、函数调用时的临时上下文等,堆用来存放全局变量。我们可以这样理解数据存放的规则:只要是局部的、占用空间确定的数据,一般都存放在stack 里面,否则就放在 heap 里面。
  3. 栈的访问速度相对比堆快。
  4. 一般来说,每个线程分配一个stack,每个进程分配一个heap,也就是说,stack 是线程独占的,heap 是线程共用的。
  5. stack 创建的时候,大小是确定的,数据超过这个大小,就发生stack overflow 错误,而heap的大小是不确定的,需要的话可以不断增加。
  6. 栈是由高地址向低地址增长的,而堆是由低地址向高地址增长的。

在 C/C++ 中,程序员申请的内存空间使用完毕后需要自己释放,在 Java、Go 等语言中,提供了一种主动释放申请的内存空间的功能,这就是垃圾回收机制。

2. Go的堆栈

首先,Go 是自己管理内存的,而不是交给操作系统,它每次从操作系统申请一大块内存,然后按照 Google 的 TCMalloc 算法进行内存分配,也划分为堆、栈等很多区域。

这里我们主要关心变量究竟放在堆还是栈里,官方有这么一段解释

只要有对变量的引用,变量就会存在,而它存储的位置与语言的语义无关。如果可能,变量会被分配到其函数的栈,但如果编译器无法证明函数返回之后变量是否仍然被引用,就必须在堆上分配该变量,采用垃圾回收机制进行管理,从而避免指针悬空。此外,局部变量如果非常大,也会存在堆上。

在编译器中,如果变量具有地址,就作为堆分配的候选,但如果逃逸分析可以确定其生存周期不会超过函数返回,就会分配在栈上。

总之,分配在堆还是栈完全由编译器确定。

所以 Go 里变量分配在由编译器决定,我们虽然无法通过变量的类型判断它的存储位置,但可以通过打印程序的汇编代码来查看,主要使用 go tool compile 命令,示例程序如下

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
	var a [1]int
	c := a[:]
	fmt.Println(c)
}

使用 -S 参数编译,可以输出详细内容。可以看到第 6 行代码出现了 runtime.newobject,这就说明 main.go 第6行 的数组变量 a 分配在堆上。反之,如果没有发现 runtime.newobject 调用,就说明分配在栈上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ go tool compile -S main.go
...   
        0x0028 00040 (main.go:6)        PCDATA  $0, $1
        0x0028 00040 (main.go:6)        PCDATA  $1, $0
        0x0028 00040 (main.go:6)        LEAQ    type.[1]int(SB), AX
        0x002f 00047 (main.go:6)        PCDATA  $0, $0
        0x002f 00047 (main.go:6)        MOVQ    AX, (SP)
        0x0033 00051 (main.go:6)        CALL    runtime.newobject(SB)
        0x0038 00056 (main.go:6)        PCDATA  $0, $1
        0x0038 00056 (main.go:6)        MOVQ    8(SP), AX
        0x003d 00061 (main.go:8)        PCDATA  $0, $0
        0x003d 00061 (main.go:8)        MOVQ    AX, (SP)
        0x0041 00065 (main.go:8)        MOVQ    $1, 8(SP)
        0x004a 00074 (main.go:8)        MOVQ    $1, 16(SP)
...

使用 -m 参数可以输出优化后的描述,这里就看起来很清楚了,可以看到 main.go 第 6 行的变量 a 分配到了堆上,在第 8 行时,变量 c 逃逸到了堆上。关于逃逸我们在下一小节介绍。

1
2
3
4
5
6
7
$ go tool compile -m main.go
main.go:8:13: inlining call to fmt.Println
main.go:6:6: moved to heap: a
main.go:8:13: c escapes to heap
main.go:8:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
<autogenerated>:1: .this does not escape

也可以直接在 go build 命令中添加 -gcflag=-m 参数,意思是编译的同时以 -m 参数运行 go tool compile 命令,结果是相同的。

1
2
3
4
5
6
7
8
$ go build -gcflags=-m main.go
# command-line-arguments
.\main.go:8:13: inlining call to fmt.Println
.\main.go:6:6: moved to heap: a
.\main.go:8:13: c escapes to heap
.\main.go:8:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
<autogenerated>:1: .this does not escape

3. 逃逸分析

前面已经提到,Go 中变量分配在栈还是堆上完全由编译器决定,而原本看起来应该分配在栈上的变量,如果其生命周期获得了延长,被分配在了堆上,就说它发生了逃逸。编译器会自动地去判断变量的生命周期是否获得了延长,整个判断的过程就叫逃逸分析。

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

func main() {
	a := test()
	println(a)
}

func test() *int {
	b := 2
	return &b
}

举个例子,上面的程序中 b 作为局部变量,如果是在 C 语言中,return &b 这样的写法无法通过,因为 b 在函数返回后已经消失了,但在 Go 中,编译器做了一下逃逸分析,发现返回 &b 说明之后 b 还要用,于是就把 b 分配在了堆上。

1
2
3
4
5
$ go tool compile -m main.go
main.go:8:6: can inline test
main.go:3:6: can inline main
main.go:4:11: inlining call to test
main.go:9:2: moved to heap: b

然后我们举另外一个例子如下,一个最简单的输出

1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
	a := 1
	fmt.Println(a)
}

结果发现变量 a 依然逃逸了

1
2
3
4
5
6
$ go tool compile -m main.go
main.go:7:13: inlining call to fmt.Println
main.go:7:13: a escapes to heap
main.go:7:13: []interface {} literal does not escape
<autogenerated>:1: .this does not escape
<autogenerated>:1: .this does not escape

这是因为输出其实是调用了 fmt 包的 Println 函数,变量的生命周期依然得到了扩展,如果我们使用 println 函数,就不会发生逃逸

1
2
3
4
5
6
package main

func main() {
	a := 1
	println(a)
}
1
2
$ go tool compile -m main.go
main.go:3:6: can inline main

所以现在我们可以更好的理解官方说明中的这句话:如果变量具有地址,就作为堆分配的候选,但如果逃逸分析可以确定其生存周期不会超过函数返回,就会分配在栈上。

变量发生逃逸的情况可以总结如下

  1. 在方法内把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
  2. 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  3. 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  4. slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
  5. 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

参考

[1] Go堆栈的理解

[2] 阮一峰,栈的三种含义

[3] 官方关于变量分配在堆还是栈上的说明

[4] Young,CSDN,Go 的变量到底在堆还是栈中分配

[5] 胖虎,微信公众号,简单聊聊内存逃逸

支付宝
微信
0%