欢迎小伙伴们观看我的文章,若文中有不正确的地方,小伙伴们可以在评论区处指出,希望能够与小伙伴们一起成长与进步,谢谢!
一、内存管理
在Go的内存管理中,主要涉及两个方面,一是内存分配、二是内存回收(垃圾回收)。
内存回收主要是通过GC
来负责,而Go的内存分配,则是基于逃逸分析来实现分配。
Go语言的内存管理通过逃逸分析的内存分配,垃圾回收等机制,实现了高效、灵活的内存管理。使得Go在处理高并发、高性能的应用程序时具有很大的优势。了解Go的内存管理机制有助于编写更高效、更可靠的程序。
如果要了解逃逸分析,则首先需要了解Go的内存管理。
在Go程序中,程序中的数据和变量主要被程序所在的虚拟内存中,内存空间包含两个重要区域,分别是栈(stack)和堆(heap)。
1、栈
在Go中,栈的内存是由编译器自动进行分配与释放 ,函数调用的参数、返回值以及局部变量大都会被分配在栈上,它们随着函数的创建而分配,随着函数的退出而销毁。
Go应用程序运行时,每个 goroutine
都维护着一个自己的栈区,这个栈区只能由goroutine
本身使用,来为自己的参数、局部变量等来分配内存,且不能被其他 goroutine
使用。
具体来说,在程序启动时,Go运行时会为每个goroutine
(Go语言中的轻量级线程)分配一个初始的栈空间。默认情况下,每个goroutine
的栈大小为2KB
。
当goroutine
的函数调用深度增加,栈空间可能会被扩展以容纳更多的函数调用帧。同样地,当函数调用深度减少时,栈空间也可能会收缩。即一个栈通常包含了许多栈帧(stack frame),它描述的是函数之间的调用关系。
要注意的是,栈内存的大小是受限制的。在大多数操作系统中,每个进程的栈内存都有一个上限。如果栈空间不足以容纳更多的函数调用帧,将会触发栈溢出错误。
总之,Go语言的栈内存大小是动态确定的,并且可以根据需要进行调整,但受操作系统和运行时的限制。
2、堆
与栈的内存由编译器分配与释放不同,堆的内存一般由编译器和开发者共同进行管理与分配 ,堆区的内存释放则由GC来释放。堆中的对象由内存分配器分配,且由垃圾回收器回收。
程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配。在堆分配内存给变量数据时,必须找到一块足够大的内存来存放新的变量数据。
在释放内存时,垃圾回收器会周期性地检查堆内存中的对象,并释放不再被引用的对象所占用的内存空间。当堆内存中的对象增加时,垃圾回收器可能会触发堆扩展,以提供更多的可用内存。相反,如果堆内存中的对象减少,垃圾回收器可能会收缩堆内存并回收一部分空间。
3、栈与堆对比
内存管理
-
栈的内存由编译器自动管理,用于存储函数的局部变量和函数调用时的参数,不需要程序员手动操作;
-
堆内存由Go的垃圾回收器(Garbage Collector, GC)管理 ,用于存储动态分配的内存,如通过
new
、make
或append
等操作分配的内存。堆内存的大小在运行时可以动态变化;
生命周期
- 栈上的变量在函数调用结束后立即释放 ,生命周期与函数的调用周期同步;
- 堆上的变量的生命周期由垃圾回收器管理 ,直到它们不再被引用时才会被回收;
容量大小
- 栈的大小通常较小,因为它需要快速分配和释放内存,以支持大量函数调用。
- 堆的大小通常较大,因为它需要存储整个程序运行期间可能需要的动态分配的内存。
内存碎片
- 栈内存分配时是连续的,不会产生内存碎片。
- 堆内存分配时可能会产生内存碎片,尤其是在频繁分配和释放小块内存时。
访问速度上
- 栈上的内存访问速度通常比堆快,因为栈内存是连续的,且分配和释放操作相对简单。
- 堆上的内存访问速度可能较慢,因为内存分配可能涉及到内存碎片和垃圾回收的操作
性能上
- 栈内存管理 性能较好,因为栈上的内存只需要两个CPU指令:一个是分配入栈,另外一个是栈内释放。它的分配与释放非常高效。
- 堆内存管理 性能较差,因为对于堆上的内存回收工作,需要使用到标记清除,例如Go采用的三色标记法。
二、逃逸分析
1、逃逸分析介绍
在Go中,编译器是如何确定内存是分配到栈上还是堆上呢?
答案是:逃逸分析。
编译器通过逃逸分析技术来确定内存是分配在栈上,还是分配到堆上。
具体来说,Go语言中的逃逸分析(Escape Analysis是一种优化技术,它用于确定在函数中分配的对象是否会逃逸到堆上。如果一个对象被逃逸分析器确定为不会逃逸,那么它就可以在栈上分配,而不是在堆上。
逃逸分析的基本思想在于检查变量的生命周期是否是完全可知的,如果通过检查,则在栈上进行分配。反之产生逃逸,需要在堆上进行分配。
通过使用逃逸分析技术来合理的分配内存,好处在于能够减少了垃圾回收(GC)的开销 ,因为栈上的对象可以通过函数返回时直接释放,而不需要等待GC的周期性扫描。
2、逃逸分析原则
逃逸分析是Go语言编译器的一部分,它在编译时进行,而不是运行时进行。逃逸分析的结果会影响编译器如何生成代码,从而优化程序的内存使用和性能。
Go语言没有明确说明逃逸分析的原则,但逃逸分析可以通过以下一些基本准则来进行参考。
对象的生命周期
逃逸分析器会追踪对象的生命周期,确定对象在函数执行期间是否会被引用。如果对象在函数执行完毕后仍然被引用,那么它就会逃逸。
对象的引用
逃逸分析器会检查对象的引用链。
如果对象只被函数栈帧中的变量直接引用,那么它就不会逃逸,即对象在函数外部没有引用,则优先放到栈中。
如果对象被传递到其他函数或者被全局变量引用,那么它就会逃逸,即对象在函数外部存在引用,则必定放在堆中。
另外,在循环中,对象可能会被多次引用。逃逸分析器需要能够识别这种模式,以避免错误地将对象分配到堆上。
对象的内存大小
如果变量占用内存较大时,则变量会优先分配到堆中;
通过上述的一些基本准则,逃逸分析器会决定对象是否应该在堆上分配,还是在栈上分配。如果对象不会逃逸,它就可以在栈上分配,这样可以减少内存分配的次数,提高程序的性能。
需要注意的是,逃逸分析并不是万能的,它依赖于编译器的实现和编译时的上下文。在某些情况下,即使对象看起来不会逃逸,编译器也可能出于保守考虑将其分配到堆上。
此外,逃逸分析的结果可能会受到编译器优化策略的影响,不同的编译器版本可能会有不同的逃逸分析行为。
3、逃逸分析案例
分析逃逸分析结果时,可以使用go build -gcflags '-m -m -l'
来查看逃逸分析的结果。
(1)interface
类型
example
go
package main
import "fmt"
func main() {
num := 123
fmt.Println(num)
}
逃逸分析结果
原因分析
上述代码中,num
变量escapes
到了heap
堆内存中,原因在于Println
函数(Println(a ...interface{})
)的参数是interface{}
类型,在编译器无法确定其具体的数据类型,因此将该num
变量分配到了堆中。
(2)变量在函数外部引用
example
go
package main
func test() *int {
num := 100
return &num
}
func main() {
_ = test()
}
逃逸分析结果
原因分析
在上述test
函数中,将初始化后的num
变量的地址返回,因此变量num
在函数外可能存在引用。
当test
函数执行完毕,对应的栈帧就被销毁,但是变量num
的引用已经被返回到函数之外。如果这时外部通过引用地址取值,虽然地址还在,但函数栈的内存已经被释放回收了,即非法内存。
为了避免上述非法内存的情况,在这种情况下变量num
的内存分配必须分配到堆上。
(3)变量内存占用大
example
go
package main
func test() {
arr := make([]int, 10000, 10000)
for i := 0; i < 10000; i++ {
arr[i] = i
}
}
func main() {
test()
}
逃逸分析结果
原因分析
在test函数中,使用make初始化了一个容量10000的int类型的arr切片变量,由于arr切片占有的内存可能会比较大,因此发生了逃逸,arr切片的内存分配到了堆上(heap)。
当把切片的容量与长度修改为1时,再次查看逃逸分析的结果。
go
package main
func test() {
arr := make([]int, 1, 1)
for i := 0; i < 1; i++ {
arr[i] = i
}
}
func main() {
test()
}
查看逃逸分析结果可以看到,当修改了切片初始化的长度与容量后,arr
切片变量并没有发生逃逸。
因此,当变量占用内存较大时,会发生逃逸分析,将内存分配到堆上。
(4)变量大小不确定
example
go
package main
func test() {
len := 1
arr := make([]int, len, len)
for i := 0; i < 1; i++ {
arr[i] = i
}
}
func main() {
test()
}
逃逸分析结果
原因分析
上述代码中,arr
切片变量发生了逃逸,内存分配到了heap
堆中。虽然在make
初始化切片,事先定义了len
变量并赋值为1
作为切片的初始容量和长度,但在编译期间,只能识别到初始化int
类型切片时,传入的长度和容量为变量len
,编译期间并不能确定len
的值,因此arr
切片变量发生了逃逸分析,将内存分配到了堆中。
三、总结
通过理解逃逸分析,了解变量分配在栈与堆中的差别后,对于日后写出更好的程序应用提供了很好的帮助。
在日常开发中,根据逃逸分析,尽量写出内存分配在栈上的代码。堆中的内存分配减少后,可以有效减轻内存分配带来的开销以及减小GC的压力,提高程序的运行速度。
例如在某些场景下,不应该传递结构体指针,而应该直接传递结构体。虽然直接传递结构体需要值拷贝,但是这是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。
但凡事并不一定绝对,需要根据特定的场景来分析:
- 如果结构体较大,传递结构体指针更合适,因为指针类型相比值类型能节省大量的内存空间,避免内存过大的值拷贝;
- 如果结构体较小,传递结构体更适合,因为在栈上分配内存,可以有效减少GC压力;