Go Extension:内存管理の垃圾收集器

本文详细介绍了Go语言的垃圾收集机制,从早期的标记-清除算法到并发的三色标记法,阐述了垃圾收集器如何降低停顿时间。Go通过写屏障技术确保并发标记的正确性,并使用辅助GC让用户线程参与回收工作。文章还概述了Go垃圾收集器的版本演进,展示了如何通过并发和优化将垃圾收集延迟降至毫秒级以下。最后,讨论了垃圾收集的完整流程,包括Mark、Sweep阶段及StopTheWorld事件。
摘要由CSDN通过智能技术生成

Go Extension:内存管理の垃圾收集器

​ 用户会通过内存分配器在堆上申请内存,而垃圾收集器负责回收堆上的内存空间,内存分配器和垃圾收集器共同管理着程序中的堆内存空间。

Golang的垃圾回收机制从最初的标记-清除算法(需要大量的STW时间),到V1.5实现了三色标记并发垃圾收集器,大幅度降低收集延迟。Golang为了实现高性能的并发垃圾收集,经过多版本的迭代,目前将垃圾收集的暂停时间优化到了毫秒级以下。这篇文章主要是围绕GC中几个重要的概念介绍了Go垃圾收集器的设计思路,梳理了go版本更迭中在垃圾收集器方面的改变,最后介绍了Go中完成一次垃圾收集的流程。

​ 由于Go垃圾收集器的实现源码多而繁杂,在写这篇笔记的时候,省略了一些实现细节和源码阅读的篇幅。

1.设计原理

JavaGo还有Python等语言使用自动方式管理内存,并不需要用户主动申请或释放内存(C和C++就需要手动管理内存),之前在阅读《深入理解Java虚拟机》的垃圾回收章节时,对于如何判定一个对象已死、常见的标记-清除/标记-复制/标记-整理算法比较熟悉了,这里会写的简略些。

​ 最早出现也是最基础的垃圾收集算法是标记-清除算法,在Go早期版本的垃圾收集器实现中,就是基于该算法。除此之外,之所以说它是最基础的收集算法,是因为后续出现的收集算法大多都是以标记-清除算法为基础的,比如说标记-复制算法在标记-清除的基础上解决了面对大量可收回对象时执行效率低的问题。

1.1 标记-清除算法

核心思想:先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,当然也可以标记出所有不需要被回收的对象,标记完了之后,统一回收没被标记的(没被标记的就是垃圾了)。

​ 标记过程是怎么实现的呢?其实有点类似于可达性分析算法,这个算法的基本思路就是通过一系列根对象GC Roots作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到根对象间没有任何引用链项链,那么这个对象是不可能在被使用的,所以这个对象就会被标记。

​ 如下图,对象object 5、 object 6、 object 7虽然互有关联, 但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。

image-20200804152154849

看了很多博客,发现很多博主对Go中根节点root具体指哪些说的很简略||不提。这里简单说明下,根节点root主要指的是全局变量、各个G的栈上的变量等。扫描根对象相关的工作在go中的具体实现在这里

​ **缺点:**因为要保证GC期间标记的对象状态不能发生变化,所以会出现我们常说的stop the world现象;其次是,执行效率不稳定,如果堆中大量对象都需要被回收,那么此时需要进行大量标记和清除的操作,导致标记和清除两个过程的执行效率都会随着对象数量增长而降低;再者,会出现内存空间碎片化的问题,标记清除之后会产生大量不连续的内存碎片,内存碎片又会导致啥问题呢,如果以后在程序运行的过程中需要分配大对象可能会无法找到足够的连续内存,不得不提前触发另一次GC。

1.2 三色标记法

​ 目前垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活,然后对对象进行标记。可达性分析算法理论要求整个过程基于能保障一致性的快照中才能够进行分析,这意味着整个过程都应该基于能够保障一致性的快照中才能够进行分析,换句话说就是需要全部冻结用户线程的运行。那么,我们可以得到一个结论:停顿时间和堆容量是成正比例关系的。

​ 停顿时间过长,终究不是一个很好的事情。要想解决或者降低用户线程的停顿,需要先搞清楚为什么必须在一个保障一致性的快照上才能进行对象图的遍历?这里通过三色标记法,说明这个事情:

​ 三色标记法将程序中的对象分成了白色、黑色、灰色三类:

  • 白色对象 — 表示对象尚未被垃圾收集器访问过。 显然在可达性分析刚刚开始的阶段, 所有的对象都是
    白色的, 若在分析结束的阶段, 仍然是白色的对象, 即代表不可达 ;

  • 黑色对象 — 表示对象已经被垃圾收集器访问过, 且这个对象的所有引用都已经扫描过。 黑色的对象代
    表已经扫描过, 它是安全存活的, 如果有其他对象引用指向了黑色对象, 无须重新扫描一遍。 黑色对
    象不可能直接(不经过灰色对象) 指向某个白色对象 ;

  • 灰色对象 — 表示对象已经被垃圾收集器访问过, 但这个对象上至少存在一个引用还没有被扫描过。

image-20200804131831427

​ 如果用户线程是暂停的,那么同一时刻只有收集器goroutine在工作,那么不会有任何问题。但现实是,用户程序和收集器是并发工作的,收集器在对象图上标记颜色,同时如果用户程序在修改引用关系,那么就会有两种情况:

  • 原本应该死亡的对象被误标记为存活(可以忍一忍,下次收集的时候清理掉就好了);

  • 原本应该存活的对象被标记为死亡,这种错误称之为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性(忍不下去的情况)。

    实际上,为了解决标记-清除算法导致的长时间stop the world问题,很多垃圾收集器会使用三色标记法的变种来缩短STW的时间,这一点,在golang V1.5版本的迭代中得以体现,但这还是会存在悬挂指针的问题,那么这需要屏障技术给予解决。

1.3 写屏障

Go支持并行GCGc的扫描工作回合用户程序一起运行,那么这样就会出现刚刚说的那个问题:用户程序可能在我们标记执行的过程中修改对象的指针。

​ 为了避免这种情况,每一轮垃圾回收开始的时候,会初始化一个”屏障“,”屏障“会记录第一次扫描时每一个对象的状态,以便和第二次扫描的结果做对比,如果对象的引用状态发生了变化,那么就会把它标记为灰色,防止丢失。其他引用状态没有变化的对象就继续执行屏障后面的逻辑即可。

​ 只要遵循下述不变性中的一个,就能保证垃圾收集算法的正确性。

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径

​ 根据操作类型的不同,我们可以将他们分成读屏障还有写屏障两种,读屏障需要在读操作中加入代码,这对性能影响很大,所以一般采用写屏障保证三色不变性。这里主要介绍go中使用的两种写屏障技术,分别是插入写屏障删除写屏障

1.4 辅助GC

​ 如果扫描后回收垃圾的速度跟不上用户程序分配对象的速度,那么会把用户程序暂停,因为用户程序暂停可以保证在标记的时候不会出现新对象,这样并发实际上又变成了STW的情况,还是需要把用户线程暂停掉,否则扫描和回收工作没完没了。

​ 所以GC执行的过程中,如果同时运行的用户程序goroutine分配了内存,那么这个goroutine会被要求辅助GC做一部分工作。

​ 在GC的过程中同时运行的G叫做mutatormutator assist机制就是描述G如何辅助GC做工作。

​ 辅助GC的工作类型有标记(Mark)还要回收(Sweep)。

  • 辅助标记的触发可以参考mallocgc函数,触发时G会帮助扫描 x x x个对象(具体扫描的对象个数 x x x是多少,有专门的公式去计算,这里不展开了)。

  • 辅助回收的触发可以参考cacheSpan函数,触发时G会帮助回收 y y y页的对象(同上, y y y具体是多少也有专门的公式计算)

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

2.版本更迭

  1. v1.0 — 完全串行的标记和清除过程,需要暂停整个程序;

  2. v1.1 — 在多核主机并行执行垃圾收集的标记和清除阶段;

  3. v1.3 — 运行时基于只有指针类型的值包含指针的假设增加了对栈内存的精确扫描支持,实现了真正精确的垃圾收集;

    • unsafe.Pointer 类型转换成整数类型的值认定为不合法的,可能会造成悬挂指针等严重问题;
  4. v1.5 — 实现了基于三色标记清扫的并发垃圾收集器;

  • 大幅度降低垃圾收集的延迟从几百 ms 降低至 10ms 以下;

  • 计算垃圾收集启动的合适时间并通过并发加速垃圾收集的过程;

  1. v1.6 — 实现了去中心化的垃圾收集协调器;

    • 基于显式的状态机使得任意 Goroutine 都能触发垃圾收集的状态迁移;

    • 使用密集的位图替代空闲链表表示的堆内存,降低清除阶段的 CPU 占用;

  2. v1.7 — 通过并行栈收缩将垃圾收集的时间缩短至 2ms 以内;

  3. v1.8— 使用混合写屏障将垃圾收集的时间缩短至 0.5ms 以内);

  4. v1.9 — 彻底移除暂停程序的重新扫描栈的过程;

  5. v1.10 — 更新了垃圾收集调频器(Pacer)的实现,分离软硬堆大小的目标;

  6. v1.12 — 使用新的标记终止算法简化垃圾收集器的几个阶段;

  7. v1.13 — 通过新的 Scavenger 解决瞬时内存占用过高的应用程序向操作系统归还内存的问题;

  8. v1.14 — 使用全新的页分配器优化内存分配的速度

3.golang中的回收流程

​ 在GC的过程中,主要有两种后台任务,一种是标记用的后台任务,一种是清扫用的后台任务。

​ 标记用的后台任务会在需要时启动, 可以同时工作的后台任务数量大约是P的数量的25%, 也就是Go所讲的让25%的cpu用在GC上的根据。清扫用的后台任务在程序启动时会启动一个, 进入清扫阶段时唤醒。

​ Go 语言的垃圾收集可以分成四个不同阶段,它们分别完成了不同的工作:

garbage-collector-phases

​ 目前整个GC流程会进行两次Stop The World, 第一次是Mark阶段的开始, 第二次是Mark Termination阶段。

  1. Mark: 包含两部分:
  • Mark Prepare: 初始化GC任务,包括开启写屏障和辅助GC,准备对root对象的扫描统计任务等。这个过程需要STW
  • GC Drains: 扫描所有root对象,包括全局指针和goroutine(G)栈上的指针(扫描对应G栈时需停止该G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空。该过程后台并行执行

​ 2.Mark Termination:完成标记工作,重新扫描全局指针和栈,禁用写屏障和辅助GC。因为Mark和用户程序是并行的,所以在Mark过程中可能会有新的对象分配和指针赋值,这个时候需要re-scan再检查一下。这个过程也是会STW的。

​ 3.Sweep: 按照标记结果回收所有的白色对象,该过程后台并行执行

​ 4.Sweep Termination: 对未清扫的span进行清扫, 只有上一轮的GC的清扫工作完成才可以开始新一轮的GC。

需要注意的是, 不是所有根对象的扫描都需要STW, 例如扫描栈上的对象只需要停止拥有该栈的G.

​ 从go 1.9开始, 写屏障的实现使用了混合写屏障, 混合写屏障可以让GC在并行标记结束后不需要重新扫描各个G的对战,减少标记时的STW时间。

触发垃圾收集的时机

​ 触发垃圾收集的条件:允许垃圾收集、程序没有崩溃、没有处于垃圾收集循环。根据gctriggertest函数总结的触发情况有以下几种:

  • gcTriggerAlways: 强制触发GC;
  • gcTriggerHeap: 当前分配的内存达到一定值就触发GC;
  • gcTriggerTime: 当一定时间没有执行过GC就触发GC;
  • gcTriggerCycle: 要求启动新一轮的GC,已启动则跳过, 手动触发GC的runtime.GC()会使用这个条件。

Link

1.关于三色标记:Golang’s Real-time GC in Theory and Practice

2.关于混合写屏障:Proposal: Eliminate STW stack re-scanning

3.Garbage Collection In Go : Part I - Semantics

4.GC Process Graph

  • Semantics](https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html)

4.GC Process Graph

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值