设计原理
Go的GC目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动和整理)、并发(与用户代码并发执行)的三色标记清扫算法。原因:
-
不整理,对象整理是解决内存碎片问题,但GO运行时的分配算法是基于 TCMalloc ,对对象整理没有实质性能提升。
-
无分代,Go的编译器通过 逃逸分析 将大部分新生对象存储在栈上,只有需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。也就是说存活时间短的对象在Go中是直接被分配到栈上,当 goroutine 死亡后栈也会被直接回收,不参GC过程。
-
并发,Go团队更关注如何更好地让GC与用户代码并发执行,而非减少STW这一单一目标。
通常,垃圾回收器的执行过程被划分为两个半独立的组件:
- 赋值器(Mutator):这一本质上是 指用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之前的引用关系 ,也就是在对象图(对象之间的引用关系的一个有向图)上进行操作。
- 回收器(Collector):负责执行垃圾回收的代码
- 内存分配器(Allocator):在堆上申请内存
- 堆(Heap)
GC常见的方式
- 追踪式GC(Go、Java、JavaScript)
- 引用计数GC(Python、Objective-C)
三色标记法
是一种描述 追踪式GC 的方法,它的作用是从逻辑上严密推导标记清理这种垃圾回收方法的正确性。
当垃圾回收开始时,只有白色对象。随着标记过程开始进行时,灰色对象开始出现(着色),这时候波面便开始扩大。当一个对象的所有子节点均完成扫描时,会被着色为黑色。当整个堆遍历完成时,只剩下黑色和白色对象,这时的黑色对象为可达对象,即存活;而白色对象为不可达对象,即死亡。这个过程可以视为以灰色对象为波面,将黑色对象和白色对象分离,使波面不断向前推进,直到所有可达的灰色对象都变为黑色对象为止的过程。如下图所示:
屏障机制
需要理解三色标记清除算法中的强弱不变性以及赋值器的颜色,垃圾回收器的正确性体现在:不应出现对象的丢失,也不应错误的回收还不需要回收的对象。
可以证明,当以下两个条件同时满足时会破坏垃圾回收器的正确性:
- 条件 1: 赋值器修改对象图,导致某一黑色对象引用白色对象;
- 条件 2: 从灰色对象出发,到达白色对象的、未经访问过的路径被赋值器破坏。
Dijkstra 插入屏障
灰色赋值器的 Dijkstra 插入屏障的基本思想是避免满足条件 1
// 灰色赋值器 Dijkstra 插入屏障
func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(ptr)
*slot = ptr
}
Dijkstra 插入屏障的好处在于可以立刻开始并发标记。但存在两个缺点:
- 由于 Dijkstra 插入屏障的“保守”,在一次回收过程中可能会残留一部分对象没有回收成功,只有在下一个回收过程中才会被回收;
- 在标记阶段中,每次进行指针赋值操作时,都需要引入写屏障,这无疑会增加大量性能开销;为了避免造成性能问题,Go 团队在最终实现时,没有为所有栈上的指针写操作启用写屏障,而是当发生栈上的写操作时,将栈标记为灰色,但此举产生了灰色赋值器,将会需要标记终止阶段 STW 时对这些栈进行重新扫描。
Yuasa 删除屏障
黑色赋值器的 Yuasa 删除屏障其基本思想是避免满足条件 2
// 黑色赋值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot)
*slot = ptr
}
为了防止丢失从灰色对象到白色对象的路径,应该假设 *slot 可能会变为黑色,为了确保 ptr 不会在被赋值到 *slot 前变为白色,shade(*slot) 会先将 *slot 标记为灰色,进而该写操作总是创造了一条灰色到灰色或者灰色到白色对象的路径,进而避免了条件 2。
Yuasa 删除屏障的优势则在于不需要标记结束阶段的重新扫描,结束时候能够准确的回收所有需要回收的白色对象。缺陷是 Yuasa 删除屏障会拦截写操作,进而导致波面的退后,产生“冗余”的扫描:
混合写屏障
Go 在 1.8 的时候为了简化 GC 的流程,同时减少标记终止阶段的重扫成本,将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,形成混合写屏障。该屏障提出时的基本思想是:对正在被覆盖的对象进行着色,且如果当前栈未扫描完成,则同样对指针进行着色。
// 混合写屏障
func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot)
shade(ptr)
*slot = ptr
}
在这个实现中,如果无条件对引用双方进行着色,自然结合了 Dijkstra 和 Yuasa 写屏障的优势,但缺点也非常明显,因为着色成本是双倍的,而且编译器需要插入的代码也成倍增加,随之带来的结果就是编译后的二进制文件大小也进一步增加。为了针对写屏障的性能进行优化,Go 1.10 前后,Go 团队随后实现了批量写屏障机制。其基本想法是将需要着色的指针统一写入一个缓存,每当缓存满时统一对缓存中的所有 ptr 指针进行着色。
STW 是什么
Stop The World,这一动作发生的这一段时间间隔,万物静止,停止赋值器进一步操作对象图的一段过程。
当前版本的 Go 以 STW 为界限,可以将 GC 划分为五个阶段:
| 阶段 | 说明 | 赋值器状态 |
|---|---|---|
| SweepTermination | 清扫终止阶段,为下一个阶段的并发标记做准备工作,启动写屏障 | STW |
| Mark | 扫描标记阶段,与赋值器并发执行,写屏障开启 | 并发 |
| MarkTermination | 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 | STW |
| GCoff | 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 | 并发 |
| GCoff | 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭 | 并发 |