golang-内存管理-GC回收

1.什么是 GC?

垃圾回收(GC)是编程语言中提供的内存管理功能。有自动和手动两种方式。
在应用程序中会使用到两种内存,分别为堆(Heap)和栈(Stack),GC 负责回收堆内存,而不负责回收栈中的内存。那么这是为什么呢?主要原因是栈是一块专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈。除此以外,栈中的数据都有一个特点——简单。比如局部变量不能被函数外访问,所以这块内存用完就可以直接释放。正是因为这个特点,栈中的数据可以通过简单的编译器指令自动清理,并不需要通过 GC 来回收。

根对象

  • 全局变量
  • 执行栈:每个协程有自己的执行栈,包含执行栈变量,分配堆内存的指针等。
  • 寄存器

2.GC 算法的种类

2.1.引用计数

对每个对象维护一个引用计数,当引用对象的对象被销毁时,引用计数-1,如果引用计数为0,则进行垃圾回收。
golang-内存管理-GC回收_第1张图片

优点:回收速度快,对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收。
缺点:不能很好的处理循环引用,而且实时维护引用计数,有也一定的代价,频繁更新引用计数降低了性能。

2.2.标记-清除

该方法分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。即:从根变量开始遍历所有引用的对象,引用的对象标记为"被引用",没有被标记的进行回收。
golang-内存管理-GC回收_第2张图片
优点:解决了引用计数的缺点。
缺点:需要STW,即要暂时停掉程序运行,回收同时可能伴有碎片整理操作。
代表语言:Golang(其采用三色标记法)

2.3.复制收集

解决效率问题,“复制”收集算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
它的主要缺点有两个:
(1)效率问题:在对象存活率较高时,复制操作次数多,效率降低;
(2)空间问题:內存缩小了一半;需要額外空间做分配担保(老年代)

复制收集算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。复制收集的方式只需要对对象进行一次扫描。从根对象开始对对象进行扫描,如果存在对这个对象的引用,就把它复制到新空间中。一次扫描结束之后,所有存在于新空间的对象就是所有的非垃圾对象。
标记清除的方式节省内存但是两次扫描需要更多的时间,复制收集更快速但是需要额外开辟一块用来复制的内存,对垃圾比例较大的情况占优势
golang-内存管理-GC回收_第3张图片

在复制收集的过程中,会按照对象被引用的顺序将对象复制到新空间中。于是,关系较近的对象被放在距离较近的内存空间的可能性会提高,这叫做局部性。局部性高的情况下,内存缓存会更有效地运作,程序的性能会提高。
优点:速度更快,没有碎片化。
缺点:需要STW,可利用空间小。在对象存活率较高时,复制操作次数多,效率降低;

2.4.标记-整理算法

golang-内存管理-GC回收_第4张图片

复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。
标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

3.Golang的三色标记

golang-内存管理-GC回收_第5张图片
这个图还认识吧,不了解的话,可以去这篇文章go-内存管理篇(二) 万字总结-golang内存分配篇。
mspan多个page的组成。一个page大小为8kb。
mspan参数介绍:

  • npages 由多少个page组成
  • nelems: 可分配的对象。
  • allocBits: 位图,每位代表一个块是否被分配。
  • allCounts: 多少块被分配
  • elemsize: object size大小 常量
  • spanclass: Span大小的级别。一个Size Class会对应两个Span Class,其中一个Span为存放需
  • GC扫描的对象,另一个Span为存放不需要GC扫描的对象。
  • gcmarkBits : allocBits位图 标记span的块是否被引用。

3.1.三色标记

  • 灰色:对象已被标记,但这个对象包含的子对象未标记。
  • 黑色:对象已被标记,且这个对象包含的子对象也已标记,gcmarkBits对应的位为1(该对象不会在本次GC中被清理)。
  • 白色:对象未被标记,gcmarkBits对应的位为0(该对象将会在本次GC中被清理)

三色标记清除的整个过程:
golang-内存管理-GC回收_第6张图片
第一步:在进入 GC 的三色标记阶段的一开始,所有对象都是白色的。
第二步, 遍历根节点集合里的所有根对象,把根对象引用的对象标记为灰色,从白色集合放入灰色集合。
第三步, 遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合
第四步:重复第三步, 直到灰色集合中无任何对象。

  • 写屏障(Write Barrier) :写屏障就是让 goroutine 与 GC 同时运行的手段,虽然不能完全消除 STW,但是可以大大减少 STW 的时间。写屏障在 GC 的特定时间开启,开启后指针传递时会把指针标记,即本轮不回收,下次 GC 时再确定。
  • 辅助 GC(Mutator Assist) :为了防止内存分配过快,在 GC 执行过程中,GC 过程中 mutator 线程会并发运行,而 mutator assist 机制会协助 GC 做一部分的工作。
    第五步:回收白色集合里的所有对象,本次垃圾回收结束。

3.2.三色标记法所存在问题

多标-浮动垃圾问题
假设 E 已经被标记过了(变成灰色了),此时 D 和 E 断开了引用,按理来说对象 E/F/G 应该被回收的,但是因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。
golang-内存管理-GC回收_第7张图片
漏标-悬挂指针问题
除了上面多标的问题,还有就是漏标问题。当 GC 线程已经遍历到 E 变成灰色,D变成黑色时,灰色 E 断开引用白色 G ,黑色 D 引用了白色 G。此时切回 GC 线程继续跑,因为 E 已经没有对 G 的引用了,所以不会将 G 放到灰色集合。尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:G 会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的,这也是 Go 需要在 GC 时解决的问题。
golang-内存管理-GC回收_第8张图片

3.3.屏障机制

为了解决上面的悬挂指针问题,我们需要引入屏障技术来保障数据的一致性。内存屏障,是一种屏障指令,它能使CPU或编译器对在该屏障指令之前和之后发出的内存操作强制执行排序约束,在内存屏障前执行的操作一定会先于内存屏障后执行的操作。

  • 强三色不变式:不存在黑色对象引用到白色对象的指针。
    golang-内存管理-GC回收_第9张图片
  • 弱三色不变式:所有被黑色对象引用的白色对象都处于灰色保护状态.
    golang-内存管理-GC回收_第10张图片

3.3.1.插入屏障

具体操作: 在A对象引用B对象的时候,B对象被标记为灰色。(将B挂在A下游,B必须被标记为灰色)
满足: 强三色不变式. (不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)

3.3.2.删除写屏障

当一个白色对象被另外一个对象时解除引用时,将该被引用对象标记为灰色。
缺点:产生内存冗余,如果上述该白色对象没有被别的对象引用,相当于还是垃圾,但是这一轮垃圾回收并没有处理掉他。

3.3.3.Go V1.8的混合写屏障机制

插入写屏障和删除写屏障的短板:
插入写屏障:结束时需要STW来重新扫描栈,标记栈上引用的白色对象的存活;
删除写屏障:回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

混合写屏障机制,避免了对栈重复扫描的过程,极大的减少了STW的时间。结合了两者的优点。
混合写屏障机制具体操作:
1、GC开始将栈上的对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW),
2、GC期间,任何在栈上创建的新对象,均为黑色。
3、被删除的对象标记为灰色。
4、被添加的对象标记为灰色。

4.GC流程

  • 首先检查上一次垃圾回收是否还有mspan未被清理,如果有还需要执行清理工作;
  • 标记准备(Mark Setup) :打开写屏障r,需 STW;
  • 标记开始(Marking) :使用三色标记法并发标记 ,与用户程序并发执行;
  • 标记终止(Mark Termination):对触发写屏障的对象进行重新扫描标记,关闭写屏障,需 STW;
  • 清理(Sweeping) :将需要回收的内存归还到堆中,将过多的内存归还给操作系统,与用户程序并发执行。

golang-内存管理-GC回收_第11张图片

4.1.stopTheworld。如何暂停用户协程呢?

不是所有根对象的扫描都需要STW, 例如扫描栈上的对象只需要停止拥有该栈的G。
线程M调度协程G是需要绑定逻辑处理器P的,那如果没有可用的逻辑处理P当然也就无法调度用户协程了?逻辑处理器P可以分为三种:
1)空闲,没有被任何线程M绑定,这种直接更新其状态即可;
2)系统调用中,说明已被线程M绑定,并且正在执行系统调用,同样的直接更新状态即可(系统调度返回后,检测逻辑处理器P的状态不对,线程M会休眠);
3)运行中,也就是已被线程M绑定,并且正在调度用户协程,这种是需要通知其暂停用户协程的,如何通知呢?还记得介绍Go语言调度器提到的抢占式调度吗?协作式抢占调度与基于信号的抢占式调度。对,就是通过这两种方案实现的。

4.2.为什么写屏障不保护栈的引用,为什么栈上触发写屏障就会影响性能?

写屏障:写屏障只监控堆上指针数据的变动,由于成本原因,没有监控栈上指针的变动,由于应用goroutine和GC的标记goroutine都在运行,当栈上的指针指向的对象变更为白色对象时,这个白色对象应当标记为黑色,需要再次扫描全局变量和栈,以免释放这类不该释放的对象。
go是并发运行的,大部分的操作都发生在栈上。数十万goroutine的栈都进行屏障保护自然会有性能问题。

4.3.如果发生栈上对象引用改变。因为不涉及屏障,为什么不会发生错误。

1.栈的操作是原子操作,要么栈全灰,要么全黑。
2.已被扫黑的栈,引用的堆上的对象至少是灰色。(比如C对象)。所以不可能发生同栈下引用改变会影响GC的问题。
3.不可能发生上述的跨栈的引用。因为“对象不是从天上掉下来的”。假设A对象可以与D对象建立引用,只有可能A也直接间接持有B对象。否则没有路径可以建立这样的引用。然而,因为Go的逃逸分析,B对象被外部引用,不可能存在于栈上。所以B一定是堆上的对象。

辅助GC:Golang GC实际上把单次暂停时间分散掉了,本来程序执⾏可能是“⽤户代码–>⼤段GC–>⽤户代码”,那么分散以后实际上变成了“⽤户代码–>⼩段 GC–>⽤户代码–>⼩段GC–>⽤户代码”这样。如果GC回收的速度跟不上用户代码分配对象的速度呢? Go **语⾔如果发现扫描后回收的速度跟不上分配的速度它依然会把⽤户逻辑暂停,⽤户逻辑暂停了以后也就意味着不会有新的对象出现,同时会把⽤户线程抢过来加⼊到垃圾回收⾥⾯加快垃圾回收的速度。**这样⼀来原来的并发还是变成了STW,还是得把⽤户线程暂停掉,要不然扫描和回收没完没了了停不下来,因为新分配对象⽐回收快,所以这种东⻄叫做辅助回收。

5.GC触发条件

  • 主动触发:调用 runtime.GC() 方法,触发 GC
  • 被动触发:
    • 定时触发,该触发条件由runtime.forcegcperiod变量控制,默认为 2 分 钟。当超过两分钟没有产生任何 GC 时,触发 GC
    • 根据内存分配阈值触发,该触发条件由环境变量GOGC控制,默认值为100(100%),当前堆内存占用是上次GC结束后占用内存的2倍时,触发GC

6.调优gc

6.1.如何观察GC?

  • 通过指令 GODEBUG =gctrace=1 ./main
go build -o main.go
GODEBUG =gctrace=1 ./main
  • go tool trace: 统计信息可视化。
  • 方法:debug.ReadGCStatus():监控
  • runtime.ReadMemStatus()

6.2.如何调优?

  • 一方面可以针对业务类型调整环境变量GOGC或者debug.SetGCPercent()(计算并调整下一次垃圾回收触发的内存门限);
  • 尽量减少用户代码分配内存的数量,比如使用对象池(复用),减少对象分配,合理重复利用;
  • 避免string与[]byte转化;
  • 少用+连接string(频繁分配内存);
  • 标记扫描就是对对象包含指针,就需要继续扫描,Go语言才会将每种mspan分为两种规格,有指针与无指针,而不包含指针的mspan是不需要继续扫描的。bigcache包是常用的本地内存缓存组件,就是通过去除指针来减少垃圾回收扫描的压力。

7.总结

  • go 1.5 采用三色标记法,插入写屏障机制(只在堆内存中生效),最后仍需对栈内存进行STW;
  • go 1.8 采用混合写屏障机制,屏障限制只在堆内存中生效。避免了最后节点对栈进行STW的问题,提升了GC效率;
  • 插入写屏障没有完全保证完整的强三色不变式(栈对象的影响),所以赋值器是灰色赋值器,最后必须 STW 重新扫描栈;
  • 混合写屏障消除了所有的 STW,实现的是黑色赋值器,不用 STW 扫描栈;
  • 混合写屏障的精度和删除写屏障的一致,比以前插入写屏障要低;
  • 混合写屏障扫描栈式逐个暂停,逐个扫描的,对于单个 goroutine 来说,栈要么全灰,要么全黑。
  • 一次完整的垃圾回收会分为四个阶段,分别是标记准备、标记、结束标记以及清理。在标记准备和标记结束阶段会需要 STW,标记阶段会减少程序的性能,而清理阶段是不会对程序有影响的。

你可能感兴趣的:(golang,golang)