Dawn's Blogs

分享技术 记录成长

0%

GO语言杂谈 (1) 垃圾回收

垃圾回收GC

Mutator 即用户线程,Collector 为 GC 线程。

Serial GC:只有一个 Collector

Parallel GC:支持多个 Collector 同时回收

Concurrent GC:Mutator 和 Collector 可用同时执行

1655261106302

经典垃圾回收策略

追踪垃圾回收

  • 被回收的条件:不可达的对象

  • 过程:

    • 标记根对象 (GC roots): 静态变量、全局变量、常量、线程栈等标记为存活

    • 标记:从根对象出发,找到所有可达对象

    • 清理:回收所有不可达对象占据的内存空间。清理策略如下:

      • Copying GC: 将存活对象从一块内存空间复制到另外一块内存空间,原先的空间可以直接进行对象分配

      img

      • Mark-sweep GC: 将死亡对象所在内存块标记为可分配,使用 free list 管理可分配的空间。缺点在于:会产生内存碎片

      img

      • Mark-compact GC: 将存活对象复制到同一块内存区域(原地)的开头缺点在于:压缩计算的成本(需要计算位置,同时需要改变引用对象的指针)、实现复杂。

      img

引用计数

每一个对象都有一个引用计数,当且仅当引用数大于 0 时,不会被回收。

  • 优点
    • 内存管理的操作被平摊到程序运行中:指针传递的过程中进行引用计数的增减
    • 不需要了解 runtime 的细节:因为不需要标记 GC roots,因此不需要知道哪里是全局变量、线程栈等
  • 缺点
    • 开销大,因为对象可能会被多线程访问,对引用计数的修改需要原子操作保证原子性和可见性
    • 无法回收环形数据结构(自己引用自己,可能永远无法被回收)
    • 每个对象都引入额外存储空间存储引用计数
    • 虽然引用计数的操作被平摊到程序运行过程中,但是回收大的数据结构依然可能引发暂停(羊群效应)

分代 GC

分代 GC(Generational GC),是一种思想。它基于分代假说:大多数对象在分配后,很快就不再使用了。

每一个对象都有年龄,即经过 GC 的次数。对于年轻和年老的对象,指定不同的 GC 策略,降低整体内存管理的开销。使得不同年龄的对象处于 heap 的不同区域

  • 年轻代:很多很快就死了,可以采用 Copying Collection 实现对象回收。
  • 老年代:对象趋向于一直活在,可以采用 Mark-sweep Collection 实现对象回收。

Go 语言的垃圾回收机制

基本实现思路

Go语言垃圾回收(Garbage Collection,简称GC)的基本实现思路:

从每个包级的变量和每个当前运行函数的每一个局部变量开始,通过指针或引用的访问路径遍历,是否可以找到该变量。如果不存在这样的访问路径,那么说明该变量是不可达的,也就是说它是否存在并不会影响程序后续的计算结果。

Go 垃圾回收演进

mark & sweep

mark & sweep(标记-清扫)算法是基于追踪的垃圾回收算法。Go语言1.3版本之前使用。

内存单元并不会在变成垃圾立刻回收,而是保持不可达状态,直到到达某个阈值或者固定时间长度。这个时候系统会挂起用户程序,也就是 STW(stop the world),转而执行垃圾回收程序。垃圾回收程序对所有的存活单元进行一次全局遍历确定哪些单元可以回收。算法分两个部分:标记(mark)和清扫(sweep)。标记阶段表明所有的存活单元,清扫阶段将垃圾单元回收。

这个算法最大的问题就是GC执行期间,需要STW,把整个程序暂停

Go语言1.3版本对STW的暂停范围做出了优化,将sweep过程移出STW范围:

Go语言1.3版本对STW的优化

三色标记法

三色标记法是对mark过程的改进,三色标记法能够让用户程序和mark并发执行。Go语言1.5版本开始,使用基于三色标记法来实现垃圾回收,主要流程如下:

  1. 开始时所有对象都是白色的
  2. 从 root 开始找到所有可达对象,标记为灰色,放入待处理队列
  3. 遍历灰色对象队列,将其引用对象标记为灰色放入待处理队列,自身标记为黑色
  4. 重复进行步骤 3,直到灰色队列为空。此时白色对象即为垃圾,进行回收

写屏障

插入写屏障

插入屏障,主要做一件事情,在对象新增的同时给它着色,并且着色为灰色。因此打开了写屏障可以保证了三色标记法在并发下安全正确地运行。

在内存中有两种空间,堆和栈。栈空间的特点是容量小,但是要求相应速度快,因为函数调用弹出频繁使用, 所以插入屏障机制,在栈空间的对象操作中不使用。而仅仅使用在堆空间对象的操作中。

为了防止出现在栈上出现黑色对象引用白色对象的情况,所以Go在标记阶段完成时,启动STW,对栈重新进行三色标记扫描

删除写屏障

删除写屏障的操作是:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色

这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉。

为了不在栈上做屏障技术,又不对栈进行重新扫描:

所以应用混合写屏障。

混合写屏障

在Go语言1.7版本之前,只使用插入写屏障,缺点是结束时需要STW来重新扫描栈。

所以从Go语言1.8版本开始,使用混合写屏障,避免重复扫描的过程。其具体操作为:

  1. GC开始将栈上的对象全部扫描并将栈上可达对象标记为黑色(之后不再进行第二次重复扫描,无需STW)
  2. GC期间,任何在栈上创建的新对象,均为黑色
  3. 堆上被删除的对象标记为灰色
  4. 堆上被添加的对象标记为灰色

总结

  • Go v1.3:普通的mark & sweep算法,整个过程需要STW
  • Go v1.5:三色标记法,堆空间启动写屏障,栈空间不启动,全部扫描之后,需要STW重新扫描一次栈空间
  • Go v1.8:三色标记法+混合写屏障,屏障限制只在堆内存中生效。

Go语言垃圾回收实现

Go 语言中垃圾回收循环阶段如下所示,主要分为:

  • 触发垃圾回收

  • 标记准备阶段(STW)

  • 并行标记阶段

  • 标记终止阶段

  • 垃圾懒清扫阶段

1665401993401

触发垃圾回收

调步算法

目标是当达到上一个 GC 标记的内存的一倍时进行垃圾回收,但是因为与用户协程是并行运行的(用户协程会分配新的对象),所以必须在这之前就进行垃圾回收。

如果 GC 完成后的内存比目标内存小,说明触发 GC 的时机早了;反之则说明触发 GC 的时机晚了。根据这种计算方式,不断的调整下一次触发 GC 的时机

1665405629448

除了当达到上一个 GC 标记的内存的一倍时进行垃圾回收以外,还可以定期触发 GC

默认情况下,最长 2 分钟触发一次 GC

标记准备(STW)

1665402209881

标记准备阶段是为了开始标记而进行的初始阶段,需要计算一些指标

  • 计算需要多少协程,去完成标记工作:Go 语言完成 GC 的目标是利用 25% 的 CPU 时间去完成标记(4 核心 CPU 有 4 个 P,则给标记协程分配 1 个 P 专门去进行标记;若只有 1 个 P,则负责标记的协程与用户协程交替运行)。
  • 如何进行调度,使得标记协程运行:后台标记协程在调度阶段开启后台标记协程,当 STW 结束后调度器做的第一件事情就是去看需不需要启动后台标记协程。

并行标记

并行标记阶段做的第一件事,找到所有的根对象。ptrmask 是一个位图,这个位图在编译时期生成,用 0/1 标记内存空间位置(8字节)上是否是一个指针,只有当是一个指针的时候才对其进行标记,根对象被标记为黑色

1665402915601

当根对象扫描完成后,使用 bitmap 对象位图,这个位图标记了对象中的各个位置(8字节)是否是一个指针,若是指针才继续进行向下扫描标记,标记为灰色

1665403452399

在 Go 1.8 之后,标记阶段在堆上会启动混合写屏障。

清扫

对白色对象进行清扫。