GC 和 Golang 的垃圾回收机制
思维导图
GC的概念
GC
全称是 garbage collection,即垃圾回收。
新开发的语言(java,python,php等等),在使用的时候,可以使语言用户不必关心内存对象的释放,只需要关心对象的申请即可
Golang本身就是一门基于 garbage collection 的语言,垃圾回收由语言本身机制实现。
gc与程序交互时候的效率会影响到整个程序的运行效率。通常程序本身的内存管理会影响gc和程序之间的效率,甚至造成性能瓶颈。
常见的GC模式
引用计数
- 流程
- 每个对象内部维护一个整数值,叫做这个对象的 引用计数
- 对象被引用的时候
+1
,对象不被引用的时候-1
- 引用计数 归零的时候,对象会被销毁
- 缺陷
- 循环引用问题:对象A和B互相引用,计数器都不为0,那么永远不会被收集
- 引用计数的赋值会造成性能消耗
- Python、PHP、 c++ 标准库的 std::shared_ptr 、微软的 COM 、Objective-C
标记清除 Mark-Sweep
-
流程
- 标记:从程序根结底开始,递归遍历所有对象,并打上标记,标记为存活
- 清除:清除阶段,将所有未被标记的对象当作垃圾销毁
-
缺陷
-
最大的缺陷就是所谓的STW(stop the world)。在垃圾回收阶段,所有程序将被挂起暂停,等待恢复完毕后再执行
就如同现在的疫情,湖北省各大城市按下了暂停键,要对病情进行控制,疑似和确诊病例进行清理。按照现在的执行效率,相信很快就能清理完(2020.3.17 周边城市已经解封,49支医疗队陆续撤离湖北)
-
显而易见的,清除后产生了大量不连续的内存碎片,导致在程序运行过程中需要分配较大对象的时候,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。
-
-
何时进行STW?
堆中的有效内存空间(available memory)被耗尽的时候,就会让整个程序stop the world, 然后进行两项工作,第一是标记,第二是清除
-
为何要STW?
GC的过程中,其他线程的代码可能会改变对象状态,可能把不应该回收的对象当做垃圾收集掉
eg: A刚刚被标记完,如果未暂停程序,申请了一个B对象,且A可达B。那么B是未被标记的,后面会被意外地清理掉,引起程序错误。
Golang1.5 之前所使用
2014/6 1.3版本并发清理的引入,go runtime 分离了 mark 和 sweep 的操作
和以前一样,也是先暂停所有任务执行并启动 mark( mark 这部分还是要把原程序停下来的),mark 完成后就马上就重新启动被暂停的任务了,并且让 sweep 任务和普通协程任务一样并行,和其他任务一起执行。
如果运行在多核处理器上,go 会试图将 gc 任务放到单独的核心上运行而尽量不影响业务代码的执行,go team 自己的说法是减少了 50%-70% 的暂停时间。
基本算法就是之前提到的清扫+回收,Golang gc 优化的核心就是尽量使得 STW 的时间越来越短。
三色标记
相比传统的标记清扫算法,三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。
三色标记法因为多了一个白色的状态来存放不确定的对象,所以可以异步地执行。当然异步执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。
标记清除 Mark-Sweep 的变种
-
分类
- 黑色:根对象,或者该对象与它的子对象都被扫描过(对象被标记了,且它的所有field也被标记完了)。
- 灰色:对象本身被扫描,但还没扫描完该对象中的子对象(它的field还没有被标记或标记完)。
- 白色:未被扫描对象,扫描完成所有对象之后,最终为白色的为不可达对象,既垃圾对象(对象没有被标记到)。
-
流程
- 创建三个集合:白、灰、黑。将所有对象放入白色集合中。
- 从根节点开始遍历所有对象(注意这里并不递归遍历),把遍历到的对象从白色集合放入灰色集合。
- 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合
- 重复 3 直到灰色中无任何对象
- 通过write-barrier检测对象有变化,重复以上操作
- 收集所有白色对象(垃圾)
这个算法可以实现 “on-the-fly”,也就是在程序执行的同时进行收集,并不需要暂停整个程序。
但是也会有一个缺陷,可能程序中的垃圾产生的速度会大于垃圾收集的速度,这样会导致程序中的垃圾越来越多无法被收集掉。
标记的root根对象包括:全局变量,各个G stack上的变量等
而使用三色标记,即使在标记过程中对象的引用关系发生了改变,例如分配内存并修改对象属性域的值,只要满足黑色对象不引用白色对象的约束条件,垃圾回收器就可以继续正常工作。
于是每次并不需要将回收过程全部执行完,只是处理一部分后停下来,后续会慢慢再次触发的回收过程,实现增量回收。相当于是把垃圾回收过程打散,减少停顿时间。
Go 1.5、Go 1.6
1.5 版本进行了较大改进,使用了三色标记算法。go 1.5 在源码中的解释是“非分代的、非移动的、并发的、三色的标记清除垃圾收集器”
- 非分代:不像java那样分为年轻代和老年代,自然也没有minor和majo gc的区别
- 非紧缩:在垃圾回收之后不会进行内存整理以清除内存碎片
- 写屏障:在并发标记的过程中,如果应用程序修改了对象图,就可能出现标记遗漏的可能,写屏障是为了处理标记遗漏的问题。
go 除了标准的三色收集以外,还有一个辅助回收功能,防止垃圾产生过快收集不过来的情况。这部分代码在 runtime.gcAssistAlloc 中。
分代清除
分代收集也是传统 Mark-Sweep 的一个改进。这个算法是基于一个经验:绝大多数对象的生命周期都很短。所以按照对象的生命周期长短来进行分代。
一般 GC 都会分三代,在 java 中称之为新生代(Young Generation)、年老代(Tenured Generation)和永久代(Permanent Generation);在 .NET 中称之为第 0 代、第 1 代和第2代。
流程:
- 新对象放入第 0 代
- 当内存用量超过一个较小的阈值时,触发 0 代收集
- 第 0 代幸存的对象(未被收集)放入第 1 代
- 只有当内存用量超过一个较高的阈值时,才会触发 1 代收集,2 代同理
因为 0 代中的对象十分少,所以每次收集时遍历都会非常快(比 1 代收集快几个数量级)。只有内存消耗过于大的时候才会触发较慢的 1 代和 2 代收集。
因此,分代收集是目前比较好的垃圾回收方式。使用的语言(平台)有 大名鼎鼎的JVM、.NET 。
Golang 未使用分代
golang 并没有分代收集,对于巨量的小对象还是很苦手的,会导致整个 mark 过程十分长,在某些极端情况下,甚至会导致 GC 线程占据 50% 以上的 CPU。
当程序由于高并发等原因造成大量小对象的gc问题时,最好可以使用 sync.Pool 等对象池技术,避免大量小对象加大 GC 压力。
Golang 的 GC
版本 | 发布时间 | GC算法 | STW时间 | 重大更新 |
---|---|---|---|---|
V1.1 | 2013/5 | STW | 可能秒级别 | |
V1.3 | 2014/6 | Mark和Sweep分离. Mark STW, Sweep并发 | 百ms级别 | |
V1.4 | 2014/12 | runtime代码基本都由C和少量汇编改为Go和少量汇编, 包括GC部分, 以此实现了准确式GC,减少了堆大小, 同时对指针的写入引入了write barrier, 为1.5铺垫 | 百ms级别 | |
V1.5 | 2015/8 | 三色标记法, 并发Mark, 并发Sweep. 非分代, 非移动, 并发的收集器 | 10ms-40ms级别 | 重要更新版本,生产上GC基本不会成为问题 |
V1.6 | 2016/2 | 1.5中一些与并发GC不协调的地方更改. 集中式的GC协调协程, 改为状态机实现 | 5-20ms | |
V1.7 | 2016/8 | GC时栈收缩改为并发, span中对象分配状态由freelist改为bitmap | 1-3ms左右 | |
V1.8 | 2017/2 | hybird write barrier, 消除了stw中的重新扫描栈 | sub ms | Golang GC进入Sub ms时代 |
Golang进行GC的时机和流程
触发机制
- 在申请内存的时候,检查当前当前已分配的内存是否大于上次GC后的内存的2倍,若是则触发(主GC线程为当前M)
- 监控线程发现上次GC的时间已经超过两分钟了,触发;将一个G任务放到全局G队列中去。(主GC线程为执行这个G任务的M)
清理流程
- stop the world,等待所有的M休眠;此时所有的业务逻辑代码都停止
- 标记:分配gc标记任务,唤醒 gcproc个 M(就是第一步休眠的那些),分别做这个,直到所有的M都做完,才结束;并且所有M再次进入休眠
- 清理:有一个单独的goroutine去清理已经标记的内存对象快
- start the world,设置gcwaiting=0,唤醒所有的M(不会超过P个数)