Golang 内存逃逸

什么是内存逃逸

在一段程序中,每一个函数都会有自己的内存区域存放自己的局部变量、返回地址等,这些内存会由编译器在栈中进行分配,每一个函数都会分配一个栈桢,在函数运行结束后进行销毁,但是有些变量我们想在函数运行结束后仍然使用它,那么就需要把这个变量在堆上分配,这种从"栈"上逃逸到"堆"上的现象就成为内存逃逸

典型场景

  • 函数返回局部指针变量。 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。

  • 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。

  • 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。

  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。

  • interface动态类型逃逸interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。

  • 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

  • 变量大小不确定及栈空间不足引发逃逸。我们可以看到,当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。同样当我们初始化切片时,没有直接指定大小,而是填入的变量,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。

举例

通过 go build -gcflags=-m 可以查看逃逸的情况,使用 go build -gcflags="-m -m -l" 可以查看到更详细的逃逸分析的结果。-m -m 查看编译器的所有优化,-l 禁用掉内联优化。

函数返回局部指针变量

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

import "fmt"

type A struct {
  s string
}

func foo(s string) *A {
  a := new(A)
  a.s = s
  return a //返回局部变量a,在C语言中妥妥野指针,但在go则ok,但a会逃逸到堆
}

func main() {
  a := foo("hello")
  b := a.s + " world"
  c := b + "!"
  fmt.Println(c)
}

执行go build -gcflags=-m main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# go build -gcflags=-m main.go

# command-line-arguments
.\main.go:11:6: can inline foo
.\main.go:18:10: inlining call to foo
.\main.go:21:13: inlining call to fmt.Println
.\main.go:11:10: leaking param: s
.\main.go:12:10: new(A) escapes to heap
.\main.go:18:10: new(A) does not escape
.\main.go:19:11: a.s + " world" does not escape
.\main.go:20:9: b + "!" escapes to heap
.\main.go:21:13: c escapes to heap
.\main.go:21:13: []interface {}{...} does not escape
<autogenerated>:1: leaking param content: .this
<autogenerated>:1: .this does not escape
  • .\main.go:12:10: new(A) escapes to heap 说明 new(A) 逃逸了,符合上述提到的常见情况中的第一种。

  • .\main.go:19:11: a.s + " world" does not escape 说明 b 变量没有逃逸,因为它只在方法内存在,会在方法结束时被回收。

  • .\main.go:20:9: b + "!" escapes to heap 说明 c 变量逃逸,通过fmt.Println(a ...interface{})打印的变量,都会发生逃逸。

interface类型逃逸

1
2
3
4
func main()  {
    str := "This is str"
    fmt.Printf("%v",str)
}

逃逸分析结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
.\main.go:7:13: str escapes to heap:
.\main.go:7:13:   flow: {storage for ... argument} = &{storage for str}:
.\main.go:7:13:     from str (spill) at .\main.go:7:13
.\main.go:7:13:     from ... argument (slice-literal-element) at .\main.go:7:12
.\main.go:7:13:   flow: {heap} = {storage for ... argument}:
.\main.go:7:13:     from ... argument (spill) at .\main.go:7:12
.\main.go:7:13:     from fmt.Printf("%v", ... argument...) (call parameter) at .\main.go:7:12
.\main.go:7:12: ... argument does not escape
.\main.go:7:13: str escapes to heap

strmain函数中的一个局部变量,传递给fmt.Println()函数后发生了逃逸,这是因为fmt.Println()函数的入参是一个interface{}类型,如果函数参数为interface{},那么在编译期间就很难确定其参数的具体类型,也会发送逃逸。

观察这个分析结果,我们可以看到没有moved to heap: str,这也就是说明str变量并没有在堆上进行分配,只是它存储的值逃逸到堆上了,也就说任何被str引用的对象必须分配在堆上。如果我们把代码改成这样:

1
2
3
4
func main() {
  str := "This is str"
  fmt.Printf("%v", &str)
}

逃逸分析结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
go build -gcflags="-m -m -l" ./main.go
# command-line-arguments
.\main.go:6:2: str escapes to heap:
.\main.go:6:2:   flow: {storage for ... argument} = &str:
.\main.go:6:2:     from &str (address-of) at .\main.go:7:19
.\main.go:6:2:     from &str (interface-converted) at .\main.go:7:19
.\main.go:6:2:     from ... argument (slice-literal-element) at .\main.go:7:12
.\main.go:6:2:   flow: {heap} = {storage for ... argument}:
.\main.go:6:2:     from ... argument (spill) at .\main.go:7:12
.\main.go:6:2:     from fmt.Printf("%v", ... argument...) (call parameter) at .\main.go:7:12
.\main.go:6:2: moved to heap: str
.\main.go:7:12: ... argument does not escape

这回str也逃逸到了堆上,在堆上进行内存分配,这是因为我们访问str的地址,因为入参是interface类型,所以变量str的地址以实参的形式传入fmt.Printf后被装箱到一个interface{}形参变量中,装箱的形参变量的值要在堆上分配,但是还要存储一个栈上的地址,也就是str的地址,堆上的对象不能存储一个栈上的地址,所以str也逃逸到堆上,在堆上分配内存。(这里注意一个知识点:Go语言的参数传递只有值传递

闭包产生的逃逸

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

func main() {
    in := Increase()
    fmt.Println(in()) // 1
}

查看逃逸分析结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
go build -gcflags="-m -m -l" .\main.go
# command-line-arguments
.\main.go:6:2: Increase capturing by ref: n (addr=false assign=true width=8)
.\main.go:7:9: func literal escapes to heap:
.\main.go:7:9:   flow: ~r0 = &{storage for func literal}:
.\main.go:7:9:     from func literal (spill) at .\main.go:7:9
.\main.go:7:9:     from return func literal (return) at .\main.go:7:2
.\main.go:6:2: n escapes to heap:
.\main.go:6:2:   flow: {storage for func literal} = &n:
.\main.go:6:2:     from n (captured by a closure) at .\main.go:8:3
.\main.go:6:2:     from n (reference) at .\main.go:8:3
.\main.go:6:2: moved to heap: n
.\main.go:7:9: func literal escapes to heap
.\main.go:15:16: in() escapes to heap:
.\main.go:15:16:   flow: {storage for ... argument} = &{storage for in()}:
.\main.go:15:16:     from in() (spill) at .\main.go:15:16
.\main.go:15:16:     from ... argument (slice-literal-element) at .\main.go:15:13
.\main.go:15:16:   flow: {heap} = {storage for ... argument}:
.\main.go:15:16:     from ... argument (spill) at .\main.go:15:13
.\main.go:15:16:     from fmt.Println(... argument...) (call parameter) at .\main.go:15:13.\main.go:15:13: ... argument does not escape
.\main.go:15:16: in() escapes to heap

因为函数也是一个指针类型,所以匿名函数当作返回值时也发生了逃逸,在匿名函数中使用外部变量n,这个变量n会一直存在直到in被销毁,所以n变量逃逸到了堆上。

Built with Hugo
主题 StackJimmy 设计