GC

垃圾回收

简介

Golang的垃圾回收策略为:并发三色标记法+混合写屏障机制。

三色标记法是一种标记-清除法的实现,三色标记法在并发的时候会存在漏标问题,也就是可能存在资源泄露,可以通过插入写或者删除写屏障来避免。(还有内存碎片的问题,通过TCMalloc解决)

但实际上,还有栈上的对象需要考虑,栈对象可能设计频繁的轻量操作,对这些操作来说,屏障会带来不小的成本。所以Golang引入了混合写屏障,这样就保证了正确性,同时避免栈对象的操作引入屏障的成本。

常用算法

标记-清除 Mark-Sweep

标记清扫(Mark-Sweep)算法,分为两步走:

  • 标记:标记出当前还存活的对象
  • 清扫:清扫掉未被标记到的垃圾对象

这是一种类似于排除法的间接处理思路,不直接查找垃圾对象,而是标记存活对象,从而取补集推断出垃圾对象.

至于标记清扫算法的不足之处,通过上图也得以窥见一二,那就是会产生内存碎片. 经过几轮标记清扫之后,空闲的内存块可能零星碎片化分布,此时倘若有大对象需要分配内存,可能会因为内存空间无法化零为整从而导致分配失败.

标记-压缩 Mark-Compact

标记压缩(Mark-Compact)算法,是在标记清扫算法的基础上做了升级,在第二步”清扫“的同时还会对存活对象进行压缩整合,使得整体空间更为紧凑,从而解决内存碎片问题.

标记压缩算法在功能性上呈现得很出色,而其存在的缺陷也很简单,就是实现时会有很高的复杂度.

引用计数

引用计数(Reference Counting)算法是很简单高效的:

  • 对象每被引用一次,计数器加1
  • 对象每被删除引用一次,计数器减1
  • GC时,把计数器等于 0 的对象删除

然而,这个朴素的算法存在一个致命的缺陷:无法解决循环引用或者自引用问题.

三色标记法

Golang GC 中用到的三色标记法属于标记清扫-算法下的一种实现,由荷兰的计算机科学家 Dijkstra 提出,下面阐述要点:

  • 对象分为三种颜色标记:黑、灰、白
  • 黑对象代表,对象自身存活,且其指向对象都已标记完成
  • 灰对象代表,对象自身存活,但其指向对象还未标记完成
  • 白对象代表,对象尙未被标记到,可能是垃圾对象
  • 标记开始前,将根对象(全局对象、栈上局部变量等)置黑,将其所指向的对象置灰
  • 标记规则是,从灰对象出发,将其所指向的对象都置灰. 所有指向对象都置灰后,当前灰对象置黑
  • 标记结束后,白色对象就是不可达的垃圾对象,需要进行清扫.

但它存在一个漏标问题。

漏标问题

漏标问题指的是在用户协程与GC协程并发执行的场景下,部分存活对象未被标记从而被误删的情况。如下图,A被置为黑,B置为灰色。此时用户协程,断开B对C的引用,让A引用C。

错标问题

即本次垃圾回收被标记为灰色,但随后被删除引用(图中A删除对B的引用,此时B已经为灰色),此时可以被删除了,但本轮没删除。

但这本身不是啥问题,可以等到下一轮回收。

强弱三色不变式

漏标问题的本质就是,一个已经扫描完成的黑对象指向了一个被灰\白对象删除引用的白色对象. 构成这一场景的要素拆分如下:

(1)黑色对象指向了白色对象

(2)灰、白对象删除了白色对象

(3)(1)、(2)步中谈及的白色对象是同一个对象

(4)(1)发生在(2)之前

一套用于解决漏标问题的方法论称之为强弱三色不变式:

  • 强三色不变式:白色对象不能被黑色对象直接引用(直接破坏(1))
  • 弱三色不变式:白色对象可以被黑色对象引用,但要从某个灰对象出发仍然可达该白对象(间接破坏了(1)、(2)的联动)

可以通过插入写屏障删除写屏障来保证强弱三色不变式。

插入写屏障

插入写屏障(Dijkstra)的目标是实现强三色不变式,保证当一个黑色对象指向一个白色对象前,会先触发屏障将白色对象置为灰色,再建立引用.

删除写屏障

删除写屏障就是在删除一个白色对象的引用前,会给它标记为灰色,避免它被黑色对象引用,但显示为白色的情况。

两个屏障,二者选其一,即可解决GC并发的漏标的问题。

混合写屏障

然而,真实场景中,需要补充一个新的设定——不太希望栈对象的操作都附带屏障机制。

这是因为栈对象可能涉及频繁的轻量操作,倘若这些高频度操作都需要一一触发屏障机制,那么所带来的成本将是无法接受的.

在这一背景下,单独看插入写屏障或删除写屏障,都无法真正解决漏标问题,除非我们引入额外的Stop the world(STW)阶段,对栈对象的处理进行兜底。

为了消除这个额外的 STW 成本,Golang 1.8 引入了混合写屏障机制,可以视为糅合了插入写屏障+删除写屏障的加强版本,要点如下:

  • GC 开始前,以栈为单位分批扫描,将栈中所有对象置黑
  • GC 期间,栈上新创建对象直接置黑
  • 堆对象正常启用插入写屏障
  • 堆对象正常启用删除写屏障

内存逃逸分析机制

在 C 语言和 C++ 这类需要手动管理内存的编程语言中,将对象或者结构体分配到栈上或者堆上是由工程师自主决定的,这也为工程师的工作带来的挑战,如果工程师能够精准地为每一个变量分配合理的空间,那么整个程序的运行效率和内存使用效率一定是最高的,但是手动分配内存会导致如下的两个问题:

  1. 不需要分配到堆上的对象分配到了堆上 — 浪费内存空间;
  2. 需要分配到堆上的对象分配到了栈上 — 悬挂指针、影响内存安全;

与悬挂指针相比,浪费内存空间反而是小问题。在 C 语言中,栈上的变量被函数作为返回值返回给调用方是一个常见的错误,在如下所示的代码中,栈上的变量 i 被错误返回:

int *dangling_pointer() {
    int i = 2;
    return &i;
}

dangling_pointer 函数返回后,它的本地变量会被编译器回收,调用方获取的是危险的悬挂指针,我们不确定当前指针指向的值是否合法时,这种问题在大型项目中是比较难以发现和定位的。

在编译器优化中,逃逸分析是用来决定指针动态作用域的方法。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 newmake 和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:

  1. 指向栈对象的指针不能存在于堆中;
  2. 指向栈对象的指针不能在栈对象回收后存活;

栈内存管理

为什么Golang需要管理栈对象,它不是在结束后直接通过调整栈指针回收了吗?

在Go中,堆和栈上的对象都需要被垃圾收集器管理。与其他语言不同,Go中的本地变量和函数参数不仅分配在栈上,也分配在堆上。这是因为它们的作用域可能超出函数的生命周期,在栈上的对象可能函数结束后,需要返回,此时它是不能被回收的,所以对于这类对象,会分配在堆上。

简单来说,管理栈对象是因为栈上的对象也可能会引用堆上的对象,因此GC需要管理整个应用程序中的对象,包括栈和堆上的对象,以确保没有内存泄漏和堆溢出。

实际上,栈管理并不是说会通过垃圾回收机制删除栈的对象,而是垃圾回收器会分析栈对象是否引用堆对象,避免误删除;栈内存的管理还是通过调整栈指针的方式;本地变量具体分配在栈上还是堆上,在编译期时的内存逃逸分析时决定。

分代算法

,采用不同的GC策略进行分类管理. 分代GC算法有效的前提是,绝大多数年轻代对象都是朝生夕死,拥有更高的GC回收率,因此适合采用特别的策略进行处理.

然而Golang中存在内存逃逸机制,会在编译过程中将生命周期更长的对象转移到堆中,将生命周期短的对象分配在栈上,并以栈为单位对这部分对象进行回收.

综上,内存逃逸机制减弱了分代算法对Golang GC所带来的优势,考虑分代算法需要产生额外的成本(如不同年代的规则映射、状态管理以及额外的写屏障),Golang 选择不采用分代GC算法.

问题

  • 内存碎片问题解决:TCMalloc
  • Golang为什么不用引用计数:引用计数在并发的时候需要用到原子操作,虽然它已经比较快了,但相较于普通的内存引用要慢,标记-清除垃圾回收不需要频繁的原子操作,因此在多线程环境下表现更好;而且,他在循环引用或者自引用时,无法清理。

参考


GC
https://messenger1th.github.io/2024/07/24/Golang/GC/
作者
Epoch
发布于
2024年7月24日
许可协议