前言:
在这篇文章中,详细列举了在Go中GC所使用的三色标记法产生悬空指针的原因、流程示意图以及Go的GC机制对于其的优化处理,可以帮助我们更好的理解Go的垃圾回收机制。
特殊说明----【受文章底色影响 】
- 白色不太显眼,我用蓝色表示了哈
- 灰色不显眼,我用橙色标记了哈
🎯 一、我们先看一组正常的GC三色标记
关键原则:黑色对象 = "已扫描完成的对象"(包括对象本身及其所有引用的对象都已被处理),而不是"有引用"的对象。白色对象只会在清扫阶段被回收。
颜色定义
- 🟦 白色:未扫描 **或** 不可达(最终被回收)--- 白色不太显眼,我用蓝色标记了哈
- 🟧 灰色:**已发现但未完成扫描** ---灰色不太显眼,我用橙色标记了哈
- ⬛️ 黑色:**已扫描完成**(包括对象本身及其所有引用)
初始状态:
🟦 A → 🟦 B → 🟦 C (所有对象都是白色)
步骤1:根扫描(A是根对象)
🟧 A → 🟦 B → 🟦 C
↑
根对象变灰色(待扫描)
步骤2:扫描A(灰色对象):
1、发现A引用B
2、将B标记为灰色(新发现的对象)
3、A本身标记为黑色(因为A的所有引用都已处理)
⬛️ A → 🟧 B → 🟦 C
↑
新灰色对象
步骤3:扫描B(灰色对象):
1、发现B引用C
2、将C标记为灰色(新发现的对象)
3、B本身标记为黑色(B的所有引用都已处理)
⬛️ A → ⬛️ B → 🟧 C
↑
新灰色对象
步骤4:扫描C(灰色对象):
1、检查C的所有引用(假设没有)
2、C本身标记为黑色(虽然无引用,但作为可达对象已完成扫描)
⬛️ A → ⬛️ B → ⬛️ C
可达性决定存活
C通过A→B→C的引用链是可达的
GC只回收不可达(白色)对象
扫描结束条件:
当没有灰色对象时标记阶段结束
此时所有黑色对象都是存活的
所有白色对象都是垃圾
二、悬空指针的产生(情况示例)
2.1 原因概括
1、初始结构:A(黑) -> B(灰) -> C(白) 同时,假设A也直接引用C(但此时还未建立这个引用)
2、 然后并发执行两个操作:
将A.next 从指向B改为指向C(插入操作)
将B.next 从指向C改为nil(删除操作)
3、 如果没有写屏障,这两个操作并发执行后,会导致C被漏标,从而被错误回收。
2.2 悬空指针的步骤分解:
初始状态(标记阶段已部分完成):
1、A已经被扫描(黑色),它引用B(灰色),B还没有被扫描(所以B还是灰色),B引用C(白色)。
此时对象图: ⬛️ A(已扫描完成) → 🟧 B(待扫描) → 🟦 C(未发现)
A(黑) -> B(灰) -> C(白)
2、然后并发执行两个操作:
操作1:A.next = C // 现在A直接指向C(原本A指向B,现在改为指向C)
操作2:B.next = nil // B不再指向C
3、操作完成后,对象图变为:
A(黑) -> C(白)
B(灰) -> nil
2.3 问题:
C是白色的,而且现在唯一指向C的A已经是黑色(黑色对象不会再被扫描),而B虽然还是灰色,但接下来扫描B时,B已经不再引用C了(所以不会标记C)。那么C就会一直保持白色,最后被当成垃圾回收。
但是,实际上C应该被标记,因为从A(根对象)可以到达C。
这就是著名的“悬挂指针”问题(即一个对象被错误回收)。
2.4 无写屏障时的危险流程
结果:C被错误回收!尽管通过A→C的引用链,C应该是可达的。
三、为什么会发生错误回收?
A是黑色对象:已经扫描完成,GC不会重新扫描
C是白色对象:没有灰色对象指向它
扫描结束条件:灰色队列为空
错误结论:C是不可达的垃圾
四、错误回收的实际后果
如果发生这种错误回收,在Go程序中会导致:
1、悬垂指针:A持有指向已回收内存的指针
2、内存访问错误:
fmt.Println(C.data) // 访问已回收内存
3、数据损坏:新对象可能重用C的内存空间
4、程序崩溃:最坏情况下导致段错误(segmentation fault)
五、Go的解决方案:混合写屏障
在Go中,写屏障会在写操作发生时做一些额外操作,确保不会漏标。
5.1 先上流程图
5.2 具体到这两个操作:
操作1:A.next = C (堆写操作,触发插入屏障)
插入屏障规则:当在堆对象(A)中插入一个指向新对象(C)的指针时,将新指向的对象(C)标记为灰色(如果还不是灰色/黑色)。
因此,在A.next=C执行时,屏障会将C标记为灰色。
操作2:B.next = nil (堆写操作,触发删除屏障)
删除屏障规则:当在堆对象(B)中删除一个指针(原本指向C)时,将原本指向的对象(C)标记为灰色(如果它是白色)。
因此,在B.next=nil执行时,屏障会将C标记为灰色(如果C是白色)。
所以,无论这两个操作发生的顺序如何,C都会被标记为灰色:
情况1:先执行操作1(A.next=C):
操作1:C被屏障标记为灰色(插入屏障)
操作2:删除屏障发现C已经是灰色(非白色),所以不做操作(或者做也无妨,因为已经是灰色)
情况2:先执行操作2(B.next=nil):
操作2:C被屏障标记为灰色(删除屏障)
操作1:插入屏障发现C已经是灰色,所以不做操作(或者做也无妨)
最终,C都会变成灰色,因此后续的扫描会处理C(将其变为黑色),从而不会被回收。
修正后的对象图(在写屏障介入后):
A(黑) -> C(灰) // 操作1后,C被屏障标记为灰
B(灰) -> nil // 操作2后,C已经被标记为灰(由操作2或操作1的屏障标记)
然后,在并发标记过程中,GC会继续扫描灰色对象(包括C),最终C会被扫描并标记为黑色。
因此,通过写屏障,我们保证了C不会被漏标。
这就是为什么写屏障是并发标记的必备机制。Go的混合写屏障结合了插入屏障和删除屏障的优点,同时避免了对栈写操作的屏障(减少开销),从而实现了高效的并发GC。
这就是双重保护:
插入屏障保证新引用对象被标记
删除屏障保证原引用对象不被漏掉
屏障组合覆盖所有情况:
| 操作类型 | 屏障类型 | 保护对象 |
|---|---|---|
| 添加新引用 | 插入屏障 | 新指向的对象 |
| 删除现有引用 | 删除屏障 | 原指向的对象 |
实时标记:屏障在指针修改时立即执行
六、示例代码
package main
import (
"runtime"
"time"
)
type Obj struct {
data int
}
func main() {
// 创建对象
a := &Obj{1}
b := &Obj{2}
c := &Obj{3}
// 初始引用关系
a.next = b
b.next = c
// 启动GC(触发标记)
runtime.GC()
// 并发修改引用关系
go func() {
a.next = c // A直接指向C
}()
go func() {
b.next = nil // B不再指向C
}()
// 等待GC完成
time.Sleep(100 * time.Millisecond)
// 安全访问C
println(c.data) // 不会崩溃,因为写屏障保护了C
}
七、总结关键点
7.1 漏标问题根源:
并发修改导致对象图变化快于GC扫描
7.2 危险场景特征:
从黑色对象添加新引用
同时删除灰色对象的引用
7.3 Go的解决方案:
插入屏障:保护新引用对象
删除屏障:保护原引用对象
组合形成完整保护网
这种精妙的设计使Go能在保证内存安全的前提下,实现亚毫秒级的GC暂停,这也是Go适合高并发服务的关键优势之一。