go 垃圾回收

本文基于整个go的gc发展,来研究其gc的演变过程,不单打针对某个版本的gc,因为go的gc一直在演变

一.GO GC的发展历史

  • go v1.1 : 标记清除法,整个过程都需要STW
  • go v1.3 : 标记清除法,标记过程仍然需要STW,但是清除过程并行化,gc pause约为几百ms
  • go v1.5 : 引入插入写屏障技术的三色标记法,仅在堆空间启动插入写屏障,全部扫描后需要STW重新扫描栈空间,gc pause耗时降到10ms以下
  • go v1.8 : 引入混合写屏障技术的三色标记法,仅在堆空间启动混合写屏障,不需要在GC 结束后对栈空间重新扫描,gc pause时间降至0.5ms以下
  • go 1.1.4 引入新的页分配器用于优化内存分配的速度

二. 常用GC算法

总共分为三类:

  • 引用计数法
  • 追踪式法
    • 可达性分析法
    • 标记-复制法
    • 标记-清除法
    • 标记-整理法
    • 三色标记法
  • 分代收集法

1.引用计数

引用计数会为每个对象维护一个计数器,当该对象被其他对象引用时加1,引用失效时减1,当引用次数为0后即可回收对象

优点:

  • 原理和实现都比较简单
  • 回收及时性高,引用计数为0则立即回收,不需要想其他GC机制需要等待特定的时间回收
  • 不需要暂停应用即可完成回收

缺点:

  • 无法解决循环引用的问题
  • 时间和空间成本高:每个对象需要额外的空间来存储引用计数,在栈上修改引用计数的时间成本高(因为需要额外的原子操作来保证线程安全)
  • 无法保证耗时:引用计数是一种摊销算法,会将内存的回收分摊到整个程序的运行过程,当销毁一个很大的树形结构时无法保证响应时间

2.可达性分析算法

该算法属于追踪式算法,目的是回收不可达对象,可达对象主要包括两类:

  • GC root对象: 包括全局对象,栈上对象
  • 与GC root对象通过引用链相连的对象
    对于不可达对象,我们认为该对象为垃圾对象,应该被回收

同上述的引用计数法相比,追踪式算法具有如下优点:

  • 解决了循环引用的问题
  • 占用的空间少了

和引用计数法相比,也有以下缺点:

  • 无法立刻识别出垃圾对象,需要依赖GC线程
  • 算法在标记时必须暂停整个程序,即STW(stop the world),否则其他线程有可能会修改对象的状态从而回收不该被回收的对象

3.标记-复制算法

主要分为标记和复制两个步骤:

  • 标记: 记录需要回收的垃圾对象
  • 复制: 将内存分为大小相同的两块,当某一块的内存使用完了之后就将使用中的对象挨个复制到另一块内存中,最后将当前内存恢复为未使用状态

优点:

  • 不用进行大量垃圾对象的扫描:标记-复制算法需要从GC-root对象出发,将可达的对象复制到另外一块内存后直接清理当前这块的内存即可
  • 解决了内存碎片问题,防止分配大空间对象时提前gc的问题

缺点:

  • 复制成本问题:在可达对象占用内存高的时候,复制成本会很高
  • 内存利用率低:相当于可利用的内存仅有一半

4.标记-清除算法

主要分为标记和清除两个步骤

  • 标记:记录需要回收的垃圾对象
  • 清除:在标记完成后回收垃圾对象的内存空间

优点:

  • 算法吞吐量高,即运行用户代码时间/(运行用户代码时间+运行垃圾收集时间)
  • 空间利用率高:和标记复制相比,不需要额外的空间复制对象,和引用计数法相比,也不需要额外的空间设置计数器

缺点:

  • 内存碎片问题:清除后会产生大量的内存碎片,导致程序在运行时无法为大对象分配内存空间,从而导致提前进行下一次GC

5.标记-整理算法

标记出所有可达对象,然后将可达对象移动到空间的另外一段,最后清理掉边界以外的内存

优点:

  • 避免了内存碎片化的问题
  • 适合老年代算法:老年代对象存活率高的情况下,标记整理算法由于不需要复制对象,效率更高

缺点:

  • 整理的过程复杂:需要多长遍历内存,导致STW时间比标记清除算法高

6.三色标记算法

前面的标记-x类算法都有一个问题,那就是STW(即gc时暂停整个应用程序),三色标记法是对标记阶段进行改进的算法
目的是在不暂停程序的情况下即可完成对象的可达性分析,gc线程将所有对象分为三类:

  • 白色对象:未搜索的对象,在回收周期开始时所有对象都是白色,在回收周期结束时,所有对象都是垃圾回收对象
  • 灰色对象:正在搜索的对象,但是对象身上还有一个或多个引用没有扫描
  • 黑色对象:已搜索完成的对象,所有的引用已被扫描完

三色标记算法属于增量式GC算法,回收器首先将所有对象着色成白色,然后从gc root出发,逐步把所有可达的对象变成灰色再到黑色,最终所有的白色对象都是不可达对象

具体实现:

  • 初始时所有对象都是白色的
  • 从gc root对象出发,扫描所有可达对象并标记为灰色,放入待处理队列
  • 从队列取出一个灰色对象并标记为黑色,将其引用对象标记为灰色,放入队列
  • 重复上一步骤,直到灰色对象队列为空
  • 此时剩下的所有白色对象都是垃圾对象

优点:

  • 不需要STW

缺点:

  • 如果产生垃圾速度大于回收速度时,可能会导致程序中垃圾对象越来越多而无法及时收集
  • 线程切换和上下文转换的消耗会使得垃圾回收的总体成本上升,从而降低系统吞吐量

三色标记法存在并发性问题,

  • 可能会出现野指针(指向没有合法地址的指针),从而造成严重的程序错误
  • 错误的回收非垃圾对象

7.分代收集算法

分代收集算法按照对象生命周期的长短划分到不同分区

  • 对于生命周期短的新生代区域,每次回收仅需要考虑如何保留少量存活对象,因此可以采用标记-复制法完成GC
  • 对于生命周期长的老年代区域,可以通过减少gc的频率来提供效率,同时由于对象存活率高没有额外的空间用于复制,因此一般可以使用标记清除法或标记整理法

这样划分,堆就分成了Young和Old两个分区,因此GC也分为新生代GC和老年代GC

对象的分配策略:

  • 对象优先在新生代上Eden区域分配
  • 大对象直接进入老年代
  • 新生代中周期较长的对象在s0或s1区每经过一次新生代Gc,就增加一岁,增加到一定阈值的时候,就进入老年代区域

三.屏障技术

要解决三色标记法的并发性问题,有两种思路

  • STW,保证标记过程不受干扰
  • 使用赋值器屏障技术,在进行指针写操作时同步垃圾回收器

内存读写屏障技术:指编译器在编译期间会生成一段代码,该代码在运行期间,用户读取,创建或更新对象指针时会拦截内存读写操作,相当于一个hook调用,根据hook时机的不同可以分为不同的屏障技术

  • 读屏障技术:在读操作中插入代码片段(会影响用户程序性能,不建议使用)
  • 写屏障技术:在写操作中插入代码片段

1.迪杰斯特拉 插入写屏障

  • 防止黑色对象指向白色对象(把所有黑色对象指向的灰色对象和白色对象都变为黑色)
  • 不适用于栈空间(栈容量小,要求速度高)

可以实现GC和用户程序并行,但是仍存在两个缺点:

  • 过于保守,可能会导致某些垃圾对象不被回收
  • 对栈上对象来说,迪杰斯特拉插入写屏障要么在用户程序执行内存写操作时为栈上对象插入写屏障,要么在一轮三色标记完成后使用STW重新对栈上对象进行三色标记,前者会降低栈空间响应速度,后者会暂停应用程序

2.Yuasa 删除写屏障

  • 防止丢失灰色对象到白色对象的可达路径(当删除对象A指向对象B的指针时,将被删除对象标记为灰色)

和迪杰斯特拉插入写屏障相比,Yuasa删除写屏障的优点,在于不需要在第一轮三色标记后对栈上空间对象重新扫描,其缺点在于会悲观的认为所有删除的对象都可能被黑色对象引用,会导致本该删除的垃圾对象会在本轮存活

3.混合写屏障

引入混合写屏障的原因:

在go v1.8引入混合写屏障之前,由于GC root对象包括了栈对象,如果运行时所有GC root对象上开启插入写屏障,意味着需要在数量庞大的Goroutine的栈上都开启迪杰斯特拉写屏障从而严重影响用户程序的性能,
之前的做法是在标记阶段结束后暂停整个程序,对栈上对象重新进行三色标记

回顾一下前面提到的两种屏障算法的劣势:

  • 迪杰斯特拉插入写屏障,一轮标记结束后需要STW重新扫描栈上对象
  • Yuasa 删除写屏障,回收精度低

混合写屏障也仅是在堆上启动

混合写屏障逻辑:

  • GC开始时将栈上所有对象标记为黑色,无须STW
  • GC期间在栈上创建的新对象均标记为黑色
  • 将被删除的下游对象标记为灰色
  • 将被添加的下游对象标记为灰色

四.增量和并发式垃圾回收

前面提到的传统GC算法都会STW,这存在两个严重的弊端

  • 对实时性程序来说,很致命
  • 对多核计算机来说,会造成计算资源的浪费

三色标记法结合写屏障技术使得GC避免了STW,因此后面的增量式GC和并发式GC都是基于三色标记和写屏障技术的

1.增量式GC

  • 分摊GC时间,避免程序长时间暂停
  • 内存屏障技术,需要额外时间开销,并且由于内存屏障技术的保守性,一些垃圾对象不会被回收,会增加一轮gc的总时长

2.并发式GC

  • 运行GC和用户程序并行
  • 一定程度上利用多核计算机的优势减少了对用户程序的干扰,不该写屏障的额外开销和保守性问题仍然存在,这是不可避免的

go v1.5至今都是基于三色标记法实现的并发式GC,将长时间的STW分为分割为多段短的STW,GC大部分执行过程都是和用户代码并行的

关于辅助GC

  • 当用户分配内存的速度超过gc回收速度时,golang会通过辅助GC暂停用户程序进行gc,避免内存耗尽问题

关于gc触发时间

  • 堆内存到达一定阈值
  • 距离上次gc超过一定阈值
  • 如果当前没有启动gc,则开始新一轮gc

关于gc调优

  • 尽量将小对象组合成大对象
  • 尽量使用小数据类型
  • 大量string拼接时使用string.join,而不是+号(go中string只读,每一个针对string的操作都会创建一个新的string)

五.写在最后

虽然go有gc,但gc也不是万能的,尽量手动释放不需要的内存,比如对象置为nil(辅助进行gc),比如将slice置为nil,就可以释放其底层引用的数组,或者在合适的时候调用runtime.GC()来触发gc

Reference