golang中的变量逃逸分析

什么是逃逸

垃圾回收是Go的一个很方便的特性–其自动的内存管理使代码更整洁,同时减少内存泄漏的可能性。但是,由于垃圾回收需要周期性的停止程序从而去收集不用的对象,不可避免的会增加额外开销。Go编译器是智能的,它会自动决定一个变量是应该分配在堆上从而在将来便于回收,还是直接分配到函数的栈空间。对于分配到栈上的变量,其与分配到堆上的变量不同之处在于:随着函数的返回,栈空间会被销毁,从而栈上的变量被直接销毁,不需要额外的垃圾回收开销。

但是在有些情况下,本应该分配到栈上的变量,被编译器智能的分配到了堆上,变量从栈逃逸到堆上就是逃逸现象。

Go的逃逸分析相对于Java虚拟机的HotSpot来说更为基础。基本规则就是,如果一个变量的引用从声明它的函数中返出去了,则发生“逃逸”,因为它有可能在函数外被别的内容使用,所以必须分配到堆上。

以下是一些触发内存逃逸的情况:

  • 在函数中返回指针:如果在函数中创建一个局部变量,然后返回它的指针,那么这个变量很可能会逃逸到堆上,因为它需要在函数退出后仍然可访问。
  • 在函数中开启 goroutine:如果在函数内部开启了一个 goroutine,并将局部变量传递给这个 goroutine,这个变量可能会逃逸,因为 goroutine 可能在函数退出后继续访问该变量。
  • 变量被闭包使用:如果一个闭包引用了外部函数的局部变量,这个变量也可能会逃逸到堆上,因为闭包可能会在函数退出后继续存在。
  • 变量占用空间太大:如果一个局部变量很大,超过了栈的大小限制,编译器可能会将其分配到堆上,以避免栈溢出。

如下几种情况会比较复杂:

  • 函数调用其他函数
  • 引用作为结构体的成员变量
  • 切片和映射
  • Cgo指向变量的指针

以下是一些避免内存逃逸的方法:

  • 避免闭包: 闭包可能导致变量的生命周期延长,从而导致内存逃逸。尽量避免在闭包中使用外部变量。
  • 避免返回指针或引用: 返回指向局部变量的指针或引用会导致内存逃逸。(Go有三个引用类型:slice,map,chan)
  • 返回数组而不是切片(slice):数组是值类型的,切片是引用类型的。
  • 使用值类型的接收器(receiver): 当定义方法时,如果不需要修改接收器的状态,尽量使用值类型的接收器而不是指针接收器,可以减少内存逃逸的可能性。
  • 使用编译器分析工具:可以使用go build -gcflags="-m"命令来触发编译器的逃逸分析报告。这会在编译过程中输出逃逸分析的结果,帮助我们了解哪些变量逃逸到了堆上。

为了实现逃逸分析,Go会在编译阶段构造函数调用关系图,同时跟踪入参和返回值的流程。一个函数如果只是引用一个参数,但这个引用并没有返出函数的话,这个变量也不会逃逸。如果一个函数返回了一个引用,但是这个引用被栈中的其他函数解除或者没有返回此引用,则也不会逃逸。为了论证几个例子,可以在编译时加-gcflags '-m'参数,这个参数会打印逃逸分析的详细信息:

package main

type S struct {}

func main() {
    var x S
    _ = identity(x)
}

func identity(x S) S {
    return x
}

你可以执行go run -gcflags '-m -l'(注:原文中略了go代码文件名)来编译这个代码,-l参数是防止函数identity被内联(换个时间再讨论内联这个话题)。你将会看到没有任何输出!Go使用值传递,所以main函数中的x这个变量总是会被拷贝到函数identity的栈空间。通常情况下没有使用引用的代码都是通过栈空间来分配内存。所以不涉及逃逸分析。下面试下困难一点的:

package main

type S struct {}
func main() {
    var x S
    y := &x
    _ = *identity(y)
}

func identity(z *S) *S {
    return z
}

其对应的输出是:

./escape.go:11: leaking param: z to result ~r1
./escape.go:7: main &x does not escape

第一行显示了变量z的“流经”:入参直接作为返回值返回了。但是函数 identity 没有取走z这个引用,所以没有发生变量逃逸。在main函数返回后没有任何对x的引用存在,所以x这个变量可以在main函数的栈空间进行内存分配。
第三次实验:

package main
type S struct {}
func main() {
  var x S
  _ = *ref(x)
}

func ref(z S) *S {
  return &z
}

其输出为:

./escape.go:10: moved to heap: z
./escape.go:11: &z escapes to heap

现在有了逃逸发生。记住Go是值传递的,所以z是对变量x的一个拷贝。函数ref返回一个对z的引用,所以z不能在栈中分配,否则当函数ref返回时,引用会指向何处呢?于是它逃逸到了堆中。其实执行完ref返回到main函数中后,main函数丢弃了这个引用而不是解除引用,但是Go的逃逸分析还不够机智去识别这种情况。
值得注意的是,在这种情况下,如果我们不停止引用,编译器将内联ref。
如果结构体成员定义的是引用又会怎样呢?

package main
type S struct {
  M *int
}

func main() {
  var i int
  refStruct(i)
}

func refStruct(y int) (z S) {
  z.M = &y
  return z
}

其输出为:

./escape.go:12: moved to heap: y
./escape.go:13: &y escapes to heap

在这种情况下,尽管引用是结构体的成员,但Go仍然会跟踪引用的流向。由于函数refStruct接受引用并将其返回,因此y必须逃逸。对比如下这个例子:

package main

type S struct {
  M *int
}

func main() {
  var i int
  refStruct(&i)
}

func refStruct(y *int) (z S) {
  z.M = y
  return z
}

其输出为:

./escape.go:12: leaking param: y to result z
./escape.go:9: main &i does not escape

尽管在main函数中对i变量做了引用操作,并传递到了函数refStruct中,但是这个引用的范围没有超出其声明它的栈空间。这和之前的那个程序语义上有细微的差别,这个会更高效:在上一个程序中,变量i必须分配在main函数的栈中,然后作为参数拷贝到函数refStruct中,并将拷贝的这一份分配在堆上。而在这个例子中,i仅被分配一次,然后将引用到处传递。

再来看一个有点弯弯绕的例子:

package main

type S struct {
  M *int
}

func main() {
  var x S
  var i int
  ref(&i, &x)
}

func ref(y *int, z *S) {
  z.M = y
}

其输出为:

./escape.go:13: leaking param: y
./escape.go:13: ref z does not escape
./escape.go:9: moved to heap: i
./escape.go:10: &i escapes to heap
./escape.go:10: main &x does not escape

问题在于,y被赋值给了一个入参结构体的成员。Go并不能追溯这种关系(go只能追溯输入直接流向输出),所以逃逸分析失败了,所以变量只能分配到堆上。由于Go的逃逸分析的局限性,许多变量会被分配到堆上。

最后,来看下映射和切片是怎样的呢?请记住,切片和映射实际上只是具有指向堆内存的指针的Go结构:slice结构是暴露在reflect包中(SliceHeader)。map结构就更隐蔽了:存在于hmap。如果这些结构体不逃逸,将会被分配到栈上,但是其底层的数组或者哈希桶中的实际数据会被分配到堆上去。避免这种情况的唯一方法是分配一个固定大小的数组(例如[10000]int)。

如果你剖析过你的程序堆使用情况(https://blog.golang.org/pprof),并且想减少垃圾回收的消耗,可以将频繁分配到堆上的变量移到栈上,可能会有较好的效果。进一步研究HotSpot JVM是如何进行逃逸分析的会是一个不错的话题,可以参考这个链接,这个里面主要讲解了栈分配,以及有关何时可以消除同步的检测。

实践意义

在堆上和栈上分配内存也有很大差异,堆区使用的是虚拟内存,分配速度较慢,栈区使用的是真实内存,分配速度很快,差别有十倍左右。

在golang中,有个问题就是,在使用结构体的时候是否要以指针的形式,排除了其实际应用之外,另外一个考虑的因素就是变量逃逸导致的GC,以及堆区和栈区的分配速度的差异。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值