Go - 逃逸分析

154 阅读4分钟

Go - 逃逸分析

逃逸分析,简单的说就是确定内存是分配到栈上还是堆上。将内存分配到栈上的性能会比分配到堆上的性能明显要高,Go在编译阶段就会完成逃逸分析,编译器会尽可能的将内存分配到栈上,但当检查到变量在生命周期内发生未知的情况,则会产生逃逸现象,从而会将变量的内存分配到堆上。

一、堆栈对比

1.1 栈

在Go语言中,栈的内存是由编译器自动进行分配和释放的,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,随着函数的退出而销毁

Go应用程序运行时,每个 goroutine 都维护着一个自己的栈区,这个栈区只能自己使用不能被其他 goroutine 使用。栈是调用栈(call stack)的简称。一个栈通常又包含了许多栈帧(stack frame),它描述的是函数之间的调用关系


1.2 堆

与栈不同的是,堆区的内存一般由编译器和工程师自己共同进行管理分配,交给 Runtime GC 来释放。在堆上分配时,必须找到一块足够大的内存来存放新的变量数据。后续释放时,垃圾回收器扫描堆空间寻找不再被使用的对象。

我们可以简单理解为:我们用GO语言开发过程中,要考虑的内存管理只是针对堆内存而言的。

程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配,并由垃圾收集器回收。


1.3 堆栈的对比

加锁对比:

  • 栈不需要加锁:每个goroutine都独享自己的栈空间,这就意味着栈上的内存操作是不需要加锁的
  • 堆有时需要加锁:堆上的内存,有时需要加锁防止多线程冲突

性能对比:

  • 栈内存管理 性能好:栈上的内存,它的分配与释放非常高效的。简单地说,它只需要两个CPU指令:一个是分配入栈,另外一个是栈内释放。只需要借助于栈相关寄存器即可完成。
  • 堆内存管理 性能差:对于程序堆上的内存回收,还需要有标记清除阶段,例如Go采用的三色标记法

二、逃逸场景

2.1 指针逃逸

我们知道Go可以返回局部变量指针,这其实是一个典型的变量逃逸案例,示例代码如下:

package main
​
type Student struct {
    Name string
    Age  int
}
​
func StudentRegister(name string, age int) *Student {
    s := new(Student) //局部变量s逃逸到堆
​
    s.Name = name
    s.Age = age
​
    return s
}
​
func main() {
    StudentRegister("Jim", 18)
}

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

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

D:\SourceCode\GoExpert\src>go build -gcflags=-m
# _/D_/SourceCode/GoExpert/src
.\main.go:8: can inline StudentRegister
.\main.go:17: can inline main
.\main.go:18: inlining call to StudentRegister
.\main.go:8: leaking param: name
.\main.go:9: new(Student) escapes to heap
.\main.go:18: main new(Student) does not escape

2.2 栈空间不足逃逸

看下面的代码,是否会产生逃逸呢?

package main
​
func Slice() {
    s := make([]int, 1000, 1000)
​
    for index, _ := range s {
        s[index] = index
    }
}
​
func main() {
    Slice()
}

上面代码Slice()函数中分配了一个1000个长度的切片,是否逃逸取决于栈空间是否足够大。

实际上当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中。

2.3 动态类型逃逸

很多函数参数为interface类型,比如fmt.Println(a …interface{}),编译期间很难确定其参数的具体类型,也会产生逃逸。 如下代码所示:

package main
​
import "fmt"func main() {
    s := "Escape"
    fmt.Println(s)
}

上述代码s变量只是一个string类型变量,调用fmt.Println()时会产生逃逸:

2.4 闭包引用对象逃逸

package main
​
import "fmt"func Fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}
​
func main() {
    f := Fibonacci()
​
    for i := 0; i < 10; i++ {
        fmt.Printf("Fibonacci: %d\n", f())
    }
}

三、应用总结

根据场景去分析:

  • 如果结构体较大,传递结构体指针更合适,因为指针类型相比值类型能节省大量的内存空间
  • 如果结构体较小,传递结构体更适合,因为在栈上分配内存,可以有效减少GC压力

如何找到应用开发效率、程序运行效率、对机器的压力及负载的平衡点,是程序员进阶之旅中的必修课。

四、链接

先聊聊「内存分配」,再聊聊Go的「逃逸分析」

Go逃逸分析:揭秘性能优化的秘密