go变量生命周期
变量的生命周期指的是在程序运行期间变量有效存在的时间间隔。对于在包一级声明的变量来说,它们的生命周期和整个程序的运行周期是一致的。而相比之下,在局部变量的声明周期则是动态的:从每次创建一个新变量的声明语句开始,直到该变量不再被引用为止,然后变量的存储空间可能被回收。函数的参数变量和返回值变量都是局部变量。它们在函数每次被调用的时候创建。
那么垃Go语言的自动圾收集器是如何知道一个变量是何时可以被回收的呢?这里我们可以避开完整的技术细节,基本的实现思路是,从每个包级的变量和每个当前运行函数的每一个局部变量开始,通过指针或引用的访问路径遍历,是否可以找到该变量。如果不存在这样的访问路径,那么说明该变量是不可达的,也就是说它是否存在并不会影响程序后续的计算结果。
因为一个变量的有效周期只取决于是否可达,因此一个循环迭代内部的局部变量的生命周期可能超出其局部作用域。同时,局部变量可能在函数返回之后依然存在。
编译器会自动选择在栈上还是在堆上分配局部变量的存储空间,但可能令人惊讶的是,这个选择并不是由用var还是new声明变量的方式决定的。
var global *int
func f() {
var x int
x = 1
global = &x
}
func g() {
y := new(int)
*y = 1
}
f函数里的x变量必须在堆上分配,因为它在函数退出后依然可以通过包一级的global变量找到,虽然它是在函数内部定义的;用Go语言的术语说,这个x局部变量从函数f中逃逸了。相反,当g函数返回时,变量y将是不可达的,也就是说可以马上被回收的。因此,y并没有从函数g中逃逸,编译器可以选择在栈上分配*y的存储空间(译注:也可以选择在堆上分配,然后由Go语言的GC回收这个变量的内存空间),虽然这里用的是new方式。其实在任何时候,你并不需为了编写正确的代码而要考虑变量的逃逸行为,要记住的是,逃逸的变量需要额外分配内存,同时对性能的优化可能会产生细微的影响。
Go语言的自动垃圾收集器对编写正确的代码是一个巨大的帮助,但也并不是说你完全不用考虑内存了。你虽然不需要显式地分配和释放内存,但是要编写高效的程序你依然需要了解变量的生命周期。例如,如果将指向短生命周期对象的指针保存到具有长生命周期的对象中,特别是保存到全局变量时,会阻止对短生命周期对象的垃圾回收(从而可能影响程序的性能)。
QA
逃逸分析
Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 new、make 和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:
- 指向栈对象的指针不能存在于堆中;
- 指向栈对象的指针不能在栈对象回收后存活;
能引起变量逃逸到堆上的典型情况:
- 在方法内把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
- 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
- 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
- slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
- 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片 b 的背后存储都逃逸掉,所以会在堆上分配。
对GC影响?
- 逃逸分析在 Go 语言中主要作用是优化内存分配,避免不必要的堆分配,减少 GC 压力,提高程序性能。它通过决定哪些变量可以在栈上分配,哪些需要在堆上分配,帮助程序更加高效地使用内存。
栈分配
运行时会在 Goroutine 的初始化函数 runtime.malg 中调用 runtime.stackalloc 分配一个大小足够栈内存空间,根据线程缓存和申请栈的大小,该函数会通过三种不同的方法分配栈空间:
- 如果栈空间较小,使用全局栈缓存或者线程缓存上固定大小的空闲链表分配内存;
- 如果栈空间较大,从全局的大栈缓存
runtime.stackLarge中获取内存空间; - 如果栈空间较大并且
runtime.stackLarge空间不足,在堆上申请一片大小足够内存空间;
早期(分段式栈):支持动态扩展,但存在性能问题(热分裂)。
现代(栈复制):按需扩展、复制栈,提升性能,并结合 GC 确保指针安全。
未来:通过 Go 语言重写运行时,使其更好地适配并行垃圾回收和高效栈管理。
连续栈也就是分段式栈虽然解决了分段栈的2个问题,但这种实现方式也会带来其他问题:
- 更多的虚拟内存碎片。尤其是你需要更大的栈时,分配一块连续的内存空间会变得更困难
- 指针会被限制放入栈。在go里面不允许二个协程的指针相互指向。这会增加实现的复杂性。
栈还可以缩容: 栈的缩容主要是发生在GC期间。一个协程变成常驻状态,繁忙时需要占用很大的内存,但空闲时占用很少,这样会浪费很多内存,为了避免浪费Go在GC时对协程的栈进行了缩容,缩容也是分配一块新的内存替换原来的,大小只有原来的1/2。