变量逃逸——堆、栈

变量逃逸——堆、栈

堆(Heap)与栈(Stack)是开发人员必须面对的两个概念,在理解这两个概念时,需要放到具体的场景下,因为不同场景下,堆与栈代表不同的含义。一般情况下,有两层含义:
(1)程序内存布局场景下,堆与栈表示两种内存管理方式;
(2)数据结构场景下,堆与栈表示两种常用的数据结构。

1、程序内存分区中的堆与栈

####### 1.1 栈简介
栈只允许从线性表的同一端放入和取出数据,按照后进先出(LIFO)的顺序,如下图:

image

栈由操作系统自动分配释放,用于存放函数的参数值、局部变量等,其操作方式类似于数据结构中的栈。参考如下代码

int main() {
	int b;				//栈
	char s[] = "abc"; 	//栈
	char *p2;			//栈
}

其中函数中定义的局部变量按照先后定义的顺序依次压入栈中,也就是说相邻变量的地址之间不会存在其它变量。栈的内存地址生长方向与堆相反,由高到底,所以后定义的变量地址低于先定义的变量,比如上面代码中变量 s 的地址小于变量 b 的地址,p2 地址小于 s 的地址。栈中存储的数据的生命周期随着函数的执行完成而结束。

1.2 堆简介

堆由开发人员分配和释放, 若开发人员不释放,程序结束时由 OS 回收,分配方式类似于链表。

对于堆在内存中的分配,我们可以类比成一个房间,分配内存时,需要找一块足够装下家具的空间来摆放家具。经过反复摆放和腾空家具后,房间里的空间会变得乱七八糟,此时再往这个空间里摆放家具会发现虽然有足够的空间,但各个空间分布在不同的区域,没有一段连续的空间来摆放家具。此时,内存分配器就需要对这些空间进行调整优化,如下图:

image

对比栈和堆可知,在编译时,一切无法确定大小或大小可以改变的数据,最好放到堆上,堆适合不可预知大小的内存分配。但是为此付出的代价是分配速度较慢,而且会形成内存碎片

函数中申请一个新的对象

  • 如果分配到栈中,则函数执行结束可自动将内存回收
  • 如果分配到堆中,则函数执行结束可教给GC(垃圾回收)处理

减少逃逸,将变量限制在栈上

变量逃逸一般发生在如下情况下:

  • 变量较大(栈空间不足)
  • 变量大小不确定(如slice长度或容量不定)
  • 返回地址
  • 返回引用(引用变量的底层是指针)
  • 返回值类型不确定(不能确定大小)
  • 闭包
  • 其他

知道变量逃逸的原因后,我们可以有意识地控制变量不发生逃逸,将其控制在栈上,减少堆变量的分配,降低 GC 成本,提高程序性能。

「逃逸分析」就是程序运行时内存的分配位置(栈或堆),是由编辑器来确定的,而非开发者。

逃逸分析的好处应该是减少了 gc 的压力,栈的分配比堆快,性能好,如果变量都分配到栈上,可以避免 Go 频繁地进行垃圾回收,而垃圾回收会占用比较大的系统开销。

逃逸分析基本原则

编译器会根据变量是否被外部引用来决定是否逃逸:

  1. 如果函数外部没有引用,优先放栈上
  2. 如果函数外部存在引用,则必定放到堆中。
  3. 如果栈上放不开,则必定放到堆上。

可以肯定的是,如果函数里面的变量返回了一个地址,那么这个变量肯定会发生逃逸。

go编译器会判断变量的生命周期,如果编译器认为函数结束后,这个变量不再被外部的引用了,会分配到栈,否则分配到堆

package main

import "fmt"

func sum(a int, b int) *int {
    var c = a + b
    var d = 1
    var e = new(int)
    fmt.Println(&d)
    fmt.Println(&e)
    return &c
}
  • 上面的变量d,虽然通过&d获取了他的地址,但只是在函数中进行打印
  • 而e虽然通过new方法定义,但不能分配到堆,因为d和e并没有被外部引用,只能被分配到栈,所以在sum函数结束之后,被自动释放
  • 但是c,return了他的地址,返回了指针,那么久表示这个变量对应的内存可以被外部访问,所以会逃逸到堆。

逃逸场景

指针逃逸
package main
​
type Person struct {
 Name string
 Age  int
}func PersonRegister(name string, age int) *Person {
 p := new(Person) //局部变量s逃逸到堆
​
 p.Name = name
 p.Age = age
​
 return p
}func main() {
 PersonRegister("微客鸟窝", 18)
}

函数 PersonRegister() 内部 p 为局部变量,其值通过函数返回值返回, p 本身为一指针,其指向的内存地址不会是栈而是堆,这就是典型的逃逸案例。

可以通过编译参数-gcflags=-m查看编译过程的逃逸分析

mac@MacdeMBP test2 % go build -gcflags=-m main.go 
# command-line-arguments
./main.go:8:6: can inline PersonRegister
./main.go:14:6: can inline main
./main.go:15:16: inlining call to PersonRegister
./main.go:8:21: leaking param: name
./main.go:9:10: new(Person) escapes to heap
./main.go:15:16: new(Person) does not escape

代码第7行显示escapes to heap 表示该行内存分配发生了逃逸现象

栈空间不足逃逸
package main
​
func Slice() {
 s := make([]int, 1000, 1000)for index, _ := range s {
  s[index] = index
 }
}func main() {
 Slice()
}

上面代码 Slice() 函数中分配了一个1000个长度的切片,是否逃逸取决于栈空间是否足够大。直接查看编译提示,如下:

mac@MacdeMBP test2 % go build -gcflags=-m main.go
# command-line-arguments
./main.go:14:6: can inline Slice
./main.go:21:6: can inline main
./main.go:23:7: inlining call to Slice
./main.go:15:11: make([]int, 1000, 1000) does not escape
./main.go:23:7: make([]int, 1000, 1000) does not escape

发现并没有发生逃逸。我们把切片长度扩大10倍再试试: s := make([]int, 10000, 10000)

mac@MacdeMBP test2 % go build -gcflags=-m main.go
# command-line-arguments
./main.go:14:6: can inline Slice
./main.go:21:6: can inline main
./main.go:23:7: inlining call to Slice
./main.go:15:11: make([]int, 10000, 10000) escapes to heap
./main.go:23:7: make([]int, 10000, 10000) escapes to heap

发现当切片长度扩大到10000时就会逃逸。当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中。

总结:

  • 栈上分配内存比在堆中分配内存效率更高
  • 栈上分配的内存不需要GC处理,而堆需要
  • 逃逸分析目的是决定内存分配地址是栈还是堆
  • 逃逸分析在编译阶段完成