GC垃圾回收机制

79 阅读7分钟

1. 三色标记法

其实是很简单的一个算法,拢共分两步:

  1. 初始遍历:找到所有的root对象,并标记为灰色即可,而在root之下,都是白色。

  2. 重复遍历:

    遍历所有灰色节点,以及能直接到达的下游节点(直接子节点),然后将灰转黑,灰下的白转灰;

    如果发现还有灰;

    就继续重复,直到没有灰色,退出循环,白色就是本次gc将回收的节点。

以前STW为啥慢:

问题:标记期间如果程序运行,可能:

  1. 新对象被创建(漏标)
  2. 引用关系改变(错标/漏标)

所以必须STW!但总是STW,随着内存的占用越来越多,程序的性能就太差了,那么并发标记是怎么保证不出上面的问题的呢?

2. 强弱三色不变式和写屏障

"强"在这里的意思是"更严格、更强硬的要求" ,就像:

  • “弱”规则:如果你要这样做,得先满足什么条件
  • “强”规则:禁止你这样做

强三色不变式:

  • 黑色节点不可以直接引用白色节点:因为有这种情况,白会被删除,称为误删

弱三色不变式:

  • 黑色节点如果想引用白色节点,该白色节点得存在灰色上游节点:其实就是为了保证能遍历到(因为灰色节点是每次遍历的对象),不漏标

什么是写屏障:

这里指的其实就是,在并发标记期间(才有意义),引用关系发生变换时,将会触发的程序,以满足不变式的“要求”

那为啥要有这样的屏障呢,因为并发标记阶段并没有STW,所以必须要有这样的屏障来确保三色标记法的正确标记,说白了就是保证这次gc的标记是安全的。

引用关系改变,无非就是删除和新建:

  • dijkstra写屏障:新建一个引用关系时,如果遇到黑引用白,将白直接转灰(说白了怕这次gc误删,也不乐意费劲找它有没有灰色父节点)

  • yuasa删屏障:删除一个引用关系时,如果遇到灰删除白,直接将白转灰(说白了还是怕误删,想在这轮gc里给它保下来,因为此时还不知道这个白和它底下有没有被谁引用,更不会费劲去确认,先保一次再说)

“不费劲”的好处就是标记的性能大大提升,d+y也被称为混合写屏障,所以并发标记也不会出错标问题。

3. 并发标记阶段对栈区对象的处理

其实gc时,栈区的对象并没有加上写屏障,不管是线程栈还是堆上的goroutine栈

为啥要考虑这个栈区对象,有啥不一样?

栈不加写屏障是因为太贵了!  栈操作太频繁了!每次栈上的指针赋值都要检查屏障,开销无法接受

  1. 对比:堆上指针赋值 vs 栈上指针赋值(写屏障的缺陷,赋值太频繁时,触发屏障的次数也就越多)
  • 堆:相对较少(创建对象、建立引用)
  • 栈:极其频繁(局部变量、函数参数、返回值)
  • 屏障开销:堆上可以接受,栈上无法承受
  1. 栈是线程私有的!(同一份内存,并发的协程越多,锁的时间就长,导致,gc的性能也会跟着变慢)
  • 堆:共享内存,多个goroutine可能同时访问
  • 栈:每个goroutine私有,不会并发访问
  • 竞争问题:栈上操作无需担心并发冲突
  1. 栈有精确的根信息!
  • 堆:对象间的引用关系动态变化
  • 栈:编译器知道哪里是指针(有栈映射表
  • 扫描优势:可以精确、快速地扫描栈

所以Go让栈不加屏障,用第二次扫描替代

记录一个简单的题外:

哪些常见情况会逃逸到堆上:如果变量的生命周期需要超出函数调用,或需要跨goroutine共享,就会逃逸到堆

例如:

// 指针逃逸(最常见)
func foo() *int {
    x := 42      // 在栈分配
    return &x    // 逃逸到堆(指针逃出函数)
}

// 切片扩容/大切片
func bigSlice() {
    s := make([]int, 0, 10000) // 可能逃逸(大对象)
    _ = s
}

// 闭包
func closure() func() int {
    x := 10
    return func() int {
        return x  // 逃逸(闭包捕获局部变量)
    }
}

// 发送到channel
func sendToChan() {
    x := new(User)
    ch <- x       // 逃逸(共享到其他goroutine)
}

// 存储到全局/包变量
var global *int
func saveGlobal() {
    x := 5
    global = &x   // 逃逸(全局可见)
}

回到正题,啥是二次扫描

go让栈来了一个STW扫描,非常快,为什么“快”?

具体流程:

  1. 第一次STW(GC开始时):暂停所有goroutine,开启写屏障
  2. 并发标记(第一次扫描):与程序并发运行,栈区正常三色,而堆区是三色+写屏障
  3. 第二次扫描(堆区扫描结束,第二次STW):再次暂停,重新扫描栈,捕获新引用
  4. 清除:回收白色对象

为什么可行?

  • 栈扫描极快(有精确映射,知道哪里是指针)
  • 短暂STW(<100μs)比每次栈操作都加屏障划算
  • 栈是私有的,扫描时无需锁竞争

也就是说,并发标记阶段,是有一个判断(通过内存地址)来分别该对像是堆区还是栈区的,堆区的就会触发写屏障,栈区就正常写就完了,直到堆区已经没有灰色对象了,才会触发第二次STW,单独对栈区来一个二次扫描

简单说:用一次的"全体罚站"替代亿万次的"逐个检查"

值得注意的是:第二次扫描不是完整的三色标记,而是"增量修正"

第一次扫描(标准三色):

// 完整的三色标记过程
for 每个栈 {
    for 栈上每个指针 {
        标记为灰色 → 加入队列 → 递归扫描
    }
}

第二次扫描(增量修正):

// 只扫描栈变化的部分
for 每个栈 {
    // 重点:只处理新出现的"黑→白"引用
    if 栈上有新指针(上次扫描后添加的) {
        // 把指向的白色对象直接标记为灰色
        // 然后再对灰递归扫描,也就是说,不再像头一次全部都要扫描
        while 灰色队列不空 {
                取出灰色对象
                扫描它的引用
                把它的白色引用变灰
                它自己变黑
            }
    }
}

只处理新出现的"黑→白"引用?因为删除引用,会直接把引用的指针赋值为nil,而引用的对象,在第一次扫描时自然是标记好了的。

不影响标记正确性

  • 如果对象还有其他引用→继续存活
  • 如果对象无其他引用→下一轮GC回收(浮动垃圾)

这就是"浮动垃圾"问题:

Go的选择:宁可多留(安全),绝不误杀(正确性第一)。

image.png

所以会发现,这两个STW居然都没有扫描堆栈的全部引用(第二次也只是增量修正,扫描部分而已),那么聪明的你这时一定就会好奇了,堆对象间的引用关系是动态变化,不像栈有映射表,第一次STW,又没有扫描动作,并发标记时,是怎么找到堆的root的?

堆的root对象一直有明确的记录,不需要在STW时扫描寻找!其中最最最重要的原因就是有P的存在,p.mcache,这是Go能在运行中一直维护root信息的关键设计。

type p struct {
    mcache *mcache  // 这个就是root
}

这就是两次STW都很快的原因