GO中的GC
go中的垃圾回收前言对于go中的垃圾回收,总是不太熟悉。来具体分析下,具体的流程。本次探究的go版本 垃圾回收垃圾回收(Garbage Collection,简称GC)是编程语言中提供的自动的内存管理机制,自动释放不需要的对象,让出存储器资源,无需程序员手动执行。 当程序向操作系统申请的内存不再需要时,垃圾回收主动将其回收并供其他代码进行内存申请时候复用,或者将其归还给操作系统,这种针对内存级别资源的自动回收过程,即为垃圾回收。而负责垃圾回收的程序组件,即为垃圾回收器。 go中的垃圾回收方式所有的 GC 算法其存在形式可以归结为追踪(Tracing)和引用计数(Reference Counting)这两种形式的混合运用。
从根对象出发,根据对象之间的引用信息,一步步推进直到扫描完毕整个堆并确定需要保留的对象,从而回收所有可回收的对象。Go、 Java、V8 对 JavaScript 的实现等均为追踪式 GC。
每个对象自身包含一个被引用的计数器,当计数器归零时自动得到回收。因为此方法缺陷较多,在追求高性能时通常不被应用。Python、Objective-C 等均为引用计数式 GC。 目前比较常见的 GC 实现方式包括: 追踪式
引用计数
go中目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。 原因: 1、对象整理的优势是解决内存碎片问题以及“允许”使用顺序内存分配器。但 Go 运行时的分配算法基于 tcmalloc,基本上没有碎片问题。并且顺序内存分配器在多线程的场景下并不适用。Go 使用的是基于 tcmalloc 的现代内存分配算法,对对象进行整理不会带来实质性的性能提升。 2、分代 GC 依赖分代假设,即 GC 将主要的回收目标放在新创建的对象上(存活时间短,更倾向于被回收),而非频繁检查所有对象。但 Go 的编译器会通过逃逸分析将大部分新生对象存储在栈上(栈直接被回收),只有那些需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说,分代 GC 回收的那些存活时间短的对象在 Go 中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不需要 GC 的参与,进而分代假设并没有带来直接优势。并且 Go 的垃圾回收器与用户代码并发执行,使得 STW 的时间与对象的代际、对象的 size 没有关系。Go 团队更关注于如何更好地让 GC 与用户代码并发执行(使用适当的 cpu 来执行垃圾回收),而非减少停顿时间这一单一目标上。 go中使用的是三色标记法 三色标记法三色标记,通过字面意思我们就可以知道它由3种颜色组成: 回收器通过将对象图划分为三种状态来指示其扫描过程。 白色对象 白色 White:潜在的垃圾,其内存可能会被垃圾收集器回收,如果扫描完成后,对象依然还是白色的,说明此对象是垃圾对象。 灰色对象 灰色 Gary:活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象; 黑色 黑色 Black:活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象; 三色标记规则:黑色不能指向白色对象。即黑色可以指向灰色,灰色可以指向白色。 根对象在介绍三色标记法之前,首先了解下什么是根对象。 根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括: 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。 在GC的标记阶段首先需要标记的就是"根对象",从根对象开始可到达的所有对象都会被认为是存活的。 在垃圾收集器开始工作时,程序中不存在任何的黑色对象,垃圾收集的根对象会被标记成灰色,垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束。 三色标记垃圾收集器的工作原理很简单,我们可以将其归纳成以下几个步骤: 1、从灰色对象的集合中选择一个灰色对象并将其标记成黑色; 三色标记结束之后,里面只剩下白色和黑色的,最后回收掉白色的对象。 STW因为用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或者增量执行的。 比如上面所示的三色标记过程中,用户程序建立了从 A 对象到 D 对象的引用,但是因为程序中已经不存在灰色对象了,所以 D 对象会被垃圾收集器错误地回收。 本来不应该被回收的对象却被回收了,这会给我们的程序带来不可预知的问题。 什么是STW? STW 是 StoptheWorld 的缩写,即万物静止,是指在垃圾回收过程中为了保证实现的正确性、防止无止境的内存增长等问题而不可避免的需要停止赋值器进一步操作对象图的一段过程。 STW的过程有明显的资源浪费,对所有的用户程序都有很大影响。早期 Go 对垃圾回收器的实现中 STW 长达几百毫秒,尽管 STW 如今已经优化到了半毫秒级别以下,但是STW的影响还是存在的。 想要并发或者增量地标记对象还是需要使用屏障技术。 屏障技术在Golang中使用并发的垃圾回收,也就是多个赋值器与回收器并发执行,与此同时,应用屏障技术来保证回收器的正确性。其原理主要就是破坏上述两个条件之一。 内存屏障技术是一种屏障指令,它可以让 cpu 或者编译器在执行内存相关操作时遵循特定的约束,目前的多数的现代处理器都会乱序执行指令以最大化性能,但是该技术能够保证代码对内存操作的顺序性,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。 想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的任意一种: 弱三色不变式 所有被黑色对象引用的白色对象都处于灰色保护状态(直接或间接从灰色对象可达)。 强三色不变式:不存在黑色对象到白色对象的指针。 强三色不变式 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象; 强三色不变式很好理解,强制性的不允许黑色对象引用白色对象即可。而弱三色不变式中,黑色对象可以引用白色对象,但是这个白色对象仍然存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。 插入屏障Dijkstra 在 1978 年提出了插入写屏障,通过如下所示的写屏障,用户程序和垃圾收集器可以在交替工作的情况下保证程序执行的正确性:
插入屏障拦截将白色指针插入黑色对象的操作,标记其对应对象为灰色状态,这样就不存在黑色对象引用白色对象的情况了,满足强三色不变式。 比如上面黑色A指向白色D,A在引用D的时候直接将D标记为灰色就可以了。 1、垃圾回收器将A对象标记为黑色,然后A对象指向的B对象标记为灰色; 插入式的 Dijkstra 写屏障虽然实现非常简单并且也能保证强三色不变性,但是它也有很明显的缺点。因为栈上的对象在垃圾收集中也会被认为是根对象,所以为了保证内存的安全,Dijkstra 必须为栈上的对象增加写屏障或者在标记阶段完成重新对栈上的对象进行扫描,这两种方法各有各的缺点,前者会大幅度增加写入指针的额外开销,后者重新扫描栈对象时需要暂停程序。 在Golang中,对栈上指针的写入添加写屏障的成本很高,所以Go选择仅对堆上的指针插入增加写屏障,这样就会出现在扫描结束后,栈上仍存在引用白色对象的情况,这时的栈是灰色的,不满足三色不变式,所以需要对栈进行重新扫描使其变黑,完成剩余对象的标记,这个过程需要STW。这期间会将所有goroutine挂起,当有大量应用程序时,时间可能会达到10~100ms。 删除屏障Yuasa 在 1990 年的论文 Real-time garbage collection on general-purpose machines 中提出了删除写屏障,因为一旦该写屏障开始工作,它就会保证开启写屏障时堆上所有对象的可达,所以也被称作快照垃圾收集(Snapshot GC)。
删除屏障也是拦截写操作的,但是是通过保护灰色对象到白色对象的路径不会断来实现的。也就是若三色不变式。 1、首先将A对象标记成黑色,然后A对象指向的B对象标记为灰色; 上面的步骤2违反了强三色不变式,黑色对象直接指向了白色对象。接下来的步骤三违反了弱三色不变式,对象D没有一个直接或间接可达的灰色对象。通过对C重新着色,来保证C和D对象的安全。 混合写屏障插入写屏障和删除写屏障的短板: 插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活; 删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。 在 Go 语言 v1.7 版本之前,运行时会使用 Dijkstra 插入写屏障保证强三色不变性,但是运行时并没有在所有的垃圾收集根对象上开启插入写屏障。因为 Go 语言的应用程序可能包含成百上千的 Goroutine,而垃圾收集的根对象一般包括全局变量和栈对象,如果运行时需要在几百个 Goroutine 的栈上都开启写屏障,会带来巨大的额外开销,所以 Go 团队在实现上选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描,在活跃 Goroutine 非常多的程序中, 具体操作: 伪代码
GO中GC的流程1、准备阶段: STW,初始化标记任务,启用写屏障 2、标记阶段 GCMark 与赋值器并发执行,写屏障处于开启状态 1、将状态切换至 _GCmark、开启写屏障、用户程序协助(Mutator Assiste)并将根对象入队; 3、标记终止阶段 STW, 保证一个周期内标记任务完成,停止写屏障 4、清理阶段 并发执行 1、将状态切换至 _GCoff 开始清理阶段,初始化清理状态并关闭写屏障; GC的触发时机Go 语言中对 GC 的触发时机存在两种形式:
例如:
阈值是由一个gc percent的变量控制的,当新分配的内存占已在使用中的内存的比例超过gcprecent时就会触发。比如一次回收完毕后,内存的使用量为5M,那么下次回收的时机则是内存分配达到10M的时候。也就是说,并不是内存分配越多,垃圾回收频率越高。 如果一直达不到内存大小的阈值呢?这个时候GC就会被定时时间触发,比如一直达不到10M,那就定时(默认2min触发一次)触发一次GC保证资源的回收。 如果内存分配速度超过了标记清除的速度怎么办?如果在后台执行的垃圾收集器不够快,应用程序申请内存的速度超过预期,运行时就会让申请内存的应用程序辅助完成垃圾收集的扫描阶段,在标记和标记终止阶段结束之后就会进入异步的清理阶段,将不用的内存增量回收。 并发标记会设置一个标志,并在 mallocgc 调用时进行检查。当存在新的内存分配时,会暂停分配内存过快的那些 goroutine,并将其转去执行一些辅助标记(Mark Assist)的工作,从而达到放缓继续分配、辅助 GC 的标记工作的目的。 如何观察GC1、使用
测试的代码片段
来分析下结果
字段 |
含义 |
|