Published on

浅谈 GC

预计阅读时长:10分钟
Authors
  • avatar
    Name
    Roitium

五一闲得没事,于是就浅浅的了解了一下 GC,感觉很多内容虽然简单,但很有趣,就写出来总结一下。

解决循环引用

最原始、最简单的 GC 是引用计数式 GC,但这种方式存在一个大问题:无法识别并清除循环引用的对象。当两个对象彼此引用,且没有第三者引用时,尽管在我们看来已经没有任何方式能访问到这两个对象,但在引用计数 GC 看来,他们的引用数都不为 0,不能被回收,这就造成了内存泄漏。

为了解决这个问题,前辈们发明了追踪式 GC。这种方法不关心对象的引用数量,而只考虑可访达性(对象的可访达性与其是否被引用是充分不必要关系)。具体来说:是从一个或多个 GC roots 开始(通常是由底层持有的全局对象),逐级遍历能访问到的对象,最终剩下访问不到的就会被清理。

减少停机时间

但先前的 GC 有个很大的问题:在进行 GC 时,所有用户代码都会被停止运行(Stop-the-World, STW),否则 GC 一边分析,另一边用户代码还在修改引用,这就乱套了。但这样的暂停会大大影响性能,有没有什么办法,可以让我们并行完成 GC,或是增量完成 GC?

1. 三色标记法

有的兄弟,有的!隆重介绍——三色标记法!其核心理念是把所有对象分为三种状态:

  • 白色——还未访问到的对象
  • 灰色——正在处理的对象
  • 黑色——已完成

其基本过程是这样的:当一次 GC 开始时,所有的对象都是白色标记,随后 GC 从 GC roots 开始处理,把所有的 GC roots 标记为灰色,并放入处理队列。在队列中,会检查每个对象所引用的其他对象,并把这些引用到的对象也标记为灰色,放入处理队列。当把该对象直接引用的所有对象都标记为灰色后(如果某个对象已经是黑色或黑色,就不做处理),该对象即处理完成,标记为黑色。

最终,所有能被访问到的对象都会被标记为黑色,所有的白色对象都可以被安全清理,不存在灰色对象(可以把灰色理解为中间状态)。

2. 对象丢失?

由于现在每个对象都有了状态,我们可以很方便的暂停/恢复 GC 过程,那岂不是可以把 GC 过程切片,缩短所需的 STW 时长了?但是等等,让我们考虑一种情况:

现在有 ABC 三个对象,最开始 A 引用 B,C 没有被任何对象引用。此时 GC 开始处理 A,当 A 处理完后,A 被标记为黑色,B 因为被 A 引用,被标记为灰色,即将处理。之后 GC 也完成了对 B 的处理,标记为黑色。但就在下一瞬间,用户代码对 B 对象进行了写操作,使它的一个字段指向了 C 对象!可是由于 B 对象已经完成了处理,且 C 对象之前没被任何对象引用(始终为白色),最终在这轮标记结束后,C 对象会被 GC 回收,导致对象丢失。

这便是「On-the-fly garbage collection: an exercise in cooperation.」这篇文章中提到的第一个原则:

During marking there will be no edge pointing from a black node to a white one. (在标记过程中,始终不应存在从黑色节点指向白色节点的引用)

3. 写屏障

那么我们要怎么做呢?其实很简单:引入写屏障,也就是在用户代码的每个写操作后面都做一些事情,来保证逻辑正确(这固然会带来一些额外开销,但在计算机领域,没有银弹)。

这里主要介绍增量更新式中的一种写屏障实现,具体逻辑我懒得用语言描述了,因为「Concurrent marking in V 8」这篇文章中的伪代码真的很好理解:

// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}

大概就是当发现这个写操作违反了上面那条原则时,就把该值(咱们先前例子中的 C 对象)直接标记为灰色,并放入处理队列中。

三、Bonus time: 分代处理

这就是我很佩服前辈们的原因——他们靠在工程实践中的细致观察和总结,总是能整出一些很有趣的优化方法,比如咱们在这一小节中要说的:分代处理。

1. 对象存活时间的惯性

在一个程序运行过程中,大部分对象实际上存活的时间都很短。而那些存活时间长的对象,往往占少数,并且通常倾向于存活更长的时间、

2. 新生代与老生代

所以,我们可以把变量分为两代:

  • 新生代:存活时间偏短、新出现的对象
  • 老生代:已存活很长时间的对象

之后,咱们就可以适当增大新生代的 GC 频率 (Minor GC),并降低对老生代的 GC 频率。且采用一些启发式算法将一些新生代对象提升为老生代。

3. 跨代引用?

不对,再等一下!如果发生了跨代引用呢?比如老生代中对象引用新生代,而对新生代的 GC 次数要远远大于老生代,这时候如果不处理跨代引用,就有可能发生对象被错误清理的情况。这怎么办,肯定不能每一次都再去扫描一次老生代,这么做分代的意义就不大了。

4. 卡表 + 写屏障

解决方案是——卡表+写屏障。

  1. 首先,我们将老生代的内存空间分片,每一片内存都对应卡表中的一张卡页,你可以简单理解为每张卡页都有 0、1 两种状态,分别表示这张卡页对应内存区域的老生代对象是否有引用新生代对象。
  2. 当老生代对象引用新生代时,触发写屏障,将这个老生代对应的卡页的状态修改为 1,表示有引用新生代。
  3. 当新生代发生 Minor GC 时,除了完成常规从 GC roots 开始的追踪检查,也会扫描卡表中的被标记区域(状态为 1),并对所有被标记区域对应对象再进行追踪检查。

演示

听懂了吗?不妨来玩玩我一分钟 vibe coding 出来的三色标记法+写屏障演示!(顺带一提 gemini-2.5-pro 确实很厉害,这下我也是 vibe coder 了!)

对象引用图

R1(根)ABCDEF

图例:

未访问
灰色队列
已处理
根对象
错误回收

控制面板

模拟突变:

当前阶段: IDLE

灰色队列: []

操作日志

    结语

    确实只能算是浅谈,但真的很有意思!有意思到我真的想写一篇文章来分享这个乐趣!