什么是 垃圾收集
垃圾收集

在计算机科学中,垃圾回收(Garbage Collection,缩写为GC)是指一种自动的存储器管理机制。
当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间。
垃圾回收器可以减轻程序员的负担,也减少程序中的错误。
(摘自 wiki)


垃圾收集 范围

在程序中,我们使用最多的内存,就是 堆(Heap)栈(Stack)

但是垃圾回收不回收 栈中内存
主要原因 是 栈是一块 专用内存,专门为了函数执行而准备的,存储着函数中的 局部变量 以及 调用栈
除此以外,栈中的数据都有一个特点 —— 简单。
比如:局部变量 就不能被 函数外 访问,所以这块内存 用完就可以直接释放。
正是因为这个特点,栈中数据 可以通过 简单的编译器指令 自动清理,也就不需要通过 GC 来回收了。


垃圾标记 算法

引用计数法

给对象中添加一个 引用计数器

垃圾对象
引用计数法
引用计数法

所谓对象之间的相互引用问题,如下面代码所示:

package main

type ReferenceCountingStruct struct {
	intance interface{}
}

func main() {
	foo := new(ReferenceCountingStruct)
	bar := new(ReferenceCountingStruct)

	foo.intance = bar
	bar.intance = foo

	foo = nil
	bar = nil
}

除了 对象foo和bar 相互引用着对方之外,这两个对象之间再无任何引用。
但是它们因为互相引用对方,导致它们的引用计数器都不为0,于是 引用计数算法无法通知 GC 回收器回收他们


可达性分析法

GC Roots
  • 搜索到的对象 都标记为 非垃圾对象
  • 其余未标记的对象 都是 垃圾对象

垃圾回收 算法

复制 算法

将内存分为 大小相同的两块,每次使用其中的一块。
当这一块的内存使用完后,就将 还存活的对象 复制到另一块去,然后再把使用的空间一次清理掉。
这样就使每次的内存回收都是对内存区间的一半进行回收


标记-清除 算法

分为 “标记” 和 “清除” 阶段:

  • 标记存活的对象, 统一回收所有未被标记的对象(一般选择这种);也可以反过来,标记出所有需要回收的对象
  • 回收所有被标记的对象

它是最基础的收集算法,比较简单,但是会带来两个明显的问题:

效率问题 (如果需要标记的对象太多,效率不高)
空间问题(标记清除后会产生大量不连续的碎片)


标记-整理 算法

根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,
但后续步骤 不是直接对可回收对象回收,而是让 所有存活的对象向一端移动,然后直接清理掉端边界以外的内存


那么,接下来,本人就来讲解下 Go的 垃圾回收发展史

Go 1.3前 —— 标记-清除(mark and sweep)算法

具体流程:

1. 暂停程序业务逻辑, 分类出 可达 和 不可达 的对象,然后做上标记

2. 开始标记,程序找出它 所有可达的对象,并做上标记

3. 标记完了之后,然后开始清除 未标记 的对象

4. 循环重复上述过程,直到程序生命周期结束


缺点:

  • 以上整个过程都是 砸瓦鲁多(STW)的!

    也就是说,在整个过程中,用户协程 是阻塞的!
  • 需要扫描 整个heap区
  • 会产生 大量的内存碎片

在Go1.3版本之前,全部的GC时间 都是 被Dio控制(STW)的

这样导致程序暂停的时间过长,影响程序的运行性能

Go 1.3 —— 改进 标记-清除(mark and sweep)算法

所以 Go 1.3时期,做了简单的优化,将STW的时间缩短,减少STW暂停的时间范围:

上图 主要是将 STW 的步骤提前了 异步
因为在 Sweep清除 的时候,可以 不需要STW,因为这些对象已经是 不可达对象 了,不会出现 回收写冲突 等问题。


三色标记法
Go 1.5 —— 三色标记法
三色标记法

具体流程:

1. 每次新创建的对象,默认的颜色都是标记为“白色”

2. 每次GC回收开始, 会从根节点开始遍历所有对象,并把遍历到的对象从白色集合放入“灰色”集合

3. 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合

4. 重复第三步, 直到灰色集合中无任何对象

5. 回收所有的白色标记表的对象. 也就是回收垃圾

总结一下,为了在 GC过程 中保证数据的安全,在 开始三色标记之前 就会加上 STW
在扫描 确定黑白对象之后放开STW


但是很明显这样的GC扫描的性能实在是太低了,根本没有解决 因 STW 导致 程序运行变慢的情况。
因此,Golang官方就实现了 “没有STW” 的三色标记法

三色标记法
如果没有STW的三色标记法

具体流程:

1. 标记 初始黑色节点

2. 在还没有扫描到对象2的时候,已经标记为黑色的对象4,此时创建指针q,并且指向白色的对象3

3. 与此同时,灰色的对象2将指针p移除,那么白色的对象3实则就是被挂在了已经扫描完成的黑色的对象4下

4. 正常指向三色标记的算法逻辑,将所有灰色的对象标记为黑色

5. 回收所有白色对象

于是乎,被对象4后来所引用的对象,就被回收掉了
这样的设计,如果被用来开发,一定会造成事故!


那么,本人来分析一下,为什么会出现 “对象引用的对象被回收” 的现象?

细节分析

从上面案例中,我们可以看出,有两种情况,在三色标记法中,是不希望被发生的:

  • 条件1:一个白色对象被黑色对象引用
    (白色被挂在黑色下)
  • 条件2:灰色对象 与 这个白色对象 之间的可达关系的白色对象遭到破坏
    (灰色同时断开了对该白色的引用)

如果当以上两个条件同时满足时,就会出现对象被错误回收的现象!


为了防止这种现象的发生,最简单的方式就是STW,直接禁止掉其他用户程序对对象引用关系的干扰。

但是STW的过程有明显的资源浪费,对所有的用户程序都有很大影响。

那么本人来介绍下,在Golang中,是如何防止上述问题发生的:

屏障机制:
强三色不变式弱三色不变式

强三色不变式


强三色不变色式 实际上是强制性的 不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况 了。


弱三色不变式


弱三色不变式强调:

黑色对象可以引用白色对象,
但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。

这样实则是黑色对象引用白色对象,白色对象处于一个危险被删除的状态,但是上游灰色对象的引用,可以保护该白色对象,使其安全

这就使得 所有被黑色对象引用的白色对象都处于灰色保护状态,就破坏了 之后的收集过程中,被引用的白色,因为不会被之后扫描到的灰色引用而被回收


插入屏障删除屏障

插入屏障

实现流程

在A对象引用B对象的时候,将B对象标记为 灰色
(将B挂在A下游,B必须被标记为灰色)

满足

强三色不变式
(不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)

伪码

添加下游对象(当前下游对象slot, 新下游对象ptr) {   
  //1
  标记灰色(新下游对象ptr)   
  
  //2
  当前下游对象slot = 新下游对象ptr  				  
}

场景

A.添加下游对象(nil, B)   //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B)     //A 将下游对象C 更换为B,  B被标记为灰色
栈堆

具体流程:

1. 初始状态

2. 标记根节点为灰色

3. 遍历灰色对象的引用对象,并将其放入灰色集合当中,当灰色的引用对象都被遍历过后,将当前灰色对象放入黑色集合当中

4. 【特殊情况】并发指向

5. 插入 写屏障


可以看到:

当标记过程中,如果已经被遍历过的黑色节点,新增指向一个白色节点,就会根据当前黑色对象所在区域,进行不同策略的执行:

  • 在堆区:将白色对象变成灰色对象,再指向该灰色对象
  • 在栈中:直接指向该白色对象

6. 递归遍历标记

7. 回收白色

但是栈不添加写屏障,当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况(如上图的对象9)
所以要对 重新进行 三色标记扫描,但这次为了对象不丢失,要对本次标记扫描启动STW暂停,直到栈空间的三色标记结束。

8. 停止STW

在标记完堆区和栈后,就会停止STW

9. 清除白色节点

最终,清除白色节点,结束本次GC


删除屏障

实现流程

被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。

满足

弱三色不变式
(不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)

伪码

添加下游对象(当前下游对象slot, 新下游对象ptr) {
  //1
  if (当前下游对象slot是灰色 || 当前下游对象slot是白色) {
  		标记灰色(当前下游对象slot)     //slot为被删除对象, 标记为灰色
  }
  
  //2
  当前下游对象slot = 新下游对象ptr
}

场景

A.添加下游对象(B, nil)   //A对象,删除B对象的引用。  B被A删除,被标记为灰(如果B之前为白)
A.添加下游对象(B, C)		 //A对象,更换下游B变成C。   B被A删除,被标记为灰(如果B之前为白)

具体流程

1. 初始状态

2. 标记根节点

3. 删除 1-> 5 的引用关系

4. 插入 删除屏障


当删除一个对象的引用时,就会如上图所示:

无论是在堆还是在栈上,都会将 被引用的白色对象 变成 灰色对象

5. 递归遍历标记

6. 继续递归

7. 清除白色

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


Go 1.8 —— 混合写屏障(hybrid write barrier)机制

插入写屏障 和 删除写屏障 的 缺点:

插入写屏障删除写屏障
插入写屏障删除写屏障

实现原理

  • 栈上对象:

GC开始,将 栈上的可达对象 全部扫描并标记为 黑色 (之后不再进行第二次重复扫描,无需STW)
GC期间,任何在 栈上创建的新对象,均为 黑色

  • 堆中对象:

被删除引用的对象 标记为 灰色
被添加引用的对象 标记为 灰色


满足

变形的 弱三色不变式


伪代码

添加下游对象(当前下游对象slot, 新下游对象ptr) {
  	//1 
	标记灰色(当前下游对象slot)    //只要当前下游对象被移走,就标记灰色
  	
  	//2 
  	标记灰色(新下游对象ptr)
  		
  	//3
  	当前下游对象slot = 新下游对象ptr
}

这里我们注意, 屏障技术是不在栈上应用的,因为要 保证栈的运行效率


具体流程

1. 初始状态

2. 标记 栈中所有可达节点为 黑色


那么,接下来,本人就来通过 4个特殊的场景,来讲解下 混合写屏障 的作用:

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

//前提:堆对象4->对象7 = 对象7;  //对象7 被 对象4引用
栈对象1->对象7 = 堆对象7;  //将堆对象7 挂在 栈对象1 下游
堆对象4->对象7 = null;    //对象4 删除引用 对象7

【特殊场景2】 对象被一个栈对象删除引用,成为另一个栈对象的下游

new 栈对象9;
对象8->对象3 = 对象3;      //将栈对象3 挂在 栈对象9 下游
对象2->对象3 = null;      //对象2 删除引用 对象3

【特殊场景3】对象被一个堆对象删除引用,成为另一个堆对象的下游

堆对象10->对象7 = 堆对象7;       //将堆对象7 挂在 堆对象10 下游
堆对象4->对象7 = null;         //对象4 删除引用 对象7



【特殊场景4】对象从一个栈对象删除引用,成为另一个堆对象的下游

堆对象10->对象7 = 堆对象7;       //将堆对象7 挂在 堆对象10 下游
堆对象4->对象7 = null;         //对象4 删除引用 对象7

总而言之

插入写屏障删除写屏障

Golang 垃圾回收成长史
标记-清除算法三色标记法三色标记法混合写屏障机制

所以可见,Golang的垃圾回收,基本上都是在 缩短STW的时间


后记:

一、插入写屏障 和 删除写屏障,好像没有被使用?

插入写屏障删除写屏障 只是一个 理论,在Golang中没有实现,具体在不在栈上执行,都没落地实现
只是当时针对 三色标记法 不开启 STW 的一种 实现理论


二、GC到底会不会回收栈空间?

不会回收,而是通过简单的编译器指令进行清理
GC过程中,只是对栈中变量进行标记,但是不会进行 “空间的释放” 等回收操作


二、Objective-C 与 引用计数法:

在上文中,本人有提到:

使用 引用计数法 的编程语言中,最有代表性的就是 Objective-C

本人也在上文中有讲过

引用计数法 会造成 “循环引用” 的现象,进而导致 “内存泄漏”

那么,Objective-C 是怎么解决 引用计数法 所带来的 “循环引用” 的 问题的呢?

Automatic Reference Counting (ARC) is great. But, a common programming practice of circular referencing can easily introduce memory leak.

Let’s say that there are two objects – A and B. If A has a strong reference to B and B has a strong reference back at A, ARC will not be able to release these objects. This pattern occurs commonly in iOS code. A view controller object creates and keeps a reference to a Data Access Object (DAO). The DAO object keeps a reference to the view controller as a delegate.

This way, neither of them will ever be destroyed.
The solution is to have one of the objects use weak reference.

weak-referenceweak-reference

这样,就可以使得 循环引用 得以解决
但是,弱引用需要程序员手动去 编码。因此,在一些调用很深的逻辑中,就很容易让程序员造成 “循环引用” 的现象


三、在Go1.8时,是不是不会再出现 STW 了呢?

Golang作者是这样回复的

10 millisecond pauses are so 2015.
Today I submitted changes to the garbage collector that make typical worst-case stop-the-world times less than 100 microseconds. This should particularly improve pauses for applications with many active goroutines, which could previously inflate pause times significantly.
—— 《Sub-millisecond GC pauses》

那么,在Go1.8的GC流程中,完全没有STW的过程,那么STW是如何发生的呢?

This proposal goes a long way toward strictly bounding the time spent in STW mark termination, but there are some other known causes of longer mark termination pauses. The primary cause is a race that can trigger mark termination while there is still remaining heap mark work. This race and how to resolve it are detailed in the "Mark completion race" appendix.
...
Currently, because of a race in the mark completion condition, the garbage collector can begin mark termination when there is still available mark work.
—— 《Proposal: Eliminate STW stack re-scanning》

我们可以看到:
STW的主要原因

由于GC的过程是 并发的,可以在 仍有剩余堆标记工作时 触发 标记终止 的race

因此,需要一小段时间的STW来保证race过程中的 并发安全性


四、Golang中,触发GC的时机?

触发条件从大方面说,可分为 手动触发系统触发 两种方式。
手动触发 一般很少用,主要由开发者通过调用 runtime.GC() 函数来实现;
而 系统自动触发 是 运行时 根据一些条件判断来进行的

那么,运行时怎么触发 GC 呢?
运行时会通过 gcTrigger.test() 函数来决定是否需要触发GC

简单总结下触发时机:

  • gcTriggerHeap —— 阈值:
    堆内存的分配的大小达到了 阈值(控制器计算的大小),将启动GC
  • gcTriggerTime —— 定时
    自从上次GC后间隔时间达到了runtime.forcegcperiod 时间(默认为2分钟),将启动GC
  • gcTriggerCycle —— 手动触发
    手动触发GC(runtime.GC())时,系统收到指令,开始 GC

五、GC Root 是什么?

包括了:

  • 全局变量(在.bss 和.data段内存中)、
  • span中的finalizer的任务所引用的对象、
  • 所有的协程栈对象

(
相关文献,可以参考:
《深入理解Go-runtime.SetFinalizer》
《bss段,data段、text段、堆(heap)和栈(stack) 》
)