模型
mark-sweep GC,从 root(比如局部和全局变量)开始遍历对象,给予标记,之后清除无法访问的对象。
- 并发
- 三色
- 标记 - 交换
分代回收对 Go 不是特别有效,因为 Go 是在基于逃逸分析选择在堆上或栈上创建对象,而不是直接创建对象在堆中。
涉及两个资源:CPU 和 内存。
目前 Go GC 的设计中,清理速度远快于标记速度,因此标记阶段主要影响 GO GC 的性能。
Go GC 的设计权衡点主要是多占用 CPU 还是多占用内存。
在内存消耗稳定的情况下,GC 的频次越少, CPU 占用越小,具体计算公式见图一,但说明这个关系不需要公式,想象一个极端情况:假如没有 GC,即 GC 频次为 0,那么就永远不会产生 GC 的 CPU 占用。
两个影响 GC 的参数:GOGC 和 memory limit
- ,live heap 代表在上一次 GC cycle 没有被清除的 heap 大小,GOGC 代表一个可以增长的比例,达到这个比例即达到 target heap memory,开始 GC。
- memory limit,在一些情况下用比例控制并不合适,Go 1.19 引入了 memory limit,理想情况一旦内存达到 limit 就立刻开启 GC,但 Go 并不是严格按照这个 limit 值执行的,用来避免 limit 过小导致 GC 无法停止。
虚拟内存
- Go runtime 不主动删除已经获得的虚拟内存,我个人理解如果释放的一个物理内存指向了虚拟内存,或者其他一些情况,相关的虚拟内存还是会被删除。
- Go runtime 会用虚拟内存存储一些内部数据结构,64位平台上这类内存大小大约是 700 MB,32位平台此类内存大小可忽略不计。
特殊优化
在 Go1.19 及之前(不确定以后的实现细节是否会改变)这些改动可以加速 GC:
- 从结构体中删除指针字段:GC 扫描指针指向的对象时需要 cache 原结构体的位置,取消结构体中的指针可以避免这种递归扫描。
- 在结构体中把指针字段放在最前面:因为 GC 在扫描到最后一个指针字段时就不会扫描剩下的字段,那么提前扫描指针就提前结束。
标记算法
三色算法
- 白色,潜在的垃圾。
- 灰色,不是垃圾,但是和白色相连。
- 黑色,不是垃圾,不和白色相连。
步骤:
- 开始时所有的对象都是白色。
- 遍历所有 root,标记它们为灰色。
- 任选一个灰色对象,将其标记为黑色,将其关联的所有白色对象标记为灰色。
- 重复以上步骤,直到没有灰色对象。
性质:不允许黑色对象指向白色对象,这样当灰色对象都被染成黑色时,内存中只有黑白两种对象,且黑色与白色不相连,白色对象可被清除。
写屏障
用来保证没有一个黑色对象会指向白色对象。当一个白色对象因为写入而可以被追溯了,写屏障会将这个对象改为灰色。
延迟
https://go.dev/talks/2015/go-gc.pdf 这里的 Go 版本为 1.15。
GC 的主要流程:
- 栈扫描
- 标记
- 标记终止
- 清除
两次 stop the world:
- 栈扫描开始时,被扫描到 root 的 goroutines 会被暂停;这个过程很快,我理解这是因为具体操作是把 goroutines 栈内的变量放到等候队列里,而不是穷举所有和栈内关联的变量。
- 标记终止阶段会有个较长的 stop the world,此时需要确定哪些内存可以被清空,因此不允许内存变化了。
Go 1.15 之前的版本还会在标记时也 stop the world,Go 1.15 做出了并发标记的改动,标记阶段会占用 25% CPU 并在 goroutines 过多的情况下占用用户的 goroutines。右图中的 assist 我理解就是占用用户 goroutines,可能理解有误。
标记阶段中对指针会有写屏障,防止在写指针值的时候改变了依赖关系,导致标记结果出错。
以上改动有效将 Go GC 的延迟降低到了毫秒级别,对比见右图。在 17 年延迟已经被优化到了微秒级别。
注意:即使是毫秒级别,在一些场景下也是无法容忍的,如果各种调整依然无法解决,当前一个许多人正在尝试的方法是用 Rust 重写:
其他
栈空间管理
逃逸分析
两个原则
- 指向栈对象的指针不能存在于堆中。
- 指向栈对象的指针在栈对象回收后无法存活。
编译器通过对语法树的分析,找到违背这两个原则的指针,更改分配位置,不断循环,确保所有指针都满足以上两个原则,逃逸分析完成。
逃逸分析把大量小对象的内存位置都锁定在了栈上,降低了堆内存的使用,这是 Go GC 无需采用分代回收来优化 GC 的原因之一。
栈扩容
自 Go1.3 起,Go 不再使用分段栈而是连续栈,当栈空间不足时,以下流程会被触发:
- 在内存空间中分配更大的栈内存空间。
- 将旧堆中的栈内容复制到新堆中。
- 将指向旧栈对应变量的指针重新指向新栈;由于逃逸分析的保证,只需要对所有栈内指针加上偏移量就完成了重新指向。
- 销毁并回收旧栈的内存空间。
栈缩容
在 GC 期间,如果一个栈只使用了栈内存的 1/4,那么其内存减半。