之前写过一篇关于Go内存分配的文章 - Go内存分配,讲的是Go堆内存的申请过程。对象除了可以在堆上分配,还有一个我们很熟悉的地方可以分配,栈。
栈内存分配
每一个协程都有自己的栈,协程访问栈的对象时,不需要加锁等同步操作
。
下面是一个例子:
调用main
函数,Go在栈上分配内存
调用square
函数,Go在栈上分配内存
当square
执行完返回main
时,上一步调用square
时,分配的内存不会被回收。仅仅是移动了栈指针的位置。
调用println
函数,Go移动栈指针的位置来分配内存。
可以看出,在栈上分配内存的开销是很小的,只需要上下移动栈指针的位置来申请和释放内存。所以Go也倾向于在栈上分配内存。(Go prefers allocation on the stack
)
PS: 相比栈,在堆上分配内存的过程比较复杂,而且会使用垃圾回收来释放内存,垃圾回收时,会对程序造成延迟和降低吞吐量。
同时,Go使用的协程栈初始大小是2k,但可以动态的扩容和缩容。在开销上比线程栈小得多。
逃逸分析
我们已经知道,在栈上分配内存远比在堆上分配内存好,但不是所有的对象都可以在栈上分配。
下面是一个例子
调用main
函数,Go在栈上分配内存
调用answer
函数,Go在栈上分配内存
answer
执行完返回main
,此时Go移动了栈指针,上一步为answer
分配的内存限制是无效的。
调用println
函数,Go移动栈指针来分配内存,此时覆盖了调用answer
时分配的内存,指针指向的内存里的值已经被修改。
Go编译器使用了逃逸分析
来将对象分配到堆上,来解决上面的问题。
同样的程序
调用main
函数,Go在栈上分配内存
调用answer
函数,Go编译器知道在栈上为对象x分配内存是不安全的,此时会在堆上为对象x分配内存。
answer
执行完返回main
,此时指针n指向的是堆上的对象x
只有编译器知道对象应该被分配到哪里
我们无法决定对象被分配到栈还是堆,只有编译器知道。当编译器无法证明
函数内的局部在函数执行完后,不会被外部引用,编译器会将变量分配到堆上,避免悬挂指针
。
我们可以使用go build -gcflags="-m 1"来了解对象如何分配。
一个我们平时写程序时注意的地方
左边的程序,每次调用read
,都会在堆
上分配对象。
这其实也是为什么io.Reader
接口为什么将Read
定义为Read(p []byte)(n int, err error)
,而不是Read(n int) (b []byte, err error)
的原因。
参考资料
Understanding Allocations: the Stack and the Heap - GopherCon SG 2019