
速通 GO 垃圾回收机制
前言
垃圾回收(Garbage Collection,简称 GC)是编程语言中自动管理内存的一种机制。Go 语言从诞生之初就带有垃圾回收机制,经过多次优化,现在已经相当成熟。本文将带您深入了解 Go 语言的垃圾回收机制。
下面先一起了解下涉及到的垃圾回收相关知识。
标记清除
标记清除(Mark-Sweep)是最基础的垃圾回收算法,分为两个阶段:
- 标记阶段:从根对象出发,标记所有可达对象(可达性分析)
- 清除阶段:遍历整个堆,回收未被标记的对象
标记清除示例
考虑以下场景:
go
type Node struct {
next *Node
data int
}
func createLinkedList() *Node {
root := &Node{data: 1}
node2 := &Node{data: 2}
node3 := &Node{data: 3}
root.next = node2
node2.next = node3
return root
}
func main() {
list := createLinkedList()
// 此时内存中有三个对象,都是可达的
list.next = nil
// 此时node2和node3变成了不可达对象,将在下次GC时被回收
}
在这个例子中:
- 初始状态:root -> node2 -> node3 形成链表
- 标记阶段:从root开始遍历,标记所有可达对象
- 修改引用后:只有root是可达的
- 清除阶段:node2和node3将被回收
go
// 伪代码展示标记清除过程
func MarkSweep() {
// 标记阶段
for root := range roots {
mark(root)
}
// 清除阶段
for object := range heap {
if !marked(object) {
free(object)
}
}
}
标记清除算法的优点是实现简单,但存在以下问题:
- 需要 STW(Stop The World),即在垃圾回收时需要暂停程序运行
- 会产生内存碎片,因为清除后最终剩下的活跃对象在堆中的分布是零散不连续的
- 标记和清除的效率都不高
内存碎片示意图
如图所示,GC后的内存空间虽然有足够的总空间,但是由于碎片化,可能无法分配较大的连续内存块。
三色标记
为了优化标记清除算法,Go 语言采用了三色标记算法。主要的目的是为了缩短 STW 的时间,提高程序在垃圾回收过程中响应速度。
三色标记将对象分为三种颜色:
- 白色:未被标记的对象
- 灰色:已被标记但其引用对象未被标记的对象
- 黑色:已被标记且其引用对象都已被标记的对象
三色标记过程图解
在垃圾回收器开始工作时,所有对象都为白色,垃圾回收器会先把所有根对象标记为灰色,然后后续只会从灰色对象集合中取出对象进行处理,把取出的对象标为黑色,并且把该对象引用的对象标灰加入到灰色对象集合中,直到灰色对象集合为空,则表示标记阶段结束了。
三色标记实际示例
go
type Person struct {
Name string
Friends []*Person
}
func main() {
alice := &Person{Name: "Alice"}
bob := &Person{Name: "Bob"}
charlie := &Person{Name: "Charlie"}
// Alice和Bob是朋友
alice.Friends = []*Person{bob}
bob.Friends = []*Person{alice, charlie}
// charlie没有朋友引用(假设bob的引用被删除)
bob.Friends = []*Person{alice}
// 此时charlie将在下次GC时被回收
}
详细标志过程如下:
- 初始时所有对象都是白色
- 从根对象开始,将其标记为灰色
- 从灰色对象集合中取出一个对象,将其引用对象标记为灰色,自身标记为黑色
- 重复步骤 3 直到灰色集合为空
- 清除所有白色对象
go
// 三色标记伪代码
func TriColorMark() {
// 初始化,所有对象设为白色
for obj := range heap {
setWhite(obj)
}
// 根对象入灰色队列
for root := range roots {
setGrey(root)
greyQueue.Push(root)
}
// 处理灰色队列
for !greyQueue.Empty() {
grey := greyQueue.Pop()
scan(grey)
setBlack(grey)
}
// 清除白色对象
sweep()
}
需要注意的是,三色标记清除算法本身是不支持和用户程序并行执行的,因为在标记过程中,用户程序可能会进行修改对象指针指向等操作,导致最终出现误清除掉活跃对象等情况,这对于内存管理而言,是十分严重的错误了。
并发标记的问题示例
go
func main() {
var root *Node
var finalizer *Node
// GC开始,root被标记为灰色
root = &Node{data: 1}
// 用户程序并发修改引用关系
finalizer = root
root = nil
// 如果这时GC继续运行,finalizer指向的对象会被错误回收
// 因为从root开始已经无法到达该对象
}
所以为了解决这个问题,在一些编程语言中,常见的做法是,三色标记分为 3 个阶段:
- 初始化阶段,需要 STW,包括标记根对象等操作
- 主要标记阶段,该阶段支持并行
- 结束标记阶段,需要 STW,确认对象标记无误
通过这样的设计,至少可以使得标记耗时较长的阶段可以和用户程序并行执行,大幅度缩短了 STW 的时间,但是由于最后一阶段需要重复扫描对象,所以 STW 的时间还是不够理想,因此引入了内存屏障等技术继续优化。
内存屏障技术
三色标记算法在并发环境下会出现对象丢失的问题,为了解决这个问题,Go 引入了内存屏障技术。
内存屏障技术是一种屏障指令,确保屏障指令前后的操作不会被越过屏障重排。
内存屏障工作原理图解
垃圾回收中的屏障更像是一个钩子函数,在执行指定操作前通过该钩子执行一些前置的操作。
对于三色标记算法,如果要实现在并发情况下的正确标记,则至少要满足以下两种三色不变性中的其中一种:
- 强三色不变性: 黑色对象不指向白色对象,只会指向灰色或黑色对象
- 弱三色不变性:黑色对象可以指向白色对象,但是该白色对象必须被灰色对象保护着(被其他的灰色对象直接或间接引用)
插入写屏障
插入写屏障的核心思想是:在对象新增引用关系时,将被引用对象标记为灰色。
go
// 插入写屏障示例
type Object struct {
refs []*Object
}
func (obj *Object) AddReference(ref *Object) {
// 写屏障:在添加引用前将新对象标记为灰色
shade(ref)
obj.refs = append(obj.refs, ref)
}
// 插入写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(ptr) // 将新引用的对象标记为灰色
*slot = ptr
}
插入写屏障是一种相对保守的策略,相当于有可能存活的对象都会被标灰,满足了强三色不变行
,缺点是会产生浮动垃圾(没有被引用但却没被回收的对象),要到下一轮垃圾回收时才会被回收。
浮动垃圾示例
go
func main() {
obj1 := &Object{}
obj2 := &Object{}
// obj1引用obj2
obj1.AddReference(obj2) // obj2被标记为灰色
// 立即删除引用
obj1.refs = nil
// 此时obj2虽然已经不可达
// 但因为已被标记为灰色,要等到下一轮GC才会被回收
}
栈上的对象在垃圾回收中也是根对象,但是如果栈上的对象也开启插入写屏障,那么对于写指针的操作会带来较大的性能开销,所以很多时候插入写屏障只针对堆对象启用,这样一来,要保证最终标记无误,在最终标记结束阶段就需要 STW 来重新扫描栈空间的对象进行查漏补缺。实际上这两种方式各有利弊。
删除写屏障
删除写屏障的核心思想是:在对象删除引用关系时,将被解引用的对象标记为灰色。 这种方法可以保证弱三色不变性
,缺点是回收精度低,同样也会产生浮动垃圾。
go
// 删除写屏障示例
func (obj *Object) RemoveReference(index int) {
// 写屏障:在删除引用前将被删除的对象标记为灰色
shade(obj.refs[index])
obj.refs = append(obj.refs[:index], obj.refs[index+1:]...)
}
// 删除写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) // 将被删除引用的对象标记为灰色
*slot = ptr
}
混合写屏障
Go 1.8 引入了混合写屏障,同时应用了插入写屏障和删除写屏障,结合了二者的优点:
go
// 混合写屏障示例
func (obj *Object) UpdateReference(index int, newRef *Object) {
// 删除写屏障
shade(obj.refs[index])
// 更新引用
obj.refs[index] = newRef
// 插入写屏障
shade(newRef)
}
// 混合写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) // 删除写屏障
*slot = ptr
shade(ptr) // 插入写屏障
}
GO 中垃圾回收机制
大致演进与版本改进
- Go 1.3之前:传统标记-清除,全程STW(秒级停顿)。
- Go 1.5:引入并发三色标记,STW降至毫秒级。
- Go 1.8:混合写屏障优化,STW缩短至微秒级。
- Go 1.12+:并行标记优化,提升吞吐量。
在 GO 1.7 之前,主要是使用了插入写屏障来保证强三色不变性,由于垃圾回收的根对象包括全局变量、寄存器、栈对象,如果要对所有的 Goroutine 都开启写屏障,那么对于写指针操作肯定会造成很大的性能损耗,所以 GO 并没有针对栈开启写屏障。而是选择了在标记完成时 STW、重新扫描栈对象(将所有栈对象标灰重新扫描),避免漏标错标的情况,但是这一过程是比较耗时的,要占用 10 ~ 100 ms 时间。
于是,GO 1.8 开始就使用了混合写屏障
+ 栈黑化
的方案优化该问题,GC 开始时全部栈对象标记为黑色,以及标记过程中新建的栈、堆对象也标记为黑色,防止新建的对象都错误回收掉,通过这样的机制,栈空间的对象都会为黑色,所以最后也无需重新扫描栈对象,大幅度地缩短了 STW 的时间。当然,与此同时也会有产生浮动垃圾等方面的牺牲,没有完成的方法,只有根据实际需求的权衡取舍。
主要特点
- 并发回收:GC 与用户程序同时运行
- 非分代式:不按对象年龄分代
- 标记清除:使用三色标记算法
- 写屏障:使用混合写屏障
- STW 时间短:平均在 100us 以内
垃圾回收触发条件
- 内存分配达到阈值
- 定期触发
- 手动触发(runtime.GC())
GC 过程
- STW,开启写屏障
- 并发标记
- STW,清除标记
- 并发清除
- 结束
GC触发示例
go
func main() {
// 1. 内存分配达到阈值触发
for i := 0; i < 1000000; i++ {
_ = make([]byte, 1024) // 大量分配内存
}
// 2. 定期触发
// Go运行时会自动触发GC
// 3. 手动触发
runtime.GC()
}
总结
Go 语言的垃圾回收机制经过多次优化,已经达到了很好的性能。它采用三色标记算法,配合混合写屏障技术,实现了高效的并发垃圾回收。虽然还有一些不足,如不支持分代回收,但对于大多数应用场景来说已经足够使用。
性能优化建议
要优化 Go 程序的 GC 性能,可以:
-
减少对象分配
go// 不好的做法 for i := 0; i < 1000; i++ { data := make([]int, 100) process(data) } // 好的做法 data := make([]int, 100) for i := 0; i < 1000; i++ { process(data) }
-
复用对象
go// 使用sync.Pool复用对象 var pool = sync.Pool{ New: func() interface{} { return make([]byte, 1024) }, } func process() { buf := pool.Get().([]byte) defer pool.Put(buf) // 使用buf }
-
使用合适的数据结构
go// 不好的做法:频繁扩容 s := make([]int, 0) for i := 0; i < 1000; i++ { s = append(s, i) } // 好的做法:预分配容量 s := make([]int, 0, 1000) for i := 0; i < 1000; i++ { s = append(s, i) }
-
控制内存使用量
go// 设置GOGC环境变量控制GC频率 // GOGC=100表示当内存扩大一倍时触发GC os.Setenv("GOGC", "100")
参考资料: