赞
踩
要了解Golang的GC机制,就需要了解什么事GC,以及GC有哪几种实现方式
当一个电脑上的动态内存不再需要时,就应该予以释放,以让出内存,这种内存资源管理,称为垃圾回收(Garbage Collection),简称 GC,垃圾回收(Garbage Collection,简称GC)是编程语言中提供的自动的内存管理机制,自动释放不需要的内存对象,让出存储器资源,它在一定程度上解决了内存管理的问题,垃圾(程序不用的内存空间视为垃圾)回收可以有效的防止内存泄露,有效的使用空闲的内存
GC过程中无需程序员手动执行,GC机制在现代很多编程语言都支持,GC能力的性能与优劣也是不同语言之间对比度指标之一
其实垃圾回收机制的原理就是利用一些算法进行内存的管理,从而有效的防止内存泄漏、有效的利用空闲空间(内存空间)
内存泄露,是从操作系统的角度上来阐述的,形象的比喻就是“操作系统可提供给所有进程的存储空间(虚拟内存空间)正在被某个进程榨干”,导致的原因就是程序在运行的时候,会不断地动态开辟的存储空间,这些存储空间在在运行结束之后后并没有被及时释放掉,应用程序在分配了某段内存之后,由于设计的错误,会导致程序失去了对该段内存的控制,而对应的程序又没有很好的gc机制去对程序申请的空间进行回收,这样就造成了内存空间的浪费,从而导致内存泄漏
上面讲解了垃圾回收机制的原理就是利用一些算法进行内存的管理,那有哪些算法来进行操作呢,它们是怎样进行的呢?
任何一种垃圾回收算法一般要做两件基本事情:
- 找到无用的对象
- 回收将无用对象占用的内存空间,使该空间可被程序再次使用
基本流程如下:
找到回收对象-->何时回收-->如何回收-->释放
那么怎么找到无用的对象呢,有如下两种方式:
给每个对象添加一个引用计数器,如果被引用则计数器加1,如果引用该对象的对象被销毁计数器减1,当计数器为0时,代表该对象没有被引用那就需要回收了
如果两个对象互相引用怎么办?比如A引用了B,B又引用了A,那就无法释放
总结:
代表语言:Python、PHP、Swift
(可达性分析)设立若干种根对象,根对象的子对象也是存活的,当任何一个根对象到某一个对象都无法可达时,那么这个对象就是可回收的
如上图右侧白色部分则为根无法到达,从根变量开始遍历所有引用的对象,引用的对象标记为"被引用",没有被标记的会被判断为垃圾进行回收
在Go语言中,可以当做GC roots的对象有以下几种:
总结:
代表语言:Golang(其采用三色标记法)
通过上面的方法找到了要回收的对象,那么在什么时候回收呢,这又是一个需要考虑的问题,这里有几种Go触发GC运行的调用方式:
了解了触发GC运用的方式,下面就来看看常见的几种GC算法
简单的说就是:把空间里的活动对象复制到其他空间,把原空间里的所有对象都回收掉
复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的.当有效内存空间耗尽时,虚拟机将暂停程序运行,开启复制算法GC线程,接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址,复制算法要想使用,最起码对象的存活率要非常低才行,而且最重要的是,必须要克服50%内存的浪费
具体流程如下:
当From空间被占满时,GC将活动的对象全部复制到To空间,当复制完成后,该算法会将From空间和To空间互换,GC结束,From 空间和To 空间大小必须一致,这是为了保证能把From 空间中的所有活动对象都收纳到To 空间里
优缺点
标记-清除算法采用从根集合进行扫描,对存活的对象标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收:
标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片,这样坏处是会产生很多不连续的内存碎片
通过上面知道:标记- 清除算法可以由标记阶段和清除阶段构成,
标记阶段
是把所有活动对象都做上标记的阶段,清除阶段
是把那些没有标记的对象,也就是非活动对象回收的阶段,通过这两个阶段,就可以令不能利用的内存空间重新得到利用
在上面标记阶段进行标记通常采用的搜索对象算法为:深度优先搜索,深度优先搜索比广度优先搜索更能压低内存使用量,因此在标记阶段经常用到深度优先搜索,它是一个是纵向搜索,如下图:
而在进行标记的时候,GC只会收集各个对象的标志位并表格化,不会跟对象一起管理,在标记的时候,不在对象的头里置位,而是在这个表格中的特定场所置位,像这样集合了用于标记的位的表格称为“位图表格”(bitmap table),利用这个表格进行标记的行为称为“位图标记”,位图表格的实现方法有多种,例如散列表和树形结构和整数型数组等
在清除阶段需要将回收的垃圾进行再次利用,这里就需要进行分配操作:在清除阶段,把垃圾对象连接到空闲的链表,搜索空闲链表并寻找大小合适的分块,然后进行合并操作:在分配的时候有不同的分配策略,根据分配策略的不同可能会产生大量的小分块,如果它们是连续的,就能把所有的小分块连在一起形成一个大分块,这种“连接连续分块”的操作就叫作合并(coalescing),合并是在清除阶段进行的
清除操作所花费的时间是与堆大小成正比的,如果处理的堆越大,清除算法所花费的时间就越长。
延迟清除法,在标记操作结束后,不一定会进行清除操作,会缩减mutator的暂停时间。
优缺点
- 优点:标记清除算法实现简单,与其他的的算法组合也就相对简单,使用了[根搜索算法]找到无用的对象
- 缺点:标记清楚算法不会移动对象,但容易产生碎片化的空间,造成内存浪费,举个列子,如下:
上图的「根」指的是「GC root」,通过「根搜索算法」确认是不是垃圾,如果需要3空间的内存,而2空间的内存就存不下,就会被空闲,从而造成内存浪费
代表语言:Golang(其采用三色标记法)
原理:此算法分为标记阶段和压缩阶段,标记阶段和上面标记-清除算法一样的方式进行对象的标记,但在压缩整理时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动并整理到一起,并更新对应的指针,具体分为下面三步:
- 设定forwarding 指针
- 更新指针
- 移动对象
标记-整理算法实际上是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是解决了内存碎片的问题,
实际效果如下:
优缺点
原理:不同的对象的生命周期是不一样的,因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效.分代收集算法的过程如下:按照对象生命周期长短不同,将堆分为新生代和老年代,生命周期长的放入老年代,而短的放入新生代,根据区域特点选用不同的收集算法,如果新生代朝生夕死,则采用复制算法,老年代采用标记清除,或标记整理
拓展:
①Eden区(80%)和两块Survivor区(10%),堆中新生代和老年代占比1:2
②每次使用Eden和一块Survivor,回收时,将存活的对象一次性复制到另一块Survivor上,如果另一块Survivor空间不足,则使用分配担保机制存入老年代,什么时候从Survivor进入老年代,视垃圾回收器类型而定
优缺点:
代表语言: JAVA
上面列举了一些GC算法,这里来看看Golang的GC操作
- Go V1.1: STW
- Go V1.3: 标记-清扫(mark and sweep)法
- Go V1.5: 三色并发标记法
- Go V1.8: 混合写屏障机制(hybrid write barrier)
go的gc采用了并发标记-清扫( Mark-Sweep)算法的三色标记法,并做了一定改进,大部分的工作是在标记垃圾,基本原理基于[根搜索算法]的根可达性分析,减少了STW的时间
下面就来看看各个阶段GoGC的操作
这里和前面介绍的算法模块一样,此算法主要有两个主要的步骤:
具体步骤如下:
图中表示是程序与对象的可达关系,目前程序的可达对象有对象1-2-3,对象4-7等五个对象
所以对象1-2-3、对象4-7等五个对象被做上标记
操作非常简单,但是有一点需要额外注意:mark and sweep算法在执行的时候,需要程序暂停!即
STW(stop the world)
,STW的过程中,CPU不执行用户代码,全部用于垃圾回收,这个过程的影响很大,所以STW也是一些回收机制最大的难题和希望优化的点,所以在执行第三步的这段时间,程序会暂定停止任何工作,卡在那等待回收执行完毕
以上就是标记-清除算法的流程
- STW,stop the world:让程序暂停,程序出现卡顿 (重要问题)
- 标记需要扫描整个heap(堆)
- 清除数据会产生heap(堆)碎片
Go V1.3版本之前就是以上来实施的, 在执行GC的基本流程就是首先启动STW暂停,然后执行标记,再执行数据回收,最后停止STW,如图所示:
从上图来看,全部的GC时间都是包裹在STW范围之内的,这样貌似程序暂停的时间过长,影响程序的运行性能,所以Go V1.3 做了简单的优化,将STW的步骤提前, 减少STW暂停的时间范围,如下所示:
上图主要是将STW的步骤提前了一步,因为在Sweep清除的时候,可以不需要STW停止,因为这些对象已经是不可达对象了,不会出现回收写冲突等问题,这就是上面介绍了的延迟清除算法,但是无论怎么优化,Go V1.3都面临这个一个重要问题:就是mark-and-sweep 算法会暂停整个程序
Go是如何面对并这个问题的呢?接下来G V1.5版本 就用三色并发标记法来优化这个问题
三色标记法是传统 Mark-Sweep(标记-清除) 的一个改进,它是一个并发的 GC 算法,GC过程和其他用户goroutine并发运行,其实大部分的工作还是在标记垃圾,基本原理基于根可达(根搜索算法),但需要一定时间的STW(stop the world) ,所以GC的过程实际上就是通过四个阶段的标记来确定清楚的对象都有哪些,具体过程如下:
三色标记法将对象的颜色分为了白、灰、黑,三种颜色
- Mark Prepare - STW: 做标记阶段的准备工作,需要停止所有正在运行的goroutine(即STW),标记根对象,启用内存屏障,内存屏障有点像内存读写钩子,它用于在后续并发标记的过程中,维护三色标记的完备性(三色不变性),这个过程通常很快,大概在10-30微秒
- Marking - Concurrent:标记阶段会将大概25%(gcBackgroundUtilization)的P用于标记对象,逐个扫描所有G的堆栈,执行三色标记,在这个过程中,所有新分配的对象都是黑色,被扫描的G会被暂停,扫描完成后恢复,这部分工作叫后台标记(gcBgMarkWorker),这会降低系统大概25%的吞吐量,比如MAXPROCS=6,那么GC P期望使用率为6*0.25=1.5,这150%P会通过专职(Dedicated)/兼职(Fractional)/懒散(Idle) 三种工作模式的Worker共同来完成。这还没完,为了保证在Marking过程中,其它G分配堆内存太快,导致Mark跟不上Allocate的速度,还需要其它G配合做一部分标记的工作,这部分工作叫辅助标记(mutator assists),在Marking期间,每次G分配内存都会更新它的”负债指数”(gcAssistBytes),分配得越快,gcAssistBytes越大,这个指数乘以全局的”负载汇率”(assistWorkPerByte),就得到这个G需要帮忙Marking的内存大小(这个计算过程叫revise),也就是它在本次分配的mutator assists工作量(gcAssistAlloc)。
- Mark Termination - STW: 标记阶段的最后工作是Mark Termination,关闭内存屏障,停止后台标记以及辅助标记,做一些清理工作,整个过程也需要STW,大概需要60-90微秒,在此之后,所有的P都能继续为应用程序G服务了
- Sweeping - Concurrent :在标记工作完成之后,剩下的就是清理过程了,清理过程的本质是将没有被使用的内存块整理回收给上一个内存管理层级(mcache -> mcentral -> mheap -> OS),清理回收的开销被平摊到应用程序的每次内存分配操作中,直到所有内存都Sweeping完成,当然每个层级不会全部将待清理内存都归还给上一级,避免下次分配再申请的开销,比如Go1.12对mheap归还OS内存做了优化,使用NADV_FREE延迟归还内存
而在Marking - Concurrent 阶段,有三个问题:
- GC 协程和业务协程是并行运行的,大概会占用 25% 的CPU,使得程序的吞吐量下降
- 如果业务goroutine 分配堆内存太快,导致 Mark(标记) 跟不上Allocate(分配) 的速度,那么业务goroutine会被招募去做协助标记,暂停对业务逻辑的执行,这会影响到服务处理请求的耗时
- Go GC在稳态场景下可以很好的工作,但是在瞬态场景下,如定时的缓存失效,定时的流量脉冲,GC 影响会急剧上升
在Mark Prepare、Mark Termination - STW 阶段,这两个阶段虽然按照官方说法时间会很短,但是在实际的线上服务中,有时会在 trace 图中观测到长达十几 ms 的停顿,原因可能为:OS 线程在做内存申请的时候触发内存整理被“卡住”,Go Runtime 无法抢占处于这种情况的 goroutine ,进而阻塞 STW 完成
通过上面GC的四个阶段知道了GC的各个流程,可以通过下面的步骤来进一步说明
上图所示,程序可抵达的内存对象关系如左图所示,右边的标记表,是用来记录目前每个对象的标记颜色分类,这里面需要注意的是:所谓“程序”,则是一些对象的根节点集合,所以如果将“程序”展开,会得到类似如下的表现形式,如图所示:
这里 要注意的是:本次遍历是一次遍历,非递归形式,是从程序抽次可抵达的对象遍历一层,如上图所示,当前可抵达的对象是对象1和对象4,那么自然本轮遍历结束,对象1和对象4就会被标记为灰色,灰色标记表就会多出这两个对象
这一次遍历是只扫描灰色对象,将灰色对象的第一层遍历可抵达的对象由白色变为灰色,如:对象2、对象7,而之前的灰色对象1和对象4则会被标记为黑色,同时由灰色标记表移动到黑色标记表中
当全部的可达对象都遍历完后,灰色标记表将不再存在灰色对象,目前全部内存的数据只有两种颜色,黑色和白色,那么黑色对象就是程序逻辑可达(需要的)对象,这些数据是目前支撑程序正常业务运行的,是合法的有用数据,不可删除,白色的对象是全部不可达对象,目前程序逻辑并不依赖他们,那么白色对象就是内存中目前的垃圾数据,需要被清除
以上将全部的白色对象进行删除回收,剩下的就是全部依赖的黑色对象
三色并发标记法的流程基本上就是上面讲解的了
,在三色标记法过程中,这里面可能会有很多并发流程均会被扫描,执行并发流程的内存可能相互依赖,从而引发一些存在性的问题
看一个流程:
假设 E 已经被标记过了(变成灰色了),此时 D 和 E 断开了引用,按理来说对象 E/F/G 应该被回收的,但是因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去,最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存
这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”
当 GC 线程已经遍历到 E 变成灰色,D变成黑色时,灰色 E 断开引用白色 G ,黑色 D 引用了白色 G,此时切回 GC 线程继续跑,因为 E 已经没有对 G 的引用了,所以不会将 G 放到灰色集合,尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:G 会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,这也是 Go 需要在 GC 时解决的问题
为了解决上面的问题,引入屏障技术来保障数据的一致性:为了在GC过程中保证数据的安全,在开始三色标记之前就会加上STW,在扫描确定黑白对象之后再放开STW,但是很明显这样的GC扫描的性能是很低的,STW的过程有明显的资源浪费,对所有的用户程序都有很大影响,因为整个GC流程会进行两次STW(Stop The World), 第一次是Mark阶段的开始, 第二次是Mark Termination阶段,为了解决标记-清除(mark and sweep)算法中的卡顿(stw,stop the world)问题,尽可能的提高GC效率,减少STW时间,这里引入了屏障机制(内存屏障)来解决,它能使CPU或编译器对在该屏障指令之前和之后发出的内存操作强制执行排序约束,在内存屏障前执行的操作一定会先于内存屏障后执行的操作
- 第一次STW会准备根对象的扫描, 启动写屏障(Write Barrier)和辅助GC(mutator assist).
- 第二次STW会重新扫描部分根对象, 禁用写屏障(Write Barrier)和辅助GC(mutator assist)
而根据操作类型的不同,可以将内存屏障分成 Read barrier(读屏障)和 Write barrier(写屏障)两种,在 Go 中都是使用 Write barrier(写屏障),原因在《Uniprocessor Garbage Collection Techniques》也提到了:
If a non copying collector is used the use of a read barrier is an unnecessary expense.there is no need to protect the mutator from seeing an invalid version of a pointer. Write barrier techniques are cheaper, because heap writes are several times less common than heap reads对于一个不需要对象拷贝的垃圾回收器来说, Read barrier(读屏障)代价是很高的,因为对于这类垃圾回收器来说是不需要保存读操作的版本指针问题。相对来说 Write barrier(写屏障)代码更小,因为堆中的写操作远远小于堆中的读操作。
来下面看看 Write barrier(写屏障)是如何实现的:
这里要注意的是: 屏障技术是不在栈上应用的,因为要保证栈的运行效率
上面的屏蔽机制是基于一个强-弱三色不变式这个公式来解决的,公式如下:
强三色不变色实际上是强制性的不允许黑色对象引用白色对象,这样就不会出现有白色对象被误删的情况
弱三色不变式强调,黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象,这样实则是黑色对象引用白色对象,白色对象处于一个危险被删除的状态,但是上游灰色对象的引用,可以保护该白色对象,使其安全
为了遵循上述的两个方式,GC算法演进到两种写屏障方式,他们“插入屏障”, “删除屏障”
插入屏障只对堆上的内存分配起作用,举个例子:
在A对象引用B对象的时候,B对象被标记为灰色,(将B挂在A下游,B必须被标记为灰色),遵循三色不变式 (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色),但有一个不足之处:结束时需要STW来重新扫描栈,大约需要10~100ms,下面可以通过几张流程图来介绍
但是如果栈不添加,当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况(如上图的对象9). 所以要对栈重新进行三色标记扫描, 但这次为了对象不丢失, 要对本次标记扫描启动STW暂停. 直到栈空间的三色标记结束.
最后将栈和堆空间 扫描剩余的全部 白色节点清除. 这次STW大约的时间在10~100ms间
删除屏障适用于栈和堆,在删除屏障机制下删除一个节点该节点会被置成灰色,后续会继续扫描该灰色对象的子对象,该方法就是精准度不够高,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉
被删除的对象,如果自身为灰色或者白色,那么被标记为灰色,遵循弱三色不变式 (保护灰色对象到白色对象的路径不会断),下面可以通过几张流程图来介绍
这种方式的回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,在下一轮GC中被清理掉
好了,Go V1.5的三色标记法原理和问题基本是就讲清楚了,下面讲解一下v1.8混合写屏障机制
混合写屏障机制目的是解决上面v1.5屏蔽机制(插入(写)屏障和删除(写)屏障)的短板:
- 插入(写)屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活
- 删除(写)屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象
混合写屏障的基本思想是:
正在被覆盖的对象进行着色,且如果当前栈未扫描完成, 则同样对指针进行着色,同时,在GC的过程中所有新分配的对象都会立刻变为黑色,在垃圾收集的标记阶段,将新建的对象标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收
Go V1.8版本引入了混合写屏障机制(hybrid write barrier),避免了对栈re-scan的过程,极大的减少了STW的时间,结合了两者的优点,具体步骤如下:
混合写屏障机制满足变形的弱三色不变式,可以大幅压缩第二次STW的时间
这里需要注意:屏障技术是不在栈上应用的,因为要保证栈的运行效率
//前提:堆对象4->对象7 = 对象7; //对象7 被 对象4引用
栈对象1->对象7 = 堆对象7; //将堆对象7 挂在 栈对象1 下游
堆对象4->对象7 = null; //对象4 删除引用 对象7
new 栈对象9;
对象8->对象3 = 对象3; //将栈对象3 挂在 栈对象9 下游
对象2->对象3 = null; //对象2 删除引用 对象3
延伸一下: 如果对象9引用对象5,栈上没有屏障,对象5最终还是白色的 这样不会造成误删除吗? 混合写屏障是对堆使用的,对栈不使用,如果栈中黑色对象引用一个白色对象,没有写屏障,最后白色的要被回收的,如下图:
对上面的这种情况,是不会出现这种情况的,因为对象9是看不见对象5的,是不可达的,如果对象5是可达对象就不会变成白色了.白色表示已经断链了,是引用不到的,否则在STW遍历期间,就不会被标记为白色了
再思考一个问题:
假如对象2删掉对对象3的引用,且没有新的对象重新引用3,对象3在这一轮GC中是否会被回收?
解答:
屏障机制不会应用在栈上,那么在这一轮中就不会被回收,要下次扫描才会被标记为白色
堆对象10->对象7 = 堆对象7; //将堆对象7 挂在 堆对象10 下游
堆对象4->对象7 = null; //对象4 删除引用 对象7
堆对象10->对象7 = 堆对象7; //将堆对象7 挂在 堆对象10 下游
堆对象4->对象7 = null; //对象4 删除引用 对象7
Golang中的混合写屏障满足
弱三色不变式
,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间
Go的垃圾回收官方形容为非分代 非紧缩 写屏障 并发标记清理
非分代是Go GC区别于JVM GC分代模型的特点;
非紧缩意味着在回收垃圾的过程中,不需要像复制算法那样移动内存中的对象,这样避免STW过长;标记清除法的字面解释,就是将可达的内存块进行标记mark,最后没有标记的不可达内存块将进行清理sweep;Golang中实现标记功能的算法就是三色标记法,Golang里面三色标记法会造成错标问题,使用写屏障来解决这种问题
制作不易,请点赞关注
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。