2025-04-12    2025-04-12    2177 字  5 分钟
Go

什么是内存逃逸?

内存逃逸(Memory Escape) 是指一个原本应该分配在栈(stack)上的变量,由于某些原因被分配到了堆(heap)上。这种现象在 Go 的垃圾回收机制中是允许的,但在某些情况下可能会导致不必要的堆分配,从而影响性能。

栈(Stack)与堆(Heap)的区别

  • 栈(Stack)
    • 栈是一个线性的内存区域,用于存储局部变量和函数调用的上下文。
    • 栈上的内存分配和释放非常快,因为它们是自动管理的,函数返回时,栈上的局部变量会被自动销毁。
    • 栈的大小通常有限,不适合存储大对象或生命周期较长的对象。
  • 堆(Heap)
    • 堆是一个动态分配的内存区域,用于存储生命周期较长的对象。
    • 堆上的内存分配和释放由垃圾回收器(Garbage Collector, GC)管理,相对栈来说,堆的分配和回收速度较慢。
    • 堆适合存储大对象或生命周期较长的对象。

为什么会发生内存逃逸

在 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66

// main.go
package main  
  
import "fmt"  
  
func main() {  
   fmt.Println("11223")  
}  
  
// 指针、slice和map作为返回值  
// 当带有指针的返回值被赋值给外部变量或者作为参数传递给其他函数时,编译器无法确定该变量何时停止使用  
// 因此,为了确保安全性和正确性,它必须将该数据分配在堆上,并使其逃离当前函数作用域  
func f1() (*int, []int, map[int]int) {  
   i := 0  
   list := []int{1, 2, 3, 4}  
   mp := map[int]int{1: 1, 2: 2}  
   return &i, list, mp  
}  
  
// 向chan中发送数据的指针或者包含指针的值  
// 编译器此时不知道值什么时候会被接受,因此只能放入堆中  
func f2() {  
   i := 2  
   ch := make(chan *int, 2)  
   ch <- &i  
   <-ch  
}  
  
// 非直接的函数调用,比如在闭包中引用包外的值,因为闭包执行的生命周期可能会超过函数调用,因此需要放入堆中  
func f3() func() {  
   i := 1  
   return func() {  
      fmt.Println(i)  
   }  
}  
  
// 在slice或map出存储指针或者包含指针的值  
// slice和map都需要动态分配内存来保存数据。当我们将一个指针或者包含指针的值放入slice或map时,编译器无法确定  
// 该指针所引用的数据是否会在函数返回后仍然被使用。  
// 为了保证数据的有效性,编译器会将其分配到堆上,以便在函数放回后继续存在  
func f4() {  
   i := 1  
   list := make([]*int, 10)  
   list[0] = &i  
}  
  
type animal interface {  
   run()  
}  
  
type dog struct{}  
  
func (a dog) run() {}  
  
// interface 类型多态的应用,可能会导致逃逸  
// 由于接口类型可以持有任意实现了该接口的类型,编译器在编译时无法确定具体的动态类型,  
// 因此,为了保证程序的正确性,在运行时需要将接口对象分配到堆上  
func f5() {  
   var a animal = dog{}  
   a.run()  
  
   var a1 animal  
   a1 = dog{}  
   a1.run()  
}

如何检测内存逃逸

Go 编译器提供了逃逸分析工具,可以帮助我们检测内存逃逸的情况。可以通过以下方式启用逃逸分析:

1
go build -gcflags "-m"

或者

1
go build -gcflags "-m -m"
  • -m 参数会显示逃逸分析的结果。

  • -m -m 参数会显示更详细的信息。
    使用逃逸分析的工具来对上面的文件进行逃逸分析

1
go build -gcflags "-m" main.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
test@test# go build -gcflags "-m" main.go
# command-line-arguments
./main.go:5:6: can inline main
./main.go:6:13: inlining call to fmt.Println
./main.go:12:6: can inline f1
./main.go:21:6: can inline f2
./main.go:31:9: can inline f3.func1
./main.go:33:14: inlining call to fmt.Println
./main.go:41:6: can inline f4
./main.go:53:6: can inline dog.run
<autogenerated>:1: inlining call to dog.run
./main.go:60:7: devirtualizing a.run to dog
./main.go:6:13: ... argument does not escape
./main.go:6:14: "11223" escapes to heap
./main.go:13:2: moved to heap: i
./main.go:14:15: []int{...} escapes to heap
./main.go:15:19: map[int]int{...} escapes to heap
./main.go:22:2: moved to heap: i
./main.go:30:2: moved to heap: i
./main.go:31:9: func literal escapes to heap
./main.go:33:14: ... argument does not escape
./main.go:33:15: i escapes to heap
./main.go:42:2: moved to heap: i
./main.go:43:14: make([]*int, 10) does not escape
./main.go:59:20: dog{} does not escape
./main.go:63:10: dog{} escapes to heap

其对应到代码中结果如下

 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67

// main.go
package main  
  
import "fmt"  
  
func main() {  
   fmt.Println("11223")  
}  
  
// 指针、slice和map作为返回值  
// 当带有指针的返回值被赋值给外部变量或者作为参数传递给其他函数时,编译器无法确定该变量何时停止使用  
// 因此,为了确保安全性和正确性,它必须将该数据分配在堆上,并使其逃离当前函数作用域  
func f1() (*int, []int, map[int]int) {  
   i := 0   // moved to heap: i
   list := []int{1, 2, 3, 4}  //[]int{...} escapes to heap
   mp := map[int]int{1: 1, 2: 2}  //map[int]int{...} escapes to heap
   return &i, list, mp  
}  
  
// 向chan中发送数据的指针或者包含指针的值  
// 编译器此时不知道值什么时候会被接受,因此只能放入堆中  
func f2() {  
   i := 2  //moved to heap: i
   ch := make(chan *int, 2)  
   ch <- &i  
   <-ch  
}  
  
// 非直接的函数调用,比如在闭包中引用包外的值,因为闭包执行的生命周期可能会超过函数调用,因此需要放入堆中  
func f3() func() {  
   i := 1  //moved to heap: i
   return func() {  
	  i++
      fmt.Println(i)  
   }  
}  
  
// 在slice或map出存储指针或者包含指针的值  
// slice和map都需要动态分配内存来保存数据。当我们将一个指针或者包含指针的值放入slice或map时,编译器无法确定  
// 该指针所引用的数据是否会在函数返回后仍然被使用。  
// 为了保证数据的有效性,编译器会将其分配到堆上,以便在函数放回后继续存在  
func f4() {  
   i := 1  ////moved to heap: i
   list := make([]*int, 10)  
   list[0] = &i  
}  
  
type animal interface {  
   run()  
}  
  
type dog struct{}  
  
func (a dog) run() {}  
  
// interface 类型多态的应用,可能会导致逃逸  
// 由于接口类型可以持有任意实现了该接口的类型,编译器在编译时无法确定具体的动态类型,  
// 因此,为了保证程序的正确性,在运行时需要将接口对象分配到堆上  
func f5() {  
   var a animal = dog{}  //可以直接采用这种方法来避免发生逃逸
   a.run()  
  
   var a1 animal  
   a1 = dog{}   //dog{} escapes to heap
   a1.run()  
}

内存逃逸的影响

  • 性能问题:堆分配比栈分配更慢,因为堆分配需要垃圾回收器管理。如果大量变量逃逸到堆上,可能会导致程序性能下降。
  • 内存使用增加:堆上的内存分配可能会导致内存使用量增加,尤其是在处理大对象时。

导致内存逃逸的原因是什么

  1. 栈空间和作用域
  2. 编译时无法确定类型或大小
  3. golang内存分配的基本原则
    • 指向栈上的指针不能被存储到堆中
    • 指向栈上的指针不能超过该栈对象的生命周期