彻底理解golang的GC - 第一篇
GC:垃圾回收(Garbage Collection)是一种自动管理内存的机制。传统的编程语言(C/C++)中,释放无用变量内存空间是程序员手动释放,存在内存泄漏或者释放不该释放内存等问题;为了解决这个问题,后续的语言(oc/swift/java/python/php/golang 等)都引入了语言层面的自动内存管理,语言使用者无需对内存进行手动释放,内存释放由虚拟机(virtual machin
由于篇幅问题,本篇博客一共分为3小篇
彻底理解golang的GC - 第一篇_YZF_Kevin的博客-CSDN博客
彻底理解golang的GC - 第二篇_YZF_Kevin的博客-CSDN博客
彻底理解golang的GC - 第三篇_YZF_Kevin的博客-CSDN博客
一、什么是 GC ?
GC:垃圾回收(Garbage Collection
)是一种自动管理内存的机制。传统的编程语言(C/C++
)中,释放无用变量内存空间是程序员手动释放,存在内存泄漏或者释放不该释放内存等问题;为了解决这个问题,后续的语言(oc/swift/java/python/php/golang
等)都引入了语言层面的自动内存管理,语言使用者无需对内存进行手动释放,内存释放由虚拟机(virtual machine
)或者运行时(runtime
)来对不再使用的内存资源进行自动回收。
Golang GC 的发展史
- go1.1,串行三色清扫。
- go1.3,提高了垃圾回收的精确度。
- go1.4,之前版本的runtime大部分是使用C写的,这个版本大量使用Go进行了重写,让GC有了扫描stack的能力,进一步提高了垃圾回收的精确度。
- go1.5,目标是降低GC延迟,采用了并发标记和并发清除,三色标记,write barrier,以及实现了更好的回收器调度,设计文档1,文档2,以及2015 版的Go talk。
- go1.6,小优化,当程序使用大量内存时,GC暂停时间有所降低。
- go1.7,小优化,当程序有大量空闲goroutine,stack大小波动比较大时,GC暂停时间有显著降低。
- go1.8,write barrier切换到hybrid write barrier,以消除STW中的re-scan,把STW的最差情况降低到50us,设计文档。
- go1.9,提升指标比较多,(1)过去
runtime.GC
,debug.SetGCPercent
, 和debug.FreeOSMemory
都不能触发并发GC,他们触发的GC都是阻塞的,go1.9可以了,变成了在垃圾回收之前只阻塞调用GC的goroutine。(2)debug.SetGCPercent
只在有必要的情况下才会触发GC。 - go.1.10,小优化,加速了GC,程序应当运行更快一点点。
- go1.12,显著提高了堆内存存在大碎片情况下的sweeping性能,能够降低GC后立即分配内存的延迟。
- go1.13,着手解决向操作系统归还内存的,提出了新的 Scavenger
- go1.14,替代了仅存活了一个版本的 Scavenger,全新的页分配器,优化分配内存过程的速率与现有的扩展性问题,并引入了异步抢占,解决了由于密集循环导致的 STW 时间过长的问题
- go1.15,删除了一些GC元数据和一些无用的类型元数据,Go 1.15编译出的二进制文件size会减少5%左右。
- ......
主要版本变化:
1.5 版本以及以后版本的GC 主要分为四个阶段,其中标记和清理都是并发执行的,但是标记阶段的前后需要使用STW来做GC的准备工作和栈的rescan(这也是1.8的优化点)。
1.8 版本引入混合屏障,最小化第一次STW,写入屏障和删除屏障各有优缺点,Dijkstra写入写屏障在标记开始时无需STW,可直接开始,并发进行,但结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;Yuasa的删除写屏障则需要在GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象,但结束时无需STW。Go1.8版本引入的混合写屏障结合了Yuasa的删除写屏障和Dijkstra的写入写屏障,结合了两者的优点。
二、常见的 GC 算法
1. 引用计数法
根据对象自身的引用计数来回收,当引用计数归零时进行回收,但是计数频繁更新会带来更多开销,且无法解决循环引用的问题。
代表:Objective-C
、Swift
- 优点:简单直接,回收速度快
- 缺点:需要额外的空间存放计数,无法处理循环引用的情况;
2. 标记清除法
标记出所有不需要回收的对象,在标记完成后统一回收掉所有未被标记的对象。
- 优点:简单直接,速度快,适合可回收对象不多的场景
- 缺点:会造成不连续的内存空间(内存碎片),导致有大的对象创建的时候,明明内存中总内存是够的,但是空间不是连续的造成对象无法分配;
3. 复制法
复制法将内存分为大小相同的两块,每次使用其中的一块,当这一块的内存使用完后,将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。
- 优点:解决了内存碎片的问题,每次清除针对的都是整块内存,但是因为移动对象需要耗费时间,效率低于标记清除法;
- 缺点:有部分内存总是利用不到,资源浪费,移动存活对象比较耗时,并且如果存活对象较多的时候,需要担保机制确保复制区有足够的空间可完成复制;
4. 标记整理
标记过程同标记清除法,结束后将存活对象压缩至一端,然后清除边界外的内容。
- 优点:解决了内存碎片的问题,也不像标记复制法那样需要担保机制,存活对象较多的场景也使适用;
- 缺点:性能低,因为在移动对象的时候不仅需要移动对象还要维护对象的引用地址,可能需要对内存经过几次扫描才能完成;
5. 分代式
将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。
1. 三色标记法的原理
三色标记法将对象分为三类,并用不同的颜色相称:
- 白色对象(可能死亡):未被回收器访问到的对象。在回收开始阶段,所有对象均为白色,当回收结束后,白色对象均不可达。
- 灰色对象(波面):已被回收器访问到的对象,但回收器需要对其中的一个或多个指针进行扫描,因为他们可能还指向白色对象。
- 黑色对象(确定存活):已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。
标记过程如下:
- 第一步:起初所有的对象都是白色的;
- 第二步:从根对象出发扫描所有可达对象,标记为灰色,放入待处理队列;
- 第三步:从待处理队列中取出灰色对象,将其引用的对象标记为灰色并放入待处理队列中,自身标记为黑色;
- 重复第三步,直到待处理队列为空,此时白色对象即为不可达的“垃圾”,回收白色对象;
根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:
- 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
- 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
- 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。
2. 屏障机制
2.1 STW
STW 是Start/Stop The World
的缩写。通常意义上指的是从 Stop The World
到 Start The World
这一段时间间隔。垃圾回收过程中为了保证准确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图以完成垃圾回收。STW
时间越长,对用户代码造成的影响越大。
2.2 No STW 存在的问题
假设下面的场景,已经被标记为灰色的对象2,未被标记的对象3被对象2用指针p引用;此时已经被标记为黑色的对象4创建指针q 指向未被标记的对象3,同时对象2将指针p移除;对象4已经被标记为黑色,对象3未被引用,对象2删除与对象3的引用,导致最后对象3被误清除;
-
垃圾回收的原则是不应出现对象的丢失,也不应错误的回收还不需要回收的对象。如果同时满足下面两个条件会破坏回收器的正确性:
- 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;(通俗的说就是A突然持有了B的指针,而B在并发标记的过程中已经被判定为白色对象要被清理掉的)
- 条件 2: 从灰色对象出发,到达白色对象且未经访问过的路径被赋值器破坏;(通俗的说就是A持有B的指针,这个持有关系被释放)
-
只要能够避免其中任何一个条件,则不会出现对象丢失的情况,因为:
- 如果“条件 1”被避免,则所有白色对象均被灰色对象引用,没有白色对象会被遗漏;
- 如果“条件 2”被避免,即便白色对象的指针被写入到黑色对象中,但从灰色对象出发,总存在一条没有访问过的路径,从而找到到达白色对象的路径,白色对象最终不会被遗漏。
可能的解决方法: 整个过程STW,浪费资源,且对用户程序影响较大,由此引入了屏障机制;
2.3 屏障机制
把回收器视为对象,把赋值器视为影响回收器这一对象的实际行为(即影响 GC 周期的长短),从而引入赋值器的颜色:
- 黑色赋值器:已经由回收器扫描过,不会再次对其进行扫描。
- 灰色赋值器:尚未被回收器扫描过或尽管已经扫描过,但仍需要重新扫描。
2.3.1 插入屏障(Dijkstra)- 灰色赋值器
写入前,对指针所要指向的对象进行着色,伪代码如下
// 灰色赋值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(ptr) //先将新下游对象 ptr 标记为灰色
*slot = ptr
}
//说明:
添加下游对象(当前下游对象slot, 新下游对象ptr) {
//step 1
标记灰色(新下游对象ptr)
//step 2
当前下游对象slot = 新下游对象ptr
}
//场景:
A.添加下游对象(nil, B) //A 之前没有下游, 新添加一个下游对象B, B被标记为灰色
A.添加下游对象(C, B) //A 将下游对象C 更换为B, B被标记为灰色
避免条件1( 赋值器修改对象图,导致某一黑色对象引用白色对象;)因为在对象A 引用对象B 的时候,B 对象被标记为灰色
Dijkstra 插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:
- 由于 Dijkstra 插入屏障的“保守”,在一次回收过程中可能会残留一部分对象没有回收成功,只有在下一个回收过程中才会被回收;
- 在标记阶段中,每次进行指针赋值操作时,都需要引入写屏障,这无疑会增加大量性能开销;为了避免造成性能问题,Go 团队在最终实现时,没有为所有栈上的指针写操作,启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段 STW 时对这些栈进行重新扫描。
更多推荐
所有评论(0)