Go中GC的悬空指针详解

149 阅读7分钟

在这里插入图片描述

前言:

在这篇文章中,详细列举了在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适合高并发服务的关键优势之一。