目录
一、栈内存
1. Go栈内存的作用
Go语言中栈内存也叫协程栈或者调用栈,协程栈中第一个栈帧是goexit(),goexit()是为了退出后重新调度使用的,同时协程栈中还记录了协程的执行路径,例如下图中,do1()调用了do2()。
在函数中声明的局部变量,如果只是函数内部使用的话,那么这个变量会记录在协程栈里面。
Go语言中函数之间的参数传递,例如:
func do1() {
num := 1
num = do2(num)
fmt.Println(num)
}
func do2(num int) int {
num++
return num
}
do2(num int),需要一个参数,而这个参数是通过do1()传递给do2()的,那么他们之间的参数传递是通过栈内存来传递的。不仅函数传参通过栈内存进行传递的,函数的返回值也是通过栈内存传递的。
所以协程栈的作用:
- 记录协程的执行路径。
- 记录局部变量。
- 参数传递、返回值传递。
2.协程栈的位置
C/C++中栈区和堆区是分开的,堆上的内存需要程序员自己去释放,栈上的内存由程序释放,但是Go语言中栈内存是从堆内存上申请的,初始空间为2KB,所以说Go协程栈位于Go堆内存上,而Go堆内存位于操作系统虚拟内存上。
3.协程栈的结构
package main
func sum(a, b int) int {
sum := 0
sum = a + b
return sum
}
func main() {
a := 3
b := 5
fmt.Println(sum(a, b))
}
main()调用sum(a,b int)时需要传递参数,在栈帧里面参数的传递顺序是反的,传递参数时在自己的栈帧里开辟空间记录下要传递的参数,因为Go采用的是值传递。然后会记录sum(a,b int)返回后的指令,也就是上述代码中fmt.Println()。
运行sum(a,b int)函数时会首先在函数的栈帧中记录调用者的栈基址,意思就是当函数返回后需要返回到哪一个栈帧。当代码运行到sum = a + b,sum()函数会到main()函数的栈帧中寻找a、b的值,sum()函数返回时,会将返回值写回它的调用者的栈内存中预留的返回值空间,也就是上图中的sum函数返回值。
4. 协程栈填满的原因
由上述可知,协程栈记录了函数的执行路径(函数调用信息)和局部变量信息,所以协程栈被填满的原因就是函数调用太深或者局部变量太大。
如果局部变量太大导致协程栈空间不足,那么局部变量会逃逸到堆上。
如果函数调用太深倒是协程栈空间不足,那么会进行栈扩容。
协程在函数调用前会调用morestack判断栈空间是否足够,在调用函数之前要给下一个函数开辟新的栈空间,必要时对栈进行扩容,栈扩容的策略有分段栈和连续栈。
5.栈扩容策略
5.1 分段栈
在1.13之前栈扩容使用的是分段栈策略,如下图所示:
分段栈策略,如果栈空间不足那么会调用 newstack 创建一个新的栈空间,但是创建新的栈空间和原来的栈空间是不连续的,协程的多个栈空间之间会以双向链表的形式串联,通过指针找到这些栈空间。
分段栈的优点是没有空间浪费,能够按需为当前协程分配内存,并且及时减少内存的占用。
分段栈的缺点是分配的栈空间是不连续的,栈指针会在不连续的空间跳转,协程的栈空间处于填满状态时,新的函数调用都会触发栈扩容,会给新的函数开辟一个不连续的栈空间,当这个函数返回后新开辟的栈空间使用完毕就会触发栈收缩,如果此时函数调用特别频繁,那么会导致频繁的栈扩容和栈收缩,增加gc压力。
5.2 连续栈
1.13之后使用的栈扩容策略为连续栈,解决了分段栈开辟的栈空间不连续问题。
连续栈策略在协程的栈空间不足时,会调用 newstack 创建一块为原来栈空间两倍大小的栈空间,然后调用 copystack 将原来栈空间中的所有内容复制到新开辟的栈空间中,将指向旧栈对应变量的指针重新指向新栈(相同变量在栈扩容前后的地址发生变化),最后调用 stackfree 销毁并回收原来的栈空间。因此连续栈的缺点就是栈扩容时开销大,而优点是栈空间是连续的。
连续栈策略也有栈收缩的情况,当栈空间使用率不足1/4时,会收缩为原来的1/2。
二、堆内存
1. 基本概念
在64位机器上Go程序启动时,首先会向操作系统申请一块大小为64M的虚拟内存单元,也叫heapArena,最多可以有2的22次方个内存单元heapArena,所有的heapArena组成了Go的堆内存。假设物理内存为64G,操作系统给每个进程分配了256T的虚拟内存。
2.分级分配
heapArena是Go程序每次申请虚拟内存的单位,这些申请的虚拟内存单位就是Go的堆内存,堆内存由mheap管理。
为了避免出现内存碎片的情况,Go采取了分级分配的策略,根据对象的大小进行内存分配。mheap将这些申请的内存单元heapArena根据对象的大小切分成多个不同规格的小内存块,这些不同规格的内存块称为mspan。
3.mspan
mspan被划分为67种,代表着67种内置不同大小的内存块的mspan,每种mspan为N个相同大小的内存块class,可以满足各种对象的内存分配,而且每种mspan的大小也可能不相同。其中四种mspan示意图如下:
- class:代表每种mspan编号。
- bytes/obj:每种mspan中内存块的大小,可以理解为上图中每个格子的大小。
- byte/span:每种mspan的大小,占用堆的字节数。
- objects:每种mspan中包含多少个小内存块,可以理解为上图中每个mspan的格子数量。
完整的class表可以在runtime包下的sizeclasses.go中查看。
mspan数据结构:
type mspan struct {
next *mspan // next span in list, or nil if none
prev *mspan // previous span in list, or nil if none
startAddr uintptr // 起始地址
npages uintptr // span中包含的页数
nelems uintptr // 上图span中的class数量,代表可分配的内存块数量
allocCount uint16 // 已分配的内存块(格子)个数
spanclass spanClass // span中内存块的规格
elemsize uintptr // span中每一个内存块大小
allocBits *gcBits // 分配位图,代表mspan中每一个内存块的分配情况
gcmarkBits *gcBits // mspan中每一个内存块的标记情况
......
}
4. mcentral
为了管理这么多的mspan,于是有了mcentral,每个mcentral用于管理一种特定规格的mspan mcentral一共有136中。按理说mcentral是管理mspan的,mspan有67种,为什么mcentral却有136种呢,其实mspan又分为需要gc扫描和不需要gc扫描的,mspan还有一个class0,用于大对象分配。
mcentral 数据结构:
type mcentral struct {
spanclass spanClass
partial [2]spanSet // list of spans with a free object,空闲块span链表
full [2]spanSet // list of spans with no free objects,没有空闲块的span链表
}
mcentral数据结构中并没有锁,协程申请内存时需要向mcentral申请mpsan,此时会调用cacheSpan() *mspan,返回值是*mspan,申请到的mspan会加入到另一个结构中供协程使用,而调用cacheSpan() *mspan的过程中是需要加锁的,在高并发的场景下,多个协程并发的申请锁,锁的频繁加锁解锁的开销非常大,因此就需要刚才说的另一个结构来缓冲这种开销压力。
5. mcache
mcache相当于一个协程的本地队列,里面存储着从mcentral申请的mspan,协程向mcentral申请mspan需要加锁,为了避免多个协程申请内存不断加锁,于是引入了mcache,这个思想就是参考了GMP模型的本地协程队列。
mcentral是全局资源,为多个协程服务,当协程内存不足时会向mcentral申请。
mcache数据结构:
type mcache struct {
...
alloc [numSpanClasses]*mspan // 保存着申请到的mpsan, numSpanClasses = 68 << 1
...
}
mcache在初始化时是没有任何mspan的,在使用过程中会动态的从mcentral中申请兵保存至alloc 中,这样协程需要申请内存时,就直接从自己的本地缓存中获取mspan,避免了加锁。
mcache中相同类型的mspan通过链表连接。
6. 内存分配的过程
6.1 分配逻辑
Go分配内存前,会按照对象的大小进行不同的分配,分配逻辑如下:
- 0-16字节不包含指针的对象:Tiny微小对象分配,从mcache中拿到一个2级的mspan,将多个微对象合并成一个16B对象存入2级mspan。
- 0-16字节包含指针的对象和16B-32KB的对象:正常对象分配至mspan。
- 32KB以上的大对象:使用0级mspan分配,0级mspan没有固定大小,专为大对象分配。
6.2申请内存过程
- 获取当前协程的私有缓存mcache。
- 根据申请内存的大小计算出合适的mspan编号。
- 从mcache的成员alloc中查询可用的mspan。
- 如果mcache中没有可用的mspan,则从mcentral中申请一个新的mspan加入mcache中。
- 如果mcentral中也没有可用的mspan,则从mheap中获取一个新的mspan加入mcentral。
- 如果mheap也没有可用内存,那么就会向操作系统再次申请新的内存块heapArena供Go程序使用。
三、垃圾回收GC
1. 垃圾回收算法
引用计数
对每一个对象维护一个计数器,引用该对象的对象被销毁时,计数器减一,计数器减为0时回收对象,表示没有被引用了。
- 优点:对象可以很快被回收,不会出现内存耗尽才回收。
- 缺点:对于循环引用的处理并不友好。
标记清除
从根变量开始遍历所有引用的对象,引用对象标记为被引用,没有标记的对象被回收。
- 优点:解决了引用计数不能很好地处理循环引用的问题。
- 缺点:GC时需要暂停程序运行。
分代收集
按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,生命周期短的放入新生代,不同代有不同的回收算法。
- 优点:回收性能高。
- 缺点:算法复杂。
Go语言采用的是标记清除法,Go把它称作三色标记法。
2. 根对象
根对象是在垃圾回收的过程中最先被检查的对象,包括:
- 全局变量:全局变量存在于程序整个生命周期。
- 协程栈中的对象,或者从栈上逃逸到堆上的对象。
- 被寄存器中的指针引用的对象。
3. 三色标记法
三色只是为了方便描述而抽象出来的一种说法,实际上并没有颜色,所说的三种颜色指的是对象的三种状态。
- 白色:对象未被标记,在mspan中的gcmarkBits成员对应的位为0。
- 灰色:等待被表记的对象
- 黑色:对象被标记,在mspan中的gcmarkBits成员对应的位为1。
三色标记的过程:
假设根对象为A,内存中存在六个对象,分别是对象1-对象6,根对象A引用了对象2,而对象2引用了对象4。
第一步,初始所有对象都标记为白色。
第二步,从根对象A对其引用的对象进行扫描,扫描到的对象(对象2)标记为灰色。
第三步,将灰色对象(对象2)标记为黑色,把灰色对象(对象2)标记为黑色的同时,扫描其(对象2)引用的对象(对象4)标记为灰色。
第四步,将灰色对象(对象4)标记为黑色,同时扫描(对象4)其引用的对象,发现没有引用其他对象了,此时不存在灰色对象了。而对象1、对象3、对象5、对象6都是白色,对象2、对象4为黑色,当不存在灰色对象时表明标记过程结束,那么所有白色对象会被回收,黑色对象保留。
4. STW
STW全称为stop the world,意思是暂停程序的运行,在垃圾回收的过程中,如果不暂停程序的运行,指针传递会引起内存引用关系变化,如果错误的回收了还在使用的内存,带来的结果可能是灾难性的。例如下图中,A、B、C、D、E、F都被标记为黑色,而G、H为白色,那么G、H将会被回收,如果没有STW,此时程序继续运行,黑色对象突然又引用了G、H,而扫描过程已经结束了,那么就会错误的回收G、H这两个还在使用的对象。
因此在进行垃圾回收时,需要暂停程序的运行,专心做垃圾回收,等待垃圾回收结束后再恢复程序运行。
5.混合屏障
由于STW对程序的执行影响较大,对于一些应用是不可接受的,特别是WEB应用,所以Go也不断地在优化GC,提出了混合屏障,使得程序和GC同时运行。
5.1 删除屏障
灰色对象B引用了对象C,如下图:
GC扫描的过程,程序继续执行,此时程序移除了灰色对象B对白色对象C的引用,如下图:
程序继续执行,此时程序添加了黑色对象E对白色对象C的引用,如下图:
GC扫描的过程只会扫描根对象和灰色对象的引用,而不会扫描黑色对象的引用,所以上述情况会导致最终对象C被错误清除。
因此出现了删除屏障,删除屏障就是在GC扫描的过程中,对于引用被移除的对象(上图中的C对象),会立即置为灰色,保证其不会被错误清除。
所以上述过程变为了:
移除了B对象对C对象的引用:
删除屏障起作用,将被移除引用的C对象置灰:
程序添加了黑色对象E对白色对象C的引用:
最终保证了C对象不会被错误回收。
5.2 插入屏障
删除屏障保证了移除引用时,对象不会被错误清除,但是并不能保证所有情况下对象都不会被误回收。例如:
GC扫描的过程中,C对象不存在被谁引用,等待被删除:
GC扫描的过程中,程序继续执行,让黑色对象E新增了一个对C对象的引用:
所以C对象最后还是会被回收,于是就出现了插入屏障,插入屏障就是在GC扫描的过程中,对于新增引用的对象(上图中的C对象),会立即置为灰色,保证其不会被错误清除。
所以上述过程变为了:
新增引用,插入屏障起作用,对象C立即标记成灰色:
插入屏障最终保证了C对象不会被错误回收。
5.3 混合屏障
Go语言GC采用的是混合屏障来保证GC过程中对象不会被错误的回收,其实混合屏障就是两种写屏障——插入屏障和删除屏障一起使用。
四、内存逃逸
1.逃逸分析
在函数中申请的新对象:
- 如果函数外部没有引用,优先放入栈中;
- 如果函数外部存在引用,则放入堆中;
- 如果分配在协程栈上,那么函数执行结束后会自动回收内存。
- 如果是分配在堆上,那么这个对象由GC进行回收。
由于栈上分配内存比在堆中分配内存的效率更高,因为栈上分配的内存不需要GC处理,所以逃逸分析的目的就是把那些不需要分配到堆上的变量直接分配到栈上,提高程序效率。
2.逃逸场景
2.1 指针逃逸
package main
import "fmt"
type student struct {
name string
}
func NewStudent1(name string) *student {
s := &student{
name: name,
}
return s
}
func main() {
NewStudent2("hi")
}
使用命令go build -gcflags=-m查看其逃逸分析的过程:
发现逃逸到了堆上。虽然指针可以减少底层值的复制,可以提高效率,如果复制底层值的数据量较小,这种情况下还使用指针的话,可能会使对象逃逸到堆上,增加了GC负担。所以并不是所有情况都适合使用指针。
2.2 空接口逃逸
package main
import "fmt"
func main() {
num := 1
fmt.Println(num)
}
2.3 大对象逃逸
当协程栈空间不足以存放当前对象或者无法判断当前切片的长度时,会将对象分配到堆中,在64位机器上超过64KB的对象会发生逃逸。
package main
func makeArr() {
arr := make([]int, 1000, 1000)
for i := 0; i < len(arr); i++ {
arr[i] = i
}
}
func main() {
makeArr()
}
package main
func makeArr() {
arr := make([]int, 10000, 10000)
for i := 0; i < len(arr); i++ {
arr[i] = i
}
}
func main() {
makeArr()
}
总结
以上就是今天要讲的内容,本文介绍了Go语言内存管理,其中包括了内存分配、GC、逃逸分析。