go

Go 系列 垃圾回收

Posted by lichao modified on July 12, 2022

GC(Garbage Collection 垃圾回收),是一种自动管理内存的机制。传统的编程语言(C/C++)中,释放无用变量内存空间是程序员手动释放,存在内存泄漏或者释放不该释放内存等问题。为了解决这个问题,后续的语言(java\python\php\golang等)都引入了语言层面的自动内存管理,语言使用者无需对内存进行手动释放,内存释放由虚拟机(virtual machine)或者运行时(runtime)来对不再使用的内存资源进行自动回收。

背景

在应用程序中分配内存有两种方法:栈和堆。绝大多数程序员是从第一次写出导致栈溢出的递归程序时熟悉栈的。另一方面,堆是一个可以用作动态分配的内存池。

一般来说,在有GC的语言中,在栈中存储对象越多就越好,因为这些分配从来都不会被GC看到。编译器使用一种叫做逃逸分析(Escape Analysis)的技术去决定一个对象是否可以在栈中分配,或者必须要放在堆中。

垃圾回收算法

业界常见的垃圾回收算法有以下几种:

  • 引用计数:对每个对象维护一个引用计数,当引用该对象的对象被销毁时,引用计数减1,当引用计数器为0是 回收该对象。
    • 优点:对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收。
    • 缺点:不能很好的处理循环引用,而且实时维护引用计数,有也一定的代价。
    • 代表语言:Python、PHP、Swift
  • 标记-清除:从根变量开始遍历所有引用的对象,引用的对象标记为”被引用”,没有被标记的进行回收。
    • 优点:解决了引用计数的缺点。
    • 缺点:需要STW,即要暂时停掉程序运行。
    • 代表语言:Golang(其采用三色标记法)
  • 分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,而短的放入新生代,不同代有 不同的回收算法和回收频率。
    • 优点:回收性能好
    • 缺点:算法复杂
    • 代表语言: JAVA

发展史

  • go1.1,提高效率和垃圾回收精确度。
  • go1.3,提高了垃圾回收的精确度。
  • go1.4,之前版本的 runtime 大部分是使用 C 写的,这个版本大量使用 Go 进行了重写,让 GC 有了扫描 stack 的能力,进一步提高了垃圾回收的精确度。
  • go1.5,目标是降低 GC 延迟,采用了并发标记和并发清除,三色标记,write barrier,以及实现了更好的回收器调度设计文档1文档2,以及2015 版的Go talk
  • go1.6,小优化,当程序使用大量内存时,GC 暂停时间有所降低。
  • go1.7,小优化,当程序有大量空闲 goroutine,stack 大小波动比较大时,GC 暂停时间有显著降低。
  • go1.8,write barrier 切换到 hybrid write barrier,以消除 STW 中的 re-scan,把 STW 的最差情况降低到 50us,设计文档
  • go1.9,提升指标比较多
    • 1)过去 runtime.GC, debug.SetGCPercent, 和 debug.FreeOSMemory都不能触发并发 GC,他们触发的 GC 都是阻塞的,go1.9 可以了,变成了在垃圾回收之前只阻塞调用 GC 的 goroutine。
    • 2)debug.SetGCPercent只在有必要的情况下才会触发GC。
  • go.1.10,小优化,加速了GC,程序应当运行更快一点点。
  • go1.12,显著提高了堆内存存在大碎片情况下的 sweeping 性能,能够降低 GC 后立即分配内存的延迟。
  • go1.13,着手解决向操作系统归还内存的,提出了新的 Scavenger
  • go1.14,替代了仅存活了一个版本的 Scavenger,全新的页分配器,优化分配内存过程的速率与现有的扩展性问题,并引入了异步抢占,解决了由于密集循环导致的 STW 时间过长的问题。

STW时间

主要版本优化:

  • 1.5 版本以及以后版本的 GC 主要分为四个阶段,其中标记和清理都是并发执行的,但是标记阶段的前后需要使用 STW 来做 GC 的准备工作和栈的 rescan(这也是1.8的优化点)。
  • 1.8 版本引入混合屏障,最小化第一次 STW,写入屏障和删除屏障各有优缺点,Dijkstra 写入写屏障在标记开始时无需 STW,可直接开始,并发进行,但结束时需要 STW 来重新扫描栈,标记栈上引用的白色对象的存活;Yuasa 的删除写屏障则需要在 GC 开始时 STW 扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,但结束时无需 STW。Go1.8 版本引入的混合写屏障结合了 Yuasa 的删除写屏障和 Dijkstra 的写入写屏障,结合了两者的优点。 GC算法环节

Go 垃圾回收

自从 1.5 开始,Go 进入了一个并发的“标记-清除GC”(Mark-and-Sweep GC)。在标记阶段,GC 运行时会遍历所有的应用程序在堆中引用的对象并标记它们仍然在使用。这个对象的集合称为实时内存(Live Memory)。在这个阶段之后,在堆中没有被标记的其它对象都被认为是垃圾,然后在接下来的清理阶段,他们会被清理器回收。

下图展示了一段内存,内存中即有已分配掉的内存,也有未分配的内存,垃圾回收的目标就是把那些已经分配的但没 有对象引用的内存找出来并回收掉:

垃圾回收原理

上图中,内存块 1、2、4 号位上的内存块已被分配(数字 1 代表已被分配,0 未分配)。变量 a, b 为一指针,指向内存的 1、2 号位。内存块的 4 号位曾经被使用过,但现在没有任何对象引用了,就需要被回收掉。

垃圾回收开始时从 root 对象开始扫描,把 root 对象引用的内存标记为”被引用”,考虑到内存块中存放的可能是指针,所以还需要递归的进行标记,全部标记完成后,只保留被标记的内存,未被标记的全部标识为未分配即完成了回收。

事实证明,在现代的操作系统中,清理(释放内存)是一个非常快的操作,所以 Go 的标记-清除 GC 的 GC 时间很大程度上由标记组件主导,而不是清理组件。

标记包括遍历应用程序当前指向的所有对象,所以时间与系统中的实时内存总数成比例,而不是堆的总大小。换句话说,堆中有额外的垃圾不会增加标记的时间,因此也不会显著地增加一个GC周期的计算时间。

综上所述,更低频率的 GC 意味着更少的标记过程,也意味着更少的 CPU 花费,这看起来说合理的,但代价是什么呢?内存。运行时等待 GC 越久,系统内存中就会积累越多的垃圾。

常见 GC 算法详情

Golang GC 算法使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。 原因在于:

  • 对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 (Thread-Caching Malloc, TCMalloc)(线程缓存分配),基本上没有碎片问题。 并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 TCMalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。
  • 分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 CPU 来执行垃圾回收),而非减少停顿时间这一单一目标上。

相关术语:

  • 堆大小——包括所有在堆中的内存分配;一些是有用的,一些是垃圾
  • 实时内存——指当前被正在运行的应用程序引用的所有内存分配;不是垃圾

内存标记(Mark)

在内存分配中有 span 数据结构,span 中维护了一个个内存块,并由一个位图 allocBits 表示每个内存块的分配情况。在 span 数据结构中还有另一个位图 gcmarkBits 用于标记内存块被引用情况。 内存标记

如上图所示,allocBits 记录了每块内存分配情况,而 gcmarkBits 记录了每块内存标记情况。标记阶段对每块内存进行标记,有对象引用的的内存标记为 1(如图中灰色所示),没有引用到的保持默认为 0。

allocBits 和 gcmarkBits 数据结构是完全一样的,标记结束就是内存回收,回收时将 allocBits 指向 gcmarkBits,则代表标记过的才是存活的,gcmarkBits 则会在下次标记时重新分配内存,非常的巧妙。

三色标记法

前面介绍了对象标记状态的存储方式,还需要有一个标记队列来存放待标记的对象,可以简单想象成把对象从标记队列中取出,将对象的引用状态标记在 span 的 gcmarkBits,把对象引用到的其他对象再放入队列中。

三色只是为了叙述上方便抽象出来的一种说法,实际上对象并没有颜色之分。这里的三色,对应了垃圾回收过程中对象的三种状态:

  • 灰色:已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。对象还在标记队列中等待;
  • 黑色:已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。对象已被标记,gcmarkBits 对应的位为 1(该对象不会在本次GC中被清理);
  • 白色:未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。对象未被标记,gcmarkBits 对应的位为 0(该对象将会在本次 GC 中被清理);

GC算法环节

标记过程如下:

  1. 起初所有的对象都是白色的;
  2. 从根对象出发扫描所有可达对象,标记为灰色,放入待处理队列;
  3. 从待处理队列中取出灰色对象,将其引用的对象标记为灰色并放入待处理队列中,自身标记为黑色;
  4. 重复步骤(3),直到待处理队列为空,此时白色对象即为不可达的“垃圾”,回收白色对象;

例如,当前内存中有 A~F 一共 6 个对象,根对象 a,b 本身为栈上分配的局部变量,根对象 a、b 分别引用了对象 A、B, 而 B 对象又引用了对象 D,则 GC 开始前各对象的状态如下图所示:

三色标记

由于根对象引用了对象 A、B ,那么 A、B 变为灰色对象,接下来就开始分析灰色对象,分析A时,A没有引用其他对象很 快就转入黑色,B引用了D,则B转入黑色的同时还需要将D转为灰色,进行接下来的分析。如下图所示: 三色标记 上图中灰色对象只有D,由于D没有引用其他对象,所以D转入黑色。标记过程结束: 三色标记 最终,黑色的对象会被保留下来,白色对象会被回收掉。

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  2. 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
  3. 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

Stop The World

印度电影《苏丹》中有句描述摔跤的一句台词是:“所谓摔跤,就是把对手控制住,然后摔倒他。” 对于垃圾回收来说,回收过程中也需要控制住内存的变化,否则回收过程中指针传递会引起内存引用关系变化,如果错误的回收了还在使用的内存,结果将是灾难性的。

Golang 中的 STW(Stop The World)就是停掉所有的 goroutine,专心做垃圾回收,待垃圾回收结束后再恢复 goroutine。 STW 时间的长短直接影响了应用的执行,时间过长对于一些web应用来说是不可接受的,这也是广受诟病的原因之一。

为了缩短 STW 的时间,Golang 不断优化垃圾回收算法,这种情况得到了很大的改善。

No STW 存在的问题

假设下面的场景,已经被标记为灰色的对象2,未被标记的对象3对象2用指针 p 引用;此时已经被标记为黑色的对象4创建指针 q 指向未被标记的对象3,同时对象2将指针 p 移除;对象4已经被标记为黑色,对象3未被引用,对象2删除与对象3的引用,导致最后对象3被误清除; STW存在的问题 STW存在的问题 STW存在的问题 STW存在的问题 STW存在的问题

垃圾回收的原则是不应出现对象的丢失,也不应错误的回收还不需要回收的对象。如果同时满足下面两个条件会破坏回收器的正确性:

  • 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象(通俗的说就是 A 突然持有了 B 的指针,而 B 在并发标记的过程中已经被判定为白色对象要被清理掉的)
  • 条件 2: 从灰色对象出发,到达白色对象的路径未经访问过且被赋值器破坏(通俗的说就是 A 持有 B 的指针,这个持有关系被释放)

只要能够避免其中任何一个条件,则不会出现对象丢失的情况,因为:

  • 如果条件 1被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏;
  • 如果条件 2被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。

可能的解决方法: 整个过程 STW,浪费资源,且对用户程序影响较大,由此引入了屏障机制。

垃圾回收优化

屏障机制(Write Barrier)

前面说过 STW 目的是防止 GC 扫描时内存变化而停掉 goroutine,而写屏障就是让 goroutine 与 GC 同时运行的手段。 虽然写屏障不能完全消除 STW,但是可以大大减少 STW 的时间。

写屏障类似一种开关,在 GC 的特定时机开启,开启后指针传递时会把指针标记,即本轮不回收,下次GC时再确定。

GC 过程中新分配的内存会被立即标记,用的并不是写屏障技术,也即GC过程中分配的内存不会在本轮GC中回收。

把回收器视为对象,把赋值器视为影响回收器这一对象的实际行为(即影响 GC 周期的长短),从而引入赋值器的颜色:

  • 黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描。
  • 灰色赋值器:尚未被回收器扫描过或尽管已经扫描过,但仍需要重新扫描。

插入屏障(Dijkstra)- 灰色赋值器

写入前,对指针所要指向的对象进行着色。 避免条件 1(赋值器修改对象图,导致某一黑色对象引用白色对象。)因为在对象 A 引用对象 B 的时候,B 对象被标记为灰色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 灰色赋值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(ptr) //先将新下游对象 ptr 标记为灰色
    *slot = ptr
}

//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {   
  //step 1
  标记灰色(新下游对象ptr)   
  
  //step 2
  当前下游对象slot = 新下游对象ptr                    
}
 
//场景:
A.添加下游对象(nil, B)   //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B)     //A 将下游对象C 更换为B,  B被标记为灰色

Dijkstra 插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:

  • 由于 Dijkstra 插入屏障的“保守”,在一次回收过程中可能会残留一部分对象没有回收成功,只有在下一个回收过程中才会被回收;
  • 在标记阶段中,每次进行指针赋值操作时,都需要引入写屏障,这无疑会增加大量性能开销;为了避免造成性能问题,Go 团队在最终实现时,没有为所有栈上的指针写操作,启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段 STW 时对这些栈进行重新扫描。

堆区指针赋值,触发写屏障,栈区指针赋值,不触发写屏障。

插入屏障流程 插入屏障流程 插入屏障流程 插入屏障流程 插入屏障流程 插入屏障流程 插入屏障流程 插入屏障流程 插入屏障流程 插入屏障流程 特点:在标记开始时无需 STW,可直接开始,并发进行,但结束时需要 STW 来重新扫描栈

删除屏障 (Yuasa)- 黑色赋值器

写入前,对指针所在对象进行着色。避免条件2(从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏),因为被删除对象,如果自身是灰色或者白色,则被标记为灰色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot) 先将*slot标记为灰色
    *slot = ptr
}

//说明:
添加下游对象(当前下游对象slot 新下游对象ptr) {
  //step 1
  if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
          标记灰色(当前下游对象slot)     //slot为被删除对象, 标记为灰色
  }  
  //step 2
  当前下游对象slot = 新下游对象ptr
}
//场景
A.添加下游对象(B, nil)   //A对象,删除B对象的引用。B被A删除,被标记为灰(如果B之前为白)
A.添加下游对象(B, C)     //A对象,更换下游B变成C。B被A删除,被标记为灰(如果B之前为白)

删除屏障流程 删除屏障流程 删除屏障流程 删除屏障流程 删除屏障流程 删除屏障流程 删除屏障流程

特点:标记结束不需要 STW,但是回收精度低,GC 开始时 STW 扫描堆栈记录初始快照,保护开始时刻的所有存活对象;且容易产生“冗余”扫描。

混合屏障

大大缩短了 STW 时间:

  • GC 开始将栈上的对象全部扫描并标记为黑色;
  • GC 期间,任何在栈上创建的新对象,均为黑色;
  • 被删除的堆对象标记为灰色;
  • 被添加的堆对象标记为灰色;
1
2
3
4
5
6
// 混合写屏障
func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot)
    shade(ptr)
    *slot = ptr
}

混合屏障流程 混合屏障流程

场景一:对象被一个堆对象删除引用,成为栈对象的下游:

由于屏障的作用,对象7不会被误删除;
混合屏障流程 混合屏障流程 场景二:对象被一个栈对象删除引用,成为栈对象的下游 混合屏障流程 混合屏障流程 混合屏障流程

场景三:对象被一个堆对象删除引用,成为堆对象的下游 混合屏障流程 混合屏障流程 混合屏障流程

场景四:对象被一个栈对象删除引用,成为另一个堆对象的下游

混合屏障流程 混合屏障流程 混合屏障流程

Golang 中的混合屏障结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各goroutine的栈,使其变黑并一直保持,标记结束后,因为栈空间在扫描后始终是黑色的,无需进行re-scan,减少了STW 的时间。

辅助GC(Mutator Assist)

为了防止内存分配过快,在 GC 执行过程中,如果 goroutine 需要分配内存,那么这 goroutine 会参与一部分 GC 的工作,即帮助 GC 做一部分工作,这个机制叫作 Mutator Assist。

GC 协助将一个 GC 周期的内存分配负荷放在 goroutine 上,这个 goroutine 负责内存分配。没有这个机制,运行时将无法阻止堆在一个 GC 周期中无限增长。

因为 Go 已经有一个后台的 GC 工人,因此“协助”一词指的是我们的 goroutine 会帮助后台工人,特别是在标记工作中有所帮助。

为了进一步了解这一点,让我们举个例子:

1
someObject := make([]int, 5)

当这段代码执行之后,经过一系列的符号转换以及类型检查,goroutines生成一个 runtime.makeslice 的调用,它最终会调用 runtime.mallocgc 去申请分配一些内存给我们的切片。

查看 runtime.mallocgc 函数的内部,它展现了这个有趣的代码路径。

注意,我已经移除了这个函数的大部分内容,只在下面展示了相关部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 分配一个字节大小的对象
// 小的对象会在每个P的缓存空闲列表被分配
// 大的对象(> 32 kB)会在堆中直接被分配
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {

 // 一些错误检查和调试代码,在此省略

 // assistG是本次分配中用于消费的G
 // 如果GC当前未启动,则为nil
 var assistG *g
 if gcBlackenEnabled != 0 {
  // 为本次分配向当前用户G收费
  assistG = getg()
  if assistG.m.curg != nil {
   assistG = assistG.m.curg
  }
  // 对G的分配收费
  // 我们会在 mallocgc 的最后结算内部碎片
  assistG.gcAssistBytes -= int64(size)

  if assistG.gcAssistBytes < 0 {
   // 这个G处于欠债状态
   // 在内存分配之前,它需要帮助GC去修正这些欠债
   // 这必须要在禁用抢占之前发生
   gcAssistAlloc(assistG)
  }
 }
 
 // 实际的分配内存代码,下文省略
}

在上面的代码中,if assistG.gcAssistBytes < 0 这一行用于检查我们的goroutine是否处于分配欠债状态。“分配欠债”是一个花哨的说法,它表示这个 goroutine 在 GC 周期内分配的资源量超过了它的GC 工作量。

你可以把它想象成在一个 GC 周期中你的 goroutine 必须要为它的分配所交的“税”,除非这个税在内存实际分配之前已经预先支付过。此外,这个税款与这个 goroutine尝试申请的资源总量成正比。这提供了一定程度的公平性,使得申请很大空间的goroutine需要为它申请的那些空间买单。

所以,假设我们的goroutine在当前的GC周期中第一次申请空间,它会被强制去做GC协助的工作。这里有一行有趣的代码就是调用 gcAssistAlloc

这个函数负责一些内部管理的工作,然后最终会调用到gcAssistAlloc去完成真正的GC协助的工作。我不会详细介绍gcAssistAlloc函数,但从本质上说,它做了以下事情:

  • 确认这个goroutine没有在做一些不可抢占的工作(即系统goroutine)
  • 进行 GC 标记的工作
  • 检查这个 goroutine 是否仍有分配欠债,如果没有则返回
  • 跳转至2

现在应该已经很清楚了,任何goroutine在一个GC分配周期中执行包含内存分配的工作将会导致GCAssist的罚时。由于这项工作必须要在分配之前完成,因此这会导致goroutine在真正想要做的工作上呈现出延迟或者缓慢的现象。

在我们的API前端中,这意味着在GC周期中API的响应延迟会上升。正如之前提到的,每个服务器的负载上升,内存分配率也会上升,这又会反过来增加GC的速率(通常是每秒十几或二十几个周期)。我们现在知道,更多的GC周期意味着服务于API的goroutine会有更多的GC协助工作,然后,产生更高的API延迟。

标记清理

Marking setup

为了打开写屏障,必须停止每个 goroutine,让垃圾收集器观察并等待每个 goroutine 进行函数调用, 等待函数调用是为了保证 goroutine 停止时处于安全点。 安全点

1
2
3
4
5
6
7
8
// 如果goroutine4 处于如下循环中,运行时间取决于slice numbers的大小
func add(numbers []int) int {
    var v int
    for _, n := range numbers {
             v += n
     }
     return v
}

下面的代码中,由于 for{}循环所在的 goroutine 永远不会中断,导致始终无法进入 STW 阶段,资源浪费;Go 1.14 之后,此类 goroutine 能被异步抢占,使得进入 STW 的时间不会超过抢占信号触发的周期,程序也不会因为仅仅等待一个 goroutine 的停止而停顿在进入 STW 之前的操作上。

1
2
3
4
5
6
7
8
9
func main() {
    go func() {
        for {
        }
    }()
    time.Sleep(time.Milliecond)
    runtime.GC()
    println("done")
}
Marking

一旦写屏障打开,垃圾收集器就开始标记阶段,垃圾收集器所做的第一件事是占用 25% CPU。

标记阶段需要标记在堆内存中仍然在使用中的值。首先检查所有现 goroutine 的堆栈,以找到堆内存的根指针。然后收集器必须从那些根指针遍历堆内存图,标记可以回收的内存。

当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。 对象标记

Mark终止

关闭写屏障,执行各种清理任务(STW - optional ) 清理

Sweep (清理)

清理阶段用于回收标记阶段中标记出来的可回收内存。当应用程序 goroutine尝试在堆内存中分配新内存时,会触发该操作,清理导致的延迟和吞吐量降低被分散到每次内存分配时。

清除阶段出现新对象:

清除阶段是扫描整个堆内存,可以知道当前清除到什么位置,创建的新对象判定下,如果新对象的指针位置已经被扫描过了,那么就不用作任何操作,不会被误清除,如果在当前扫描的位置的后面,把该对象的颜色标记为黑色,这样就不会被误清除了

什么时候进行清理?

主动触发(runtime.GC()) 被动触发 (GC百分比、定时)

垃圾回收触发时机

Golang GC 使用一个调度器(Pacer) 去决定下一个 GC 周期何时触发。调度被建模成一个类似于控制问题的模型,它试图找到合适的时间去触发 GC 周期,以使它可以达到预期的堆大小目标。Go 的默认调度器会在堆的大小是原来的两倍时尝试触发 GC 周期。 它是通过在当前的 GC 周期的标记(Mark)阶段的终止阶段设置下一次触发的堆大小实现的。因此在标记完所有的实时内存之后,它就能做出当下一次的堆大小达到当前实时集的 2 倍时就运行 GC 的决定。2 倍这个数字来自于一个 GOGC 的环境变量,运行时用它来设置触发比例。

内存分配量达到阀值触发GC

每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC。

1
阀值 = 上次GC内存分配量 * 内存增长率 

内存增长率由环境变量 GOGC 控制,默认为100,即每当内存扩大一倍时启动GC。

定期触发GC

默认情况下,最长2分钟触发一次GC,这个间隔在 src/runtime/proc.go:forcegcperiod 变量中被声明:

1
2
3
4
5
6
1. // forcegcperiod is the maximum time in nanoseconds between garbage 
2. // collections. If we go this long without a garbage collection, one 
3. // is forced to run. 
4. // 
5. // This is a variable for testing purposes. It normally doesn't change. 
6. var forcegcperiod int64 = 2 * 60 * 1e9

手动触发

程序代码中也可以使用 runtime.GC() 来手动触发 GC。这主要用于 GC 性能测试和统计。

GC 性能优化

GC 性能与对象数量负相关,对象越多 GC 性能越差,对程序影响越大。

所以 GC 性能优化的思路之一就是减少对象分配个数,比如对象复用或使用大对象组合多个小对象等等。

另外,由于内存逃逸现象,有些隐式的内存分配也会产生,也有可能成为 GC 的负担。

关于 GC 性能优化的具体方法,后面单独介绍。

清理

参考文献

【1】Golang三色标记、混合写屏障GC模式图文全分析

【2】Golang 三色标记

【3】The Journey of Go’s Garbage Collector

【4】Garbage Collection In Go

【5】Go垃圾回收

【6】Go内存分配和管理

【7】GC的认识

【8】如何做Go性能分析

Richard L.Hudson-Go 的垃圾收集之旅

Mark Pusher - Golang 实时 GC 的理论与实践

Austin Clements - Go 1.5 并发垃圾收集调度器