垃圾回收(GC)是编程语言中提供的内存管理功能。有自动和手动两种方式。
在应用程序中会使用到两种内存,分别为堆(Heap)和栈(Stack),GC 负责回收堆内存,而不负责回收栈中的内存。那么这是为什么呢?主要原因是栈是一块专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈。除此以外,栈中的数据都有一个特点——简单。比如局部变量不能被函数外访问,所以这块内存用完就可以直接释放。正是因为这个特点,栈中的数据可以通过简单的编译器指令自动清理,并不需要通过 GC 来回收。
根对象:
对每个对象维护一个引用计数,当引用对象的对象被销毁时,引用计数-1,如果引用计数为0,则进行垃圾回收。
优点:回收速度快,对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收。
缺点:不能很好的处理循环引用,而且实时维护引用计数,有也一定的代价,频繁更新引用计数降低了性能。
该方法分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。即:从根变量开始遍历所有引用的对象,引用的对象标记为"被引用",没有被标记的进行回收。
优点:解决了引用计数的缺点。
缺点:需要STW,即要暂时停掉程序运行,回收同时可能伴有碎片整理操作。
代表语言:Golang(其采用三色标记法)
解决效率问题,“复制”收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
它的主要缺点有两个:
(1)效率问题:在对象存活率较高时,复制操作次数多,效率降低;
(2)空间问题:內存缩小了一半;需要額外空间做分配担保(老年代)
复制收集算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。复制收集的方式只需要对对象进行一次扫描。从根对象开始对对象进行扫描,如果存在对这个对象的引用,就把它复制到新空间中。一次扫描结束之后,所有存在于新空间的对象就是所有的非垃圾对象。
标记清除的方式节省内存但是两次扫描需要更多的时间,复制收集更快速但是需要额外开辟一块用来复制的内存,对垃圾比例较大的情况占优势。
在复制收集的过程中,会按照对象被引用的顺序将对象复制到新空间中。于是,关系较近的对象被放在距离较近的内存空间的可能性会提高,这叫做局部性。局部性高的情况下,内存缓存会更有效地运作,程序的性能会提高。
优点:速度更快,没有碎片化。
缺点:需要STW,可利用空间小。在对象存活率较高时,复制操作次数多,效率降低;
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
这个图还认识吧,不了解的话,可以去这篇文章go-内存管理篇(二) 万字总结-golang内存分配篇。
mspan
多个page
的组成。一个page
大小为8kb。
mspan参数介绍:
三色标记清除的整个过程:
第一步:在进入 GC 的三色标记阶段的一开始,所有对象都是白色的。
第二步, 遍历根节点集合里的所有根对象,把根对象引用的对象标记为灰色,从白色集合放入灰色集合。
第三步, 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合
第四步:重复第三步, 直到灰色集合中无任何对象。
多标-浮动垃圾问题
假设 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 时解决的问题。
为了解决上面的悬挂指针问题,我们需要引入屏障技术来保障数据的一致性。内存屏障,是一种屏障指令,它能使CPU或编译器对在该屏障指令之前和之后发出的内存操作强制执行排序约束,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。
具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)
满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)
当一个白色对象被另外一个对象时解除引用时,将该被引用对象标记为灰色。
缺点:产生内存冗余,如果上述该白色对象没有被别的对象引用,相当于还是垃圾,但是这一轮垃圾回收并没有处理掉他。
插入写屏障和删除写屏障的短板:
插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;
删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。
混合写屏障机制,避免了对栈重复扫描的过程,极大的减少了STW的时间。结合了两者的优点。
混合写屏障机制具体操作:
1、GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
2、GC期间,任何在栈上创建的新对象,均为黑色。
3、被删除的对象标记为灰色。
4、被添加的对象标记为灰色。
不是所有根对象的扫描都需要STW, 例如扫描栈上的对象只需要停止拥有该栈的G。
线程M调度协程G是需要绑定逻辑处理器P的,那如果没有可用的逻辑处理P当然也就无法调度用户协程了?逻辑处理器P可以分为三种:
1)空闲,没有被任何线程M绑定,这种直接更新其状态即可;
2)系统调用中,说明已被线程M绑定,并且正在执行系统调用,同样的直接更新状态即可(系统调度返回后,检测逻辑处理器P的状态不对,线程M会休眠);
3)运行中,也就是已被线程M绑定,并且正在调度用户协程,这种是需要通知其暂停用户协程的,如何通知呢?还记得介绍Go语言调度器提到的抢占式调度吗?协作式抢占调度与基于信号的抢占式调度。对,就是通过这两种方案实现的。
写屏障:写屏障只监控堆上指针数据的变动,由于成本原因,没有监控栈上指针的变动,由于应用goroutine和GC的标记goroutine都在运行,当栈上的指针指向的对象变更为白色对象时,这个白色对象应当标记为黑色,需要再次扫描全局变量和栈,以免释放这类不该释放的对象。
go是并发运行的,大部分的操作都发生在栈上。数十万goroutine的栈都进行屏障保护自然会有性能问题。
1.栈的操作是原子操作,要么栈全灰,要么全黑。
2.已被扫黑的栈,引用的堆上的对象至少是灰色。(比如C对象)。所以不可能发生同栈下引用改变会影响GC的问题。
3.不可能发生上述的跨栈的引用。因为“对象不是从天上掉下来的”。假设A对象可以与D对象建立引用,只有可能A也直接间接持有B对象。否则没有路径可以建立这样的引用。然而,因为Go的逃逸分析,B对象被外部引用,不可能存在于栈上。所以B一定是堆上的对象。
辅助GC:Golang GC实际上把单次暂停时间分散掉了,本来程序执⾏可能是“⽤户代码–>⼤段GC–>⽤户代码”,那么分散以后实际上变成了“⽤户代码–>⼩段 GC–>⽤户代码–>⼩段GC–>⽤户代码”这样。如果GC回收的速度跟不上用户代码分配对象的速度呢? Go **语⾔如果发现扫描后回收的速度跟不上分配的速度它依然会把⽤户逻辑暂停,⽤户逻辑暂停了以后也就意味着不会有新的对象出现,同时会把⽤户线程抢过来加⼊到垃圾回收⾥⾯加快垃圾回收的速度。**这样⼀来原来的并发还是变成了STW,还是得把⽤户线程暂停掉,要不然扫描和回收没完没了了停不下来,因为新分配对象⽐回收快,所以这种东⻄叫做辅助回收。
runtime.GC()
方法,触发 GCruntime.forcegcperiod
变量控制,默认为 2 分 钟。当超过两分钟没有产生任何 GC 时,触发 GCGODEBUG =gctrace=1 ./main
go build -o main.go
GODEBUG =gctrace=1 ./main
go tool trace
: 统计信息可视化。GOGC
或者debug.SetGCPercent()
(计算并调整下一次垃圾回收触发的内存门限);bigcache包
是常用的本地内存缓存组件,就是通过去除指针来减少垃圾回收扫描的压力。