golang的垃圾回收采用的是 标记-清理(Mark-and-Sweep) 算法
就是先标记出需要回收的内存对象快,然后在清理掉;
在这里不介绍标记和清理的具体策略,只介绍 GC过程是怎么调度的以及stw相关
这个算法,会导致 stw (stop the world) 的问题,中断用户逻辑
触发GC机制
在申请内存的时候,检查当前当前已分配的内存是否大于上次GC后的内存的2倍,若是则触发(主GC线程为当前M)
监控线程发现上次GC的时间已经超过两分钟了,触发;将一个G任务放到全局G队列中去。(主GC线程为执行这个G任务的M)
每当触发的时候,在主GC线程中就会走如下的GC流程:
stop the world,等待所有的M休眠;此时所有的业务逻辑代码都停止
标记:分配gc标记任务,唤醒 gcproc个 M(就是第一步休眠的那些),分别做这个,直到所有的M都做完,才结束;并且所有M再次进入休眠
清理:有一个单独的goroutine去清理已经标记的内存对象快
start the world,设置gcwaiting=0,唤醒所有的M(不会超过P个数)
对于上面的三个步骤,分别解释:
stop the world:
设置gcwaiting=1,这个在每一个G任务之前会检查一次这个状态,如是,则会将当前M 休眠;
如果这个M里面正在运行一个长时间的G任务,咋办呢,难道会等待这个G任务自己切换吗?这样的话可要等10ms啊,不能等!坚决不能等! 所以会主动发出抢占标记(类似于上一篇),让当前G任务中断,再运行下一个G任务的时候,就会走到第1步
一直等待所有的M进入休眠,此时所有的业务逻辑代码都停止
标记:
根据gcproc的个数,分配成gcproc任务段;唤醒gcproc-1 个M来执行(当前M也算一个)
对于一个M,唤醒前设置它的helpgc标记,唤醒之后这个M会立马判断这个标记,如是,则开始做分配给自己的标记任务,如果先做完了,就会从别的M里面找一些来做
等每一个M都做完,会再次进入休眠
清理:
通过设置参数,可以以一个单独goroutine 运行,这个功能是在1.3版本之后增加的,这样的话就直接到下一步了,清理过程不是stw的
也可以串行的在主GC线程执行;这样的话则清理过程也是stw的,
start the world:
设置gcwaiting=0
唤醒P个M来继续做G任务(此时没有helpgc标记),业务逻辑代码开始
综上:
是基于1.4 版本的,GC过程在标记过程是(STW)的
在1.5 版本里面对GC做了很大的优化;采用三色标记,将标记过程细化成三段,只有前后的两段是stw的;极大地缩短了gc的stw时间
golang的垃圾回收采用的是 标记-清理(Mark-and-Sweep) 算法
就是先标记出需要回收的内存对象快,然后在清理掉;
在这里不介绍标记和清理的具体策略(可以参考https://lengzzz.com/note/gc-in-golang),只介绍 GC过程是怎么调度的以及stw相关
这个算法,会导致 stw (stop the world)的问题,中断用户逻辑
触发GC机制
1. 在申请内存的时候,检查当前当前已分配的内存是否大于上次GC后的内存的2倍,若是则触发(主GC线程为当前M)
2. 监控线程发现上次GC的时间已经超过两分钟了,触发;将一个G任务放到全局G队列中去。(主GC线程为执行这个G任务的M)
每当触发的时候,在主GC线程中就会走如下的GC流程:
1. stop the world,等待所有的M休眠;此时所有的业务逻辑代码都停止
2. 标记:分配gc标记任务,唤醒 gcproc个 M(就是第一步休眠的那些),分别做这个,直到所有的M都做完,才结束;并且所有M再次进入休眠
3. 清理:有一个单独的goroutine去清理已经标记的内存对象快
4. start the world,设置gcwaiting=0,唤醒所有的M(不会超过P个数)
对于上面的三个步骤,分别解释:
stop the world:
1. 设置gcwaiting=1,这个在每一个G任务之前会检查一次这个状态,如是,则会将当前M休眠;
2. 如果这个M里面正在运行一个长时间的G任务,咋办呢,难道会等待这个G任务自己切换吗?这样的话可要等10ms啊,不能等!坚决不能等!
所以会主动发出抢占标记(类似于上一篇),让当前G任务中断,再运行下一个G任务的时候,就会走到第1步
3. 一直等待所有的M进入休眠,此时所有的业务逻辑代码都停止
标记:
1. 根据gcproc的个数,分配成gcproc任务段;唤醒gcproc-1个M来执行(当前M也算一个)
2. 对于一个M,唤醒前设置它的helpgc标记,唤醒之后这个M会立马判断这个标记,如是,则开始做分配给自己的标记任务,如果先做完了,就会从别的M里面找一些来做
3. 等每一个M都做完,会再次进入休眠
清理:
1. 通过设置参数,可以以一个单独goroutine 运行,这个功能是在1.3版本之后增加的,这样的话就直接到下一步了,清理过程不是stw的
2. 也可以串行的在主GC线程执行;这样的话则清理过程也是stw的,
start the world:
1. 设置gcwaiting=0
2. 唤醒P个M来继续做G任务(此时没有helpgc标记),业务逻辑代码开始
垃圾回收器⼀种做法是标记清理,标记清理最典型的做法是三⾊标记。
⾸先当垃圾回收器第⼀次启动的时候,它把所有的对象都看成⽩⾊的,如果这个对象引⽤了另外⼀个对象,那么被引⽤的对象称之为灰⾊的,把灰⾊的放⼊⼀个队列⾥去,那么当它第⼀次扫描完了以后这个⽆⾮就是变成两种状态,⽩⾊的和灰⾊的,⽩⾊的不属于我们要管的。
接下来扫描所有灰⾊的对象,灰⾊对象从队列⾥拿出来进⾏扫描,灰⾊对象被拿出来以后灰⾊对象本⾝被标记为⿊⾊的。如果它引⽤了其他对象那么这个对象重新变成灰⾊的,它会放⼊队列⾥⾯去,那么⿊⾊对象肯定是活着的不⽤管了,那么通过这样⼀级⼀级的扫描最终因为灰⾊对象被放⼊队列⾥⾯然后灰⾊对象拿出来进⾏扫描,灰⾊对象本⾝变成⿊⾊的,最终⾥就变成两种对象,⼀种是活下来⿊⾊的,第⼆种是所有扫描都没有⼈碰过的⽩⾊,那么⿊⾊的都是活着的,⽩⾊的都是统统干掉的。
那么最早的扫描是从哪来的呢,我们称之为从根 Root 对象来的,⽣命周期可以保证的对象是根对象,线程栈本⾝就是⼀个根,线程栈⾥⾯可能存了某个对象的指针,那线程栈就会引⽤那个对象,所以像全局变量、线程栈这些就是根对象。从它们开始扫描,如果全局变量没有引⽤任何东⻄,线程栈也没有引⽤任何东⻄,那这些根对象引⽤的对象肯定可以干掉。全局变量就不说了,线程栈就表⽰了当前正在引⽤的那对象,如果线程栈都没有引⽤过,那些对象肯定不要了,⽩⾊对象可以去掉了。
从根对象开始扫描从⼀开始⼤家都是⽩的,如果根对象有引⽤,那个对象变成灰⾊的,灰⾊对象依次扫描以后就剩下变成两种对象,⽩⾊对象和灰⾊对象,⽩⾊对象先放在这,灰⾊对象放⼊队列⾥⾯去,接下来我们从队列⾥把灰⾊对象取出来,看看灰⾊对象引⽤了什么对象,灰⾊对象本⾝变成⿊⾊的它肯定活下来的,因为它是被别⼈引⽤了才会放⼊队列⾥⾯,所以它从灰⾊变成⿊⾊肯定是活下来的。通过这样把灰⾊对象⼀级⼀级进⾏递归扫描以后最后这个队列被清空了,剩下来的世界只有两种对象,⼀种是⿊⾊的肯定被引⽤过,第⼆种是没有被引⽤过的⽩⾊对象,⿊⽩两⾊,⿊⾊活着⽩⾊干掉,这就是很典型的三⾊标记
golang垃圾回收使用的标记清理
STW(stop the world)
在扫描之前执⾏ STW(Stop The World)操作,就是Runtime把所有的线程全部冻结掉,所有的线程全部冻结掉意味着⽤户逻辑肯定都是暂停的,所有的⽤户对象都不会被修改了,这时候去扫描肯定是安全的,对象要么活着要么死着,所以会造成在 STW 操作时所有的线程全部暂停,⽤户逻辑全部停掉,中间暂停时间可能会很⻓,⽤户逻辑对于⽤户的反应就中⽌了。
如何减短这个过程呢, STW过程中有两部分逻辑可以分开处理。我们看⿊⽩对象,扫描完结束以后对象只有⿊⽩对象,⿊⾊对象是接下来程序恢复之后需要使⽤的对象,如果不碰⿊⾊对象只回收⽩⾊对象的话肯定不会给⽤户逻辑产⽣关联,因为⽩⾊对象肯定不会被⽤户线程引⽤的,所以回收操作实际上可以和⽤户逻辑并发的,因为可以保证回收的所有目标都不会被⽤户线程使⽤,所以第⼀步回收操作和⽤户逻辑可以并发,因为我们回收的是⽩⾊对象,扫描完以后⽩⾊对象不会被全局变量引⽤、线程栈引⽤。回收⽩⾊对象肯定不会对⽤户线程产⽣竞争,⾸先回收操作肯定可以并发的,既然可以和⽤户逻辑并发,这样回收操作不放在 STW时间段⾥⾯缩短 STW 时间。
写屏障
写屏障:该屏障之前的写操作和之后的写操作相比,先被系统其它组件感知。
刚把⼀个对象标记为⽩⾊的,⽤户逻辑执⾏了突然引⽤了它,或者说刚刚扫描了 100 个对象正准备回收结果⼜创建了1000个对象在⾥⾯,因为没法结束没办法扫描状态不稳定,像扫描操作就⽐较⿇烦。于是引⼊了写屏障的技术。
,先做⼀次很短暂的STW,为什么需要很短暂的呢,它⾸先要执⾏⼀些简单的状态处理,接下来对内存进⾏扫描,这个时候⽤户逻辑也可以执⾏。⽤户所有新建的对象认为就是⿊⾊的,这次不扫描了下次再说,新建对象不关⼼了,剩下来处理已经扫描过的对象是不是可能会出问题,已经扫描后的对象可能因为⽤户逻辑造成对象状态发⽣改变,所以对扫描过后的对象使⽤操作系统写屏障功能⽤来监控⽤户逻辑这段内存。任何时候这段内存发⽣引⽤改变的时候就会造成写屏障发⽣⼀个信号,垃圾回收器会捕获到这样的信号后就知道这个对象发⽣改变,然后重新扫描这个对象,看看它的引⽤或者被引⽤是否被改变,这样利⽤状态的重置从⽽实现当对象状态发⽣改变的时候依然可以判断它是活着的还是死的,这样扫描操作实际上可以做到⼀定程度上的并发,因为它没有办法完全屏蔽STW起码它当开始启动先拿到⼀个状态,但是它的确可以把扫描时间缩短,现在知道了扫描操作和回收操作都可以⽤户并发。
golang回收的本质
实际上把单次暂停时间分散掉了,本来程序执⾏可能是“⽤户逻辑、⼤段GC、⽤户逻辑”,那么分散以后实际上变成了“⽤户逻辑、⼩段 GC、⽤户逻辑、⼩段GC、⽤户逻辑”这样。其实这个很难说 GC 快了。因为被分散各个地⽅以后可能会频繁的保存⽤户状态,因为垃圾回收之前要保证⽤户状态是稳定的,原来只需要保存⼀次就可以了现在需要保存多次,很难说这种⽅式就⼀定让程序变的快了
辅助回收
Go 语⾔“⼀段⽤户逻辑,⼀段并发扫描Scan,⼀段并发回收Collect”,那可能会造成这种状态:描的速度跟不上⽤户分配的速度,会造成扫描永远结束不了,结束不了的情况下很⼤的⿇烦在于垃圾回收就会出问题,⽤户内存膨胀,必须在性能和内存膨胀之间做出平衡。
以 Go 语⾔如果发现扫描后回收的速度跟不上分配的速度它依然会把⽤户逻辑暂停,⽤户逻辑暂停了以后也就意味着不会有新的对象出现,同时会把⽤户线程抢过来加⼊到垃圾回收⾥⾯加快垃圾回收的速度。因为并⾏有四个核,有三个核⽤户线程执⾏只有⼀个核在做垃圾回收,那⼀个核就有可能跑不过三个核,那把那三个核也抢过来做垃圾回收。这样⼀来原来的并发还是变成了STW,还是得把⽤户线程暂停掉,要不然扫描和回收没完没了了停不下来,因为新分配对象⽐回收快,所以这种东⻄叫做辅助回收
控制器
很多语⾔⽐如 Java 对垃圾回收器做了很多控制开关,是因为那些算法未必适合当前的这种算法,有些语⾔⽐较适合并发扫描有些语⾔不适合,甚⾄是像做⼤数据计算完全把GC关掉就是⼀直把内存⽤完了导致系统崩溃了为⽌,只不过崩溃之前保证把状态保存然后重新执⾏这个进程然后进⾏密集计算,把垃圾回收那段时间抢出来⽤来做密集计算。所以说垃圾回收器的算法不是万能的,它也没有办法做到真正意义上的智能。
Java、 Go 语⾔都有垃圾回收预值,甚⾄来决定预值什么时候启动垃圾回收,像Go语⾔有百分⽐来控制到底有多⼤合适,这个 GC堆到底分配多⼤合理,这都需要在了解垃圾回收器原理情况下做动态调节。因为我们的服务程序很复杂,在服务器上可能⻓时间运⾏,垃圾回收器算法对性能影响很关键的.
Go 语⾔垃圾回收器⼀直被⼤家说实现的是原始版,因为Go早期版本对垃圾回收器预值怎么触发的特别蠢,第⼀次回收的时候回收完了剩下来对象是2M,那么下次垃圾回收的内存消耗变成4M,假设第⼀次回收之前内存是 100G,下次回收可能就变成 200G,可问题是下次回收⽤不了200G,可能第⼀次回收⽤的 100G 是引⽤了⼤字典,在下次回收之前这字典清空了接下来⼀直⽤⼏⼗M,垃圾回收器很难启动,所以 Go 语⾔在后台⽤⼀个循环线程扫描,每2分钟发现不执⾏就强制回收⼀次,这样的做法显然⽐较蠢。后来在 1.5 版本引⼊⼀个控制器,控制器有点像Java语⾔动态概念,当这次回收释放⽐例、或者是这些对象相关⼀些数据,控制器和辅助回收的作⽤GitChat来对预值动态调整决定下次回收