Golang GC 三色标记+混合写屏障
目录
一、逃逸分析
二、内存回收
三、GC-标记清楚法
过程:
问题:
方向:
三、三色标记法
过程
问题:
方向:
四、插入写屏障
实现:
问题:
五、删除写屏障
实现:
问题:
六、GC-三色标记+混合写屏障
过程:
七、问题
1、屏障机制为什么要扫描栈,栈上的对象不是会在函数结束时被回收吗
2、在当前go的gc方案基础上,只在堆上启用插入写屏障行不行?
3、在当前go的gc方案基础上,只在堆上启用删除写屏障行不行?
4、在当前go的gc方案基础上,只在堆上启用插入写屏障已经能实现GC,为什么还要在堆上启用删除写屏障?
5、根对象是哪些?
6、三色标记的具体实现?
7、三色标记+混合写屏障,是否还需要stw?
8、为什么可以首先一次性将栈对象标黑,堆是否可以这样?
9、golang gc时,首先要将所有栈对象标黑。这时是如何找到所有的栈对象的,有没有不再被引用的栈对象这种情况,不再被引用的栈对象会被标黑吗
10、闭包引用的局部变量逃逸到堆上,那么这个变量如何被GC找到并被标记的
11、闭包内局部变量引用的堆对象,生命周期如何确定?
12、如果闭包结构体在堆上,则通过递归扫描堆上的对象找到并标记。堆上的扫描是如何进行的,是从哪个地方开始的
13、假如一个局部指针变量引用了一个堆对象,在这个局部变量所在的函数结束后,这个堆对象是不是可能还存在,只是等待gc回收
一、逃逸分析
(1)逃逸分析在编译阶段由编译器进行,确定变量存储在栈上还是堆上
(2)逃逸分析并不总是能够完全准确地确定一个对象的生命周期和存储位置
(3)如果一个变量的作用域或者生命周期超出函数范围,则可能逃逸到堆上
(4)向函数传递指针,或函数返回一个指针,可能会逃逸(但若该指针没有被使用,则不会逃逸)
(5)闭包内的变量可能逃逸到堆上,变量可能会逃逸(但是若闭包函数不被调用,则不会逃逸)
(6)如果分配一个大对象,可能会发生逃逸
二、内存回收
栈对象:在函数返回时回收
堆对象:通过GC回收
GC触发时机
(1)定时2min
(2)分配对象时对分配内存达到阈值
(3)主动触发GC,且上一次GC已结束
三、GC-标记清楚法
过程:
1、stw开启,停止程序运行。通过在程序前方插入安全点来实现。
2、从根对象开始,标记有引用的节点为活跃节点。
3、清楚未标记的节点
4、stw结束
问题:
1、stw期间程序停止,降低了程序效率
2、扫描期间需要扫描整个堆
3、节点删除之后会有内存碎片
方向:
1、并发标记和删除,不使用stw
三、三色标记法
过程
1、首先将所有节点标记为白色、
2、从根对象开始层序遍历,首先将当前对象标记为灰色,遍历完某对象的子对象之后,将父节点标记为黑色。
3、将白色节点删除
问题:
1、某些白色对象可能还有引用,但却被删除。(当一个白色对象被黑色对象引用,然后被灰色对象删除引用。由于后续只会从遍历灰色对象,该对象最后还是白色,会被删除。)
方向:
1、满足:强三色不变式,或,弱三色不变式。
(1)强三色不变式:不存在白色对象被黑色对象引用
(2)弱三色不变式:黑色对象可以引用白色对象,但存在引用该白色对象的灰色祖先
四、插入写屏障
实现:
1、黑色对象引用白色对象时,需将该白色对象标记为灰色
2、满足强三色不变式
3、不在栈上使用插入屏障:
(1)栈上的操作更加频繁且更高效,使用插入屏障会增加复杂度和开销
(2)栈上的对象有明确的生命周期,可以在函数返回时被回收,不需要插入屏障也能被回收。
问题:
1、需要stw二次扫描栈,避免误删栈上的白色对象。(黑栈对象到白栈对象)
五、删除写屏障
实现:
1、白色对象被删除时,标记为灰色
2、满足弱三色不变式
问题:
1、存在回收延迟(没有被黑色对象引用的白色对象,需要等到下一轮被回收)
六、GC-三色标记+混合写屏障
过程:
1、GC开始前,以栈为单位,将所有栈对象置黑(将栈指针引用的堆对象都置为灰色)
2、GC期间栈上新创建的对象直接置黑(将栈指针引用的堆对象都置为灰色)
3、GC期间,从堆的根对象开始扫描标记
4、堆对象启用插入写屏障、删除写屏障
七、问题
1、屏障机制为什么要扫描栈,栈上的对象不是会在函数结束时被回收吗
(1)栈指针可能引用堆对象,扫描栈的目的是将这部分堆对象置灰,否则这部分堆对象会成为孤立的白色对象,最后被误删。
2、在当前go的gc方案基础上,只在堆上启用插入写屏障行不行?
go三色标记+插入写屏障实现,仅在堆上启用插入写屏障,而不启用删除写屏障:
(1)GC开始前,以栈为单位,将所有栈对象置黑(将栈指针引用的堆对象都置为灰色)
(2)GC期间栈上新创建的对象直接置黑(将栈指针引用的堆对象都置为灰色)
(4)堆对象启用插入写屏障
可行。
在三色标记算法中,三色不变性是最关键的要求,即:黑色对象不应该指向白色对象,否则可能导致未标记的可达对象被误回收。你的方案中:
- 栈对象直接标黑:在GC开始时和GC期间新创建的栈对象直接标黑,避免了栈上引用的对象被错误回收。
- 堆对象插入写屏障:通过插入写屏障,黑色堆对象不会直接指向白色对象,而是会将新引用的对象置为灰色,从而保持三色不变性。
- 删除写屏障的省略:因为栈对象都立即置黑,且堆上对象通过插入写屏障保持引用完整性,所以删除写屏障不是必需的。栈对象消失时,其所引用的堆对象不会变为不可达,因为堆对象的引用关系都由插入写屏障来维护。
该方案是可行的,因为:
- 栈对象直接置黑避免了栈上对象引用的变化导致的误回收问题。
- 插入写屏障在堆对象中保证了黑色对象不指向白色对象的三色不变性。
- 删除写屏障不是必需的,因为栈对象直接置黑,不会再影响GC的可达性判断
3、在当前go的gc方案基础上,只在堆上启用删除写屏障行不行?
go三色标记+插入写屏障实现,仅在堆上启用删除写屏障,而不启用插入写屏障:
(1)GC开始前,以栈为单位,将所有栈对象置黑(将栈指针引用的堆对象都置为灰色)
(2)GC期间栈上新创建的对象直接置黑(将栈指针引用的堆对象都置为灰色)
(3)堆对象启用删除写屏障
不可行。
仅启用删除写屏障,而不启用插入写屏障,在某些情况下可能会导致GC出现问题,主要是因为这种方法无法确保三色不变性(即“黑色对象不能直接指向白色对象”)。
假设某个黑色堆对象 A
在并发标记阶段新增了一个指向白色堆对象 B
的引用:
- 缺少插入写屏障:由于没有插入写屏障,
B
仍然是白色,因为它没有被标记为灰色。 - 违反三色不变性:
A
是黑色,但现在直接引用了一个白色对象B
,导致三色不变性被破坏。 - 对象可能被错误回收:在标记阶段结束时,GC可能会认为
B
是不可达对象,因为它仍然是白色,从而将其回收,但实际上B
是可达的。
这种情况下,黑色对象可能直接指向白色对象,导致GC错误地将一些可达的对象回收,破坏了GC的正确性。
- 栈对象置黑:在GC开始前将所有栈对象置黑,并将它们指向的堆对象置为灰色,确保了栈对象的引用关系被纳入标记范围,防止GC遗漏栈对象引用的堆对象。
- 栈上新创建的对象置黑:GC期间新创建的栈对象直接置黑,并将其指向的堆对象置为灰色,确保这些新创建的对象及其引用也都被纳入标记范围。
- 堆对象仅启用删除写屏障:删除写屏障在引用被删除时将目标对象置灰,可以解决“删除引用”导致的丢失标记问题,但无法解决“新增引用”的问题。新增的引用可能导致三色不变性被破坏,尤其是在并发标记期间。
4、在当前go的gc方案基础上,只在堆上启用插入写屏障已经能实现GC,为什么还要在堆上启用删除写屏障?
- 只启用插入写屏障方案:确实可以减少浮动垃圾,因为没有删除写屏障时,被删除引用的对象若不可达就会在当前GC周期内被回收。
- 删除写屏障:虽然增加了浮动垃圾,而Go选择删除写屏障更多是为了一致性和高并发下的安全性,带来了更稳定的并发标记行为,防止暂时失去引用的对象被误回收。
- 避免“短暂丢失可达性”:在高并发程序中,某些对象可能会在并发标记阶段暂时失去所有引用(例如,短暂从一个对象图中移除)。如果没有删除写屏障,它们可能会因为暂时丢失可达性而被误回收。删除写屏障通过将引用删除的对象直接标记为灰色,即使它们暂时没有引用,也确保它们不会被误回收。
5、根对象是哪些?
(1)全局对象:如 .bss段,未初始化的全局变量;.data段,已初始化的全局变量
(2)局部变量:函数参数、局部变量、局部指针
(3)特定的runtime结构:如finalizer队列、defer链表等
在Go语言的垃圾回收过程中,堆上的对象扫描是从根对象(GC Roots)开始的。GC Roots是标记阶段的起点,通常包含所有可以被直接访问的对象。这些根对象包括:
-
当前所有活动的goroutine的栈:
GC会扫描所有goroutine的栈,从栈顶开始,识别出局部变量、指针等。若栈中的变量指向堆上的对象,GC就会将这些堆对象标记为可达并进一步扫描。 -
全局变量和静态变量:
编译器会将全局变量和静态变量作为GC Roots,因此GC会检查这些变量。如果其中包含指向堆上对象的指针,GC就会将这些对象标记为可达。 -
特定的runtime结构:
Go运行时会维护一些内部结构,包含需要特殊管理的对象,如finalizer队列、defer链表等。这些内部结构也被视为GC Roots,它们引用的对象也会被标记。
6、三色标记的具体实现?
将栈指针引用的堆对象都置灰,则相当于将栈指针都置黑了
置黑:mscan.gcmakrbits对应位置1
置灰:mscan.gcmakrbits对应位置1,且在gcw灰色对象队列
置白:mscan.gcmakrbits对应位置0
进队,置灰;出队,置黑
7、三色标记+混合写屏障,是否还需要stw?
标记开始前,stw,开启写屏障,停止所有p,分配25%的p用于gc
标记结束后,stw,开启清除协程,设置下次gc参数
8、为什么可以首先一次性将栈对象标黑,堆是否可以这样?
(1)可以确定有哪些栈对象。栈对象在编译器编译阶段确定了类型和数量,但在运行时创建和管理。(编译器知道每个函数的局部变量类型和数量,但不知道这些变量的具体值和生命周期。)
(2)栈对象不需要在gc期间回收。栈对象是在函数返回时回收的,因此可以无脑全部标黑。
(3)不存在不可达的栈对象,只有不可达的堆对象。因此需要逐步扫描标记可达的堆对象,找到不可达的堆对象进行清除。
9、golang gc时,首先要将所有栈对象标黑。这时是如何找到所有的栈对象的,有没有不再被引用的栈对象这种情况,不再被引用的栈对象会被标黑吗
在Go语言的垃圾回收(GC)机制中,Go运行时会首先将所有的栈对象标记为黑色,这是因为栈上的对象是活动的,即它们被某些函数调用所使用,并且这些对象始终是被引用的,因此不会被回收。下面是一些关键点,解释Go垃圾回收是如何找到栈对象以及处理不再引用的栈对象的:
-
找到栈对象:
Go的GC会从所有的goroutine的栈开始扫描。Go运行时维护了一个goroutine的列表,每个goroutine都有自己独立的栈。GC在标记阶段会遍历所有的goroutine,并从每个栈的根节点(也就是栈顶)开始向下扫描,找到所有当前函数调用链中使用的局部变量。栈中的每一个变量都会被标记。 -
栈对象的黑色标记:
在标记阶段,GC将栈对象直接标记为黑色,因为栈上的对象总是被认为是被引用的。栈对象是在运行时始终活跃的,直到它们的函数调用结束,栈帧被释放。因为栈对象的生命周期由函数调用控制,它们不会进入“灰色”状态(灰色表示需要进一步递归扫描),而是直接标记为黑色。 -
不再引用的栈对象:
通常栈对象的生命周期由函数调用栈来控制,当一个函数退出时,其栈帧(包括局部变量)会自动释放。因此,栈对象不会有“不再被引用”的情况,因为一旦它们不再被需要,整个栈帧会被立即回收,不需要GC来处理。也就是说,栈对象的生命周期是由栈的结构本身管理的,而非GC。因此,不再被引用的栈对象会被直接释放,不会进入GC的标记清除阶段,也就不涉及“标黑”或“标灰”。 -
逃逸到堆的栈对象:
如果一个栈变量被“逃逸”到了堆上(例如,当闭包捕获了一个栈变量,或者该变量的地址被传递给其他goroutine),它会成为堆上的对象,此时才会被GC管理。如果它后来不再被引用,则会按正常的GC流程被清理掉。
因此,总的来说,Go的垃圾回收在处理栈对象时,不存在“不再引用”的栈对象被标黑的问题。栈对象的生命周期是明确的,依赖于函数调用的结束,自动清理,而不是依赖GC的标记清除。
10、闭包引用的局部变量逃逸到堆上,那么这个变量如何被GC找到并被标记的
当一个闭包捕获了一个局部变量(例如一个函数内部的变量),Go会将该变量从栈上分配到堆上,闭包会持有一个指向该变量的指针。这意味着,闭包的结构体中包含了指向这些堆上变量的引用。
如果闭包结构体在栈上,GC通过栈扫描可以找到并标记引用的堆对象;如果在堆上,则通过递归扫描堆上的对象找到并标记。
11、闭包内局部变量引用的堆对象,生命周期如何确定?
闭包返回的是一个函数,如果这个函数不被调用,那么也不需要发生逃逸;如果这个函数被赋值给一个变量,那这个变量什么时候被回收,那么这个逃逸的堆对象的使命也就完成了。
12、如果闭包结构体在堆上,则通过递归扫描堆上的对象找到并标记。堆上的扫描是如何进行的,是从哪个地方开始的
从GC Roots到递归扫描堆对象
-
初始标记: GC首先扫描GC Roots,把所有直接引用到的堆对象标记为“灰色”。灰色对象是那些已经找到但尚未完成递归扫描的对象。
-
递归扫描灰色对象: 接下来,GC会继续扫描每一个灰色对象。对于每个灰色对象,它会查找其中的指针引用,将所有被引用的对象标记为灰色并放入扫描队列中,同时将当前对象标记为“黑色”,表示扫描已完成。
-
堆上闭包结构的扫描: 如果堆上对象是一个闭包结构体,且闭包捕获了其他堆上的变量,那么GC在扫描该闭包结构体时,会识别其中的指针字段(即对捕获变量的引用)。被引用的堆对象也会被标记为灰色,并进一步递归扫描。
-
结束标记阶段: 标记阶段会持续进行,直到没有灰色对象为止。此时所有可达的对象都已经被标记为黑色,表示它们在当前GC周期内是可达的,不会被回收。
堆上的扫描从GC Roots开始,通过递归标记所有可达的堆对象。对于堆上的闭包结构,GC在扫描时会处理它们的指针字段,将所引用的对象也加入标记队列。整个过程确保了堆上任何被直接或间接引用的对象都会被正确标记为可达。
13、假如一个局部指针变量引用了一个堆对象,在这个局部变量所在的函数结束后,这个堆对象是不是可能还存在,只是等待gc回收
是的,如果一个局部指针变量引用了一个堆对象,在这个局部变量所在的函数结束后,这个堆对象可能还会存在一段时间,直到垃圾回收器(GC)检测到它不再被引用,并将其回收。
具体来说,当一个函数结束后,局部变量随栈帧一起被销毁,但堆上分配的对象并不会立即被回收,除非:
- 没有其他引用:没有其他地方(如全局变量、闭包、其他栈帧等)再持有对该堆对象的引用。
- GC扫描:垃圾回收器在下一次GC扫描时发现该对象不可达(即没有任何GC Roots或其他可达对象引用它),则会将其标记为垃圾并进行回收。
流程说明
-
局部指针变量的生命周期: 在函数中,局部变量的生命周期受限于栈帧。当函数结束时,局部变量(包括指针)随栈帧一同销毁,意味着栈上不再有该堆对象的引用。
-
堆对象的生命周期: 虽然局部指针变量消失了,但堆对象的生命周期不受局部变量的控制。堆对象的生命周期是由GC决定的,GC会在扫描时判断该对象是否有任何引用。如果没有引用,GC会将其标记为不可达,并在随后的清除阶段释放它。
-
等待GC回收: 如果此时没有其他地方引用该堆对象,那么在下一次GC周期中,GC会发现该对象不可达,将其回收。回收时间并不确定,因为它取决于GC的执行时机和当前的内存压力。
特殊情况:逃逸到闭包的变量
如果局部变量是通过闭包捕获的并逃逸到了堆上,那么即使函数返回,该局部变量仍然会被闭包引用,导致其堆对象不会被立即回收。这种情况下,只有当闭包本身也不再被引用时,闭包和其捕获的堆对象才会在下一次GC中被回收。
因此,一个被局部指针引用的堆对象在函数结束后并不会立即回收。它会在没有其他引用后等待GC的下一次回收周期。在这段时间内,堆对象仍然存在,直到GC将其识别为不可达并清除。