垃圾回收就是找出不再使用的对象并回收这些内存。如何找出呢?这就不得不说一下三色标记法,这是Go语言垃圾回收的基础。本篇文章主要介绍三色标记法,包括三色标记算法,写屏障技术;以及Go语言是如何实现三色标记和写屏障的。

三色标记

  想想写C程序时,我们需要自申请内存(malloc),使用完毕后还需要自己释放内存(free),如果不释放可是会造成内存泄露的。写Go程序貌似不需要关注内存的释放,因为垃圾回收帮助我们回收了无用内存(称之为垃圾)。思考一下,垃圾回收都负责回收哪些内存呢?通常指的是堆内存,栈上的内存为什么不用回收呢?因为随着函数的调用与返回,栈内存自动分配与释放。那如何识别堆内存是不是垃圾呢?

  垃圾内存的定义应该是什么呢?想想如果没有任何途径能访问到这块内存,那这块内存是不是就是垃圾内存了?如何判断有无途径能访问到呢?有一个经典的方案叫引用计数法,假如对象A引用了对象B,这时候B对象的引用计数为1(对象互相引用时更新引用计数),那么对象B内存肯定就不是垃圾了,因为对象A还能访问到对象B,而回收之后对象A再访问对象B程序是会异常的。那对象A如果没有任何途径能访问到呢?也就是说对象A本身就是垃圾,这时候显然对象A和对象B都应该被回收。

  那这样操作呢:先判断对象A的引用计数为0,回收对象A,同时将对象A指向的所有对象引用计数减1,再判断这些对象的引用计数如果为0,则回收,以此类推。这种方案可行吗?想想如果对象A引用对象B,并且对象B也引用对象A,也就是出现了循环引用情况,并且没有其他任何对象引用到这两个对象,理论上这时候对象A和对象B应该被回收,但是引用计数法又无法回收这两个对象。

  还有什么其他办法吗?想想什么对象一定不可回收,访问某对象的途径有什么特点呢?比如说栈对象,比如全局对象呢,这两种类型对象肯定是不能随便回收的,而且堆内存上的对象,一般来说也都是从栈对象或全局对象,逐步引用,才访问到的。如下图所示:

  那只需要从根对象开始扫描(如栈对象,全局对象),扫描到的对象肯定就不是垃圾,剩下的没有被扫描到的对象就是垃圾需要被回收了。这也就是三色标记法的基本思路了。为什么是三色呢?不是只有两种状态吗,已扫描,未扫描;因为还有部分对象处于待扫描状态,想想最初根节点是不是待扫描,扫描到这些节点时,需要以此判断(标记)其指向的所有节点,这些节点将称为下一波待扫描节点。

  三色标记法声明了三种类型对象:1)黑色,已经扫描过的对象;2)灰色,就是待扫描对象;3)白色,没有扫描的对象。整个过程可以总结为:1)从灰色对象集合中选择一个对象,标为黑色;2)扫描该对象指向的所有对象,将其加入到灰色对象集合;3)不断重复步骤1/2。扫描结束后,最终只剩下黑色对象与白色对象,而白色对象就是需要回收的垃圾。

  思考下Go语言是如何实现这一过程呢?黑色对象如何标记呢?最终白色对象是需要回收的,如何实现白色对象的快速回收呢?还记得上一篇文章介绍内存管理的基本单元是mspan,申请内存就是从mspan查找空闲内存块(bitmap记录内存空闲与否,allocBits)。其实还有另一个字段,也是一个bitmap,用来实现内存块的标记:

type mspan struct {
    gcmarkBits *gcBits
}

s.gcmarkBits = newMarkBits(s.nelems)

  gcmarkBits的比特位数目与mspan分隔的内存块数目一致,1表示黑色对象,0表示白色对象。等等,那灰色对象呢?一个必填位怎么表示三种颜色?想想如果用两个比特位表示黑灰白三种对象,第一步从灰色对象集合中选择一个对象将其标黑,灰色对象集合在哪?怎么选择灰色对象?遍历吗?所以灰色对象,其实是另外有一个队列维护的,而且灰色对象的gcmarkBits已经置位1了。函数greyobject实现了对象标灰的逻辑,参考如下:

func greyobject(obj, base, off uintptr, span *mspan, gcw *gcWork, objIndex uintptr) {
    // objIndex为该内存块在mspan的位置
    mbits := span.markBitsForIndex(objIndex)
    
    // 如果没有标记才执行标记操作
    if mbits.isMarked() {
            return
    }
    mbits.setMarked()

    //加入队列
    if !gcw.putFast(obj) {
        gcw.put(obj)
    }
}

// wbuf1就是一个数组
func (w *gcWork) putFast(obj uintptr) bool {
    wbuf := w.wbuf1
    if wbuf == nil {
        return false
    } else if wbuf.nobj == len(wbuf.obj) {
        return false
    }

    wbuf.obj[wbuf.nobj] = obj
    wbuf.nobj++
    return true
}

  而整个标记扫描过程,其实就是一个for循环,不断从队列获取灰色对象,扫描并标记其指向的对象(垃圾回收初始化阶段,已经将跟对象添加到灰色对象集合了):

for {
    // 获取灰色对象
    b := gcw.tryGetFast()
    if b == 0 {
        b = gcw.tryGet()
    }

    //扫描 & 标记
    scanobject(b, gcw)
}

  貌似整个逻辑稍微清晰了,不过你有没有想过这么一个问题:获取到灰色对象A后,需要扫描其指向的所有对象,那么对象A存储的是什么数据呢?有包含指针吗?哪几个字节存储的是指针呢?之前我们提到,申请内存时,根据该类型对象是否包含指针,分为两种规格的mspan,所以扫描到某个对象时,先计算出其属于哪一个mspan(怎么计算呢?),判断mspan规格就知道该对象是否包含指针了。只是,哪几个字节包含指针了呢?不可能对象的所有字段都是指针类型吧?

  先解决第一个问题,怎么计算对象分配到那种类型的mspan呢?毕竟只有一个内存首地址。其实在分配mspan的时候,为每一个heapArena记录了其所有的mspan

//arenas数组维护了所有的heapArena
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena

//为heapArena维护其分配的mspan
func (h *mheap) setSpans(base, npage uintptr, s *mspan)

//返回mspan指针,不就知道了其规格
func spanOfUnchecked(p uintptr) *mspan {
    // heapArena大小为64M,并且首地址也是64M对齐(首地址除以64M取整可以作为heapArena索引)
    ai := arenaIndex(p)
    // 首地址除以页大小,就是第几个页,取余数
    return mheap_.arenas[ai.l1()][ai.l2()].spans[(p/pageSize)%pagesPerArena]
}

  再解决第二个问题,如何知道对象哪几个字节存储的是指针呢?貌似没有什么好办法,只能记录了。heapArena使用一个bitmap维护了每一个8字节内存是否是指针:

// bitmap需要多少字节
heapArenaBitmapBytes = heapArenaBytes / (goarch.PtrSize * 8 / 2)


type heapArena struct {
    //
    bitmap [heapArenaBitmapBytes]byte

    //上面刚介绍过,每一个heapArena记录了其所有的mspan
    spans [pagesPerArena]*mspan
}

  理论上不应该是 64M/8 比特位,64M/8/8 字节吗?怎么貌似bitmap大小还翻倍了?其实bitmap不止记录每一个8字节内存是否是指针,还记录了后续字节是否需要继续扫描。想想看,如果一个对象占了1024字节,并且只有第一个字段是指针类型,难道需要扫描128次吗?bitmap的每一个字节,0-3比特表示是否包含指针,4-7比特表示是否需要继续扫描。

  函数scanobject实现了对象扫描的过程,明显能看到判断是否包含指针,查找指针指向的对象并加入到灰色对象集合等等:另外mallocgc函数在申请内存时,如果发现该对象包含指针,还需要维护更新bitmap对应比特位。

func scanobject(b uintptr, gcw *gcWork) {
    //计算bitmap
    hbits := heapBitsForAddr(b)
    //计算mspan
    s := spanOfUnchecked(b)
    //对象占用内存大小
    n := s.elemsize

    //遍历扫描这一块内存,注意hbits.next(),移动到下一对比特位
    for i = 0; i < n; i, hbits = i+goarch.PtrSize, hbits.next() {
        bits := hbits.bits()
        if bits&bitScan == 0 {
            break // no more pointers in this object
        }
        if bits&bitPointer == 0 {
            continue // not a pointer
        }

        // obj就是指向的对象
        obj := *(*uintptr)(unsafe.Pointer(b + i))

        // 指向自己不需要扫描
        if obj != 0 && obj-b >= n {
            
            //查找对象,加入到灰色对象集合
            if obj, span, objIndex := findObject(obj, b, i); obj != 0 {
                greyobject(obj, b, i, span, gcw, objIndex)
            }
        }
    }
}

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    if !noscan {
        //维护bitmap
        heapBitsSetType(uintptr(x), size, dataSize, typ)
    }
}

type _type struct {
    //每种类型的gcdata维护着垃圾回收相关数据
    gcdata    *byte
}

  最后还有一个问题:如何实现白色对象的快速回收呢?想想mspan.allocBits记录内存空闲与否,0表示空闲,1表示已分配;mspan.gcmarkBits用户标记黑色和白色对象,0表示白色也就是需要回收的对象,1表示黑色对象。两个字段定义很近进!在三色标记完成之后,只需要allocBits=gcmarkBits不就可以了!

写屏障

  Go语言是多线程+多协程程序,垃圾回收过程也是基于协程并发执行,并且垃圾回收器标记-清理过程中,用户协程还正常执行。这就必然存在一个问题:假设初始情况对象A指向对象B,对象B指向对象C,也就是A->B->C;垃圾回收器已经标记对象A为黑色,对象B为灰色,对象C还未扫描到是白色;由于用户协程并发执行,此时若用户协程修改对象B指向nil,对象A指向对象C;对象A已经是黑色,不会再扫描了,对象B指向的又是空地址,此时对象C将永远无法再次被扫描。最终,理论上对象C还在使用(对象A指向了对象C),但是由于对象还是白色,回被回收。也就是垃圾回收器出错了。再想想看,标记扫描过程中,用户协程新申请的内存呢?垃圾回收器还能扫描到这些对象吗?

  本质上是因为用户协程与垃圾回收器并发执行,导致黑色的对象指向了白色对象,而且没有其他任何灰色对象存在某条链路能指向该白色对象;最终,不应该被回收的对象被错误的回收了,当然也可能导致某些应该被回收的对象没有被回收(这还好,至少不会异常,下次还能回收)。那怎么办呢?有什么办法吗?总不能在垃圾回收器标记-清理过程暂停所有用户协程吧。

  在介绍解决方案之前,先提出几个概念:1)强三色不变性,黑色对象执行指向灰色对象,灰色对象只能指向白色对象,这样垃圾回收显然不会有任何异常;2)弱三色不变性,黑色对象可以指向白色对象,但是一定要存在一条链路,使得存在灰色对象指向该白色对象,这样经过若干次扫描,依然能扫描到该白色对象。

  只要始终满足强三色不变性与弱三色不变性,垃圾回收就不会有问题。而针对这两个概念,也提出了两种不通的屏障技术(垃圾回收过程中,用户协程更改指针引用时,额外添加一些操作),插入写屏障和删除写屏障。

  举个例子,假设初始情况对象A指向对象B,对象B指向对象C,也就是A->B->C;此时用户协程修改对象A指向对象C,也就是黑色对象指向了白色对象,从而打破了强三色不变性,怎么办呢?在修改指针引用的同时,将对象C染为灰色即可,这样依然满足强三色不变性。这种方案称为插入写屏障,伪代码如下:

//slot即将指向ptr,如果prt为白色,将ptr染为灰色对象
writePointer(slot, ptr):
    shade(ptr)
    *slot = ptr

  再举个例子,假设初始情况对象A指向对象B,对象B指向对象C,也就是A->B->C;此时用户协程修改对象A指向对象C,也就是黑色对象指向了白色对象,但是依然满足弱三色不变性,因为通过灰色对象B还是有可能扫描到对象C的;当然后续如果要修改对象B的引用时,是需要将对象C染为灰色的。这种方案称为删除写屏障,伪代码如下:

//slot即将指向ptr,也就是删除了slot和另一个对象的引用关系,将另一个对象染为灰色对象
writePointer(slot, ptr)
    shade(*slot)
    *slot = ptr

  Go语言同时使用插入写屏障与删除写屏障,也就是说既将ptr染为灰色,又将* slot染为灰色。当然只有在垃圾回收过程中,才需要开启写屏障,平时是不需要的(降低性能)。

  我们平时写的Go程序,肯定存在对象的互相引用,以及引用关系的变更,也没看到什么写屏障逻辑啊。其实在编译过程,会注入写屏障相关逻辑。我们写一个小程序,反编译看看汇编代码,测试一下:

package main

type student struct {
    score int
    name string
    next *student
}

func main() {
    var s = new(student)
    var s1 = new(student)
    var s2 = new(student)

    s.next = s1
    s1.next = s2
}

/*
go tool compile -S -N -l test.go
"".main STEXT
    0x0060 00096 (test.go:14)    CMPL    runtime.writeBarrier(SB), $0
    0x0067 00103 (test.go:14)    JEQ    107
    0x0069 00105 (test.go:14)    JMP    113
    0x006b 00107 (test.go:14)    MOVQ    DX, 24(CX)
    0x006f 00111 (test.go:14)    JMP    120
    0x0071 00113 (test.go:14)    CALL    runtime.gcWriteBarrierDX(SB)
*/

  可以看到,判断runtime.writeBarrier如果不为0,则跳转到runtime.gcWriteBarrierDX执行写屏障逻辑。在开启垃圾回收过程时,开启了写屏障标识:

func setGCPhase(x uint32) {
    atomic.Store(&gcphase, x)

    // 在标记-清理过程时,开启写屏障标识
    writeBarrier.needed = gcphase == _GCmark || gcphase == _GCmarktermination
    writeBarrier.enabled = writeBarrier.needed || writeBarrier.cgo
}

  写屏障逻辑主要做了什么呢?其实就是加入到了一个缓存队列,灰度对象队列为空时,或者标记扫描过程结束时,会将该缓存队列的对象标灰(重新加入到灰色队列)从而再次扫描。如下面程序事例:

//标记过程
for {
    b := gcw.tryGetFast()
        if b == 0 {
            b = gcw.tryGet()
            if b == 0 {
                //flushes p's write barrier buffer to the GC work queue
                wbBufFlush(nil, 0)
                b = gcw.tryGet()
            }
        }
}

  另外其实还有很多底层函数本身也都包含写屏障逻辑,参考atomicstorep函数(指针引用赋值)以及其调用:

func atomicstorep(ptr unsafe.Pointer, new unsafe.Pointer) {
    if writeBarrier.enabled {
        atomicwb((*unsafe.Pointer)(ptr), new)
    }
    atomic.StorepNoWB(noescape(ptr), new)
}

  最后还有一个问题,已有对象引用关系变更有写屏障技术保障三色不变性,那新申请的对象呢?直接标记为黑色对象呗!这个过程也能在内存分配主函数mallocgc看到:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    if gcphase != _GCoff {
        //新申请对象标黑
        gcmarknewobject(span, uintptr(x), size, scanSize)
    }
}

总结

  本篇文章主要介绍了三色标记的整个过程以及实现细节,注意需要结合内存管理文章一起学习;另外由于用户协程与垃圾回收的并发执行,可能导致的回收错误,Go语言还引入了写屏障技术。


李烁
156 声望91 粉丝