堆内存与栈内存
Go 程序会在 2 个地方为变量分配内存,一个是全局的堆(heap)空间用来动态分配内存,另一个是每个 goroutine 的栈(stack)空间。与 Java、Python 等语言类似,Go 语言实现垃圾回收(Garbage Collector)机制,因此,Go 语言的内存管理是自动的,通常开发者不需要关心内存分配在栈上,还是堆上。但是从性能的角度出发,在栈上分配内存和在堆上分配内存,性能差异是非常大的。
栈
- 栈的内存是由编译器自动进行分配和释放的,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,随着函数的退出而销毁。
- Go应用程序运行时,每个 goroutine 都维护着一个自己的栈区,这个栈区只能自己使用不能被其他 goroutine 使用。(所以不需要加锁)栈是调用栈(call stack)的简称。一个栈通常又包含了许多栈帧(stack frame),它描述的是函数之间的调用关系
堆
- 堆区的内存一般由编译器和工程师自己共同进行管理分配,交给 Runtime GC 来释放。在堆上分配时,必须找到一块足够大的内存来存放新的变量数据。后续释放时,垃圾回收器扫描堆空间寻找不再被使用的对象。(所有有时候会有加锁的操作防止数据竞争)
在函数中申请一个对象,如果分配在栈中,函数执行结束时自动回收,如果分配在堆中,则在函数结束后某个时间点进行垃圾回收。
在栈上分配和回收内存的开销很低,在栈上分配内存,消耗的仅是将数据拷贝到内存的时间,而内存的 I/O 通常能够达到 30GB/s,因此在栈上分配内存效率是非常高的。
在堆上分配内存,一个很大的额外开销则是垃圾回收。Go 语言使用的是标记清除算法,并且在此基础上使用了三色标记法和写屏障技术,提高了效率。
函数参数是值传递的,且在调用的时立即执行值拷贝的。所以无论传递什么参数都会被copy到函数的参数变量的内存地址中,堆或者栈上,具体是堆还是栈上涉及到逃逸问题
什么是逃逸分析
逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为。
确定一个变量是在堆上还是在栈上 ?
- 是否有在其他地方(非局部)被引用。只要有可能被引用了,那么它一定分配到堆上。否则分配到栈上。
- 即使没有被外部引用,但对象过大,无法存放在栈区上。依然有可能分配到堆上。
比如这样的例子
代码语言:javascript复制func main() {
var i int
fmt.Printf("main: %pn", &i)
foo(i)
}
func foo(i int) {
fmt.Printf("foo : %pn", &i)
}
// 输出的变量地址不一样
main: 0xc0000382b0
foo : 0xc0000382b8
所以对于复杂结构应该尽量的传递指针减少copy时的开销。
指针传递的同时也带来变量逃逸,和GC压力,也是一把双刃剑,好在大部分情况下不需要特别的对GC进行调优。所以,在make it simple的理念下,在需要时再针对性调优是个不错的选择。
什么时候我们应该传递值,什么时候应该传递指针,这主要取决于copy开销和是否需要在函数内部对变量值进行更改。
指针逃逸
指针逃逸应该是最容易理解的一种情况了,即在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配在堆上。
代码语言:javascript复制// main.go
package main
import "fmt"
type Demo struct {
name string
}
func createDemo(name string) *Demo {
d := new(Demo) // 局部变量 d 逃逸到堆
d.name = name
return d
}
func main() {
demo := createDemo("demo")
fmt.Println(demo)
}
这个例子中,函数 createDemo
的局部变量 d
发生了逃逸。d 作为返回值,在 main 函数中继续使用,因此 d 指向的内存不能够分配在栈上,随着函数结束而回收,只能分配在堆上。
编译时可以借助选项 -gcflags=-m
,查看变量逃逸的情况:
go run -gcflags '-m' main.go
加 -l 了是为了不让Go 编译时自动内敛函数
代码语言:javascript复制go run - gcflags '-m -l' escape . go
代码语言:javascript复制# command-line-arguments
./main.go:13:18: moved to heap: userInfo
GetUserInfo函数里面的变量 userInfo 逃到堆上了(分配到堆内存空间上了)。 GetUserInfo 函数的返回值为 *UserData 指针类型,然后 将值变量userInfo 的地址返回,此时编译器会判断该值可能会在函数外使用,就将其分配到了堆上,所以变量userInfo就逃逸了。
interface{} 动态类型逃逸
在 Go 语言中,空接口即 interface{}
可以表示任意的类型,如果函数参数为 interface{}
,编译期间很难确定其参数的具体类型,也会发生逃逸。
func main() {
demo := createDemo("demo")
fmt.Println(demo)
}
demo
是 main 函数中的一个局部变量,该变量作为实参传递给 fmt.Println()
,但是因为 fmt.Println()
的参数类型定义为 interface{}
,因此也发生了逃逸。
对于 Go 语言来说,运行时(runtime) 尝试在 goroutine 需要的时候动态地分配栈空间,goroutine 的初始栈大小为 2 KB。当 goroutine 被调度时,会绑定内核线程执行,栈空间大小最也不会超过操作系统的限制。 对 Go 编译器而言,超过一定大小的局部变量将逃逸到堆上,不同的 Go 版本的大小限制可能不一样。
当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配。
发生逃逸的几种情况
- 在某个函数中new或字面量创建出的变量,将其指针作为函数返回值,则该变量一定发生逃逸(构造函数返回的指针变量一定逃逸);
- 被已经逃逸的变量引用的指针,一定发生逃逸;
- 被指针类型的slice、map和chan引用的指针,一定发生逃逸;一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
- slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
必然不会逃逸
- 指针被未发生逃逸的变量引用;
- 仅仅在函数内对变量做取址操作,而未将指针传出;
可能发生逃逸,也可能不会发生逃逸:
- 将指针作为入参传给别的函数;这里还是要看指针在被传入的函数中的处理过程,如果发生了上边的三种情况,则会逃逸;否则不会逃逸;
一些例子
例1
代码语言:javascript复制package main
type S struct{}
func main() {
var x S
y := &x
_ = *identity(y)
}
func identity(z *S) *S {
return z
}
代码语言:javascript复制 go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:10:15: leaking param: z to result ~r0 level=0
第一行是z变量是流经某个函数的意思,仅作为函数的输入,并且直接返回,在 identity()中也没有使用到 z的引用,所以变量没有逃逸。第二行, x在 main()函数中声明,所以是在 main()函数中的栈中的,也没有逃逸。
当然要是上面的例子,打印出 *identity(y) 的返回值,那肯定就是逃逸了。比如 例2
代码语言:javascript复制package main
import "fmt"
type S struct{}
func main() {
var x S
y := &x
c := *identity(y)
fmt.Println(c)
}
func identity(z *S) *S {
return z
}
代码语言:javascript复制 go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:13:15: leaking param: z to result ~r0 level=0
./main.go:11:13: ... argument does not escape
./main.go:11:14: c escapes to heap
那是否是不引用返回值就不逃逸了呢。不,一样的逃逸的,看下面这个例子
例3
代码语言:javascript复制package main
type S struct{}
func main() {
var x S
_ = *ref(x)
}
func ref(z S) *S { return &z }
代码语言:javascript复制 go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:9:10: moved to heap: z
ref()的参数 z是通过值传递的,所以 z是 main()函数中 x的一个值拷贝,而 ref()返回了 z的引用,所以 z不能放在 ref()的栈中,实际上被分配到了堆上。
例4
代码语言:javascript复制package main
type S struct{ M *int }
func main() {
var i int
refStruct(&i)
}
func refStruct(y *int) (z S) {
z.M = y
return z
}
代码语言:javascript复制 go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:9:16: leaking param: y to result z level=0
这个 y没有逃逸的原因是, main()中带着 i的引用调用了 refStruct()并直接返回了,从来没有超过 main()函数的调用栈
例5
代码语言:javascript复制package main
type S struct{ M *int }
func main() {
var x S
var i int
ref(&i, &x)
}
func ref(y *int, z *S) { z.M = y }
代码语言:javascript复制# command-line-arguments
./main.go:10:10: leaking param: y
./main.go:10:18: z does not escape
./main.go:7:6: moved to heap: i
y和 z没有逃逸很好理解,但问题在于 y还被赋值到函数 ref()的输入 z的成员了,而Go的逃逸分析不能跟踪变量之间的关系,不知道 i变成了 x的一个成员,分析结果说 i是逃逸的,但本质上 i是没逃逸的
例6 interface类型逃逸
代码语言:javascript复制package main
import "fmt"
func main() {
str := "str"
fmt.Printf("%p", &str)
}
代码语言:javascript复制# command-line-arguments
./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也逃逸到堆上,在堆上分配内存。
例7 闭包发生的逃逸
代码语言:javascript复制func Increase() func() int {
n := 0
return func() int {
n
return n
}
}
func main() {
in := Increase()
fmt.Println(in()) // 1
}
代码语言:javascript复制# command-line-arguments
./main.go:6:2: moved to heap: n
./main.go:7:9: func literal escapes to heap
./main.go:15:13: ... argument does not escape
./main.go:15:16: in() escapes to heap
函数也是一个指针类型,所以匿名函数当作返回值时也发生了逃逸,在匿名函数中使用外部变量n,这个变量n会一直存在直到in被销毁,所以n变量逃逸到了堆上。
例8 变量大小不确定以及栈空间不足引发逃逸
先使用ulimit -a查看操作系统的栈空间:
代码语言:javascript复制-t: cpu time (seconds) unlimited
-f: file size (blocks) unlimited
-d: data seg size (kbytes) unlimited
-s: stack size (kbytes) 8192
-c: core file size (blocks) 0
-v: address space (kbytes) unlimited
-l: locked-in-memory size (kbytes) unlimited
-u: processes 2784
-n: file descriptors 256
我的电脑是mac,栈大小是8192
代码语言:javascript复制package main
import (
"math/rand"
)
func LessThan8192() {
nums := make([]int, 8191) // < 64KB
for i := 0; i < len(nums); i {
nums[i] = rand.Int()
}
}
func MoreThan8192(){
nums := make([]int, 8192) // = 64KB
for i := 0; i < len(nums); i {
nums[i] = rand.Int()
}
}
func NonConstant() {
number := 10
s := make([]int, number)
for i := 0; i < len(s); i {
s[i] = i
}
}
func main() {
NonConstant()
MoreThan8192()
LessThan8192()
}
代码语言:javascript复制go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:8:14: make([]int, 100) does not escape
./main.go:15:14: make([]int, 1000000) escapes to heap
./main.go:23:11: make([]int, number) escapes to heap
当栈空间足够时,不会发生逃逸,但是当变量过大时,已经完全超过栈空间的大小时,将会发生逃逸到堆上分配内存。 同样当我们初始化切片时,没有直接指定大小,而是填入的变量,这种情况为了保证内存的安全,编译器也会触发逃逸,在堆上进行分配内存。
例10
代码语言:javascript复制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)
}
例11 变量类型不确定发生的逃逸
代码语言:javascript复制package main
import "fmt"
func main() {
a := 123
fmt.Println(a)
}
代码语言:javascript复制go run -gcflags '-m -l' main.go
# command-line-arguments
./main.go:8:13: ... argument does not escape
./main.go:8:14: a escapes to heap
变量a逃逸到了堆上。但是我们并没有外部引用,为什么也会有逃逸呢?为了看到更多细节,可以在语句中再添加一个-m参数。
代码语言:javascript复制 go run -gcflags '-m -m -l' main.go
# command-line-arguments
./main.go:7:14: a escapes to heap:
./main.go:7:14: flow: {storage for ... argument} = &{storage for a}:
./main.go:7:14: from a (spill) at ./main.go:7:14
./main.go:7:14: from ... argument (slice-literal-element) at ./main.go:7:13
./main.go:7:14: flow: {heap} = {storage for ... argument}:
./main.go:7:14: from ... argument (spill) at ./main.go:7:13
./main.go:7:14: from fmt.Println(... argument...) (call parameter) at ./main.go:7:13
./main.go:7:13: ... argument does not escape
./main.go:7:14: a escapes to heap
a逃逸是因为它被传入了fmt.Println的参数中,这个方法参数自己发生了逃逸。因为fmt.Println的函数参数为interface类型,编译期不能确定其参数的具体类型,所以将其分配于堆上。
源码位置
这里就暂时不贴了,可以链接过去直接看
大概就是说
片段中通过定义 labelState 常量和 func 方法来标记不需要增加循环深度的标签,并且给它们赋予 nonlooping 状态。
paramTag 函数,用于向函数参数添加逃逸分析信息。该函数首先获取参数名称,然后检查是否需要为当前函数生成诊断信息,以及该函数是否包含主体语句。
如果函数没有主体语句,则假定 uintptr 参数必须在调用期间保持活动状态,并设置 pragma 表示此信息。接着,如果参数类型不包含指针,则返回空字符串;否则,创建一个新的泄漏对象(leaks object)来表示参数可能逃逸的位置。如果函数被标记为“noescape”,则将堆位置添加到泄漏对象中;否则,在启用诊断的情况下生成一个警告并将堆位置添加到泄漏对象中。对于具有主体的函数,paramTag 函数从旧位置检索参数的现有逃逸分析信息,优化它,并将其分配给 leaks 变量。如果启用了诊断且参数没有逃逸,则会产生警告。如果参数逃逸到结果参数,则将显示带有逃逸级别的警告。最后,函数将泄漏对象编码为字符串并返回。
所以分析了这么多,函数传递指针真的比传值效率高吗?
- 传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。
如果想要减少垃圾回收的时间,提高程序性能,那就要尽量避免在堆上分配空间
总结一下
- 函数返回变量的指针时,这个变量会逃逸
- 当觉得栈上的空间不够时,会分配在堆上
- 在切片上存储指针或带指针的值的时候,对应的变量会逃逸
- chan里面的元素是指针的时候,也会发生逃逸
- map的value是指针的时候,也会发生逃逸
- 在interface类型上调用方法,也会发生逃逸
- 当给一个slice分配一个动态的空间容量时,也会发生逃逸
- 函数或闭包外声明指针,在函数或闭包内分配,也会发生逃逸
- 函数外初始化变量,函数内使用变量,然后返回函数,也会发生逃逸
- 被已经逃逸的指针引用的指针,也会发生逃逸
- 逃逸分析在编译阶段完成的
注意
- go run -gcflags ‘-m -m -l’ xx.main 不一定100%对,详情参考
参考
逃逸分析优化性能的论文 通过实例理解Go逃逸分析 逃逸分析对性能的影响