【Golang】垃圾收集 详解

shadowLogo

什么是 垃圾收集

在生活中,“垃圾收集” 人人皆知,目的是 循环利用资源
映射到我们程序中的 “垃圾收集”,其概念也十分类似,就是为了 循环利用内存资源

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


垃圾收集 范围

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

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


垃圾标记 算法

引用计数法

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

每当有一个地方引用它,计数器就加1;
当引用失效,计数器就减1;
任何时候 计数器为0的对象 就是 不可能再被使用的,即:垃圾对象
link

这个方法实现简单,效率高,目前 Objective-C 就是使用 引用计数法 来管理内存的

但是 类似于 Golang 和 Java 这两门当今后端主要开发语言,则不会使用 引用计数法
其最主要的原因 是 它很难解决对象之间 相互循环引用 的问题。

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

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” 对象作为 起点,从这些节点开始向下搜索引用的对象:

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

trace


垃圾回收 算法

复制 算法

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

copy


标记-清除 算法

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

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

ms

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

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


标记-整理 算法

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


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

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

具体流程:

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

mark

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

search

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

sweep

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


缺点:

  • 以上整个过程都是 砸瓦鲁多(STW)的!
    STW
    也就是说,在整个过程中,用户协程 是阻塞的!
    zwld
  • 需要扫描 整个heap区
  • 会产生 大量的内存碎片

在Go1.3版本之前,全部的GC时间 都是 被Dio控制(STW)的
high
这样导致程序暂停的时间过长,影响程序的运行性能

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

所以 Go 1.3时期,做了简单的优化,将STW的时间缩短,减少STW暂停的时间范围:
update
上图 主要是将 STW 的步骤提前了 异步
因为在 Sweep清除 的时候,可以 不需要STW,因为这些对象已经是 不可达对象 了,不会出现 回收写冲突 等问题。


但是无论怎么优化,Go 1.3 都面临这个一个重要问题,就是 mark-and-sweep 算法暂停整个程序
于是乎,Go 1.5版本 就用 三色标记法 来优化这个问题:

Go 1.5 —— 三色标记法

​Golang中的垃圾回收 主要应用 三色标记法GC过程用户goroutine 可以 并发运行
但是也需要一定时间的 STW
dio

具体流程:

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

mark

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

recursive

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

continue

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

repeat1

repeat2

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

end

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


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

首先,本人来讲解下,如果在没有STW情况下的 三色标记法

如果没有STW的三色标记法

具体流程:

1. 标记 初始黑色节点

donthave

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

special

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

error

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

mark-end

5. 回收所有白色对象

sweep

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


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

细节分析

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

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

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


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

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

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

屏障机制:

让GC回收器,满足下面两种情况之一时,即可保对象不丢失。
这两种方式就是 “强三色不变式” 和 “弱三色不变式

强三色不变式

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


弱三色不变式

leak
弱三色不变式强调:

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

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

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


为了遵循上述的两个方式,GC算法演进到两种屏障方式:“插入屏障” 和 “删除屏障”。

插入屏障

实现流程

在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. 初始状态

init

2. 标记根节点为灰色

mark

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

change

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

concurrent

5. 插入 写屏障

barrier
可以看到:

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

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

6. 递归遍历标记

recursion

7. 回收白色

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

recycle2

8. 停止STW

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

9. 清除白色节点

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


删除屏障

实现流程

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

满足

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

伪码

添加下游对象(当前下游对象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. 初始状态

init

2. 标记根节点

root

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

delete

4. 插入 删除屏障

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

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

5. 递归遍历标记

recursion

6. 继续递归

continue

7. 清除白色

delete

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


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

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

  • 插入写屏障
    结束时需要STW重新扫描栈,标记栈上引用的白色对象的存活
  • 删除写屏障
    回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象
    (这个快照用于 给被删除引用关系的对象 换颜色)

Go 1.8版本引入了 混合写屏障机制(hybrid write barrier),避免了 对栈re-scan 的过程,极大的 减少了STW的时间。结合了 插入写屏障删除写屏障 的优点。


实现原理

  • 栈上对象:

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

  • 堆中对象:

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


满足

变形的 弱三色不变式


伪代码

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

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


具体流程

1. 初始状态

init

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

mark-stack


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

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

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

link

break


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

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

new

link

delete


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

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

mark
link

delete


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

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

delete

move

mark


总而言之

Golang中的 混合写屏障 ,满足弱三色不变式,是在综合了 插入写屏障删除写屏障 的基础上,保证 栈中对象全黑
只需要在 开始时 并发扫描各个goroutine的栈对象,使其变黑一直保持,这个过程 不需要STW
标记结束后,因为栈在扫描后 始终是黑色 的,也 无需再进行re-scan操作 了,减少了 STW的时间


Golang 垃圾回收成长史

  • Go1.3 —— 普通 标记-清除算法整体过程需要STW,效率极低。
  • Go1.5 —— 三色标记法堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通
  • Go1.8 —— 三色标记法 + 混合写屏障机制堆空间启动写屏障,栈空间不启动整个过程几乎不需要STW ,效率较高。

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


后记:

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

插入写屏障删除写屏障 只是一个 理论,在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.

在 Objective-C 中,就提供了 weak-reference 来帮助程序员来解决 “循环引用” 的问题
当使用 weak-reference 来引用对象,可以使得 当前引用 不会使得 该对象 增加引用次数

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

针对上述现象,也是有处理方法的,由于本文旨在讲解 GC的原理,以及 在Go中是如何实现的
因此,对 Objective-C 中,如何使用弱引用来避免“循环引用”,并且不会使得对象被提前释放,就需要同学们自己去摸索了
(相关文章链接:
《聊聊循环引用的检测》
《Understanding weak 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) 》
)

headcold

posted @ 2021-11-21 23:29  在下右转,有何贵干  阅读(508)  评论(0)    收藏  举报