Go 语言内存管理详解 | 青训营笔记

323 阅读14分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第5天

1. 手动内存管理

  面向对象的概念中,随着程序的运行,对象被加载到内存,从某个时刻开始,对象不会再被使用,将无用的对象移除的机制称为内存管理。

  在C语言中,程序员可以调用malloc和calloc函数来为对象分配内存,函数返回一个地址,指向对象在堆内存中的位置。当这个对象不被使用时,再调用free函数释放这块内存,这种内存管理的方式称为显式释放。

  这种内存管理的方式,将内存分配和内存释放的权限授权给程序员,如果程序员操作不当,会产生一定问题。(1)悬空指针:如果free调用过早,会导致该指针成为悬空指针,悬空指针是指不再指向内存中有效对象的指针。(2)内存泄露:程序员忘记释放一个对象,将会造成该对象一直占用内存的现象,成为内存泄露,如果存在大量内存泄露会导致程序变慢或直接崩溃。

  针对手动内存管理会造成的问题,在很多后续高级语言中,将内存管理机制设计成语言层面,程序员不用关心内存管理,而是由系统自动进行。Python、Ruby、Java和Go等语言使用自动的内存管理机制,称为垃圾回收机制。

2. Go内存布局和分配

2.1. 内存分配   Golang在处理高并发方面具有独特的优势,一方面得益于GMP模型,另一方面go的内存布局和分配机制也有起到很大作用。

2.1.1. 三大组件

Golang在内存分配的过程中,主要由三大组件所管理:mheap、mcentral、mcache。

mheap: Go 在程序启动时,首先会向操作系统申请一大块内存,并交由mheap结构全局管理。mheap 会将这一大块内存,切分成不同规格的小内存块,为 mspan,根据规格大小不同,mspan 大概有 70类左右,足以满足各种对象内存的分配。管理这么多大大小小规格的mspan,便有了mcentral。

mcentral: 启动一个 Go 程序,会初始化很多的 mcentral ,每个 mcentral 只负责管理一种特定规格的 mspan。但是 mcentral 在 Go 程序中是全局可见的,因此如果每次协程来 mcentral 申请内存的时候,都需要加锁。可以预想,如果每个协程都来 mcentral 申请内存,那频繁的加锁释放锁开销是非常大的。于是,便有了mcache。

mcache: 在一个 Go 程序里,每个线程M会绑定给一个处理器P,在单一粒度的时间里只能做多处理运行一个goroutine,每个P都会绑定一个叫 mcache 的本地缓存。当需要进行内存分配时,当前运行的goroutine会从mcache中查找可用的mspan。从本地mcache里分配内存时不需要加锁,这种分配策略效率更高。

   mcache 的 mspan 数量并不总是充足的,当供不应求的时候,mcache 会从 mcentral 再次申请更多的 mspan,同样的,如果 mcentral 的 mspan 数量也不够的话,mcentral 也会向它的上级 mheap 申请 mspan。再极端一点,如果 mheap 里的 mspan 也无法满足程序的内存申请,mheap 只能厚着脸皮跟操作系统申请。

2.1.2. 堆内存和栈内存

  根据内存管理(分配和回收)方式的不同,可以将内存分为 堆内存 和 栈内存。堆内存:由内存分配器和垃圾收集器负责回收,栈内存:由编译器自动进行分配和释放。

   一个程序运行过程中,也许会有多个栈内存,但肯定只会有一个堆内存。每个栈内存都是由线程或者协程独立占有,因此从栈中分配内存不需要加锁,并且栈内存在函数结束后会自动回收,性能相对堆内存好要高。而堆内存呢?由于多个线程或者协程都有可能同时从堆中申请内存,因此在堆中申请内存需要加锁,避免造成冲突,并且堆内存在函数结束后,需要 GC (垃圾回收)的介入参与,如果有大量的 GC 操作,将会使程序性能下降得历害。

  为了提高程序的性能,应当尽量减少内存在堆上分配,这样就能减少 GC 的压力。在判断一个变量是在堆上分配内存还是在栈上分配内存,虽然已经有前人已经总结了一些规律,但依靠程序员能够在编码的时候时刻去注意这个问题,对程序员的要求相当之高。好在 Go 的编译器,也开放了逃逸分析的功能,使用逃逸分析,可以直接检测出你程序员所有分配在堆上的变量(这种现象,即是逃逸)

2.2 函数调用栈

  按照编程语言语法编写的函数,会被编译器编译成一堆机器指令,写入可执行文件。程序执行时,可执行文件被加载到内存,这些机器指令对应到虚拟地址空间中,位于代码段。

  如果在一个函数中调用另一个函数,编译器会对应生成一条call指令(可执行文件),程序执行到call时就会跳转到被调用函数入口处开始执行;每个函数最后,都有一条ret指令,负责在函数结束后,跳回到调用处,继续执行。

2.2.1 栈帧布局

  内存空间:函数执行时,需要有足够内存空间,供它存放局部变量、返回值、参数等数据,即虚拟地址空间中的栈。运行时,栈上面时高地址,向下增长,分配给函数的栈空间,被称为函数栈帧。栈底通常称为栈基(bp),栈顶称为栈指针(sp)。

  程序执行时,CPU用特定寄存器来存储运行时栈基和栈指针,同时也有指令指针寄存器用于存储下一条要执行的指令地址。CPU读取一条指令(入栈3),会将指令指针移向下一条指令,栈指针向下移动(3存入函数栈帧中);CPU读取下一条指令(入栈4),会将指令指针移向下一条指令,栈指针向下移动(4存入函数栈帧中)。

  Golang中函数栈帧并不是这样逐步扩张的,而是一次性分配,即在分配栈帧时,直接将栈指针移动到所需最大栈空间位置(函数栈帧的大小,可以在编译期间确定),使用sp+偏移这种相对寻址方式使用函数栈帧。

  为什么要一次性分配内存,而不是逐步分配?主要是为了避免栈访问越界。对于栈消耗较大的函数,Golang编译器会在函数头部插入一段检测代码,如需进行栈增长,就会另分配一段足够大的栈空间,并把原来栈上的数据拷过来。原来的这段栈空间就会释放掉。

  Golang中栈帧布局从上到下依此是:调用者栈基,局部变量,被调用者返回值,被调用者参数。   call指令做两件事:(1)将下一条指令的地址入栈,即返回地址,被调用函数执行完后,回到这里,继续执行后面的指令。(2)跳转到被调用函数入口处执行。

2.2.2 函数跳转与返回

  函数跳转和返回是通过call指令和ret指令实现。例如,一个函数A在a1处调用b1处的函数B,执行过程如下。

  第一,执行到call指令会做两件事:(1)把下一条指令地址,a2入栈保存(2)指令指针,跳转到指令地址b1处。做完这两件事,call指令便结束了。

  第二,函数B开始执行。先把sp向下移动24个字节,为自己分配足够大的栈帧;bp寄存器的值保存到sp+16处;sp+16存入栈基寄存器,之后便可以执行函数剩下的指令了。在ret指令之前,编译器还会插入两条指令:(1)恢复调用者函数A的栈基地址(2)释放自己的栈帧空间,分配时向下移动多少,释放时就向上移动多少。

  第三,做完这两步操作,就到ret指令处了,ret指令也会做两件事:(1)弹出call指令压栈的返回地址。(2)跳转到这个返回地址。

  总结来说,首先,函数通过call指令实现跳转;其次,每个函数开始时会分配栈帧,结束前释放栈帧;最后,ret指令会把栈恢复成call之前的样子。

2.2.3. 传参

  Golang函数调用栈中,从高地址向下依次是:局部变量、返回值和参数。参数按照从右到左的顺序入栈,这样会方便使用sp+偏移的方式操作变量。

  Golang中参数传递都是值传递。如下swap()函数的例子可以很好说明问题。

// 该函数不可以实现交换功能
func swap(a, b int) {
	a, b = b, a
}

func main() {
	a, b := 1, 2
	swap(a, b)
	fmt.Println(a, b)
}

// 该函数可以实现交换功能
func swap(a, b *int) {
	*a, *b := *b, *a
}

func main() {
	a, b := 1, 2
	swap(&a, &b)
	fmt.Prinln(a, b)
}

2.2.4. 返回值

  通常我们认为返回值是通过寄存器传递的,然而Golang中支持多返回值,所以在栈上分配返回值空间更合适。在ret指令前,需要恢复调用者函数的栈基地址,释放自己的栈帧空间。如果被调用者函数中注册了defer函数,被调用者函数会先给返回值赋值,再执行defer函数。

  如果函数A中调用了两个函数B和C,但是B和C两个函数参数和返回值占用的空间并不相同,B和C会以最大的参数和返回值空间为标准来分配,才能满足所有被调用函数的需求。假设B调用完,执行C时,栈帧上面会空出很大一块空间,这样在使用sp+偏移进行寻址会方便很多。

3. Golang中垃圾回收机制

  今天的编程语言通常会使用手动和自动两种方式管理内存,C、C++ 以及 Rust 等编程语言使用手动的方式管理内存,工程师需要主动申请或者释放内存; Python、Ruby、Java 和 Go 等语言使用自动的内存管理系统,一般都是垃圾收集机制,不过 Objective-C 却选择了自动引用计数,虽然引用计数也是自动的内存管理机制,但是我们在这里不会详细介绍它,本节的重点还是垃圾收集。   相信很多人对垃圾收集器的印象都是暂停程序(Stop the world,STW),随着用户程序申请越来越多的内存,系统中的垃圾也逐渐增多;当程序的内存占用达到一定阈值时,整个应用程序就会全部暂停,垃圾收集器会扫描已经分配的所有对象并回收不再使用的内存空间,当这个过程结束后,用户程序才可以继续执行,Go 语言在早期也使用这种策略实现垃圾收集,但是今天的实现已经复杂了很多。 draveness.me/golang/docs…

  Golang 在V1.3版本之前使用标记清除(mark and sweep)机制;V1.5 版本引入了三色标记法;V1.8版本引入了三色标记法 + 混合写屏障机制。

3.1. 标记-清除法(mark and sweep)   mark and sweep方法是Golang V1.3版本之前使用的垃圾回收机制,也是最原始的垃圾回收机制。这种方法整体过程需要STW,效率极低。

流程:

暂停程序业务逻辑(stop the world)

将可达对象进行标记

清除垃圾

暂停STW,程序继续执行。 标记-清除法 缺点:

STW使程序暂停,程序出现卡顿。 标记过程,需要扫描的整个heap。 清除数据,会产生很多heap碎片。 3.2. 三色标记法   为了解决传统标记-清除法会导致长时间STW的问题,设计出了三色标记法。

三色标记法流程:

程序起初创建,全部标记为白色,将所有对象放入白色集合中。

将程序根节点展开

从第一层开始遍历,得到灰色对象集合

遍历灰色标记表,将可达对象变为灰色,结束后,自己变成黑色

循环上一步,直到灰色对象不存在。(最终目的是把灰色,全部变成黑色,灰色对象只是一个中间状态,白色的对象会被回收。)   如果三色标记不启动STW进行保护,会发生对象丢失现象: (1)一个白色对象被挂在黑色对象下。(2) 灰色对象同时丢了该白色对象。   以上两个条件同时满足,就会出现对象丢失现象。本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误,这种错误被称为悬挂指针。要做到在保证对象不丢失的情况下,尽可能提高GC效率。可以通过破坏上述两个条件来实现,于是,设计了强弱三色不变式。

强弱三色不变式

强三色不变式:强制性不允许黑色对象引用白色对象。(破坏了条件1) 弱三色不变式:黑色对象可以引用白色对象,但是要保证白色对象存在其他灰色对象对它的引用。(破坏了条件2)   如果三色标记中满足强/弱中的一项,即可保证对象不丢失。为实现强弱三色不变式,设计了屏障机制。

3.3. 屏障机制

  插入屏障: 对象被引用时,触发的机制。   删除屏障:对象被删除时,触发的机制。

插入写屏障

  当A对象引用B对象时,B对象被标记为灰色(将B挂在A下游,B必须被标记为灰色)。满足强三色不变式(不会存在黑色对象引用白色对象的形式了,因为白色会被强制变成灰色)。

  为了保证栈的速度,插入对象不在栈上使用。只有在堆上添加下游对象时,才会触发插入屏障。对于栈的情况,还是需要短暂的STW保护(10-100ms),不允许创建和删除新的对象,再扫描一遍所有对象。与纯粹的STW相比,已经很大程度上提高了性能。

删除写屏障

  被删除的对象,如果自身是灰色或者白色,那么被标记为灰色。满足弱三色不变式(保护灰色对象到白色对象的路径不会变) 回收精度比较低,一个对象即使被删除了,最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

混合写操作屏障

  插入写屏障的不足:结束时需要STW重新扫描栈,大约需要10-100ms;删除写屏障的不足:回收精度低,一个对象即使被删除了,最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

3.4. 三色标记法+混合写屏障机制

  Golang v1.8版本引入了三色标记法 + 混合写屏障机制

操作流程:

GC开始将栈上的对象全部扫描,并标记为黑色(之后不再进行第二次扫描,无需STW)。 GC期间,任何在栈上创建的新对象,均为黑色。 被删除的对象标记为灰色。 被添加的对象标记为灰色。 满足:变形的弱三色不变式(结合了插入、删除写屏障的两者的优点)。