本节讨论golang声明变量时,是放在栈上还是堆上的问题

引言
我:“golang 函数传参是不是应该跟 c 一样,尽量不要直接传结构体,而要传结构体指针?“

leader:“不对,咱们项目很多都是直接传结构体的。“

我:“那样不会造成不必要的内存 copy 开销吗?”

leader:“确实会有,但这样可以减小 gc 压力,因为传值会在栈上分配,而一旦传指针,结构体就会逃逸到堆上。“

我:“有道理。。。“

下面看一段代码

package main

func foo(arg_val int)(*int) {

    var foo_val int = 11;
    return &foo_val;
}

func main() {

    main_val := foo(666)

    println(*main_val)
}

编译运行

$ go run pro_1.go 
11
foo_val
foo_val
堆和栈

应用程序的内存载体,我们可以简单地将其分为堆和栈。

在Go中,栈的内存是由编译器自动进行分配和释放,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,函数的退出而销毁。一个goroutine对应一个栈,栈是调用栈(call stack)的简称。一个栈通常又包含了许多栈帧(stack frame),它描述的是函数之间的调用关系,每一帧对应一次尚未返回的函数调用,它本身也是以栈形式存放数据。

举例:在一个goroutine里,函数A()正在调用函数B(),那么这个调用栈的内存布局示意图如下。

在这里插入图片描述
与栈不同的是,应用程序在运行时只会存在一个堆。狭隘地说,内存管理只是针对堆内存而言的。程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配,并由垃圾收集器回收。

栈是每个goroutine独有的,这就意味着栈上的内存操作是不需要加锁的。而堆上的内存,有时需要加锁防止多线程冲突(为什么要说有时呢,因为Go的内存分配策略学习了TCMalloc的线程缓存思想,他为每个处理器P分配了一个mcache,从mcache分配内存也是无锁的)。

而且,对于程序堆上的内存回收,还需要通过标记清除阶段,例如Go采用的三色标记法。但是,在栈上的内存而言,它的分配与释放非常廉价。简单地说,它只需要两个CPU指令:一个是分配入栈,另外一个是栈内释放。而这,只需要借助于栈相关寄存器即可完成。

另外还有一点,栈内存能更好地利用CPU的缓存策略。因为它们相较于堆而言是更连续的。

Golang编译器的逃逸分析

在Go官网的FAQ 上有一个关于变量分配的问题如下:

在这里插入图片描述

​ go语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有跑出函数范围,就可以在栈上(但不是绝对会被分配在栈上),反之则必须分配在堆。另外,如果局部变量非常大,也会将其分配在堆上。

看如下代码:

package main

func foo(arg_val int) (*int) {

    var foo_val1 int = 11;
    var foo_val2 int = 12;
    var foo_val3 int = 13;
    var foo_val4 int = 14;
    var foo_val5 int = 15;


    //此处循环是防止go编译器将foo优化成inline(内联函数)
    //如果是内联函数,main调用foo将是原地展开,所以foo_val1-5相当于main作用域的变量
    //即使foo_val3发生逃逸,地址与其他也是连续的
    for i := 0; i < 5; i++ {
        println(&arg_val, &foo_val1, &foo_val2, &foo_val3, &foo_val4, &foo_val5)
    }

    //返回foo_val3给main函数
    return &foo_val3;
}


func main() {
    main_val := foo(666)

    println(*main_val, main_val)
}

运行结果:

$ go run pro_2.go 
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
0xc000030758 0xc000030738 0xc000030730 0xc000082000 0xc000030728 0xc000030720
13 0xc000082000
0xc000082000

我们用go tool compile测试一下

$ go tool compile -m pro_2.go
pro_2.go:24:6: can inline main
pro_2.go:7:9: moved to heap: foo_val3
foo_val3foo_val3
new的变量在栈还是堆?
newheap
package main

func foo(arg_val int) (*int) {

    var foo_val1 * int = new(int);
    var foo_val2 * int = new(int);
    var foo_val3 * int = new(int);
    var foo_val4 * int = new(int);
    var foo_val5 * int = new(int);


    //此处循环是防止go编译器将foo优化成inline(内联函数)
    //如果是内联函数,main调用foo将是原地展开,所以foo_val1-5相当于main作用域的变量
    //即使foo_val3发生逃逸,地址与其他也是连续的
    for i := 0; i < 5; i++ {
        println(arg_val, foo_val1, foo_val2, foo_val3, foo_val4, foo_val5)
    }

    //返回foo_val3给main函数
    return foo_val3;
}


func main() {
    main_val := foo(666)

    println(*main_val, main_val)
}

运行结果

$ go run pro_3.go 
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
666 0xc000030728 0xc000030720 0xc00001a0e0 0xc000030738 0xc000030730
0 0xc00001a0e0
foo_val30xc00001a0e0

即:Golang中一个函数内局部变量,不管是不是动态new出来的,它会被分配在堆还是栈,是由编译器做逃逸分析之后做出的决定。

发生逃逸现象举例

逃逸分析。编译器通过逃逸分析技术去选择堆或者栈,逃逸分析的基本思想如下:检查变量的生命周期是否是完全可知的,如果通过检查,则可以在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。

Go语言虽然没有明确说明逃逸分析规则,但是有以下几点准则,是可以参考的。

  • 逃逸分析是在编译器完成的,这是不同于jvm的运行时逃逸分析;
  • 如果变量在函数外部没有引用,则优先放到栈中;
  • 如果变量在函数外部存在引用,则必定放在堆中;
go build -gcflags '-m -l'
  1. 变量类型不确定
  2. 暴露给外部指针
  3. 变量所占内存较大
  4. 变量大小不确定

以上四种发生逃逸现象的具体实例展示详见:详解 Go 逃逸分析的后半部分

总结

理解逃逸分析能帮助我们写出更好的程序。知道变量分配在栈堆之上的差别,那么我们就要尽量写出分配在栈上的代码,堆上的变量变少了,可以减轻内存分配的开销,减小gc的压力,提高程序的运行速度

所以,你会发现有些Go上线项目,它们在函数传参的时候,并没有传递结构体指针,而是直接传递的结构体。这个做法,虽然它需要值拷贝,但是这是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。当然该做法不是绝对的,如果结构体较大,传递指针将更合适。

因此,从GC的角度来看,指针传递是个双刃剑,需要谨慎使用,否则线上调优解决GC延时可能会让你崩溃。

参考文档:
刘丹冰讲golang的逃逸分析
Go内存管理之代码的逃逸分析
详解 Go 逃逸分析