1. 三色标记法
其实是很简单的一个算法,拢共分两步:
-
初始遍历:找到所有的root对象,并标记为灰色即可,而在root之下,都是白色。
-
重复遍历:
遍历所有灰色节点,以及能直接到达的下游节点(直接子节点),然后将灰转黑,灰下的白转灰;
如果发现还有灰;
就继续重复,直到没有灰色,退出循环,白色就是本次gc将回收的节点。
以前STW为啥慢:
问题:标记期间如果程序运行,可能:
- 新对象被创建(漏标)
- 引用关系改变(错标/漏标)
所以必须STW!但总是STW,随着内存的占用越来越多,程序的性能就太差了,那么并发标记是怎么保证不出上面的问题的呢?
2. 强弱三色不变式和写屏障
"强"在这里的意思是"更严格、更强硬的要求" ,就像:
- “弱”规则:如果你要这样做,得先满足什么条件
- “强”规则:禁止你这样做
强三色不变式:
- 黑色节点不可以直接引用白色节点:因为有这种情况,白会被删除,称为误删
弱三色不变式:
- 黑色节点如果想引用白色节点,该白色节点得存在灰色上游节点:其实就是为了保证能遍历到(因为灰色节点是每次遍历的对象),不漏标
什么是写屏障:
这里指的其实就是,在并发标记期间(才有意义),引用关系发生变换时,将会触发的程序,以满足不变式的“要求”
那为啥要有这样的屏障呢,因为并发标记阶段并没有STW,所以必须要有这样的屏障来确保三色标记法的正确标记,说白了就是保证这次gc的标记是安全的。
引用关系改变,无非就是删除和新建:
-
dijkstra写屏障:新建一个引用关系时,如果遇到黑引用白,将白直接转灰(说白了怕这次gc误删,也不乐意费劲找它有没有灰色父节点)
-
yuasa删屏障:删除一个引用关系时,如果遇到灰删除白,直接将白转灰(说白了还是怕误删,想在这轮gc里给它保下来,因为此时还不知道这个白和它底下有没有被谁引用,更不会费劲去确认,先保一次再说)
“不费劲”的好处就是标记的性能大大提升,d+y也被称为混合写屏障,所以并发标记也不会出错标问题。
3. 并发标记阶段对栈区对象的处理
其实gc时,栈区的对象并没有加上写屏障,不管是线程栈还是堆上的goroutine栈
为啥要考虑这个栈区对象,有啥不一样?
栈不加写屏障是因为太贵了! 栈操作太频繁了!每次栈上的指针赋值都要检查屏障,开销无法接受
- 对比:堆上指针赋值 vs 栈上指针赋值(写屏障的缺陷,赋值太频繁时,触发屏障的次数也就越多)
- 堆:相对较少(创建对象、建立引用)
- 栈:极其频繁(局部变量、函数参数、返回值)
- 屏障开销:堆上可以接受,栈上无法承受
- 栈是线程私有的!(同一份内存,并发的协程越多,锁的时间就长,导致,gc的性能也会跟着变慢)
- 堆:共享内存,多个goroutine可能同时访问
- 栈:每个goroutine私有,不会并发访问
- 竞争问题:栈上操作无需担心并发冲突
- 栈有精确的根信息!
- 堆:对象间的引用关系动态变化
- 栈:编译器知道哪里是指针(有栈映射表)
- 扫描优势:可以精确、快速地扫描栈
所以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扫描,非常快,为什么“快”?
具体流程:
- 第一次STW(GC开始时):暂停所有goroutine,开启写屏障
- 并发标记(第一次扫描):与程序并发运行,栈区正常三色,而堆区是三色+写屏障
- 第二次扫描(堆区扫描结束,第二次STW):再次暂停,重新扫描栈,捕获新引用
- 清除:回收白色对象
为什么可行?
- 栈扫描极快(有精确映射,知道哪里是指针)
- 短暂STW(<100μs)比每次栈操作都加屏障划算
- 栈是私有的,扫描时无需锁竞争
也就是说,并发标记阶段,是有一个判断(通过内存地址)来分别该对像是堆区还是栈区的,堆区的就会触发写屏障,栈区就正常写就完了,直到堆区已经没有灰色对象了,才会触发第二次STW,单独对栈区来一个二次扫描
简单说:用一次的"全体罚站"替代亿万次的"逐个检查"
值得注意的是:第二次扫描不是完整的三色标记,而是"增量修正"
第一次扫描(标准三色):
// 完整的三色标记过程
for 每个栈 {
for 栈上每个指针 {
标记为灰色 → 加入队列 → 递归扫描
}
}
第二次扫描(增量修正):
// 只扫描栈变化的部分
for 每个栈 {
// 重点:只处理新出现的"黑→白"引用
if 栈上有新指针(上次扫描后添加的) {
// 把指向的白色对象直接标记为灰色
// 然后再对灰递归扫描,也就是说,不再像头一次全部都要扫描
while 灰色队列不空 {
取出灰色对象
扫描它的引用
把它的白色引用变灰
它自己变黑
}
}
}
只处理新出现的"黑→白"引用?因为删除引用,会直接把引用的指针赋值为nil,而引用的对象,在第一次扫描时自然是标记好了的。
不影响标记正确性:
- 如果对象还有其他引用→继续存活
- 如果对象无其他引用→下一轮GC回收(浮动垃圾)
这就是"浮动垃圾"问题:
Go的选择:宁可多留(安全),绝不误杀(正确性第一)。
所以会发现,这两个STW居然都没有扫描堆栈的全部引用(第二次也只是增量修正,扫描部分而已),那么聪明的你这时一定就会好奇了,堆对象间的引用关系是动态变化,不像栈有映射表,第一次STW,又没有扫描动作,并发标记时,是怎么找到堆的root的?
堆的root对象一直有明确的记录,不需要在STW时扫描寻找!其中最最最重要的原因就是有P的存在,p.mcache,这是Go能在运行中一直维护root信息的关键设计。
type p struct {
mcache *mcache // 这个就是root
}
这就是两次STW都很快的原因