Go语言设计与实现 学习笔记 第七章 内存管理(1)

7.1 内存分配器

程序中的数据和变量都会被分配到程序所在的虚拟内存中,内存空间包含两个重要区域——栈区(Stack)和堆区(Heap)。函数调用的参数、返回值、局部变量大都会被分配到栈上,这部分内存会由编译器进行管理;不同编程语言使用不同的方法管理堆区的内存,C++等编程语言会由工程师主动申请和释放内存,Go以及Java等编程语言会由工程师和编译器共同管理,堆中的对象由内存分配器分配并由垃圾收集器回收。

不同的编程语言会选择不同的方式管理内存,本节会介绍Go语言内存分配器,详细分析内存分配的过程以及其背后的设计与实现原理。

7.1.1 设计原理

内存管理一般包含三个不同的组件,分别是用户程序(Mutator)、分配器(Allocator)、收集器(Collector),当用户程序申请内存时,它会通过内存分配器申请新的内存,而分配器会负责从堆中初始化相应的内存区域。
在这里插入图片描述
Go语言的内存分配器实现非常复杂,在分析内存分配器的实现之前,我们需要了解内存分配的设计原理,帮助我们更快掌握内存的分配过程。这里将要详细介绍一般内存分配器的分配方法以及Go语言内存分配器的分级分配方法、虚拟内存布局、地址空间。

分配方法

编程语言的内存分配器一般包含两种分配方法,一种是线性分配器(Sequential Allocator或Bump Allocator),另一种是空闲链表分配器(Free-List Allocator),这两种分配方法有着不同的实现机制和特性,本节会依次介绍它们的分配过程。

线性分配器

线性分配(Bump Allocator)是一种高效的内存分配方法,但有较大局限性。当我们在编程语言中使用线性分配器,我们只需要在内存中维护一个指向内存特定位置的指针,当用户程序申请内存时,分配器只需要检查剩余的空闲内存、返回分配的内存区域、修改指针在内存中的位置,即移动下图中的指针:
在这里插入图片描述
根据线性分配器的原理,我们可以推测它有较快的执行速度,以及较低的实现复杂度;但是线性分配器无法在内存被释放时重用内存。如下图所示,如果已经分配的内存被回收,线性分配器是无法重新利用红色的这部分内存的:
在这里插入图片描述
正是因为线性分配器的这种特性,我们需要合适的垃圾回收算法配合使用。标记压缩(Mark-Compact)、复制回收(Copying GC)、分代回收(Generational GC)等算法可以通过拷贝的方式整理存活对象的碎片,将空闲内存定期合并,这样就能利用线性分配器的效率提升内存分配器的性能了。

因为线性分配器的使用需要配合具有拷贝特性的垃圾回收算法,所以C和C++等需要直接对外暴露指针的语言就无法使用该策略,我们会在下一节详细介绍常见垃圾回收算法的设计原理。

空闲链表分配器

空闲链表分配器(Free-List Allocator)可以重用已经被释放的内存,它在内部会维护一个类似链表的数据结构。当用户程序申请内存时,空闲链表分配器会依次遍历空闲的内存块,找到足够大的内存,然后申请新的资源并修改链表:
在这里插入图片描述
因为不同的内存块以链表的方式连接,所以使用这种方式分配内存的分配器可以重新利用回收的资源,但是因为分配内存时需要遍历链表,所以它的时间复杂度是O(n)。空闲链表分配器可以选择不同的策略在链表中的内存块中进行选择,最常见的是以下四种方式:
1.首次适应(First-Fit)——从链表头开始遍历,选择第一个大小大于申请内存的内存块;

2.循环首次适应(Next-Fit)——从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;

3.最优适应(Best-Fit)——从链表头遍历整个链表,选择最合适的内存块;

4.隔离适应(Segregated-Fit)——将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;

上述四种策略的前三种就不过多介绍了,Go语言使用的内存分配策略与第四种策略有些相似,我们通过下图了解一下该策略的原理:
在这里插入图片描述
如上图所示,该策略会将内存分割成由4、8、16、32字节的内存块组成的链表,当我们向内存分配器申请8字节的内存时,我们会在上图中的第二个链表找到空闲的内存块并返回。隔离适应的分配策略减少了需要遍历的内存块数量,提高了内存分配效率。

分级分配

线程缓存分配(Thread-Caching Malloc,TCMalloc)是用于分配内存的机制,它比glibc中的malloc函数还要快很多。Go语言的内存分配器就借鉴了TCMalloc的设计实现高速的内存分配,它的核心理念是使用多级缓存根据对象大小分类,并按照类别实施不同的分配策略。

对象大小

Go语言的内存分配器会根据申请分配的内存大小选择不同的处理逻辑,运行时根据对象的大小将对象分成微对象、小对象、大对象三种:
在这里插入图片描述
因为程序中的绝大多数对象的大小都在32KB以下,而申请的内存大小影响Go语言运行时分配内存的过程和开销,所以分别处理大对象和小对象有利于提高内存分配器的性能。

多级缓存

内存分配器不仅会区别对待大小不同的对象,还会将内存分成不同的级别分别管理,TCMalloc和Go运行时分配器都会引入线程缓存(Thread Cache)、中心缓存(Central Cache)、页堆(Page Heap)三个组件分级管理内存:
在这里插入图片描述
线程缓存属于每一个独立的线程,它能够满足线程上绝大多数的内存分配需求,因为不涉及多线程,所以也不需要使用互斥锁来保护内存,这能够减少锁竞争带来的性能损耗。当线程缓存不能满足需求时,就会使用中心缓存作为补充解决小对象的内存分配问题;在遇到32KB以上的对象时,内存分配器会选择页堆直接分配大量内存。

这种多层级的内存分配设计与计算机操作系统中的多级缓存也有些类似,因为多数的对象都是小对象,我们可以通过线程缓存和中心缓存提供足够的内存空间,发现资源不足时就从上一级组件中获取更多内存资源。

虚拟内存布局

这里会介绍Go语言堆区内存地址空间的设计以及演进过程,在Go语言1.10以前的版本,堆区的内存空间都是连续的;但是在1.11版本,Go团队使用稀疏的堆内存空间替代了连续的内存,解决了连续内存带来的限制以及在特殊场景下可能出现的问题。

线性内存

Go语言程序的1.10版本在启动时会初始化整片虚拟内存区域,如下所示的三个区域spansbitmaparena分别预留了512MB、16GB、512GB的内存空间,这些内存并不是真正存在的物理内存,而是虚拟内存:
在这里插入图片描述
1.spans区域存储了指向内存管理单元runtime.mspan的指针,每个内存管理单元会管理几页的内存空间,每页大小为8KB;

2.bitmap用于标识arena区域中的哪些地址保存了对象,位图中的每个字节都会表示堆区中的32字节是否包含空闲;

3.arena区域是真正的堆区,运行时会将8KB看做一页,这些内存页中存储了所有在堆上初始化的对象;

对于任意一个地址,我们都可以根据arena的基地址计算该地址所在页数,并通过spans数组获得管理该片内存的管理单元runtime.mspanspans数组中多个连续的位置可能对应同一个runtime.mspan

Go语言在垃圾回收时会根据指针的地址判断对象是否在堆中,并通过上一段介绍的过程找到管理该对象的runtime.mspan。这些都建立在堆区的内存是连续的这一假设上。这种设计虽然简单且方便,但是在C和Go混合使用时会导致程序崩溃:
1.分配的内存地址会发生冲突,导致堆的初始化和扩容失败;

2.没有被预留的大块内存可能被分配给C语言的二进制,导致扩容后的堆不连续;

线性的堆内存需要预留大块的内存空间,但是申请大块的内存空间而不使用是不切实际的,不预留内存空间却会在特殊场景下造成程序崩溃。虽然连续的内存实现比较简单,但是这些问题我们也没有办法忽略。

稀疏内存

稀疏内存是Go语言在1.11中提出的方案,使用稀疏的内存布局不仅能移除堆大小的上限,还能解决C和Go混合使用时的地址空间冲突问题。不过因为基于稀疏内存的内存管理失去了内存的连续性这一假设,这也使内存管理变得更加复杂:
在这里插入图片描述
如上图所示,运行时使用二维的runtime.heapArena数组管理所有的内存,每个单元都会管理64MB的内存空间:

type heapArena struct {
    bitmap [heapArenaBitmapBytes]byte
    spans [pagesPerArena]*mspan
    pageInUse [pagesPerArena / 8]uint8
    pageMarks [pagesPerArena / 8]uint8
    zeroedBase uintptr
}

该结构体中的bitmapspans与线性内存中的bitmapspans区域一一对应,zeroedBase字段指向了该结构体管理的内存的基地址。这种设计将原有的连续大内存切分成稀疏的小内存,而用于管理这些内存的元信息也被切分成了小块。

不同平台和架构的二维数组大小可能完全不同,如果我们的Go语言服务在Linux的x86-64架构上运行,二维数组的一维大小(指元素数量)会是1,而二维大小(指元素数量)是4194304(2的22次方),因为每一个指针占用8字节(2的3次方)的内存空间,所以元信息的总大小为32MB(2的25次方)。由于每个runtime.heapArena都会管理64MB(2的26次方)的内存,整个堆区最多可以管理256TB(2的48次方)的内存,这比之前的512GB多好几个数量级。

Go语言团队在1.11版本中通过以下几个提交将线性内存变成稀疏内存,移除了512GB的内存上限以及堆区内存连续性的假设:
1.runtime: use sparse mappings for the heap

2.runtime: fix various contiguous bitmap assumptions

3.runtime: make the heap bitmap sparse

4.runtime: abstract remaining mheap.spans access

5.runtime: make span map sparse

6.runtime: eliminate most uses of mheap_.arena_*

7.runtime: remove non-reserved heap logic

8.runtime: move comment about address space sizes to malloc.go

由于内存的管理变得更加复杂,上述改动对垃圾回收稍有影响,大约会增加1%的垃圾回收开销,不过这也是我们为了解决已有问题必须付出的成本。

地址空间

因为所有的内存最终都是要从操作系统中申请的,所以Go语言的运行时构建了操作系统的内存管理抽象层,该抽象层将运行时管理的地址空间分成以下四种状态:
1.None:内存没有被保留或映射,是地址空间的默认状态;

2.Reserved:运行时持有该地址空间,但是访问该内存会导致错误;

3.Prepared:内存被保留,一般没有对应的物理内存,访问该片内存的行为是未定义的,可以快速转换到Ready状态;

4.Ready:可以被安全访问;

每一个不同的操作系统都会包含一组特定的方法,这些方法可以让内存地址空间在不同的状态之间做出转换,我们可以通过下图了解不同状态之间的转换过程:
在这里插入图片描述
运行时中包含多个操作系统对状态转换方法的实现,所有的实现都包含在以mem_开头的文件中,本节将介绍Linux对上图中方法的实现:
1.runtime.sysAlloc会从操作系统中获取一大块可用的内存空间,可能为几百KB或几MB;

2.runtime.sysFree会在程序发生内存不足(Out-of Memory,OOM)时调用,并无条件地返回内存;

3.runtime.sysReserve会保留操作系统中的一片内存区域,对这片内存的访问会触发异常;

4.runtime.sysMap保证内存区域可以快速转换至准备就绪;

5.runtime.sysUsed通知操作系统应用程序需要使用该内存区域,需要保证内存区域可以安全访问;

6.runtime.sysUnused通知操作系统虚拟内存对应的物理内存已经不再需要了,它可以重用物理内存;

7.runtime.sysFault将内存区域转换成保留状态,主要用于运行时的调试;

运行时使用Linux提供的mmapmunmapmadvise等系统调用实现了操作系统的内存管理抽象层,抹平了不同操作系统的差异,为运行时提供了更加方便的接口,除了Linux之外,运行时还实现了BSD、Darwin、Plan9、Windows等平台上抽象层。

7.1.2 内存管理组件

Go语言的内存分配器包含内存管理单元、线程缓存、中心缓存、页堆几个重要组件,本节将介绍这几种最重要组件对应的数据结构runtime.mspanruntime.mcacheruntime.mcentralruntime.mheap,我们会详细介绍它们在内存分配器中的作用以及实现。
在这里插入图片描述
所有的Go语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器都会被分配一个线程缓存runtime.mcache用于处理微对象和小对象的分配,它们会持有内存管理单元runtime.mspan

每个类型的内存管理单元都会管理特定大小的对象,当内存管理单元中不存在空闲对象时,它们会从runtime.mheap持有的134个中心缓存runtime.mcentral中获取新的内存单元,中心缓存属于全局的堆结构体runtime.mheap,它(指中心缓存)会从操作系统中申请内存。

在amd64的Linux系统上,runtime.mheap会持有4194304个runtime.heapArena,每一个runtime.heapArena都会管理64MB的内存,单个Go语言的内存上限也就是256TB。

内存管理单元

runtime.mspan是Go语言内存管理的基本单元,该结构体中包含nextprev两个字段,它们分别指向了前一个和后一个runtime.mspan

type mspan struct {
    next *mspan
    prev *mspan
    // ...
}

串联后的上述结构体会构成如下双向链表,运行时会使用runtime.mSpanList存储双向链表的头节点和尾节点,并在线程缓存以及中心缓存中使用。
在这里插入图片描述
因为相邻的管理单元会相互引用,所以我们可以从任意一个结构体访问双向链表中的其他节点。

页和内存

每个runtime.mspan都管理npages个大小为8KB的页,这里的页不是操作系统中的内存页,它们是操作系统内存页的整数倍,该结构体会使用下面这些字段来管理内存页的分配和回收:

type mspan struct {
    startAddr uintptr // 起始地址
    npages    uintptr // 页数
    freeindex uintptr
    
    allocBits  *gcBits
    gcmarkBits *gcBits
    allocCache uint64
    // ...
}

1.startAddrnpages——确定该结构体管理的多个页所在的内存,每个页的大小是8KB;

2.freeindex——扫描页中空闲对象的初始索引;

3.allocBitsgcmarkBits——分别用于标记内存的占用和回收情况;

4.allocCache——allocBits的补码,可以用于快速查找内存中未被使用的内存;

runtime.mspan会以两种不同的视角看待管理的内存,当结构体管理的内存不足时,运行时会以页为单位向堆申请内存:
在这里插入图片描述
当用户程序或线程向runtime.mspan申请内存时,该结构会使用allocCache字段以对象为单位在管理的内存中快速查找待分配的空间:
在这里插入图片描述
如果我们能在内存中找到空闲的内存单元,就会直接返回,当内存中不包含空闲的内存时,上一级的组件runtime.mcache可能会为该结构体添加更多的内存页以满足为更多对象分配内存的需求。

状态

运行时会使用runtime.mSpanStateBox结构体存储内存单元的状态runtime.mSpanState

type mspan struct {
    ...
    state mSpanStateBox
    ...
}

该状态可能处于mSpanDeadmSpanInUsemSpanManualmSpanFree四种情况。当runtime.mspan在空闲堆中,它会处于mSpanFree状态;当runtime.mspan已经被分配时,它会处于mSpanInUsemSpanManual状态,这些状态会遵循以下规则进行转换:
1.在垃圾回收的任意阶段,可能从mSpanFree转换到mSpanInUsemSpanManual

2.在垃圾回收的清除阶段,可能从mSpanInUsemSpanManual转换到mSpanFree

3.在垃圾回收的标记阶段,不能从mSpanInUsemSpanManual转换到mSpanFree

设置runtime.mspan结构体状态的读写操作必须是原子的,以避免垃圾回收造成的线程竞争问题。

跨度类

runtime.spanClassruntime.mspan结构体的跨度类,它决定了内存管理单元中存储的对象大小和个数:

type mspan struct {
   ...
   spanclass spanClass
   ...
}

Go语言的内存管理模块中一共包含67种跨度类,每一个跨度类都对应一种特定大小的对象,同时规定了包含的页数和对象数,所有的数据都会被预先计算好存储在runtime.class_to_sizeruntime.class_to_allocnpages等变量中:
在这里插入图片描述
上表展示了对象大小从8B到32KB,总共66种跨度类的大小、存储的对象数、浪费的内存空间,以表中第四个跨度类为例,跨度类为4的runtime.mspan中对象的大小上限为48字节、管理一个页、最多可以存储170个对象。因为内存需要按照页进行管理,所以在尾部会浪费32字节的内存,当页中存储的对象都是33字节时(此处的33字节是因为,如果对象大小是32字节,那么应使用第三行的跨度类),最多会浪费31.52%的资源:
在这里插入图片描述
在这里插入图片描述
除了上述66个跨度类之外,运行时中还包含ID为0的特殊跨度类,它能够管理大于32KB的特殊对象,我们会在后面详细介绍大对象的分配过程。

跨度类中除了会存储类别ID之外,还会存储一个noscan标记位,该标记位表示对象是否包含指针,垃圾回收会对包含指针的runtime.mspan结构体进行扫描。我们可以通过下面的几个函数和方法了解ID和标记位的底层存储方式:

// 创建spanClass,它是一个uint8,前7位是大小
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
    return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

// 获取spanClass能存储的对象大小
func (sc spanClass) sizeclass() int8 {
    return int8(sc >> 1)
}

func (sc spanClass) noscan() bool {
    return sc&1 != 0
}

runtime.spanClass是一个uint8类型的整数,它的前7位存储着跨度类的ID,最后一位表示是否包含指针,该类型提供的两个方法能够帮我们快速获取对应的字段。

线程缓存

runtime.mcache是Go语言中的线程缓存,它会与线程上的处理器一一绑定,主要用来缓存用户程序申请的微小对象。每一个线程缓存都持有67*2个runtime.mspan,这些内存单元都存储在mcache结构体的alloc字段中:
在这里插入图片描述
线程缓存在刚被初始化时是不包含runtime.mspan的,只有当用户程序申请内存时才会从上一级组件获取新的runtime.mspan满足内存分配的需求。

初始化

运行时在初始化处理器时会调用runtime.allocmcache初始化线程缓存,该函数会在系统栈中使用runtime.mheap中的线程缓存分配器初始化新的runtime.mcache结构体:

// 分配一个mcache结构
func allocmcache() *mcache {
    var c *mcache
    // 在系统栈上运行函数
    systemstack(func() {
        lock(&mheap_.lock)
        // 为mcache结构分配内存
        c = (*mcache)(mheap_.cachealloc.alloc())
        c.flushGen = mheap_.sweepgen
        unlock(&mheap_.lock)
    })
    for i := range c.alloc {
        c.alloc[i] = &emptymspan
    }
    return c
}

就像我们上面提到的,初始化后的runtime.mcache中的所有runtime.mspan都是空的占位符emptyspan

替换

runtime.mcache.refill方法为线程缓存获取一个指定跨度类的内存管理单元,被替换的单元不能包含空闲的内存空间(即mcache的alloc字段中的mspan指针元素必须是空指针),而获取的单元中需要至少包含一个空闲对象用于分配内存:

func (c *mcache) refill(spc spanClass) {
    // 获取对应的mspan指针
    s := c.alloc[spc]
    // cacheSpan方法负责分配和回收跨度类,分配一个新的跨度类
    s = mheap_.central[spc].mcentral.cacheSpan()
    // 保存新的跨度类
    c.alloc[spc] = s
}

如上述代码所示,该函数会从中心缓存中申请新的runtime.mspan存储到线程缓存中,这也是向线程缓存中插入内存管理单元的唯一方法。

微分配器

线程缓存中还包含几个用于分配微对象的字段,下面这三个字段组成了微对象分配器,专门为16字节以下对象申请和管理内存:

type mcache struct {
    tiny             uintptr
    tinyoffset       uintptr
    local_tinyallocs uintptr
}

微分配器只会用于分配非指针类型的内存,上述三个字段中tiny会指向堆中的一片内存,tinyOffset是下一个空闲内存所在的偏移量,最后的local_tinyalloc会记录内存分配器中分配的对象个数。

中心缓存

runtime.mcentral是内存分配器的中心缓存,与线程缓存不同,访问中心缓存中的内存管理单元需要使用互斥锁:

type mcentral struct {
    lock      mutex
    spanclass spanClass
    nonempty  mSpanList
    empty     mSpanList
    nmalloc   uint64
}

每一个中心缓存都会管理某个跨度类的内存管理单元,它会同时持有两个runtime.mSpanList,分别存储包含空闲对象的链表和不包含空闲对象的链表:
在这里插入图片描述
结构体mcache在初始化时,两个链表都不包含任何内存,程序运行时会扩容结构体持有的两个链表,nmalloc字段也记录了该结构体中分配的对象个数(作者指的应该是mcache已经分配出去的对象个数)。

内存管理单元

线程缓存会通过中心缓存的runtime.mcentral.cacheSpan方法获取新的内存单元,该方法的实现比较复杂,我们可以将其分成以下几个部分:
1.从有空闲对象的runtime.mspan链表中查找可以使用的内存管理单元;

2.从没有空闲对象的runtime.mspan链表中查找可以使用的内存管理单元;

3.调用runtime.mcentral.grow从堆中申请新的内存管理单元;

4.更新内存管理单元的allocCache等字段来帮助快速分配内存;

首先我们会在中心缓存的非空链表nonempty中查找可用的runtime.mspan,根据sweepgen字段分别进行不同的处理:
1.当内存单元等待回收时,将其插入empty链表,调用runtime.mspan.sweep清理该单元并返回;

2.当内存单元正在被后台回收时,跳过该内存单元;

3.当内存单元已经被回收时,将内存单元插入empty链表并返回;

func (c *mcentral) cacheSpan() *mspan {
    // 获取当前的全局扫描代,这是一个全局计数器,用于追踪垃圾回收的周期
    sg := mheap_.sweepgen
retry:
    var s *mspan
    // 遍历nonempty链表
    for s = c.nonempty.first; s != nil; s = s.next {
        // 如果该mspan的sweepgen比当前的小2 && 原子地将sweepgen从sg-2更新为sg-1成功
        // 此时该mspan正等待被回收
        if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) { // 等待回收
            // 将该mspan从nonempty链表移动到empty链表
            s.nonempty.remove(s)
            c.empty.insertBack(s)
            // 清理该mspan
            s.sweep(true)
            goto havespan
        }
        // 如果该mspan正在回收中,跳过它
        if s.sweepgen == sg-1 { // 正在回收
            continue
        }
        // 此时mspan已被回收,将该mspan从nonempty链表移动到empty链表
        c.nonempty.remove(s) // 已经回收
        c.empty.insertBack(s)
        goto havespan
    }
    ...
}

如果中心缓存没有在nonempty中找到可用的内存管理单元,就会继续遍历其持有的empty链表,我们在这里的处理与包含空闲对象的链表几乎完全相同。当找到需要回收的内存单元时,也会触发runtime.mspan.sweep进行清理,如果清理后的内存单元仍然不包含空闲对象,就会重新执行相应的代码:

func (c *mcentral) cacheSpan() *mspan {
    ...
    // 遍历empty链表
    for s = c.empty.first; s != nil; s = s.next {
        // 同上,如果该mspan正等待被回收
        if s.sweepgen == sg-2 && atomic.Cas(&s.sweepgen, sg-2, sg-1) {
            // 从empty链表里移除该mspan
            c.empty.remove(s)
            // 清理该mspan
            s.sweep(true)
            // 找到该mspan里的下一个空闲对象位置
            freeIndex := s.nextFreeIndex()
            // 如果还有空闲位置
            if freeIndex != s.nelems {
                // 更新空闲位置
                s.freeindex = freeIndex
                goto havespan
            }
            goto retry // 不包含空闲对象
        }
        // 如果该mspan正在被扫描,则跳过它
        if s.sweepgen == sg-1 {
            continue
        }
        break
    }
    ...
}

如果runtime.mcentral在两个链表中都没有找到可用的内存单元,它会调用runtime.mcentral.grow触发扩容操作从堆中申请新的内存:

func (c *mcentral) cacheSpan() *mspan {
    ...
    // 获取一个新的mspan
    s = c.grow()
    if s == nil {
        return nil
    }
    // 将获取的mspan插入empty链表
    c.empty.insertBack(s)

havespan:
    // 计算mspan中还未被分配的元素数量
    n := int(s.nelems) - int(s.allocCount)
    // 增加已分配出去的元素个数
    atomic.Xadd64(&c.nmalloc, int64(n))
    // 如果启用了gc黑化
    if gcBlackenEnabled != 0 {
        // 对gc进行相应调整
        gcController.revise()
    }
    // 通过位清除将freeindex设为小于它的第一个64的整数倍
    freeByteBase := s.freeindex &^ (64 - 1)
    // 计算freeByteBase对应的字节
    whichByte := freeByteBase / 8
    // 在计算出的字节位置,将其填充为新的mspan
    s.refillAllocCache(whichByte)
    // 调整缓存,以便快速处理下一次分配请求
    s.allocCache >>= s.freeindex % 64
    
    return s
}

无论通过哪种方法获取到了内存单元,该方法的最后都会对内存单元的allocBitsallocCache等字段进行更新,让运行时在分配内存时能够快速找到空闲的对象。

扩容

中心缓存的扩容方法runtime.mcentral.grow会根据预先计算的class_to_allocnpagesclass_to_size获取待分配的页以及跨度类,然后调用runtime.mheap.alloc获取新的runtime.mspan结构:

func (c *mcentral) grow() *mspan {
    // 计算需要分配的页面数
    npages := uintptr(class_to_allocnpages[c.spanclass.sizeclass()])
    // 获取单个元素的大小
    size := uintptr(class_to_size[c.spanclass.sizeclass()])
    
    // 从页堆中分配npages个页面
    // 第三个参数为true,意味着需要清零内存
    s := mheap_.alloc(npages, c.spanclass, true)
    if s == nil {
        return nil
    }
    
    // 计算在新分配的内存中可以放置多少个元素
    // npages << _PageShift将页面数转换为字节
    n := (npages << _PageShift) >> s.divShift * uintptr(s.divMul) >> s.divShift2
    // 计算可用内存的上界
    // s.base是分配的内存的起始地址,size*n是内存块总大小
    s.limit = s.base() + size*n
    // 为新分配的mspan清除堆上的位图
    heapBitsForAddr(s.base()).initSpan(s)
    return s
}

获取了runtime.mspan之后,我们会在上述方法中初始化limit字段并清除该结构在堆上对应的位图。

页堆

runtime.mheap是内存分配的核心结构体,Go语言程序只会存在一个全局的结构,而堆上初始化的所有对象都由该结构体统一管理,该结构体中包含两组非常重要的字段,其中一个是全局的中心缓存列表central,另一个是管理堆区内存的arenas以及相关字段。

页堆中包含一个长度为134的runtime.mcentral数组,其中67个为跨度类需要scan的中心缓存(什么是需要scan?我的理解是其中对象包含指向其他对象的指针,在垃圾回收过程中需要被扫描以判断哪些是活跃的),另外的67个是noscan的中心缓存(这句话中的数字应该会随Go版本演进):
在这里插入图片描述
在这里插入图片描述
我们在设计原理一节中介绍过,Go所有的内存空间都是由下图所示的二维矩阵runtime.heapArena管理的,这个二维矩阵管理的内存可以是不连续的:
在这里插入图片描述
在除了Windows以外的64位系统中,每个runtime.heapArena都会管理64MB的内存空间,如下所示的表格展示了不同平台上Go语言程序管理的堆区大小以及runtime.heapArena占用的内存空间:
在这里插入图片描述
本节将介绍页堆的初始化、内存分配、内存管理单元分配的过程,这些过程能帮助我们理解全局变量页堆与其他组件的关系,以及它管理内存的方式。

初始化

堆区的初始化会使用runtime.mheap.init方法,我们能看到该方法初始化了很多结构体和字段,其中初始化的两类变量比较重要:
1.spanalloccacheallocarenaHintAlloc等类型为runtime.fixalloc的空闲链表分配器;

2.central切片中类型为runtime.mcentral的中心缓存;

func (h *mheap) init() {
    // 初始化spanalloc字段,它用于分配mspan结构
    h.spanalloc.init(unsafe.Sizeof(mspan{}), recordspan, unsafe.Pointer(h), &memstats.mspan_sys)
    // 初始化cachealloc字段,它用于分配mcache结构,下面同理
    h.cachealloc.init(unsafe.Sizeof(mcache{}), nil, nil, &memstats.mcache_sys)
    h.specialfinalizeralloc.init(unsafe.Sizeof(specialfinalizer{}), nil, nil, &memstats.other_sys)
    h.specialprofilealloc.init(unsafe.Sizeof(specialprofile{}), nil, nil, &memstats.other_sys)
    h.arenaHintAlloc.init(unsafe.Sizeof(arenaHint{}), nil, nil, &memstats.other_sys)
    
    h.spanalloc.zero = false
    
    // 初始化mheap中的所有mcentral结构,mcentral结构用于管理特定大小类的mspan
    for i := range h.central {
        h.central[i].mcentral.init(spanClass(i))
    }
    
    // 初始化页级内存管理相关的数据结构
    h.pages.init(&h.lock, &memstats.gc_sys)
}

堆中初始化的多个空闲链表分配器与我们在设计原理一节中提到的分配器没有太多区别,当我们调用runtime.fixalloc.init初始化分配器时,需要传入待初始化的结构体大小等信息,这会帮助分配器分割待分配的内存,该分配器提供了以下两个用于分配和释放内存的方法:
1.runtime.fixalloc.alloc——获取下一个空闲的内存空间;

2.runtime.fixalloc.free——释放指针指向的内存空间;

除了这些空闲链表分配器外,我们还会在该方法(指mheap.init)中初始化所有的中心缓存,这些中心缓存会维护全局的内存管理单元,各个线程会通过中心缓存获取新的内存单元。

内存管理单元

runtime.mheap是内存分配器中的核心组件,运行时会通过它的runtime.mheap.alloc方法在系统栈中获取新的runtime.mspan

func (h *mheap) alloc(npages uintptr, spanclass spanClass, needzero bool) *mspan {
    var s *mspan
    // 系统栈上执行函数
    systemstack(func() {
        // 如果还未完成内存清理
        if h.sweepdone == 0 {
            // 尝试回收一些内存
            h.reclaim(npages)
        }
        // 分配mspan结构
        s = h.allocSpan(npages, false, spanclass, &memstats.heap_inuse)
    })
    ...
    return s
}

为了阻止内存的大量占用和堆的增长,我们在分配对应页数的内存前需要先调用runtime.mheap.reclaim方法回收一部分内存,接下来我们通过runtime.mheap.allocSpan分配新的内存管理单元,我们将该方法的执行过程拆分成两个部分:
1.从堆上分配新的内存页和内存管理单元runtime.mspan

2.初始化内存管理单元并将其加入runtime.mheap持有内存单元列表;

首先我们需要在堆上申请npages数量的内存页并初始化runtime.mspan

func (h *mheap) allocSpan(npages uintptr, manual bool, spanclass spanClass, sysStat *uint64) (s *mspan) {
    // 获取当前Goroutine
    gp := getg()
    // base是分配的内存基址
    base, scan := uintptr(0), uintptr(0)
    // 获取当前处理器P的指针
    pp := gp.m.p.ptr()
    // 如果P存在 && 所需页数小于页缓存的1/4
    if pp != nil && npages < pageCachePages/4 {
        // 尝试从P的页缓存中分配内存
        c := &pp.pcache
        base, scav = c.alloc(npages)
        // 如果基址分配成功
        if base != 0 {
            // 尝试分配mspan
            s = h.tryAllocMSpan()
            // 如果mspan分配成功 && 没有启用GC压缩 && (手动分配 || span类别不为0)
            if s != nil && gcBlakenEnabled == 0 && (manual || spanclass.sizeclass() != 0) {
                goto HaveSpan
            }
        }
    }
    
    // 如果基址还未分配
    if base == 0 {
        // 尝试从mheap的页管理器中(页堆中)分配内存
        base, scan = h.pages.alloc(npages)
        // 如果没有足够空间
        if base == 0 {
            // 尝试增长堆并重新分配
            h.grow(npages)
            base, scan = h.pages.alloc(npages)
            // 如果再次失败,则抛出异常
            if base == 0 {
                throw("grew heap, but no adequate free space found")
            }
        }
    }
    // 如果mspan还没分配成功
    if s == nil {
        // 尝试在锁定状态下获取mspan
        s = h.allocMSpanLocked()
    }
    ...
}

上述方法会通过处理器的页缓存runtime.pageCache或全局的页分配器runtime.pageAlloc两种途径从堆中申请内存:
1.如果申请的内存比较小,获取申请内存的处理器,然后尝试调用runtime.pageCache.alloc获取内存区域的基地址和大小;

2.如果申请的内存比较大或线程的页缓存中内存不足,会通过runtime.pageAlloc.alloc在页堆上申请内存;

3.如果发现页堆上的内存不足,会尝试通过runtime.mheap.grow进行扩容并重新调用runtime.pageAlloc.alloc申请内存:
(1)如果申请到内存,意味着扩容成功;

(2)如果没有申请到内存,意味着扩容失败,宿主机可能不存在空闲内存,运行时会直接中止当前程序;

无论通过哪种方式获得内存页,我们都会在allocSpan函数中分配新的runtime.mspan结构体;alloc方法的剩余部分会通过页数、内存空间、跨度类等参数初始化通过allocSpan函数获取的mspan的多个字段:

func (h *mheap) alloc(npages uintptr, spanclass spanClass, needzero bool) *mspan {
    ...
HaveSpan:
    // 初始化mspan
    s.init(base, npages)
    
    ...
    
    // 下一个空闲内存块的索引从0开始
    s.freeindex = 0
    // 将allocCache设为全1,这是一个位图,全1表示全是空闲位
    s.allocCache = ^uint64(0)
    // 创建一个位图,用于标识哪些块已经被垃圾回收标记
    s.gcmarkBits = newMarkBits(s.nelems)
    // 创建一个位图,用于标识哪些块已被分配
    s.allocBits = newAllocBits(s.nelems)
    // 将mspan插入堆的spans数组中
    h.setSpans(s.base(), npages, s)
    return s
}

在上述代码中,我们通过调用runtime.mspan.init方法以及设置字段值来初始化刚刚分配的runtime.mspan结构,并通过runtime.mheaps.setSpans方法建立页堆与内存单元的联系。

扩容

runtime.mheap.grow方法会向操作系统申请更多的内存空间,我们将该方法的执行过程分成以下几个部分:
1.通过传入的页数获取期望分配的内存空间大小以及内存的基地址;

2.如果arena区域没有足够的空间,调用runtime.mheap.sysAlloc从操作系统中申请更多内存;

3.扩容runtime.mheap持有的arena区域并更新页分配器的元信息;

4.在某些场景下,调用runtime.pageAlloc.scavenge回收不再使用的空闲内存页;

在页堆扩容的过程中,runtime.mheap.sysAlloc是页堆用来申请虚拟内存(有了虚拟内存,我们就能将进程的地址空间与内存解耦,这样就可以结合磁盘来支持更大地址空间,当然也不一定必须要有磁盘才叫虚拟内存,它只是一种解耦方式)的方法,我们会分几部分介绍该方法的实现。首先,该方法会尝试在预保留的区域申请内存:

func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
    // 将n向上对齐到heapArenaBytes的整数倍
    n = alignUp(n, heapArenaBytes)
    
    // 从堆中进行内存分配
    v = h.arena.alloc(n, heapArenaBytes, &memstats.heap_sys)
    // 如果分配成功
    if v != nil {
        size = n
        goto mapped
    }
    ...
}

上述代码会调用线性分配器的runtime.linearAlloc.alloc方法在预先保留的内存中申请一块可以使用的空间。如果没有可用空间,我们会根据页堆的arenaHints在目标地址上尝试扩容:

func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
    ...
    // 遍历mheap.arenaHints链表,每个链表节点都保存了内存分配的提示地址,用于尝试在某些特定地址分配内存
    for h.arenaHints != nil {
        // 获取当前的提示节点
        hint := h.arenaHints
        // 从提示节点中获取要分配的内存地址
        p := hint.addr
        // 尝试在p处预留n字节内存
        v = sysReserve(unsafe.Pointer(p), n)
        // 如果预留成功
        if p == uintptr(v) {
            // 这里作者代码抄漏了,实际此处还会更新p的值为p+n
            // 更新提示节点的addr字段,表示p到p+n的地址已被使用
            hint.addr = p
            size = n
            break
        }
        // 此处说明分配失败,获取下一个提示节点
        h.arenaHints = hint.next
        // 释放当前提示节点
        h.arenaHintAlloc.free(unsafe.Pointer(hint))
    }
    ...
    // 将虚拟内存映射到物理内存中
    sysMap(v, size, &memstats.heap_sys)
    ...
}

runtime.sysReserveruntime.sysMap是上述代码的核心部分,它们会从操作系统中申请内存并将内存转换至Prepared状态。

func (h *mheap) sysAlloc(n uintptr) (v unsafe.Pointer, size uintptr) {
    ...
mapped:
    // 遍历从v到v+size-1下标的arena,这个遍历处理刚刚分配的内存块跨多个arena的情况
    for ri := arenaIndex(uintptr(v)); ri <= arenaIndex(uintptr(v)+size-1); ri++ {
        // 获取mheap的arenas数组的二级索引l2
        l2 := h.arenas[ri.l1()]
        // 分配一个heapArena结构
        r := (*heapArena)(h.heapArenaAlloc.alloc(unsafe.Sizeof(*r), sys.PtrSize, &memstats.gc_sys))
        ...
        // 扩展allArenas列表
        h.allArenas = h.allArenas[:len(h.allArenas)+1]
        // 在列表最后位置添加当前arena索引
        h.allArenas[len(h.allArenas)-1] = ri
        // 原子地将刚分配的heapArena结构添加到二级索引l2的位置
        atomic.StoreNoWB(unsafe.Pointer(&l2[ri.l2()]), unsafe.Pointer(r))
    }
    return
}

runtime.mheap.sysAlloc方法在最后会初始化一个新的runtime.heapArena结构体来管理刚刚申请的内存空间,该结构体会被加入页堆的二维矩阵中。

7.1.3 内存分配

堆上所有的对象都会通过调用runtime.newobject函数分配内存,该函数会调用runtime.mallocgc分配指定大小的内存空间,这也是用户程序向堆上申请内存空间的必经函数:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // 获取当前操作系统线程M的控制结构
    mp := acquirem()
    // 标记当前M正在进行内存分配,可以防止调度器在内存分配期间进行上下文切换
    mp.mallocing = 1
    
    // 获取当前处理器P的内存缓存,用来快速分配小对象
    c := gomcache()
    var x unsafe.Pointer
    // 是否需要扫描,垃圾回收时,会扫描类型中的指针
    // 如果没有类型信息或不含指针,则不需扫描
    noscan := typ == nil || typ.ptrdata == 0
    if size <= maxSmallSize {
        if noscan && size < maxTinySize {
            // 微对象分配
        } else {
            // 小对象分配
        }
    } else {
        // 大对象分配
    }
    
    // 用于内存栅栏,确保内存写入的可见性
    publicationBarrier()
    mp.mallocing = 0
    // 将当前M不再与P绑定,这样调度器可以将这个M执行其他G
    releasem(mp)
    
    return x
}

上述代码使用runtime.gomcache获取了线程缓存并判断类型是否为指针类型。我们从这个代码片段可以看出runtime.mallocgc会根据对象的大小执行不同的分配逻辑,在前面的章节也曾经介绍过运行时根据对象大小将它们分成微对象、小对象、大对象,这里会根据大小选择不同的分配逻辑:
在这里插入图片描述
1.微对象(0, 16B)——先使用微型分配器,再依次尝试线程缓存、中心缓存、堆分配缓存;

2.小对象[16B, 32KB]——依次尝试使用线程缓存、中心缓存、堆分配缓存;

3.大对象(32KB, +∞)——直接在堆上分配内存;

我们会依次介绍运行时分配微对象、小对象、大对象的过程,梳理内存分配的核心执行流程。

微对象

Go语言运行时将小于16字节的对象划分为微对象,它会使用线程缓存上的微分配器提高微对象分配的性能,我们主要使用它来分配较小的字符串以及逃逸的临时变量。微分配器可以将多个较小的内存分配请求合入同一个内存块中,只有当内存块中的所有对象都需要被回收时,整片内存才可能被回收。

微分配器管理的对象不可以是指针类型,管理多个对象的内存块大小maxTinySize是可以调整的,在默认情况下,内存块的大小为16字节。maxTinySize的值越大,组合多个对象的可能性就越高,内存浪费也就越严重;maxTinySize越小,内存浪费就会越少,不过无论如何调整,8的倍数都是一个很好的选择。
在这里插入图片描述
如上图所示,微分配器已经在16字节的内存块中分配了12字节的对象,如果下一个待分配的对象小于4字节,它就会直接使用上述内存块的剩余部分,减少内存碎片,不过该内存块只有在3个对象都被标记为垃圾时才会被回收。

线程缓存runtime.mcache中的tiny字段指向了maxTinySize大小的块,如果当前块中还包含大小合适的空闲内存,运行时会通过基地址和偏移量获取并返回这块内存:

func malloc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size <= maxSmallSize {
        if noscan && size < maxTinySize {
            // 获取当前微对象区域偏移量
            off := c.tinyoffset
            // 如果微对象区还有足够的空闲内存
            if off+size <= maxTinySize && c.tiny != 0 {
                // 计算要分配的内存的起始地址
                x = unsafe.Pointer(c.tiny + off)
                // 更新微对象区域偏移
                c.tinyoffset = off + size
                // 更新当前线程缓存中的微对象数量
                c.local_tinyallocs++
                releasem(mp)
                return x
            }
            ...
        }
        ...
    }
    ...
}

当内存块(指上面代码中的mcache)中不包含空闲的内存时,下面这段代码会先从线程缓存找到跨度类对应的内存管理单元runtime.mspan,调用runtime.nextFreeFast获取空闲内存;当不存在空闲内存时,会调用runtime.mcache.nextFree从中心缓存或页堆中获取可分配的内存块:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size <= maxSmallSize {
        if noscan && size < maxTinySize {
            ...
            // 获取tinySpanClass对应的span
            span := c.alloc[tinySpanClass]
            // 从指定span中快速获取一个空闲块
            v := nextFreeFast(span)
            // 如果没有获取到
            if v == 0 {
                // 使用更慢但一定能获取到的方法来获取
                v, _, _ = c.nextFree(tinySpanClass)
            }
            // 显式将前16字节清零
            x = unsafe.Pointer(v)
            (*[2]uint64)(x)[0] = 0
            (*[2]uint64)(x)[1] = 0
            // 如果要分配的大小小于当前微对象区域偏移 || 当前tiny指针为空
            // 这是基于当前剩余的空闲空间来决定是否替换tiny block
            if size < c.tinyoffset || c.tiny == 0 {
                // 重置微对象区域为新获取的块
                c.tiny = uintptr(x)
                // 更新微对象区域偏移为新分配的size大小
                c.tinyoffset = size
            }
            size = maxTinySize
        }
        ...
    }
    ...
    return x
}

在上述代码片段中,我们会重点分析两个函数和方法的实现原理,它们分别是runtime.nextFreeFastruntime.mcache.nextFree,这两个函数会帮助我们获取空闲的内存空间。runtime.nextFreeFast会利用内存管理单元中的allocCache字段,快速找到该字段中位1的位数,我们在上面介绍过1表示该位对应的内存空间是空闲的:

func nextFreeFast(s *mspan) gclinkptr {
    // 获取allocCache中第一个被设置为0的位的索引
    theBit := sys.Ctz64(s.allocCache)
    // 如果空闲位的索引小于64
    if theBit < 64 {
        // 计算实际的空闲位置索引
        result := s.freeindex + uintptr(theBit)
        // 如果空闲位置不超过mspan中的元素总数
        if result < s.nelems {
            // 准备下一次搜索的起始位置
            freeidx := result + 1
            // 如果下一次搜索的位置是末尾索引(64的倍数) && freeidx不是nelems(超出有效范围)
            if freeidx%64 == 0 && freeidx != s.nelems {
                return 0
            }
            // 将所有位清0
            s.allocCache >>= uint(theBit + 1)
            // 更新freeidx为新的搜索起点
            s.freeindex = freeidx
            // 增加mspan的分配计数
            s.allocCount++
            // 返回计算出的空闲块的实际内存地址
            return gclinkptr(result*s.elemsize + s.base())
        }
    }
    return 0
}

找到了空闲的对象后,我们就可以更新内存管理单元的allocCachefreeindex等字段并返回该片内存了;如果我们没有找到空闲的内存,运行时会通过runtime.mcache.nextFree找到新的内存管理单元:

func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {
    // 获取对应类别的mspan
    s = c.alloc[spc]
    // 获取mspan中下一个空闲位置的索引
    freeIndex := s.nextFreeIndex()
    // 如果所有内存块都已被分配
    if freeIndex == s.nelems {
        // 重新填充替换指定类型的mspan
        c.refill(spc)
        // 填充后获取新的mspan和freeindex
        s = c.alloc[spc]
        freeIndex = s.nextFreeIndex()
    }
    
    // 计算具体的内存块地址
    v = gclinkptr(freeIndex*s.elemsize + s.base())
    // 增加分配的内存块数量
    s.allocCount++
    return
}

在上述方法中,如果我们在线程缓存中没有找到可用的内存管理单元,会通过前面介绍的runtime.mcache.refill使用中心缓存中的内存管理单元替换已经不存在可用对象的结构体,该方法会调用新结构体的runtime.mspan.nextFreeIndex获取空闲的内存并返回。

大对象

运行时对于大于32KB的大对象会单独处理,我们不会从线程缓存或中心缓存中获取内存管理单元,而是直接在系统的栈中调用runtime.largeAlloc函数分配大片的内存:

func malloc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    ...
    if size <= maxSmallSize {
        ...
    } else {
        var s *mspan
        // 在系统栈上调用匿名函数
        systemstack(func() {
            // 分配大块内存
            s = largeAlloc(size, needzero, noscan)
        })
        // 设置下一个可用索引和分配的块数
        s.freeindex = 1
        s.allocCount = 1
        // 将分配的内存的基地址转换为unsafe.Pointer以返回调用者
        x = unsafe.Pointer(s.base())
        // 更新请求的大小为实际的元素大小,可能由于内存策略或内存对齐,实际大小会与请求的大小不同
        size = s.elemsize
    }
    
    // 内存屏障,确保内存写入在此之前完成,对其他处理器可见
    publicationBarrier()
    // 标记当前线程M的内存分配过程已结束
    mp.mallocing = 0
    releasem(mp)
    
    return x
}

runtime.largeAlloc函数会计算分配该对象所需页数,它会按8KB的倍数为对象在堆上申请内存:

func largeAlloc(size uintptr, needzero bool, noscan bool) *mspan {
    // 计算应分配的页数
    npages := size >> _PageShift
    if size&_PageMask != 0 {
        npages++
    }
    ...
    // 使用全局堆mheap_分配内存
    s := mheap_.alloc(npages, makeSpanClass(0, noscan), needzero)
    // 设置内存块的界限
    s.limit = s.base() + size
    // 初始化刚分配的内存块的堆位图
    heapBitsForAddr(s.base()).initSpan(s)
    return s
}

申请内存时会创建一个跨度类为0的runtime.spanClass并调用runtime.mheap.alloc分配一个管理对应内存的管理单元。

7.1.4 小结

内存分配是Go语言运行时内存管理的核心逻辑,运行时的内存分配器使用类似TCMalloc的分配策略将对象根据大小分类,并设计多层级的组件提高内存分配器的性能。本节不仅介绍了Go语言内存分配器的设计和实现原理,同时也介绍了内存分配器的常见设计,帮助我们理解不同编程语言在设计内存分配器时做出的不同选择。

内存分配器虽然非常重要,但是它只解决了如何分配内存的问题,我们在本节中省略了很多与垃圾回收相关的代码,没有分析运行时垃圾回收的实现原理,在下一节中我们将详细分析Go语言垃圾回收的设计与实现原理。

7.2 垃圾收集器

我们在上一节中详细介绍了Go语言内存分配器的设计与实现原理,分析了运行时内存管理组件之间的关系以及不同类型对象的分配原理,然而编程语言的内存管理系统除了负责堆内存的分配之外,它还需要负责回收不再使用的对象和内存空间,这部分职责就是由本节即将介绍的垃圾收集器完成的。

在几乎所有的现代编程语言中,垃圾收集器都是一个复杂的系统,为了在不影响用户程序的情况下回收废弃的内存需要付出很多努力,Java的垃圾收集机制是一个很好的例子,Java 8中包含线性、并发、并行标记清除、G1四个垃圾收集器,想要理解它们的工作原理和实现细节需要花费很多精力。

本节会详细介绍Go语言运行时系统中垃圾收集器的设计与实现原理,我们不仅会讨论常见的垃圾收集机制、从Go语言的v1.0版本开始分析其演进过程,还会深入源代码分析垃圾收集器的工作原理。接下来,我们进入Go语言内存管理的另一个重要组成部分——垃圾收集。

7.2.1 设计原理

今天的编程语言通常会使用手动和自动两种方式管理内存,C、C++、Rust等语言使用手动方式管理内存,工程师需要主动申请或释放内存;而Python、Ruby、Java、Go等语言使用自动的内存管理系统,一般都是垃圾收集机制,不过Objective-C却选择了自动引用计数,虽然引用计数也是自动的内存管理机制,但是我们在这里不会详细介绍它,本节的重点还是垃圾收集。

很多人对垃圾收集器的印象都是暂停程序(Stop the world,STW),随着用户程序申请越来越多的内存,系统中的垃圾也逐渐增多;当程序的内存占用达到一定阈值时,整个应用程序就会全部暂停,垃圾收集器会扫描已经分配的所有对象并回收不再使用的内存空间,这个过程结束后,用户程序才可以继续执行,Go语言在早期也使用这种策略实现垃圾收集,但今天的实现已经复杂了很多。
在这里插入图片描述
在上图中,用户程序(Mutator)会通过内存分配器(Allocator)在堆上申请内存,而垃圾收集器(Collector)负责回收堆上的内存空间,内存分配器和垃圾收集器共同管理着程序中的堆内存空间。

标记清除

标记清除(Mark-Sweep)算法是最常见的垃圾收集算法,标记清除收集器是跟踪式垃圾收集器,其执行过程可分成标记(Mark)和清除(Sweep)两个阶段:
1.标记阶段——从根对象出发查找并标记堆中所有存活的对象;

2.清除阶段——遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表;

如下图,内存中包含多个对象,我们从根对象出发依次遍历对象的子对象并将从根节点可达的对象都标记成存活状态,即A、C、D三个对象,剩余的B、E、F三个对象因为从根节点不可达,所以会被当做垃圾:
在这里插入图片描述
标记阶段结束后会进入清除阶段,在该阶段中收集器会依次遍历堆中所有对象,释放其中没有被标记的B、E、F三个对象并将新的空闲内存以链表的结构串联起来,方便内存分配器使用。
在这里插入图片描述
这里介绍的是最传统的标记清除算法,垃圾收集器从垃圾收集的根对象出发,递归遍历这些对象指向的子对象并将所有可达的对象标记成存活;标记阶段结束后,垃圾收集器会依次遍历堆中的对象并清除其中的垃圾,整个过程需要标记对象的存活状态,用户程序在垃圾收集的过程中也不能执行,我们需要用到更复杂的机制来解决STW的问题。

三色抽象

为了解决原始标记清除算法带来的长时间STW,多数现代的追踪式垃圾收集器都会实现三色标记算法的变种以缩短STW的时间。三色标记算法将程序中的对象分成白色、黑色、灰色三类:
1.白色对象——潜在的垃圾,其内存可能会被垃圾收集器回收(补充一下:它们是尚未被标记或访问的对象,所以它们可能是垃圾);

2.黑色对象——活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;

3.灰色对象——活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;
在这里插入图片描述
在垃圾收集器开始工作时,程序中不存在任何黑色对象,垃圾收集的根对象会被标记为灰色,垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束。
在这里插入图片描述
三色标记垃圾收集器的工作原理可归纳为以下几个步骤:
1.从灰色对象的集合中选择一个灰色对象并将其标记成黑色;

2.将黑色对象指向的所有对象都标记为灰色,保证该对象和被该对象引用的对象都不会被回收;

3.重复上述两个步骤直到对象图中不存在灰色对象;

当三色的标记清除的标记阶段结束之后,应用程序的堆中就不存在任何灰色对象,我们只能看到黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾,下面是使用三色标记垃圾收集器执行标记后的堆内存,堆中只有对象D为待回收的垃圾:
在这里插入图片描述
因为用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或增量执行的,它仍然需要STW,在如下所示的三色标记过程中,用户程序建立了从A对象到D对象的引用,但是因为程序中已经不存在灰色对象了,所以D对象会被垃圾收集器错误地回收。
在这里插入图片描述
本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误,我们将这种错误称为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性,想要并发或增量地标记对象需要使用屏障技术。

屏障技术

内存屏障技术是一种屏障指令,它可以让CPU或编译器在执行内存相关操作时遵循特定的约束,目前多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证代码对内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。

想要在并发或增量的标记算法中保证正确性,我们需要达成两种三色不变性(Tri-color invariant)中的任意一种:
1.强三色不变性——黑色对象不会指向白色对象,只会指向灰色对象或黑色对象;

2.弱三色不变性——黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径;
在这里插入图片描述
上图分别展示了遵循强三色不变性和弱三色不变性的堆内存,遵循上述两个不变性中的任意一个,我们都能保证垃圾收集算法的正确性,而屏障技术就是在并发或增量标记过程中保证三色不变性的重要技术。

垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象、更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往会采用写屏障保证三色不变性。

我们在这里想要介绍的是Go语言中使用的两种写屏障技术,分别是Dijkstra提出的插入写屏障和Yuasa提出的删除写屏障,这里会分析它们如何保证三色不变性和垃圾收集器的正确性。

插入写屏障

Dijkstra在1978年提出了插入写屏障,通过如下所示的写屏障,用户程序和垃圾收集器可以在交替工作的情况下保证程序执行的正确性:

writePointer(slot, ptr):
    shade(ptr)
    *field = ptr

上述插入写屏障的伪代码非常好理解,每当我们执行类似*slot = ptr的表达式时,我们会执行上述写屏障通过shade函数尝试改变指针的颜色,如果ptr是白色的,那么该函数会将该对象设置成灰色,其他情况则保持不变。
在这里插入图片描述
假设我们在应用程序中使用Dijkstra提出的插入写屏障,在一个垃圾收集器和用户程序交替运行的场景中会出现如上图所示的标记过程:
1.垃圾收集器将根对象指向A对象并将A对象指向的对象B标记成灰色;

2.用户程序修改A对象的指针,将原本指向B对象的指针指向C对象,这时触发写屏障将C对象标记成灰色;

3.垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;

Dijkstra的插入写屏障是一种相对保守的屏障技术,它会将有存活可能的对象都标记成灰色以满足强三色不变性。在如上所示的垃圾收集过程中,实际上不再存活的B对象最后没有被回收;而如果我们在第二和第三步之间将指向C对象的指针改回指向B,垃圾收集器仍然认为C对象是存活的,这些被错误标记的垃圾对象只有在下一个循环才会被回收。

插入式的Dijkstra写屏障虽然实现非常简单并且也能保证强三色不变性,但是它也有很明显的缺点。因为栈上的对象在垃圾收集过程中也会被认为是根对象,所以为了保证内存安全,Dijkstra必须为栈上的对象增加写屏障或在标记阶段完成后重新对栈上的对象进行扫描,这两种方法各有各的缺点,前者会大幅增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序,垃圾收集算法的设计者需要在这两者之间做出权衡。

删除写屏障

Yuasa在1990年的论文Real-time garbage collection on general-purpose machines中提出了删除写屏障,因为一旦该写屏障开始工作,它就会保证开启写屏障时堆上所有对象的可达(我个人理解可达的含义应该是从根集合出发,可通过指针引用到达这个对象,根集合应该是全局变量和栈帧中的局部变量),所以也被称作快照垃圾收集(Snapshot GC):

This guarantees that no objects will become unreachable to the garbage collector traversal all objects which are live at the beginning of garbage collection will be reached even if the pointers to them are overwritten.

上面这段引用的含义是,在垃圾收集开始阶段存活的对象不会变得不可达,即使后面又发生了指针覆写(指针覆写是改变了指向对象的指针,可能没有指针指向该对象了)。

该算法会使用如下写屏障保证增量或并发执行垃圾收集时程序的正确性:

writePointer(slot, ptr)
    shade(*slot)
    *slot = ptr

上述代码会在老对象的引用被删除时,将白色的老对象涂成灰色,这样删除写屏障就能保证弱三色不变性,老对象引用的下游对象一定可以被灰色对象引用(作者这里讲得不太清晰,实际上,当黑色对象到白色对象的引用被删除时,白色对象会被标记为灰色,从而确保这些对象在垃圾回收过程中不会被错过)。
在这里插入图片描述
假设我们在应用程序中使用Yuasa提出的删除写屏障,在一个垃圾收集器和用户程序交替运行的场景中会出现如上所示的标记过程:
1.垃圾收集器将根对象指向的A对象标记成黑色,并将A对象指向的B对象标记为灰色;

2.用户程序将A对象原本指向B的指针指向C,触发删除写屏障,但因为B对象已经是灰色,所以不做改变;

3.用户程序将B对象原本指向C对象的指针删除,触发删除写屏障,白色的C对象被涂成灰色;

4.垃圾收集器依次遍历程序中的其他灰色对象,将它们分别标记成黑色;

上述过程中的第三步触发了Yuasa删除写屏障的着色,因为用户程序删除了B指向C的指针,所以C和D两个对象会分别违反强三色不变性和弱三色不变性:
1.强三色不变性——黑色的A对象直接指向白色的C对象;

2.弱三色不变性——垃圾收集器无法从某个灰色对象出发,经过几个连续的白色对象访问白色的C和D两个对象;

Yuasa删除写屏障通过对C对象的着色,保证了C对象和下游的D对象能够在这一次垃圾收集的循环中存活,避免发生悬挂指针以保证用户程序正确性。

增量和并发

传统的垃圾收集算法会在垃圾收集的执行期间暂停应用,一旦触发垃圾收集,垃圾收集器就会抢占CPU的使用权,占据大量的计算资源以完成标记和清除工作,然而很多追求实时的应用程序无法接受长时间的STW。
在这里插入图片描述
远古时代的计算资源还没有今天这么丰富,今天的计算机往往都是多核处理器,垃圾收集器一旦开始执行就会浪费大量的计算资源,为了减少应用程序暂停的最长时间和垃圾收集的总暂停时间,我们使用下面的策略优化现代的垃圾收集器:
1.增量垃圾收集——增量地标记和清除垃圾,降低应用程序暂停的最长时间;

2.并发垃圾收集——利用多核的计算资源,在用户程序执行时并发标记和清除垃圾;

因为增量和并发两种方式都可以与用户程序交替运行,所以我们需要使用屏障技术保证垃圾收集的正确性;与此同时,应用程序也不能等到内存溢出时触发垃圾收集,因为当内存不足时,应用程序已经无法分配内存,这与直接暂停程序没有什么区别,增量和并发的垃圾收集需要提前触发并在内存不足前完成整个循环,避免程序的长时间暂停。

增量收集器

增量式(Incremental)的垃圾收集是减少程序最长暂停时间的一种方案,它可以将原本时间较长的暂停时间切分成多个更小的GC时间片,虽然从垃圾收集开始到结束的时间更长了,但这也减少了应用程序暂停的最大时间:
在这里插入图片描述
需要注意的是,增量式的垃圾收集需要与三色标记法一起使用,为了保证垃圾收集的正确性,我们需要在垃圾收集开始前打开写屏障,这样用户程序对内存的修改都会先经过写屏障处理,保证了堆内存中对象关系的强三色不变性或弱三色不变性。虽然增量式的垃圾收集能减少最大程序暂停时间,但增量式收集也会增加一次GC循环的总时间,在垃圾收集期间,因为写屏障的影响用户程序也需要承担额外的计算开销,所以增量式的垃圾收集也不是只有优点的。

并发收集器

并发(Concurrent)的垃圾收集不仅能减少程序的最长暂停时间,还能减少整个垃圾收集阶段的时间,通过开启读写屏障、利用多核优势与用户程序并行执行,并发垃圾收集器确实能减少垃圾收集对应用程序的影响:
在这里插入图片描述
虽然并发收集器能够与用户程序一起运行,但并不是所有阶段都可以与用户程序一起运行,部分阶段还是需要暂停用户程序的,不过与传统的算法相比,并发的垃圾收集可以将能够并发执行的工作尽量并发执行;当然,因为读写屏障的引入,并发的垃圾收集器也一定会带来额外开销,不仅会增加垃圾收集的总时间,还会影响用户程序,这是我们在设计垃圾收集策略时必须要注意的。

7.2.2 演进过程

Go语言的垃圾收集器从诞生的第一天起就一直在演进,除了少数几个版本没有大更新之外,几乎每次发布的小版本都会提升垃圾收集的性能,而与性能一同提升的还有垃圾收集器代码的复杂度,本节从Go语言v1.0版本开始分析垃圾收集器的演进过程。
1.v1.0——完全串行的标记和清除过程,需要暂停整个程序;

2.v1.1——在多核主机并行执行垃圾收集的标记和清除阶段;

3.v1.3——运行时基于只有指针类型的值包含指针的假设增加了对栈内存的精确扫描支持,实现了真正精确的垃圾收集;将unsafe.Pointer类型转换成整数类型的值认定为不合法的,可能会造成悬挂指针等严重问题;

4.v1.5——实现了基于三色标记清扫的并发垃圾收集器;
(1)大幅度降低垃圾收集的延迟,从几百ms降低至10ms以下;

(2)计算垃圾手机启动的合适时间并通过并发加速垃圾收集过程;

5.v1.6——实现了去中心化的垃圾收集协调器;
(1)基于显式的状态机使得任意Goroutine都能触发垃圾收集的状态迁移;

(2)使用密集的位图替代空闲链表表示的堆内存,降低清除阶段的CPU占用;

6.v1.7——通过并行栈收缩将垃圾收集的时间缩短至2ms以内;

7.v1.8——使用混合写屏障将垃圾收集的时间缩短至0.5ms以内;

8.v1.9——彻底移除暂停程序的重新扫描栈的过程;

9.v1.10——更新了垃圾收集调频器(Pacer)的实现,分离软硬堆大小的目标(将堆的目的大小分成软硬两个目标,目的是使堆大小不至于过大才进行收集,或过小就进行收集);

10.v1.12——使用新的标记终止算法简化垃圾收集器的几个阶段;

11.v1.13——通过新的Scavenger解决瞬时内存占用过高的应用程序向操作系统归还内存的问题;

12.v1.14——使用全新的页分配器优化内存分配的速度;

我们从Go语言垃圾收集器的演进能够看到该组件的实现和算法变得越来越复杂,最开始的垃圾收集器还是不精确的单线程STW收集器,但是最新版本的垃圾收集器却支持并发垃圾收集、去中心化协调等特性,我们在这里将介绍与最新版垃圾收集器相关的组件和特性。

并发垃圾收集

Go语言在v1.5中引入了并发的垃圾收集器,该垃圾收集器使用了我们上面提到的三色抽象和写屏障技术保证垃圾收集器执行的正确性,如何实现并发的垃圾收集器在这里就不展开介绍了,我们来了解一些并发垃圾收集器的工作流程。

首先,并发垃圾收集器必须在合适的时间点触发垃圾收集循环,假设我们的Go语言程序运行在一台4核的物理机上,那么在垃圾收集开始后,收集器会占用25%计算资源在后台来扫描并标记内存中的对象:
在这里插入图片描述
Go语言的并发垃圾收集器会在扫描对象之前暂停程序做一些标记对象的准备工作,其中包括启动后台标记的垃圾收集器以及开启写屏障,如果在后台执行的垃圾收集器不够快,应用程序申请内存的速度超过预期,运行时就会让申请内存的应用程序辅助完成垃圾收集的扫描阶段,在标记和标记终止阶段结束后就会进入异步的清理阶段,将不用的内存增量回收。

v1.5版本实现的并发垃圾收集策略由专门的Goroutine负责在处理器之间同步和协调垃圾收集的状态,当其他的Goroutine发现需要触发垃圾收集时,它们需要将该信息通知给负责修改状态的主Goroutine,然而这个通知的过程会带来一定延迟,这个延迟的时间窗口很可能是不可控的,用户程序在这段时间可能会分配内存。

v1.6引入了去中心化的垃圾收集协调机制,将垃圾收集器变成一个显式的状态机,任意Goroutine都可以调用方法触发状态迁移,常见的状态迁移方法包括以下几个:
1.runtime.gcStart——从_GCoff转换至_GCmark阶段,进入并发标记阶段并打开写屏障;

2.runtime.gcMarkDone——如果所有可达对象都已经完成扫描,调用runtime.gcMarkTermination

3.runtime.gcMarkTermination——从_GCmark转换到_GCmarktermination阶段,进入标记终止阶段并在完成后进入_GCoff

上述三个方法就是在runtime: replace GC coordinator with state machine问题相关的提交中引入的,它们移除了过去中心化的状态迁移过程。

回收堆目标

STW的垃圾收集器虽然需要暂停程序,但是它能够有效控制堆内存的大小,Go语言运行时的默认配置会在堆内存达到上一次垃圾收集时内存大小的2倍时,触发新一轮的垃圾收集,这个行为可通过环境变量GOGC调整,在默认情况下它的值为100,即增长100%的堆内存才会触发GC。
在这里插入图片描述
因为并发垃圾收集器会与程序一起运行,所以它无法准确控制堆内存的大小,并发收集器需要在达到目标前触发垃圾收集,这样才能保证内存大小的可控,并发收集器需要尽可能保证垃圾收集结束时的堆内存与用户配置的GOGC一致。
在这里插入图片描述
Go语言v1.6引入并发垃圾收集器的同时使用垃圾收集调步(Pacing)算法计算触发垃圾收集的最佳时间,确保触发的时间既不会浪费计算资源,也不会超出预期的堆大小。如上图所示,其中黑色部分是上一次垃圾收集后标记的堆大小,绿色部分是上次垃圾收集结束后新分配的内存,因为我们使用并发垃圾收集,所以黄色部分就是在垃圾收集期间分配的内存,最后的红色部分是垃圾收集结束时与目标的差值,我们希望尽可能减少红色部分内存,降低垃圾收集带来的额外开销以及程序的暂停时间。

垃圾收集调步算法是跟随v1.5一同引入的,该算法的目标是优化堆的增长速度和垃圾收集器的CPU利用率,而在v1.10版本中又对该算法进行了优化,将原有的目的堆大小拆分成了软硬两个目标,因为调整垃圾收集的执行频率涉及较为复杂的公式,对理解垃圾收集原理帮助较为有限,本节就不展开介绍了,感兴趣的读者可以自行阅读。

混合写屏障

在Go语言v1.7版本之前,运行时会使用Dijkstra插入写屏障保证强三色不变性,但是运行时并没有在所有的垃圾收集根对象上开启插入写屏障。因为Go语言的应用程序可能包含成百上千的Goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个Goroutine的栈上都开启写屏障,会带来巨大的额外开销,所以Go团队在实现上选择在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描,在活跃Goroutine非常多的程序中,重新扫描的过程需要占用10~100ms。

Go语言在v1.8组合Dijkstra插入写屏障和Yuasa删除写屏障构成了如下所示的混合写屏障,该写屏障会将被覆盖的对象标记成灰色并在当前栈没有扫描时将新对象也标记成灰色:

writePointer(slot, ptr):
    // 先把指针指向的对象变灰
    shade(*slot)
    if current stack is grey:
        shade(ptr)
    *slot = ptr

为了移除栈的重扫描过程,除了引入混合写屏障之外,在垃圾收集的标记阶段,我们还需要将创建的所有新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。

7.2.3 实现原理

在介绍垃圾收集器的演进过程之前,我们需要对最新垃圾收集器的执行周期有一些初步了解,这对我们了解其全局的设计会有比较大的帮助。Go语言的垃圾收集可以分成清除终止、标记、标记终止、清除四个不同阶段:
在这里插入图片描述
上图中的Off(Sweep)就是清理阶段。

1.清理终止阶段:
(1)暂停程序,所有处理器在这时会进入安全点(Safe point);

(2)如果当前垃圾收集循环是强制触发的,我们还需要处理还未被清理的内存管理单元;

2.标记阶段:
(1)将状态切换至_GCmark、开启写屏障、用户程序协助(Mutator Assiste)、根对象入队;

(2)恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色;

(3)开始扫描根对象,包括所有Goroutine的栈、全局对象、不在堆中的运行时数据结构,扫描Goroutine栈期间会暂停当前处理器;

(4)依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;

(5)使用分布式的终止算法检查剩余工作,发现标记阶段完成后进入标记终止阶段;

3.标记终止阶段:
(1)暂停程序、将状态切换至_GCmarktermination并关闭辅助标记的用户程序;

(2)清理处理器上的线程缓存;

4.清理阶段:
(1)将状态切换至_GCoff开始清理阶段,初始化清理状态并关闭写屏障;

(2)恢复用户程序,所有新创建的对象会标记成白色;

(3)后台并发清理所有的内存管理单元,当Goroutine申请新内存管理单元时就会触发清理;

运行时虽然只会使用_GCoff_GCmark_GCmarktermination三个状态表示垃圾收集的全部阶段,但是在实现上却复杂很多,本节将按照垃圾收集的不同阶段详细分析其实现原理。

全局变量

在垃圾收集中有一些比较重要的全局变量,在分析其过程前,我们先逐一介绍这些重要的变量,这些变量在垃圾收集的各个阶段中会反复出现,所以理解它们的功能是非常重要的,我们先介绍一些比较简单的变量:
1.runtime.gcphase是垃圾收集器当前处于的阶段,可能处于_GCoff_GCmark_GCmarktermination,Goroutine在读取或修改该阶段时要保证原子性;

2.runtime.gcBlackenEnabled是一个布尔值,当垃圾收集处于标记阶段时,该变量会被置为1,此时辅助垃圾收集的用户程序和后台标记的任务可以将对象涂黑。

3.runtime.gcController实现了垃圾收集的调步算法,它能够决定触发并行垃圾收集的时间和待处理的工作;

4.runtime.gcpercent是出发垃圾收集的内存增长百分比,默认情况下为100,即堆内存相比上次垃圾收集增长100%时应触发GC,并行的垃圾收集器会在到达该目标前完成垃圾收集;

5.runtime.writeBarrier是一个包含写屏障状态的结构体,其中的enabled字段表示写屏障的开启与关闭;

6.runtime.worldsema是全局的信号量,获取该信号量的线程有权暂停当前应用程序;

除了上述全局变量外,我们还需简单了解一下runtime.work变量:

var work struct {
    full  lfstack
    empty lfstack
    pad0  cpu.CacheLinePad
    
    wbufSpans struct {
        lock mutex
        free mSpanList
        busy mSpanList
    }
    ...
    nproc  uint32
    tstart int64
    nwait  uint32
    ndone  uint32
    ...
    mode gcMode
    cycles uint32
    ...
    stwprocs, maxprocs int32
    ...
}

该结构体中包含大量垃圾收集的相关字段,例如:表示完成的垃圾收集循环的次数、当前循环时间和CPU利用率、垃圾收集的模式等。

触发时机

运行时会通过以下runtime.gcTrigger.test方法决定是否需要触发垃圾收集,当满足触发垃圾收集的基本条件——允许垃圾收集、程序没有崩溃、没有处于垃圾收集循环时,该方法会根据触发方式的不同进行不同的检查:

func (t gcTrigger) test() bool {
    if !memstats.enablegc || panicking != 0 || gcphase != _GCoff {
        return false
    }
    switch t.kind {
    // 堆触发
    case gcTriggerHeap:
        // 当前堆使用量超过gc触发阈值时,需要垃圾收集
        return memstats.heap_live >= memstats.gc_trigger
    // 时间触发
    case gcTriggerTime:
        // 当gcpercent小于0,即gc被禁用
        if gcpercent < 0 {
            return false
        }
        // 获取上次gc时间戳
        lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
        // 如果举例上次gc时间超过强制gc的时间间隔
        return lastgc != 0 && t.now-lastgc > forcegcperiod
    // 周期触发
    case gcTriggerCycle:
        // 当目标gc周期大于当前已完成的周期,则需要进行gc
        return int32(t.n-work.cycles) > 0
    }
    return true
}

1.gcTriggerHeap——堆内存的分配到达控制器计算的触发堆大小;

2.gcTriggerTime——如果一定时间内没有触发,就会触发新的循环,该触发条件由runtime.forcegcperiod变量控制,默认为2分钟;

3.gcTriggerCycle——如果当前没有开启垃圾收集,则触发新的循环(这句话作者写得有些模糊,根据以上代码,我的理解是,需要目标周期数t.n大于已经进行的周期数work.cycles才能触发新的垃圾收集循环,作者说的没有开启垃圾收集指的应该是没有开启目标周期的垃圾收集);

用于开启垃圾收集的方法runtime.gcStart会接收一个runtime.gcTrigger类型的谓词(谓词指一个条件或判断标准),我们可以根据这个结构体来定位到所有触发垃圾收集的代码路径,如果触发了垃圾收集,会退出_GCoff状态:
1.runtime.sysmonruntime.forcehelper——后台运行定时检查和垃圾收集;

2.runtime.GC——用户程序手动触发垃圾收集;

3.runtime.mallocgc——申请内存时根据堆大小触发垃圾收集;
在这里插入图片描述
除了使用后台运行的系统监控器和强制垃圾收集助手出发垃圾收集外,另外两个方法会从任意处理器上触发垃圾收集,这种不需要中心组件协调的方式是在v1.6版本中引入的,接下来我们展开介绍这三种不同的触发时机。

后台触发

运行时会在应用程序启动时在后台开启一个用于强制触发垃圾收集的Goroutine,该Goroutine的职责非常简单——调用runtime.gcStart方法尝试启动新一轮垃圾收集:

// 包被导入初始化时,启动一个Goroutine帮助垃圾收集
func init() {
    go forcegchelper()
}

func forcegchelper() {
    // 保存当前Goroutine,以供其他地方使用
    forcegc.g = getg()
    for {
        // 确保对forcegc结构的原子性操作
        lock(&forcegc.lock)
        // 原子地将idle状态设为空闲
        atomic.Store(&forcegc.idle, 1)
        // 进入休眠并解锁
        goparkunlock(&forcegc.lock, waitReasonForceGGIdle, traceEvGoBlock, 1)
        // 被唤醒后触发垃圾回收
        gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
    }
}

为了减少对计算资源的占用,该Goroutine会在循环中调用runtime.goparkunlock主动陷入休眠等待其他Goroutine的唤醒,runtime.forcegchelper在大多数时间都是陷入休眠的,但是它会被系统监控器runtime.sysmon在满足垃圾收集条件时唤醒:

func sysmon() {
    ...
    for {
        ...
        // 构建一个类型为gcTriggerTime的gcTrigger
        // 如果可以进行垃圾回收 && forcegchelper当前处于空闲状态,可以进行gc
        if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 {
            lock(&forcegc.lock)
            // forcegchelper协程将被唤醒,不再空闲
            forcegc.idle = 0
            // 将forcegchelper协程加入全局运行队列,使其能被调度器选中运行
            var list gList
            list.push(forcegc.g)
            injectglist(&list)
            unlock(&forcegc.lock)
        }
    }
}

系统监控在每个循环中都会主动构建一个runtime.gcTrigger并检查垃圾收集的触发条件是否满足,如果满足条件,系统监控会将runtime.forcegc状态中持有的Goroutine加入全局队列等待调度器的调度。

手动触发

用户程序会通过runtime.GC函数在程序运行期间主动通知运行时执行,该方法在调用时会阻塞调用方,直到当前垃圾收集循环完成,在垃圾收集期间也可能会通过STW暂停整个程序:

func GC() {
    // 原子地获取垃圾收集的循环次数
    n := atomic.Load(&work.cycles)
    // 等待第n轮垃圾收集的标记阶段完成
    gcWaitOnMark(n)
    // 启动垃圾回收,类型为gcTriggerCycle,轮数为n+1
    gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1})
    // 等待第n+1轮垃圾收集的标记阶段完成
    gcWaitOnMark(n + 1)
    
    // 在循环中主动清扫内存
    // 确保还在当前垃圾回收循环中 && 还有可清扫的span
    // sweepone函数会清扫还未被清扫的heap span,且返回值为返还给堆的页数,^uintptr(0)表示没有要清扫的
    for atomic.Load(&work.cycles) == n+1 && sweepone() != ^uintptr(0) {
        // 增加清扫计数器
        sweep.nbgsweep++
        // 让出处理器,让其他Goroutine运行,防止长时间占用导致饥饿
        Gosched()
    }
    
    // 循环等待所有清扫任务完成
    // 确保还在当前垃圾收集循环中 && 清扫任务数非0
    for atomic.Load(&work.cycles) == n+1 && atomic.Load(&mheap_.sweepers) != 0 {
        Gosched()
    }
    
    // 获取当前操作系统线程M,因为有些运行时函数需要在没有处理器P的情况下运行
    mp := acquirem()
    cycle := atomic.Load(&work.cycles)
    if cycle == n+1 || (gcphase == _GCmark && cycle == n+2) {
        mProf_PostSweep()
    }
    releasem(mp)
}

1.在正式开始垃圾收集前,运行时需要通过runtime.gcWaitOnMark函数等待上一个循环标记、标记终止阶段完成;

2.调用runtime.gcStart触发新一轮垃圾收集,并通过runtime.gcWaitOnMark等待该轮垃圾收集的标记终止阶段正常结束;

3.持续调用runtime.sweepone清理全部待处理的内存管理单元,并等待所有的清理工作完成,等待期间会调用runtime.Gosched让出处理器;

4.完成本轮垃圾收集的清理工作后,通过runtime.mProf_PostSweep将该阶段的堆内存状态快照发布出来,我们可以获取这时的内存状态;

手动触发垃圾收集的过程不是特别常见,一般只会在运行时的测试代码中才会出现,不过如果我们认为触发主动垃圾收集是有必要的,我们也可以直接调用该方法,但作者并不认为这是一种推荐做法。

申请内存

最后一个可能会触发垃圾收集的就是runtime.mallocgc函数了,我们在上一节内存分配器中曾介绍过运行时会将堆上的对象按大小分成微对象、小对象、大对象三类,这三类对象的创建都可能触发新的垃圾收集循环:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    shouldhelpgc := false
    ...
    if size <= maxSmallSize {
        if noscan && size < maxTinySize {
            ...
            // 如果当前线程的内存管理单元中没有空闲空间
            v := nextFreeFast(span)
            if v == 0 {
                v, _, shouldhelpgc = c.nextFree(tinySpanClass)
            }
            ...
        } else {
            ...
            v := nextFreeFast(span)
            if v == 0 {
                v, span, shouldhelpgc = c.nextFree(spc)
            }
            ...
        }
    } else {
        shouldhelpgc = true
        ...
    }
    ...
    if shouldhelpgc {
        if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {
            gcStart(t)
        }
    }
    
    return x
}

1.当前线程的内存管理单元不存在空闲空间时,创建微对象和小对象需要调用runtime.mcache.nextFree方法从中心缓存或页堆中获取新的管理单元,这时就可能触发垃圾收集;

2.档用户程序申请分配32KB以上大对象时,一定会构建runtime.gcTrigger结构体尝试触发垃圾收集;

通过堆内存触发垃圾收集需要比较runtime.mstats中的两个字段——表示垃圾收集中存活对象字节数的heap_live和表示触发标记的堆内存大小gc_trigger;当内存中存活的对象字节数大于触发垃圾收集的堆大小时,新一轮垃圾收集就会开始。我们分别介绍两个值的计算过程:
1.heap_live——为了减少锁竞争,运行时只会在中心缓存分配或释放内存管理单元、堆上分配大对象时才会更新;

2.gc_trigger——在标记终止阶段调用runtime.gcSetTriggerRatio更新触发下一次垃圾收集的堆大小;

runtime.gcController会在每个循环结束后计算触发比例,并通过runtime.gcSetTriggerRatio设置gc_trigger,它能够决定触发垃圾收集的时间,以及用户程序和后台处理标记任务的多少,利用反馈控制算法根据堆的增长情况和垃圾收集CPU利用率确定触发垃圾收集的时机。

可以在runtime.gcControllerState.endCycle方法中找到v1.6提出的垃圾收集调步算法,在runtime.gcControllerState.revise方法中找到v1.10引入的软硬堆目标分离算法。

垃圾收集启动

垃圾收集在启动过程一定会调用runtime.gcStart函数,虽然该函数的实现比较复杂,但它的主要职责就是修改全局的垃圾收集状态到_GCmark并做一些准备工作,我们分以下几个阶段介绍该函数的实现:
1.两次调用runtime.gcTrigger.test方法检查是否满足垃圾收集条件;

2.暂停程序、在后台启动用于处理标记任务的工作Goroutine、确定所有内存管理单元都被清理、其他标记阶段开始前的准备工作;

3.进入标记阶段、准备后台的标记工作、根对象的标记工作、恢复用户程序,进入并发扫描和标记阶段;

验证垃圾收集条件的同时,该方法还会在循环中不断调用runtime.sweepone清理已经被标记的内存单元,完成上一个垃圾收集循环的收尾工作:

func gcStart(trigger gcTrigger) {
    // 先循环地清理上一个垃圾收集循环的垃圾
    // 如果可以触发垃圾收集 && 没有垃圾没有清理完
    for trigger.test() && sweepone() != ^uintptr(0) {
        sweep.nbgsweep++
    }
    
    // 获取信号量,保证只有一个Goroutine可以进入垃圾清理的启动过程
    semacquire(&work.startSema)
    // 如果垃圾清理条件不再满足,可能是其他Goroutine获得了信号量,启动了垃圾清理
    if !trigger.test() {
        semrelease(&work.startSema)
        return
    }
    ...
}

在验证了垃圾收集的条件并完成了收尾工作后,该方法会通过semacquire获取全局的worldsema信号量、调用runtime.gcBgMarkStartWorkers启动后台标记任务、在系统栈中调用runtime.stopTheWorldWithSema暂停程序并调用runtime.finishsweep_m保证上一个周期内存单元的正常回收:

func gcStart(trigger gcTrigger) {
    ...
    // 获取信号量,防止多个垃圾回收同时操作全局状态
    semacquire(&worldsema)
    // 启动后台标记任务
    gcBgMarkStartWorkers()
    // 设置STW阶段使用的处理器数量和垃圾回收阶段最大可使用的处理器数量
    work.stwprocs, work.maxprocs = gomaxprocs, gomaxprocs
    ...
    
    // 系统栈上执行stopTheWorldWithSema,暂停程序,以便垃圾回收器能安全地操作
    systemstack(stopTheWorldWithSema)
    systemstack(func() {
        // 完成上一垃圾回收周期未完成的清扫工作,保证在新周期开启前完成清扫
        finishsweep_m()
    })
    
    // 增加垃圾回收次数
    work.cycles++
    // 为新周期初始化gc控制器的参数
    gcController.startCycle()
    ...
}

上述过程还会修改全局变量runtime.work持有的状态,包括垃圾收集需要的Goroutine数量以及已完成的循环数。

在完成全部准备工作后,该方法就进入了执行的最后阶段。在该阶段,我们会修改全局的垃圾收集状态到_GCmark并依次执行下面的步骤:
1.调用runtime.gcBgMarkPrepare函数初始化后台扫描需要的状态;

2.调用runtime.gcMarkRootPrepare函数扫描栈上、全局变量等根对象并将它们加入队列;

3.设置全局变量runtime.gcBlackenEnabled,用户程序和标记任务可以将对象涂黑;

4.调用runtime.startTheWorldWithSema启动程序,后台任务也会开始标记堆中的对象;

func gcStart(trigger gcTrigger) {
    ...
    // 将当前gc阶段设为_GCmark
    setGCPhase(_GCmark)
    
    gcBgMarkPrepare()
    gcMarkRootPrepare()
    
    // 原子地启用黑化过程,防止新创建的对象在并发标记期间被遗漏
    atomic.Store(&gcBlackenEnabled, 1)
    systemstack(func() {
        // 在垃圾回收器的准备工作完成后,恢复程序
        now = startTheWorldWithSema(trace.enabled)
        // 累加STW的时间
        work.pauseNS += now - work.pauseStart
        // 记录标记阶段的开始时间
        work.tMark = now
    })
    semrelease(&work.startSema)
}

在分析垃圾收集的启动过程中,我们省略了几个关键过程,其中包括暂停和恢复应用程序和后台任务,下面将详细分析这几个过程的实现原理。

暂停与恢复程序

runtime.stopTheWorldWithSemaruntime.startTheWorldWithSema是一对用于暂停和恢复程序的核心函数,它们有着完全相反的功能,但是程序的暂停会比恢复要复杂一些,我们看一下暂停的实现原理:

func stopTheWorldWithSema() {
    _g_ := getg()
    // 设置需要等待停止的处理器数量,此处需要等待所有处理器停止
    sched.stopwait = gomaxprocs
    // 通知调度器正在等待垃圾回收,阻塞其调度Goroutine
    atomic.Store(&sched.gcwaiting, 1)
    // 请求抢占所有Goroutine
    preemptall()
    // 设置当前处理器P的状态为_Pgcstop
    _g_.m.p.ptr().status = _Pgcstop
    // 减少需要等待的P数量,因为当前P的状态已改变
    sched.stopwait--
    // 遍历所有P
    for _, p := range allp {
        s := p.status
        // 如果P处于系统调用状态 && 原子地将其状态改变为_Pgcstop成功
        if s == _Psyscall && atomic.Cas(&p.status, s, _Pgcstop) {
            // 增加P的系统调用计数器
            p.syscalltick++
            // 减少需要停止的P数量
            sched.stopwait--
        }
    }
    // 循环处理器所有空闲P
    for {
        // 从空闲P列表中取出一个
        p := pidleget()
        if p == nil {
            break
        }
        // 改变其状态为停止,减少需要停止的P数量
        p.status = _Pgcstop
        sched.stopwait--
    }
    wait := sched.stopwait > 0
    // 如果还有其他P需要停止
    if wait {
        for {
            // 尝试休眠100us,等待通知
            // 如果被通知,会返回true,表示所有P已停止
            if notesleep(&sched.stopnote, 100*1000) {
                // 清除通知,准备下一次使用
                noteclear(&sched.stopnote)
                break
            }
            // 再次请求抢占
            preemptall()
        }
    }
}

暂停程序主要使用了runtime.preemptall函数,该函数会调用我们在前面介绍过的runtime.preemptone,因为程序中活跃的最大处理器数量为gomaxprocs,所以runtime.stopTheWorldWithSema在每次发现停止的处理器时都会对该变量减1,直到所有处理器都停止运行。该函数会依次停止当前处理器、等待处于系统调用的处理器、获取并抢占空闲的处理器,处理器的状态在stopTheWorldWithSema函数返回时都会被更新至_Pgcstop,等待垃圾收集器的重新唤醒。

程序恢复过程会使用runtime.startTheWorldWithSema,该函数的实现也相对比较简单:
1.调用runtime.netpoll从网络轮询器中获取待处理的任务并加入全局队列;

2.调用runtime.procresize扩容或缩容全局的处理器;

3.调用runtime.notewakeupruntime.newm依次唤醒处理器或为处理器创建新线程;

4.如果当前待处理的Goroutine数量过多,创建额外的处理器辅助完成任务;

func startTheWorldWithSema(emitTraceEvent bool) int64 {
    // 获取当前的操作系统线程M
    mp := acquirem()
    // 如果网络轮询器已初始化
    if netpollinited() {
        // 检查是否有就绪的网络事件,参数0表示非阻塞地检查
        list := netpoll(0)
        // 将从网络轮询器中获取的Goroutine加入全局运行队列中
        injectglist(&list)
    }
    
    procs := gomaxprocs
    // 调整处理器P的数量到procs,并返回链表形式的P链表
    p1 := procresize(procs)
    // 将调度器的GC等待状态设为0,表示GC已完成,不再等待
    sched.gcwaiting = 0
    ...
    // 遍历P链表
    for p1 != nil {
        // 获取链表头
        p := p1
        // 另下一个节点成为链表头
        p1 = p1.link.ptr()
        // 如果P已经关联了一个M
        if p.m != 0 {
            // 获取关联的M
            mp := p.m.ptr()
            // 解除P和M的关联
            p.m = 0
            // 设置M的nextp,以便M被唤醒后继续处理这个P
            mp.nextp.set(p)
            // 唤醒休眠的M
            notewakeup(&mp.park)
        // 如果P没有关联M
        } else {
            // 创建一个新M,将其与P关联
            newm(nil, p)
        }
    }
    
    // 如果有空闲的P && 有自旋的M
    if atomic.Load(&sched.npidle) != 0 && atomic.Load(&sched.nmspinning) == 0 {
        // 尝试唤醒或创建一个新的M来运行Goroutine
        wakep()
    }
    ...
}

程序的暂停和启动过程都比较简单,暂停程序会使用runtime.preemptall抢占所有处理器,恢复程序时会使用runtime.notewakeupruntime.newm唤醒程序中的处理器。

后台标记模式

在垃圾收集启动期间,运行时会调用runtime.gcBgMarkStartWorkers为全局每个处理器创建用于执行后台标记任务的Goroutine,每一个Goroutine都会运行runtime.gcBgMarkWorker,所有运行runtime.gcBgMarkWorker的Goroutine在启动后都会陷入休眠等待调度器的唤醒:

func gcBgMarkStartWorkers() {
    // 遍历所有P
    for _, p := range allp {
        // 如果尚未关联后台标记工作Goroutine
        if p.gcBgMarkWorker == 0 {
            // 创建后台标记工作Goroutine
            go gcBgMarkWorker(p)
            // 进入休眠,直到work.bgMarkReady上的通知唤醒它,参数-1表示无限等待
            notetsleepg(&work.bgMarkReady, -1)
            // 重置通知状态
            noteclear(&work.bgMarkReady)
        }
    }
}

这些Goroutine与处理器是一一对应的关系,当垃圾收集处于标记阶段,并且当前处理器不需要做任何任务时,runtime.findrunnable函数会在当前处理器上执行该Goroutine辅助并发地进行对象标记:
在这里插入图片描述
调度器在调度循环runtime.schedule中还可以通过垃圾收集控制器的runtime.gcControllerState.findRunnableGCWorker方法获取并执行用于后台标记的任务。

用于并发扫描对象的工作协程Goroutine总共有三种不同模式(runtime.gcMarkWorkerMode),这三种不同模式的Goroutine在标记对象时使用完全不同的策略,垃圾收集控制器会按照需要执行不同类型的工作协程:
1.gcMarkWorkerDedicatedMode——处理器专门负责标记对象,不会被调度器抢占;

2.gcMarkWorkerFractionalMode——当垃圾收集的后台CPU使用率达不到预期时(默认为25%),启动该类型的工作协程帮助垃圾收集达到利用率的目标,因为它只占用同一个CPU的部分资源,所以可以被调度;

3.gcMarkWorkerIdleMode——当处理器没有可以执行的Goroutine时,它会运行垃圾收集的标记任务直到被抢占;

runtime.gcControllerState.startCycle会根据全局处理器的个数以及垃圾收集的CPU利用率计算出dedicatedMarkWorkersNeededfractionUtilizationGoal以决定不同模式的工作协程的数量。

因为后台标记任务的CPU利用率为25%,如果主机是4核或8核,那么垃圾收集需要1个或2个专门处理相关任务的Goroutine;不过如果主机是3核或6核,因为无法被4整除,所以这时需要0个或1个专门处理垃圾收集的Goroutine,运行时需要占用某个CPU的部分时间,使用gcMarkWorkerFractionalMode模式的协程保证CPU的利用率。
在这里插入图片描述
垃圾收集控制器会在runtime.gcControllerState.findRunnableGCWorker方法中设置处理器的gcMarkWorkerMode

func (c *gcControllerState) findRunnableGCWorker(_p_ *p) *g {
    ...
    // 尝试将dedicatedMarkWorkersNeeded减1,如果减1后还是正数,说明还需要一个专用垃圾收集Goroutine
    if decIfPositive(&c.dedicatedMarkWorkersNeeded) {
        // dedicate专用模式
        _p_.gcMarkWorkerMode = gcMarkWorkDedicatedMode
    // 如果分时的垃圾收集CPU利用目标为0,说明不需要分时的垃圾收集Goroutine
    } else if c.fractionalUtilizationGoal == 0 {
        return nil
    // 处理分时的垃圾收集Goroutine情况
    } else {
        // 从垃圾收集标记阶段开始到现在的时间差
        delta := nanotime() - gcController.markStartTime
        // 如果delta为正数 && 当前处理器在垃圾收集上花费的时间/delta大于CPU利用率目标
        // 即利用率已超标,此时不再需要分时的垃圾收集Goroutine
        if delta > 0 && float64(_p_.gcFractionalMarkTime)/float64(delta) > c.fractionalUtilizationGoal {
            return nil
        }
        _p_.gcMarkWorkerMode = gcMarkWorkerFractionalMode
    }
    
    // 获取与当前处理器关联的垃圾收集后台标记Goroutine
    gp := _p_.gcBgMarkWorker.ptr()
    // 使用cas将该垃圾收集Goroutine状态从_Gwaiting转换为_Grunnable
    casgstatus(gp, _Gwaiting, _Grunnable)
    return gp
}

上述方法的实现比较清晰,控制器通过dedicatedMarkWorkersNeeded决定专门执行标记任务的Goroutine数量并根据执行标记任务的时间和总时间决定是否启动gcMarkWorkerFractionalMode模式的Goroutine;除了这两种控制器要求的工作协程外,调度器还会在runtime.findrunnable函数中利用空闲的处理器执行垃圾收集以加速该过程:

// findrunnable函数为处理器寻找下一个要执行的Goroutine
func findrunnable() (gp *g, inheritTime bool) {
    ...
stop:
    // 如果垃圾收集的标记阶段已启动 && 当前处理器有一个垃圾收集后台标记工作Goroutine 
    // && 当前存在需要处理的垃圾收集标记工作
    if gcBlackenEnabled != 0 && _p_.gcBgMarkWorker != 0 && gcMarkWorkAvailable(_p_) {
        // 设为idle模式,即空闲时间执行垃圾收集
        _p_.gcMarkWorkerMode = gcMarkWorkerIdleMode
        // 用cas将垃圾收集后台标记工作Goroutine的状态从_Gwaiting改为_Grunnable
        gp := _p_.gcBgMarkWorker.ptr()
        casgstatus(gp, _Gwaiting, _Grunnable)
        return gp, false
    }
    ...
}

三种模式的工作协程会互相协同保证垃圾收集的CPU利用率达到期望的阈值,在到达目标堆大小前完成标记任务。

并发扫描与标记辅助

runtime.gcBgMarkWorker是后台的标记任务执行的函数,该函数的循环中执行了对内存中对象图的扫描和标记,我们分三个部分介绍该函数的实现原理:
1.获取当前处理器以及Goroutine,打包成parkInfo类型结构体,然后主动陷入休眠等待唤醒;

2.根据处理器上的gcMarkWorkerMode模式决定扫描任务的策略;

3.所有标记任务都完成后,调用runtime.gcMarkDone方法完成标记阶段;

首先我们来看后台标记任务的准备工作,运行时在这里创建了一个parkInfo结构体,该结构体会预先存储处理器和当前Goroutine,当我们调用runtime.gopark触发休眠时,运行时会在系统栈中安全地建立处理器和后台标记任务的绑定关系:

func gcBgMarkWorker(_p_ *p) {
    gp := getg()
    
    type parkInfo struct {
        // 表示操作系统线程M的指针
        m      muintptr
        // 表示处理器P的指针
        attach puintptr
    }
    
    // 创建一个新parkInfo实例
    park := new(parkInfo)
    // 设置parkInfo的M和P
    park.m.set(acquirem())
    park.attach.set(_p_)
    // 唤醒所有等待bgMarkReady通知的Goroutine,表示后台标记工作Goroutine已准备好执行垃圾收集任务
    notewakeup(&work.bgMarkReady)
    
    for {
        // 挂起当前Goroutine,在挂起前,会执行第一个参数表示的回调函数,回调函数返回true时才能挂起
        gopark(func(g *g, parkp unsafe.Pointer) bool {
            park := (*parkInfo)(parkp)
            // 解除操作系统线程M与当前Goroutine的关联,使其他Goroutine可以使用该M
            releasem(park.m.ptr())
            
            // 如果park结构的P存在
            if park.attach != 0 {
                // 获取P,并将attach设为nil,表示已处理
                p := park.attach.ptr()
                park.attach.set(nil)
                // 使用cas操作将后台标记工作Goroutine从0设为g
                if !p.gcBgMarkWorker.cas(0, guintptr(unsafe.Pointer(g))) {
                    return false
                }
            }
            return true
        }, unsafe.Pointer(park), waitReasonGCWorkerIdle, traceEvGoBlock, 0)
    ...
}

通过runtime.gopark陷入休眠的Goroutine不会进入运行队列,它只会等待垃圾收集控制器或调度器的直接唤醒;唤醒后,我们会根据处理器gcMarkWorkerMode选择不同的标记执行策略,不同的执行策略都会调用runtime.gcDrain扫描工作缓冲区runtime.gcWork

        // 如果当前Goroutine不是处理器P的后台标记工作Goroutine,则跳出循环
        // 这确保了只有正确的Goroutine才能进行后台标记工作
        if _p_.gcBgMarkWorker.ptr() != gp {
            break
        }
        // 获取一个OS线程M,存储在park.m中
        park.m.set(acquirem())
        
        // 当前Goroutine开始工作,原子地减少一个等待中的后台标记Goroutine
        atomic.Xadd(&work.nwait, -1)
        // 在系统栈上执行匿名函数
        systemstack(func() {
            // 将Goroutine状态从_Grunning改为_Gwaiting
            casgstatus(gp, _Grunning, _Gwaiting)
            // 根据当前处理器的垃圾收集模式,进行垃圾收集
            switch _p_.gcMarkWorkerMode {
            // dedicate专用模式
            case gcMarkWorkerDedicateMode:
                // 执行标记任务
                // gcDrainUntilPreempt表示允许被抢占,标记持续到被抢占
                // gcDrainFlushBgCredit表示flush后台标记的信用计数,即将信用计数记录下来
                // 信用计数可用于抵消mutator在内存分配时需要执行的垃圾收集辅助工作
                gcDrain(&_p_.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit)
                // 如果当前Goroutine被抢占
                if gp.preempt {
                    lock(&sched.lock)
                    for {
                        // 获取P的本地运行队列中的所有Goroutine
                        gp, _ := runqget(_p_)
                        if gp == nil {
                            break
                        }
                        // 将P的本地运行队列中的Goroutine全放入全局运行队列
                        globrunqput(gp)
                    }
                    unlock(&sched.lock)
                }
                // 再次执行标记任务,这次只有gcDrainFlushBgCredit,确保后台信用flush
                gcDrain(&_p_.gcw, gcDrainFlushBgCredit)
            // 分时模式
            case gcMarkWorkerFractionalMode:
                // 执行标记任务
                // gcDrainFractional表示分时模式,避免CPU占用比例超出预设值
                gcDrain(&_p_.gcw, gcDrainFractional|gcDrainUntilPreempt|gcDrainFlushBgCredit)
            // 空闲模式,只有系统空闲时才进行,不影响应用性能
            case gcMarkWorkerIdleMode:
                gcDrain(&_p_.gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit)
            }
            // 恢复Goroutine的执行
            casgstatus(gp, _Gwaiting, _Grunning)
        })
        // 原子地增加work.nwait,表示当前工作线程已完成任务
        incnwait := atomic.Xadd(&work.nwait, +1)

需要注意的是,gcMarkWorkerDedicatedMode模式的任务是不能被抢占的,为了减少额外开销,第一次调用runtime.gcDrain方法时是允许抢占的,但是一旦处理器被抢占,当前Goroutine会将处理器上的所有可运行Goroutine转移至全局队列中,保证垃圾收集占用的CPU资源。

当所有后台标记工作任务都陷入等待,且没有剩余工作时,我们就认为该轮垃圾收集的标记阶段结束了,这时我们会调用runtime.gcMarkDone函数:

        // 如果所有后台标记工作Goroutine都陷入等待 && 没有剩余的标记工作可处理
        if incnwait == work.nproc && !gcMarkWorkAvailable(nil) {
            // 清除该P的后台标记工作Goroutine
            _p_.gcBgMarkWorker.set(nil)
            // 释放M,使其可用其他Goroutine使用
            releasem(park.m.ptr())
            // 通知垃圾收集器标记阶段已完成
            gcMarkDone()
            // 重新获取一个M和P绑定到park
            park.m.set(acquirem())
            park.attach.set(_p_)
        }
    }
}

runtime.gcDrain是用于扫描和标记堆内存中对象的核心方法,除了该方法外,我们还会介绍工作池、写屏障、标记辅助的实现原理。

工作池

在调用runtime.gcDrain函数时,运行时会传入处理器上的runtime.gcWork,这个结构体是垃圾收集器中工作池的抽象,它实现了一个生产者和消费者模型,我们可以该结构为起点从整体理解标记工作:
在这里插入图片描述
写屏障、根对象扫描、栈扫描都会向工作池中增加额外的灰色对象等待处理,而对象的扫描过程会将灰色对象标记成黑色,同时也可能发现新灰色对象,当工作队列中不包含灰色对象时,整个扫描过程就会结束。

为了减少锁竞争,运行时在每个处理器上会保存独立的待扫描工作,然而这会遇到与调度器一样的问题——不同处理器的资源不平均,导致部分处理器无事可做,调度器引入了工作窃取来解决这个问题,垃圾收集器也使用了差不多的机制平衡不同处理器上的待处理任务。
在这里插入图片描述
runtime.gcWork.balance方法会将处理器本地一部分工作放回全局队列中,让其他的处理器处理,保证不同处理器负载的平衡。

runtime.gcWork为垃圾收集器提供了生产和消费任务的抽象,该结构体持有了两个重要的工作缓冲区wbuf1wbuf2,这两个缓冲区分别是主缓冲区和备缓冲区:

type gcWork struct {
    wbuf1, wbuf2 *workbuf
    ...
}

type workbufhdr struct {
    node lfnode // must be first
    nobj int
}

type workbuf struct {
    workbufhdr
    obj [(_WorkbufSize - unsafe.Pointer(workbufhdr{})) / sys.PtrSize]uintptr
}

当我们向结构体中增加或删除对象时,它总会先操作主缓冲区,一旦主缓冲区空间不足或没有对象,就会触发主备缓冲区的切换;当两个缓冲区空间都不足或都为空时,会从全局的工作缓冲区中插入或获取对象,该结构相关方法的实现都非常简单,这里就不展开分析了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值