浅析Golang的垃圾回收机制

发表时间: 2022-06-29 18:00

作者:lajipeng,腾讯TEG未成年人服务产品开发组员工

什么是GC?

GC ,全称 Garbage Collection ,即垃圾回收,是一种自动内存管理的机制。 当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而负责垃圾回收的程序组件,即为垃圾回收器。

也就是说,GC是一个过程,而golang gc是一个垃圾回收器,是一个程序组件,当然,还有很多问题没有得到答案,我就尝试着带着问题,去寻求答案,慢慢的去理解和分析golang gc。

什么是垃圾?

  • 现实中:你开着汽车行驶在马路上,吃完东西后丢出的剩余包装袋,是垃圾;你随地丢下的烟头是垃圾;但是你丢下的钱包或许不应该归类为垃圾
  • 程序中:程序运行过程中,会不断的创建对象,当程序运行过程中或完结后,已经确定不会再次使用的,还占用着内存的对象是垃圾。

为什么需要回收?

  • 现实中:
    1、人们在生活中会产生垃圾,并且会有人随处遗留下垃圾。2、我们需要一个干净的马路,不然会导致很多因为垃圾产生的问题(环保问题,行车安全....)。
  • 程序中:
    1、程序运行周期内,会不断的创建对象,,创建对象的时候需要分配内存空间来存储这些对象。2、很多的对象的生命周期并不长,远远小于程序本身的运行时间。3、因为内存空间是有限的,如果我们不去回收已经不被使用的对象所占用的空间,会导致程序可使用的内存空间不断减少或没有空间可用。

谁来回收?

  • 现实中:自然是环卫师傅,骑着小电车,手里拿着小夹子,来回穿梭在城市的角落。
  • 程序中:自然就是本文讨论的这个Golang GC模块来完成这些操作。

如何回收?

  • 现实中:
    1、环卫师傅每次骑着小电车,来回穿梭在他所管辖的区域内。2、他会不断地用眼睛扫描管辖区域内的每个角落,通过眼睛和大脑,给所见的每一件事物打上标记(花草树木,路边的行人,行驶的轿车,散落的烟头,揉成一坨的纸巾)3、他会在标识的事务中标记出垃圾。4、用随身携带的工具把垃圾拾起放入垃圾车里面。5、如果垃圾相对而言还有价值(塑料瓶,纸壳...),则会被放入一个专属的小袋子中,提供个下一个流程(出售,二次使用)。
  • 程序大致也是如此,不过程序中的垃圾都是需要二次使用的:
    1、makr(标记)识别垃圾2、sweep(垃圾销毁)3、垃圾回收到可用内存管理链表上(freelist)

一个更头疼的问题,怎么判定某个事物或程序中的空间是垃圾?

  • 现实中:
    1、首先,这个是一个不好掌控的问题,大爷终究是大爷,他可以说路边的杂草是垃圾,他也可以不去管理这些杂草。2、但是,社会共识会告诉我们每一个人,什么是垃圾,什么不是垃圾,什么可以直接收集到垃圾站(烟头、纸屑),什么事物最好不要(掉落的身份证)。
  • 程序中:
    1、程序本身并不知道,什么是垃圾,什么不是。2、我们需要告诉程序,什么是垃圾。3、标记对象是否是垃圾的方法大致分为两类:引用计数式:在对象上标记一个数字,记录有多少变量在使用它,
    Python,PHP等语言的GC实现就是这个方式。(引用计数式)。
    追踪式:从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。Golang、 Java的实现等均为追踪式 GC。

引用计数式和追踪式的优劣

引用计数法

优点: 1、引用计数法可以在对象不活跃时(引用计数为0)立刻回收其内存。因此可以保证堆上时时刻刻都没有垃圾对象的存在(先不考虑循环引用导致无法回收的情况)。 2、引用计数法的最大暂停时间短。由于没有了独立的GC过程,而且不需要遍历整个堆来标记和清除对象,取而代之的是在对象引用计数为0时立即回收对象,这相当于将GC过程“分摊”到了每个对象上,不会有最大暂停时间特别长的情况发生。

劣势:

  1. 引用计数的增减开销在一些情况下会比较大,比如一些根引用的指针更新非常频繁,此时这种开销是不能忽视的。
  2. 另外对象引用计数器本身是需要空间的,而计数器要占用多少位也是一个问题,理论上系统内存可寻址的范围越大,对象计数器占用的空间就要越大,这样在一些小对象上就会出现计数器空间比对象本身的域还要大的情况,内存空间利用率就会降低。
  3. 还有一个问题是循环引用的问题,假设两个对象A和B,A引用B,B也引用A,除此之外它们都没有其他引用关系了,这个时候A和B就形成了循环引用,变成一个“孤岛”,且它们的引用计数都是1,按照引用计数法的要求,它们将无法被回收,造成内存泄漏。

追踪式

优点:

  1. 相对于引用计数算法,完全不必考虑环形引用问题。
  2. 操纵指针时没有额外的开销。
  3. 与用户程序完全分离。

缺点:

  1. 标记清扫算法是非实时的,它要求在垃圾收集器运行时暂停用户程序运行,这对于实时和交互式系统的影响非常大。
  2. 基本的标记清扫算法通常在回收内存时会同时合并相邻空闲内存块,然而在系统运行一段时间后仍然难免会生成大量内存碎片,内存碎片意味着可用内存的总数量上足够但实际上不可用,同时还会增加分配内存的时间,降低内存访问的效率。
  3. 保守式的标记清扫算法可能会将某些无用对象当做存活对象,导致内存泄露。

Golang的GC

首先,Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。可见,go在追踪式的GC模式中,引入了并发三色标记清扫算法(V1.5),在go的不断调优下,已经是做到了准实时(1ms以内)的gc过程。

STW

我们知道,在追踪式的GC过程中,我们需要进行两步操作,分别是标记清除,为了避免程序本身运行给GC标记和清除带来不一致性,导致误删,为了保证一致性,golang会停止除了GC模块程序之外的程序运行,这个过程被称为STW

在这个过程中整个用户代码被停止或者放缓执行, STW 越长,对用户代码造成的影响(例如延迟)就越大,早期 Go 对垃圾回收器的实现中 STW 的停顿时间甚至是达到s级,对时间敏感的实时通信等应用程序会造成巨大的影响。举例:

package mainimport (   "runtime"   "time")func main() {   go func() {     for {      }  }()    time.Sleep(time.Millisecond)	   runtime.GC()    println("OK")}

在这行代码中,程序步骤大概如下:

  1. 主程序go func开启一个goroutine,并在goroutine中开启一个for死循环。
  2. 主程序Sleep(time.Millisecond),次数子协程已经开始执行
  3. 主协程主动调用GC的触发方法runtime.GC()
  4. 打印ok,退出程序

结果:不会有打印结果(v1.14以前)。 v1.14之后可以打印。

原因就在于GC中,会试图去等待其他goroutine所有的用户代码停止,但显然,示例中的代码因为for {}无法停止,导致始终无法进入 STW 阶段,造成程序卡死。

可见,如果实际业务中在,当某个goroutine的代码一直得不到停止,就会导致程序一直停留在STW阶段而无法执行GC,造成程序卡死或其他问题。

STW优化的版本迭代

既然gc stw的持续时间,直接影响到程序中因为gc所带来的负面影响,那我们需要想办法去缩短stw所持续的时间,所以,go在各个版本中,便对gc的过程做了一定的优化。

go V1.1

  1. 程序运行开始。
  2. 触发GC。
  3. GC 在需要进入 STW 时,需要通知并让所有的用户态代码停止(stop the world)。
  4. mark(标记)。
  5. sweep(清理)。
  6. start the world。

在这个过程中,gc过程是串行的,stw的时间 = mark时间+ sweep时间

go V1.3版本的优化就是Mark和Sweep分离. Mark STW, Sweep并发 也就是大致变成了一下流程:

  1. 程序运行开始。
  2. 触发GC。
  3. GC 在需要进入 STW 时,需要通知并让所有的用户态代码停止(stop the world)。
  4. mark。
  5. start the world。
  6. sweep(并发)。
    可以看到,这个时候的stw只存在于mark阶段,且sweep清扫阶段变成了并发执行。

go V1.5 再次将mark也变成了并发的。

go1.8 整合插入屏障和删除屏障为混合写屏障,将STW的停顿时间真正进入到毫秒级。

go 1.14:替引入了异步抢占,解决了由于密集循环导致的 STW 时间过长的问题。也就是上文示例所示的因为GC等待用户代码停止时间过长的问题

mark(标记)是如何实现的?

从上文可知,mark所作的事情,是识别出内存中的垃圾,我们也知道,当一个被声明的对象,我们可以确定已经没有程序需要使用到它的时候,他就可以被判定为垃圾。 而go是基于追踪式方法又引入了三色标记法,实现了垃圾的识别问题。

三色标记法

从垃圾回收器的视角来看,三色标记法是一种抽象,它规定了三种不同类型的对象,并用不同的颜色相称:

  • 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
  • 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
  • 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

回收器

回收器(Collector):负责执行垃圾回收的代码。对应的还有赋值器

赋值器

赋值器(Mutator):这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上进行操作。

追踪式GC方法提到一个很重要的内容,那就是根对象。那什么是根对象呢?

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

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

也就是说,三色标记法是从根对象出发,不断地把白色对象(可能死亡)一步步标记为灰色(确认存活,还需要扫描)、黑色对象(存活)的过程。

可以简单理解为(不是真正的go gc的逻辑):

  1. GC开始,所有对象都是白色节点
  2. stw开始 用户程序停止
  3. mark,三色标记法标记可达对象。
  4. 查找所有灰节点的子节点(最开始的时候根节点是灰色)。
  5. 把灰色节点下可以找到有关联的白色子节点标记为灰色。
  6. 灰色节点下所有子节点遍历完之后,把灰色节点本身标记为黑色(不是垃圾)。
  7. 再从灰节点列表中拿出一个灰节点,重复子节点查找,标记操作。
  8. 所有节点遍历完 mark结束。
  9. stw 结束 用户程序开始。
  10. sweep 清理剩下的所有白色节点(垃圾)。

动图示例(图中的颜色不是黑灰白,而是使用蓝黄白相对应):

可以看到,以上的mark过程即2-9的过程,同样处于一个stw的过程中,也就是说,Golang GC真正运行的时候,用户程序是不能够运行的。

为什么需要这样呢,因为GC mark的时候,如果不关闭用户程序的操作,那我们就可能出现以下情况导致错误标记和清除:

其中C是根节点,在gc开始的准备阶段被标记为灰色

时序

回收器

赋值器

1

A是C的子节点,A着色为灰色


2

C的所有子节点遍历完毕,C被着色为黑色


3


C.ref3 = C.ref2.ref1 C关联B

4


A断开于B的关联

5

遍历灰色节点A的所有子节点,因为此时 A.ref1nil ,所以没有子节点


6

A被着色为黑色


7

回收器:由于所有子节点均已标记,回收器也不会重新扫描已经被标记为黑色的对象,此时 A 被着色为黑色, scan(A) 什么也不会发生,进而 B 在此次回收过程中永远不会被标记为黑色,进而错误地被回收。


结果:虽然根节点有指向B的关系,但是B被错误的回收了。

为什么会出现这个情况?很明显,根本原因是当回收器在执行标记的时候,赋值器也在不断的更改对象之间的关系。所以在mark标记过程中,我们依旧要STW这个过程。

有办法解决这个问题吗?也就是我在mark标记的过程中,赋值器同样可以执行,也就是用户程序可以运行。

写屏障

写屏障是一个在并发垃圾回收器中才会出现的概念,垃圾回收器的正确性体现在:不应出现对象的丢失,也不应错误的回收还不需要回收的对象。 大佬们已经证明,当以下两个条件同时满足时会破坏垃圾回收器的正确性:

  • 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;
  • 条件 2: 从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。

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

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

也就是说,在上面举例中,C黑色节点不能直接指向一个白色节点"B",或者灰色节点A不能删除白色节点"B"的引用,就不会导致错误回收的问题。

很显然,在go想要把mark过程从stw中分离出来,与业务程序并行,就要解决整个问题,写屏障是go团队最开始的方法。

写屏障是针对赋值器改变对象间引用关系改变时的一种同步机制,有两种非常经典的写屏障:Dijkstra 插入屏障和 Yuasa 删除屏障。

Dijkstra 插入屏障

插入屏障旨在破坏正确性的条件一,也就是黑色对象建立于白色对象的链接。

// 灰色赋值器 Dijkstra 插入屏障func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {`    shade(ptr)   *slot = ptr}

因为黑色对象不会再被扫描标记,那如果一旦有未扫描的对象被关联到一个黑色对象上,且整个白色对象没有其他关联,就会导致白色对象被标记清除。

所以Dijkstra 插入屏障在建立关系之前,把指针本身着色成灰色,放入待扫描的灰色节点池中,我们知道,mark会扫描标记所有灰色池,所有灰色最终都会变成黑色而不会被清除

显然,这可以解决并发mark和用户程序赋值器的不一致性问题,但是它的缺点就是可能会导致应该被删除的对象,在mark过程中因为存在赋值操作,而在本次gc过程中未被回收。

网图说明:

可以看见,在C于B建立关系ref3的时候,A并未扫描到B,但是B已经变成灰色,进而最终被标记成黑色。

Yuasa 删除屏障

插入屏障针对的是条件1,那么删除屏障就是针对的条件2:从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。

1.  `// 黑色赋值器 Yuasa 屏障` 2.  `func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {` 3.  `    shade(*slot)` 4.  `    *slot = ptr` 5.  `}`

通过代码可以看到,当赋值器需要删除节点的关联时,会将父节点的颜色shade(*slot)着色成灰色,也就是需要重新扫描。

网图说明:

很明显,删除屏障的缺点就是会带来重复扫描的问题,因为一旦存在删除关系的操作,就需要重新扫描。

但是有缺点没关系,相对优秀就可以。在三色标记法+写屏障的保证下,我们就可以让mark的大部分过程从stw中解放出来,并且可以对mark进行并发操作。

这时候,我们理解的gc流程就可以优化成:

  1. GC开始,所有对象都是白色节点
  2. stw开始 用户程序停止
  3. gc准备工作(根节点标记为灰色,写屏障开启...)
  4. stw结束,
  5. mark开始(并发,于用户程序并行)。
  6. 查找所有灰节点的子节点(最开始的时候根节点是灰色)。
  7. 把灰色节点下可以找到有关联的白色子节点标记为灰色。
  8. 灰色节点下所有子节点遍历完之后,把灰色节点本身标记为黑色(不是垃圾)。
  9. 再从灰节点列表中拿出一个灰节点,重复子节点查找,标记操作。
  10. 所有节点遍历完 mark结束。
  11. sweep 清理剩下的所有白色节点(垃圾)。

也就是说,stw的过程仅包含了2-4这几个步骤,那stw的时间相对的减少很多,并且mark、sweep的并发操作,可以让整个流程都缩短很多。

写屏障优化

很显然,golang的开发者们知道自己选择的写屏障的优缺点,所以也在版本的更迭中不断的去优化,使其能做到更好。

如上版本迭代所示,golang团队的人员在V1.5进入mark的并发版本(普通写屏障)之后,V1.8就优化了写屏障变成混合写屏障,于其他的GC优化一起,把Golang GC真正的带入了毫秒级时代。

Golang GC的流程

当然,这只是我们所梳理出来的GC过程,真正的Golang GC流程应该是有出入的,比如mark结束之后应该会有写屏障关闭的阶段,而这个阶段应该也会有一个stw。

当前版本的 Go 以 STW 为界限,可以将 GC 划分为五个阶段(来自:https://www.bookstack.cn/read/qcrao-Go-Questions/GC-GC.md )

阶段

说明

赋值器状态

SweepTermination

清扫终止阶段,为下一个阶段的并发标记做准备工作,启动写屏障

STW

Mark

扫描标记阶段,与赋值器并发执行,写屏障开启

并发

MarkTermination

标记终止阶段,保证一个周期内标记任务完成,停止写屏障

STW

GCoff

内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭

并发

第一个阶段 gc开始 (stw)

  1. stop the world 暂停程序执行
  2. 启动标记工作携程( mark worker goroutine ),用于第二阶段
  3. 启动写屏障
  4. 将root 跟对象放入标记队列(放入标记队列里的就是灰色)
  5. start the world 取消程序暂停,进入第二阶段

第二阶段 marking(这个阶段,用户程序跟标记携程是并行的)

  1. 从标记队列里取出对象,标记为黑色
  2. 然后检测是否指向了另一个对象,如果有,将另一个对象放入标记队列
  3. 在扫描过程中,用户程序如果新创建了对象 或者修改了对象,就会触发写屏障,将对象放入单独的 marking队列,也就是标记为灰色
  4. 扫描完标记队列里的对象,就会进入第三阶段

第三阶段 处理marking过程中修改的指针 (stw)

  1. stop the world 暂停程序
  2. 将marking阶段 修改的对象 触发写屏障产生的队列里的对象取出,标记为黑色
  3. 然后检测是否指向了另一个对象,如果有,将另一个对象放入标记队列
  4. 扫描完marking队列里的对象,start the world 取消暂停程序 进入第四阶段

第四阶段 sweep 清除白色的对象 到这一阶段,所有内存要么是黑色的要么是白色的,清楚所有白色的即可

有没有GC的优劣

首先,我们可以先了解一下GC在那些语言里面有涉及。

有GC模块的语言:

  • Golang
  • Python
  • PHP
  • Java
  • ...

没有GC的语言:

  • C
  • C++

看到这样的集合分类,大致就明白,没有GC的优势:

  1. 程序运行应该会快一些,没有GC的额外开销。
  2. 精准的手动内存管理,极致的利用机器的性能

劣势应该就是需要手动管理内存,开发周期和开发所需要的技术知识储备会要求高一些。

相对的,有GC的优势就是 开发人员可以专心完成业务代码,而不用在内存管理这块花太多心思。同样的,GC所带来的开销肯定会让程序相对没那么快。各有优劣,根据实际情况选择就好。

JAVA的GC

我倒是没怎么使用过Java,因为学习GC简单了解了一下。

java的GC也是基于追踪式方式的,它本身实现的Java GC完成了分代GC的具体实现。

分代GC

简单说,分代GC的目的是减少需要频繁扫描的节点数量,希望每一次标注的扫描都是有意义的。

他的背景是:大多数的对象是不会持久活跃的,而真正持久活跃的对象不用每一次都去参与正常的GC。

他的实现:所以使用新生代老年代进行对象进行区分,然后根据不同的频率对不同类型的对象进行扫描回收。

年轻代

几乎所有新生成的对象首先都是放在年轻代的。当一个变量在新生代经历一次GC之后,他的年龄+1。

年老代

在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

分代GC回收

很明显,当对象经过分代后,我们可以用不同的GC,以不同的频率去清理对应代的对象。新生代的就需要频繁一些,而老年代的就可以间隔长一些,不用每次都去扫描,这样可以减少GC过程中需要扫描的对象的额数量。

当然,不管是Java还是Golang的GC,都是需要经过STW这个过程的, 不过经过不断的迭代更新,都已经已达到了用户代码几乎无法感知到的状态。

GOlang 和 Java对GC的调优

Java

可以通过各种参数调优,所以Java的GC好像是必须要熟悉的。

Golang

Go 的 GC 被设计为极致简洁,与较为成熟的 Java GC 的数十个可控参数相比,严格意义上来讲,Go 可供用户调整的参数只有 GOGC 环境变量,他简单来说就是一个阈值,数值越大,GC执行的频率越低。

当然GC调优的核心还是:

  1. 减小GC对资源的消耗,提高业务程序本身对 CPU 的利用率。
  2. 减少并复用内存。
  3. 需要时,降低 GC 的运行频率。

GO 需要引入分代GC吗?

看网上的有人说,go的GC优化或许就会基于当前的GC引入分代GC的内容,因为当前GC虽然效率上去了,但是却是用CPU的开销来撑起来的,所以还有优化的空间。

也有一些人说,分代假设并不适用于 Go 的运行栈机制,年轻代对象在栈上就已经死亡,扫描本就该回收的执行栈并没有为由于分代假设带来明显的性能提升。这也是这一设计最终没有被采用的主要原因。