注册

搞懂 GO 的垃圾回收机制

🏖️细腻的美好,藏在生活各处_2_横塘路_来自小红书网页版.jpg


速通 GO 垃圾回收机制


前言


垃圾回收(Garbage Collection,简称 GC)是编程语言中自动管理内存的一种机制。Go 语言从诞生之初就带有垃圾回收机制,经过多次优化,现在已经相当成熟。本文将带您深入了解 Go 语言的垃圾回收机制。


下面先一起了解下涉及到的垃圾回收相关知识。


标记清除


标记清除(Mark-Sweep)是最基础的垃圾回收算法,分为两个阶段:



  1. 标记阶段:从根对象出发,标记所有可达对象(可达性分析)
  2. 清除阶段:遍历整个堆,回收未被标记的对象

标记清除示例


考虑以下场景:


type Node struct {
next *Node
data int
}

func createLinkedList() *Node {
root := &Node{data: 1}
node2 := &Node{data: 2}
node3 := &Node{data: 3}

root.next = node2
node2.next = node3

return root
}

func main() {
list := createLinkedList()
// 此时内存中有三个对象,都是可达的

list.next = nil
// 此时node2和node3变成了不可达对象,将在下次GC时被回收
}

在这个例子中:



  1. 初始状态:root -> node2 -> node3 形成链表
  2. 标记阶段:从root开始遍历,标记所有可达对象
  3. 修改引用后:只有root是可达的
  4. 清除阶段:node2和node3将被回收

// 伪代码展示标记清除过程
func MarkSweep() {
// 标记阶段
for root := range roots {
mark(root)
}

// 清除阶段
for object := range heap {
if !marked(object) {
free(object)
}
}
}

标记清除算法的优点是实现简单,但存在以下问题:



  • 需要 STW(Stop The World),即在垃圾回收时需要暂停程序运行
  • 会产生内存碎片,因为清除后最终剩下的活跃对象在堆中的分布是零散不连续的
  • 标记和清除的效率都不高

内存碎片示意图


%%{init: {"flowchart": {"htmlLabels": false}} }%%
flowchart LR
subgraph Before["GC前的堆内存"]
direction LR
A1["已分配"] --- B1["已分配"] --- C1["空闲"] --- D1["已分配"] --- E1["已分配"]
end

Before ~~~ After

subgraph After["GC后的堆内存"]
direction LR
A2["已分配"] --- B2["空闲"] --- C2["空闲"] --- D2["已分配"] --- E2["空闲"]
end

classDef default fill:#fff,stroke:#333,stroke-width:2px;
classDef allocated fill:#a8d08d,stroke:#333,stroke-width:2px;
classDef free fill:#f4b183,stroke:#333,stroke-width:2px;

class A1,B1,D1,E1 allocated;
class C1 free;
class A2,D2 allocated;
class B2,C2,E2 free;

如图所示,GC后的内存空间虽然有足够的总空间,但是由于碎片化,可能无法分配较大的连续内存块。


三色标记


为了优化标记清除算法,Go 语言采用了三色标记算法。主要的目的是为了缩短 STW 的时间,提高程序在垃圾回收过程中响应速度。


三色标记将对象分为三种颜色:



  • 白色:未被标记的对象
  • 灰色:已被标记但其引用对象未被标记的对象
  • 黑色:已被标记且其引用对象都已被标记的对象

三色标记过程图解


graph TD
subgraph "最终状态"
A4[Root] --> B4[Object 1]
B4 --> C4[Object 2]
B4 --> D4[Object 3]
D4 --> E4[Object 4]

style A4 fill:#000000
style B4 fill:#000000
style C4 fill:#000000
style D4 fill:#000000
style E4 fill:#000000
end

subgraph "处理灰色对象"
A3[Root] --> B3[Object 1]
B3 --> C3[Object 2]
B3 --> D3[Object 3]
D3 --> E3[Object 4]

style A3 fill:#000000
style B3 fill:#808080
style C3 fill:#FFFFFF
style D3 fill:#FFFFFF
style E3 fill:#FFFFFF
end

subgraph "标记根对象为灰色"
A2[Root] --> B2[Object 1]
B2 --> C2[Object 2]
B2 --> D2[Object 3]
D2 --> E2[Object 4]

style A2 fill:#808080
style B2 fill:#FFFFFF
style C2 fill:#FFFFFF
style D2 fill:#FFFFFF
style E2 fill:#FFFFFF
end

subgraph "初始状态"
A1[Root] --> B1[Object 1]
B1 --> C1[Object 2]
B1 --> D1[Object 3]
D1 --> E1[Object 4]

style A1 fill:#D3D3D3
style B1 fill:#FFFFFF
style C1 fill:#FFFFFF
style D1 fill:#FFFFFF
style E1 fill:#FFFFFF
end

在垃圾回收器开始工作时,所有对象都为白色,垃圾回收器会先把所有根对象标记为灰色,然后后续只会从灰色对象集合中取出对象进行处理,把取出的对象标为黑色,并且把该对象引用的对象标灰加入到灰色对象集合中,直到灰色对象集合为空,则表示标记阶段结束了。


三色标记实际示例


type Person struct {
Name string
Friends []*Person
}

func main() {
alice := &Person{Name: "Alice"}
bob := &Person{Name: "Bob"}
charlie := &Person{Name: "Charlie"}

// Alice和Bob是朋友
alice.Friends = []*Person{bob}
bob.Friends = []*Person{alice, charlie}

// charlie没有朋友引用(假设bob的引用被删除)
bob.Friends = []*Person{alice}
// 此时charlie将在下次GC时被回收
}

详细标志过程如下:



  1. 初始时所有对象都是白色
  2. 从根对象开始,将其标记为灰色
  3. 从灰色对象集合中取出一个对象,将其引用对象标记为灰色,自身标记为黑色
  4. 重复步骤 3 直到灰色集合为空
  5. 清除所有白色对象

// 三色标记伪代码
func TriColorMark() {
// 初始化,所有对象设为白色
for obj := range heap {
setWhite(obj)
}

// 根对象入灰色队列
for root := range roots {
setGrey(root)
greyQueue.Push(root)
}

// 处理灰色队列
for !greyQueue.Empty() {
grey := greyQueue.Pop()
scan(grey)
setBlack(grey)
}

// 清除白色对象
sweep()
}

需要注意的是,三色标记清除算法本身是不支持和用户程序并行执行的,因为在标记过程中,用户程序可能会进行修改对象指针指向等操作,导致最终出现误清除掉活跃对象等情况,这对于内存管理而言,是十分严重的错误了。


并发标记的问题示例


func main() {
var root *Node
var finalizer *Node

// GC开始,root被标记为灰色
root = &Node{data: 1}

// 用户程序并发修改引用关系
finalizer = root
root = nil

// 如果这时GC继续运行,finalizer指向的对象会被错误回收
// 因为从root开始已经无法到达该对象
}

所以为了解决这个问题,在一些编程语言中,常见的做法是,三色标记分为 3 个阶段:



  1. 初始化阶段,需要 STW,包括标记根对象等操作
  2. 主要标记阶段,该阶段支持并行
  3. 结束标记阶段,需要 STW,确认对象标记无误

通过这样的设计,至少可以使得标记耗时较长的阶段可以和用户程序并行执行,大幅度缩短了 STW 的时间,但是由于最后一阶段需要重复扫描对象,所以 STW 的时间还是不够理想,因此引入了内存屏障等技术继续优化。


内存屏障技术


三色标记算法在并发环境下会出现对象丢失的问题,为了解决这个问题,Go 引入了内存屏障技术。


内存屏障技术是一种屏障指令,确保屏障指令前后的操作不会被越过屏障重排。


内存屏障工作原理图解


graph TD
subgraph "插入写屏障"
A1[黑色对象] -->|新增引用| B1[白色对象]
B1 -->|标记为灰色| C1[灰色对象]
end

subgraph "删除写屏障"
A2[黑色对象] -->|删除引用| B2[白色对象]
B2 -->|标记为灰色| C2[灰色对象]
end

垃圾回收中的屏障更像是一个钩子函数,在执行指定操作前通过该钩子执行一些前置的操作。


对于三色标记算法,如果要实现在并发情况下的正确标记,则至少要满足以下两种三色不变性中的其中一种:



  • 强三色不变性: 黑色对象不指向白色对象,只会指向灰色或黑色对象
  • 弱三色不变性:黑色对象可以指向白色对象,但是该白色对象必须被灰色对象保护着(被其他的灰色对象直接或间接引用)

插入写屏障


插入写屏障的核心思想是:在对象新增引用关系时,将被引用对象标记为灰色。


// 插入写屏障示例
type Object struct {
refs []*Object
}

func (obj *Object) AddReference(ref *Object) {
// 写屏障:在添加引用前将新对象标记为灰色
shade(ref)
obj.refs = append(obj.refs, ref)
}

// 插入写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(ptr) // 将新引用的对象标记为灰色
*slot = ptr
}

插入写屏障是一种相对保守的策略,相当于有可能存活的对象都会被标灰,满足了强三色不变行,缺点是会产生浮动垃圾(没有被引用但却没被回收的对象),要到下一轮垃圾回收时才会被回收。


浮动垃圾示例


func main() {
obj1 := &Object{}
obj2 := &Object{}

// obj1引用obj2
obj1.AddReference(obj2) // obj2被标记为灰色

// 立即删除引用
obj1.refs = nil

// 此时obj2虽然已经不可达
// 但因为已被标记为灰色,要等到下一轮GC才会被回收
}

栈上的对象在垃圾回收中也是根对象,但是如果栈上的对象也开启插入写屏障,那么对于写指针的操作会带来较大的性能开销,所以很多时候插入写屏障只针对堆对象启用,这样一来,要保证最终标记无误,在最终标记结束阶段就需要 STW 来重新扫描栈空间的对象进行查漏补缺。实际上这两种方式各有利弊。


删除写屏障


删除写屏障的核心思想是:在对象删除引用关系时,将被解引用的对象标记为灰色。
这种方法可以保证弱三色不变性,缺点是回收精度低,同样也会产生浮动垃圾。


// 删除写屏障示例
func (obj *Object) RemoveReference(index int) {
// 写屏障:在删除引用前将被删除的对象标记为灰色
shade(obj.refs[index])
obj.refs = append(obj.refs[:index], obj.refs[index+1:]...)
}

// 删除写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) // 将被删除引用的对象标记为灰色
*slot = ptr
}

混合写屏障


Go 1.8 引入了混合写屏障,同时应用了插入写屏障和删除写屏障,结合了二者的优点:


// 混合写屏障示例
func (obj *Object) UpdateReference(index int, newRef *Object) {
// 删除写屏障
shade(obj.refs[index])
// 更新引用
obj.refs[index] = newRef
// 插入写屏障
shade(newRef)
}

// 混合写屏障伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) // 删除写屏障
*slot = ptr
shade(ptr) // 插入写屏障
}

GO 中垃圾回收机制


大致演进与版本改进



  • ​Go 1.3之前​​:传统标记-清除,全程STW(秒级停顿)。
  • ​Go 1.5​​:引入并发三色标记,STW降至毫秒级。
  • ​Go 1.8​​:混合写屏障优化,STW缩短至微秒级。
  • ​Go 1.12+​​:并行标记优化,提升吞吐量。

在 GO 1.7 之前,主要是使用了插入写屏障来保证强三色不变性,由于垃圾回收的根对象包括全局变量、寄存器、栈对象,如果要对所有的 Goroutine 都开启写屏障,那么对于写指针操作肯定会造成很大的性能损耗,所以 GO 并没有针对栈开启写屏障。而是选择了在标记完成时 STW、重新扫描栈对象(将所有栈对象标灰重新扫描),避免漏标错标的情况,但是这一过程是比较耗时的,要占用 10 ~ 100 ms 时间。


于是,GO 1.8 开始就使用了混合写屏障 + 栈黑化 的方案优化该问题,GC 开始时全部栈对象标记为黑色,以及标记过程中新建的栈、堆对象也标记为黑色,防止新建的对象都错误回收掉,通过这样的机制,栈空间的对象都会为黑色,所以最后也无需重新扫描栈对象,大幅度地缩短了 STW 的时间。当然,与此同时也会有产生浮动垃圾等方面的牺牲,没有完成的方法,只有根据实际需求的权衡取舍。


主要特点



  1. 并发回收:GC 与用户程序同时运行
  2. 非分代式:不按对象年龄分代
  3. 标记清除:使用三色标记算法
  4. 写屏障:使用混合写屏障
  5. STW 时间短:平均在 100us 以内

垃圾回收触发条件



  • 内存分配达到阈值
  • 定期触发
  • 手动触发(runtime.GC())

GC 过程



  1. STW,开启写屏障
  2. 并发标记
  3. STW,清除标记
  4. 并发清除
  5. 结束

GC触发示例


func main() {
// 1. 内存分配达到阈值触发
for i := 0; i < 1000000; i++ {
_ = make([]byte, 1024) // 大量分配内存
}

// 2. 定期触发
// Go运行时会自动触发GC

// 3. 手动触发
runtime.GC()
}

总结


Go 语言的垃圾回收机制经过多次优化,已经达到了很好的性能。它采用三色标记算法,配合混合写屏障技术,实现了高效的并发垃圾回收。虽然还有一些不足,如不支持分代回收,但对于大多数应用场景来说已经足够使用。


性能优化建议


要优化 Go 程序的 GC 性能,可以:



  1. 减少对象分配
    // 不好的做法
    for i := 0; i < 1000; i++ {
    data := make([]int, 100)
    process(data)
    }

    // 好的做法
    data := make([]int, 100)
    for i := 0; i < 1000; i++ {
    process(data)
    }


  2. 复用对象
    // 使用sync.Pool复用对象
    var pool = sync.Pool{
    New: func() interface{} {
    return make([]byte, 1024)
    },
    }

    func process() {
    buf := pool.Get().([]byte)
    defer pool.Put(buf)
    // 使用buf
    }


  3. 使用合适的数据结构
    // 不好的做法:频繁扩容
    s := make([]int, 0)
    for i := 0; i < 1000; i++ {
    s = append(s, i)
    }

    // 好的做法:预分配容量
    s := make([]int, 0, 1000)
    for i := 0; i < 1000; i++ {
    s = append(s, i)
    }


  4. 控制内存使用量
    // 设置GOGC环境变量控制GC频率
    // GOGC=100表示当内存扩大一倍时触发GC
    os.Setenv("GOGC", "100")



参考资料:



作者:Lemon程序馆
来源:juejin.cn/post/7523256725126873114

0 个评论

要回复文章请先登录注册