文章目录
- 三色标记算法
- 混合写屏障
- 并发、增量回收机制
- GC触发时机
go语言作为内存托管类型的开发语言,go runtime提供了自动的内存管理机制,无需程序员手动管理对象的内存释放,go runtime会在合适的时机自动释放不需要的内存对象。
一、三色标记算法
传统的内存对象标记算法
早期的开发语言一般使用引用计数,或引用追踪算法来管理,回收那些“不可达”的对象。它们的过程大致如下,对象分配通过一个基地址和偏移量在托管堆上有条不紊的分配,在某次对象分配时,对应运行时发现托管堆的剩余空间不足以分配线程所申请的内存,此时,运行时进行“STW”,停止所有工作线程,垃圾回收线程开始进行工作,从根对象开始扫描,将“不可达”的对象做特殊标记,待扫描结束后,非垃圾对象进行基于托管堆的内存压缩,防止内存碎片的产生。在这个过程中,多次GC扫描的后,一直不被是认为是“不可达”的对象会被提升至更高的“代”,后面再进行垃圾扫描就暂时不扫描它们以提高效率。对应下(图1)和(图2)。
(图1)
(图2)
三色标记法
go语言中,垃圾回收算法采用三色标记法,将内存对象抽象为黑、灰、白三类对象。每次创建新对象时,该对象都会被标记为白色。而对象之间的相互引用关系,将整个go进程的内存对象相关关联起来,如同一张网一样(如图3)。
(图3)
接下来,待到go runtime触发GC时,会依次执行如下步骤:
- 第一步,GC进程将会从根节点开始遍历所有对象,,将可以遍历到的对象标记为灰色(放入至灰色标记表中),这一次遍历为第一次遍历,目的就是找出接下来循环遍历所需的根节点。
-
在go runtime中,GC过程是由一个工作协程来完成的,这里的GC进程即该工作协程,后续描述都称为GC进程。
- 在go runtime中,根对象指的是 go routine栈对象 、 全局对象、寄存器中的指针 和 运行时数据结构(如go runtime的调度器、网络轮询器、定时器等)、显式表留的根对象(如sync.Pool中活跃的对象)
-
- 第二步,GC进程(这里其实会有用户进程辅助标记)将会遍历第一步找到的所有灰色对象集合,并且将灰色对象引用的对象,从白色标记为灰色,之后将自己标记为黑色。
- 第三步,重复第二步,直至进程中所有的对象再也没有灰色对象,此时所有可达的对象都遍历完成,所有内存对象只存在两种颜色,即黑色与白色。其中黑色代表进程中,逻辑可达的对象,这部分对象保留;白色的对象则代表不可达的垃圾对象,需要被回收清理。(如图4的对象1、5)
- 最后一步,清理白色对象,针对白色对象的所占用的内存回收释放。
(图4)
二、混合写屏障
在垃圾回收算法中,有一个很重要的概念叫做STW(Stop The World),即垃圾回收过程中,需要停止其他工作线程的执行,等待GC线程工作完成后,才恢复其他工作线程继续工作。这样,就带来了一个问题,GC会造成程序正常处理任务的时延,假如频繁、长时间的GC,势必会影响整个程序的性能。
这里延伸下,那为什么GC要有STW? 假如GC过程中,GC线程和工作线程一起执行,将会影响标记的准确性。例如,T1时刻,对象A被根对象引用,GC进程会将对象A标记为灰色。T2时刻,一个工作线程将这个根对象的引用指向另一个对象B,此时对象A应该作为垃圾(因为是不可达的,没有跟对象或其他可达对象对它的引用)而无法被回收。更为严重的情况是,可能会存在有的对象是可达的,而被误认为垃圾对象被回收,造成内存的不安全(见图5)。
(图5)
为了解决GC过程中,内存标记的准确性,内存的安全性,以及在GC过程中不影响其他工作线程的执行(低时延),引入了“写屏障”的概念。
- 内存屏障技术:https://zh.wikipedia.org/wiki/%E5%86%85%E5%AD%98%E5%B1%8F%E9%9A%9C
在了解写屏障之前,需要了解另外一个概念:“不变性原则”。GC为了解决并发标记的正确性,整个标记过程需要遵循两个原则“强三色不变性”与“弱三色不变性”
-
“强三色不变性”:黑色对象不会指向白色对象,只会指向黑色对象或者灰色对象。
-
“弱三色不变性”:黑色对象指向白色对象必须包含一条从灰色对象经由多个白色对象的可达路径。
强三色不变性
为了满足“强三色不变性”,go runtime实现了插入写屏障。插入写屏障的行为类似这样,当A对象引用B对象时,B对象被标记为灰色。伪代码如下所示:
添加下游对象(当前下游对象 slot,新下游对象 ptr){标记灰色(新下游对象 ptr)当前下游对象slot = 新下游对象 ptr}
例如对象A为黑色对象,开始时对象A的下游对象为对象B,现在将对象A的下游对象改为对象C,此时将会触发插入写屏障,直接将对象C标记为灰色(见图6)。(插入写屏障对栈空间不生效,所以再go1.8之前版本的GC要二次扫描栈空间,保证栈空间对象不丢失,这个过程需要STW,一般栈扫描时间为10~100ms)
(图6)
弱三色不变性
添加下游对象(当前下游对象 slot,新下游对象 ptr){if (当前下游对象slot是灰色 || 当前下游对象slot是白色{标记灰色(当前下游对象 slot)}当前下游对象slot = 新下游对象 ptr }
例如对象A为灰色对象,A的下游对象为对象B,B此时为白色,现在将对象A对B的引用删除(即A的下游对象为空),此时将触发删除写屏障,将B标记为灰色,接下来GC任务扫描时,B对象的下游对象也不会被标为垃圾回收掉(见图7)。(删除写屏障带来的问题是回收精度比较低,很多垃圾对象会延迟到下一轮GC扫描才会被识别、回收内存)
(图7)
混合写屏障
go V1.8引入混合写屏障,结合插入写屏障和删除写屏障,解决栈对象重新扫描的问题,大大缩短了GC的STW时间。它的工作原理如下所示:
-
GC开始时,将所有栈对象,全都标记为黑色,避免二次扫描栈(进而避免扫描栈空间时STW)。
-
goV1.8之前,go routine栈空间上对象并非都被标记成黑色,只会标记go routine栈上的活跃指针。判断活跃指针的方式为,根据指针映射表(属于go编译器为每个函数生成的栈帧元数据,标记了栈帧中哪些位置存储了指针)中的指针标记,判断栈帧的每个内存槽,匹配的内存槽对象被标识为根对象。
-
-
GC期间,在栈上创建的对象均被标记为黑色。
-
GC期间,被删除的对象被标记为灰色,被添加的对象标记为灰色。
添加下游对象(当前下游对象 slot,新下游对象 ptr){标记灰色(新下游对象 slot)标记灰色(新下游对象 ptr)当前下游对象slot = 新下游对象 ptr }
例如对象A,B,C为栈区对象,E,F,G为堆区对象(见图8)。 做一些,栈引用堆场景下的,删除、增加引用,堆引用栈场景下的删除、增加引用。
(图8)
三、并发、增量回收机制
go runtime将原本较长的STW时间的GC任务,分割为多个小的GC时间片,并在应用程序运行期间交替执行,并以写屏障的机制保证内存安全性,最终达到避免GC长时间的暂停。
goV1.6版本,将垃圾回收器工作的各个阶段抽象为一个状态机,状态机有_GCMark、_GCmarktermination、_GCoff这3个状态,划分为标记准备阶段(GC Mark Start)、增量标记阶段(GC Mark)、再扫描阶段(Mark Termination)、清除阶段(Sweep)、空闲阶段(Idle)五个运行阶段。通过这个状态机的状态跃迁,任何一个工作协程都能执行GC任务,从而移除中心化GC的限制,无需专门的goroutine在处理器之间同步、协调垃圾回收状态,从而降低了延迟。
go的垃圾收集器可以简化为的标记、标记终止、清除、清除终止这四个阶段,每个阶段有自己的任务,详情如下:
- 标记阶段
-
GC状态切换至_GCMark,开启写屏障,根对象入队。
-
标记任务和用户协程协同运行,用户协程协助一起标记活跃的内存对象(此时受混合写屏障保护,不会有内存安全问题)。
-
使用分布式算法检查剩余工作,在标记阶段完成后进入标记终止阶段。
-
-
标记终止阶段
-
暂停程序,状态切换至_GCmarktermination,关闭辅助标记的用户协程。
-
清理每个处理器上的线程缓存中的内容,以保证下一阶段没有悬挂的未标记对象(即未被标记为黑色的可达的对象)。
-
-
清理阶段
-
状态切换至_GCoff并开始清理阶段,初始化清理状态并关闭写屏障。
-
用户协程恢复执行。
-
GC以后台并发的方式清理所有的未使用的内存单元(即未标记的白色对象) * 此时无需用户程序停止执行,清理和用户程序是并发的运行。
-
-
清除终止阶段
-
清理结束后,GC进入空闲状态(Idle),程序正常运行,等待下一次回收周期。
-
四、垃圾回收的触发时机
go runtime 的GC整个过程是并发、增量的,它通过调用 "runtime.gcTrigger.test"函数,决定是否要执行垃圾回收,具体行为如下:
-
通过一个“调步算法”,在堆空间内存分配到一定程度(总内存的一个区间内)时,再次申请内存(runtime.mallocgc函数)会触发GC。
-
当堆内存达到上次GC时,内存分配的两倍时,触发新一轮的GC。
-
go runtime默认2分钟,进行定期触发。
-
用户显性的通过runtime.GC()进行触发。
相关代码:https://github.com/golang/go/blob/01ba8bfe868df2eea10ea8dd5bfbe5af0549909d/src/runtime/mgc.go#L601
func (t gcTrigger) test() bool {if !memstats.enablegc || panicking.Load() != 0 || gcphase != _GCoff {return false}switch t.kind {case gcTriggerHeap:trigger, _ := gcController.trigger()return gcController.heapLive.Load() >= triggercase gcTriggerTime:if gcController.gcPercent.Load() < 0 {return false}lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))return lastgc != 0 && t.now-lastgc > forcegcperiodcase gcTriggerCycle:// t.n > work.cycles, but accounting for wraparound.return int32(t.n-work.cycles.Load()) > 0}return true }
最后总结一下,go runtime 通过内存对象三色抽象、混合写屏障、并发增量回收,用户协程协助等机制,共同将GC的暂停时间降至毫秒级别,为go语言成为高并发的代名词提供了重要的支持。