和node.js一样,go语言也是有 GC 的,但是 GC 的方式和 node.js 有很大的区别。

本片文章主要对照着 node.js ,分析一下 go 的 GC 机制。

常见的gc算法

这里整理一下常见的几种垃圾回收算法,为后文做一下铺垫。。

引用计数

引用计数 (Reference counting)会为每个对象维护一个计数器,当该对象被其他对象引用时加一,引用失效时减一,当引用次数归零后即可回收对象。

主要使用语言:

python、php、objective-C 和 C++ 标准库中的std::shared_ptr等。

优点:

  • 原理和实现都比较简单
  • 回收的即时性:当对象的引用计数为0时立即回收,不像其他GC机制需要等待特定时机再回收,提高了内存的利用率
  • 不需要暂停应用即可完成回收

缺点:

  • 无法解决循环引用的回收问题:当ObjA引用了ObjBObjB也引用ObjA时,这两个对象的引用次数使用大于0,从而占用的内存无法被回收。
  • 时间和空间成本较高:一方面是因为每个对象需要额外的空间存储引用计数器变量,另一方面是在栈上的赋值时修改引用次数时间成本较高
  • 引用计数是一种摊销算法,会将内存的回收分摊到整个程序的运行过程,但是当销毁一个很大的树形结构时无法保证响应时间

标记-清除 算法

标记-清除Mark-Sweep算法是最基础的追踪式算法,分为“标记”和“清除”两个步骤:

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

优点:

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

缺点:

  • 清除后会产生大量的内存碎片空间,导致程序在运行时可能没法为较大的对象分配内存空间,导致提前进行下一次垃圾回收

标记-复制 算法

标记-复制Mark-Copy算法将内存分成大小相同的两块,当某一块的内存使用完了之后就将使用中的对象挨个复制到另一块内存中,最后将当前内存恢复未使用的状态。

优点:

  • 标记-清除法需要在清除阶段对大量垃圾对象进行扫描,标记-复制则只需要从GC Root对象出发,将“可到达”的对象复制到另一块内存后直接清理当前这块的内存,因此提升了垃圾回收的效率
  • 解决了内存碎片化的问题,防止分配较大连续空间时的提前GC问题

缺点:

  • 同标记-清除法相比,在“可达”对象占比较高的情况下有复制对象的开销
  • 内存利用率较低,相当于可利用的内存仅有一半

标记-整理 算法

标记-整理Mark-Compact算法综合了标记-清除法和标记-复制法的优势,既不会产生内存碎片化的问题,也不会有一半内存空间浪费的问题。

该方法首先标记出所有“可达”的对象,然后将存活的对象移动到内存空间的一端,最后清理掉端边界以外的内存。

优点:

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

缺点:

  • 整理过程较为复杂,需要多次遍历内存导致STW时间比标记-清除算法更长

三色标记法

前面提到的“标记”类算法都有一个共同的瑕疵,即在进行垃圾回收的时候会暂停整个程序(STW问题)。

三色标记法是对“标记”阶段的改进,在不暂停程序的情况下即可完成对象的可达性分析。

三色标记法将对象分为三类:

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

具体的实现如下:

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

优点:

  • 不需要暂停整个程序进行垃圾回收

缺点:

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

node.js和go GC的区别

node.js

以前整理过node.js的垃圾回收机制:原文

node.js 用的是分代收集算法,实质是对上述算法的混用:

  • 分代:将堆分为新生代和老生代。
  • 新生代:使用 标记-复制 算法
  • 老生代:
    • 使用 标记-清除 算法进行回收
    • 结合使用 标记-整理 算法,处理内存碎片,防止大对象无法分配,频繁触发GC

golang

golang 没有使用分代算法,也没有 mark-compact 去整理内存碎片。

golang 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 v1.14:引入新的页分配器用于优化内存分配的速度

总结

比起 node.js 的 GC,golang的 GC 显得更加简单粗暴。

相同点:

  • node.js 和 golang 都使用三色标记法优化 ”标记“阶段
  • 都引入了 读写屏障技术 来解决 三色标记法的并发性问题

不同点:

  • node.js 采用的是 分代收集算法,而 golang 没有分代
  • node.js 采用 标记-整理算法 来处理内存碎片,而 golang 没有处理

写在最后: 关于读写屏障技术,看了下没有很懂,只是知道了它的目的。所以就没有详细说明,可以去参考链接里面看原文。

参考链接:

https://zhuanlan.zhihu.com/p/245214547

https://studygolang.com/articles/29930

https://zhuanlan.zhihu.com/p/297177002

https://v8.js.cn/blog/concurrent-marking/