垃圾回收就是找出不再使用的对象并回收这些内存。如何找出呢?这就不得不说一下三色标记法,这是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语言还引入了写屏障技术。