搞懂 GO 的垃圾回收机制

速通 GO 垃圾回收机制

前言

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

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

标记清除

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

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

标记清除示例

考虑以下场景:

go 复制代码
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将被回收
go 复制代码
// 伪代码展示标记清除过程
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

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

三色标记实际示例

go 复制代码
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. 清除所有白色对象
go 复制代码
// 三色标记伪代码
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()
}

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

并发标记的问题示例

go 复制代码
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

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

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

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

插入写屏障

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

go 复制代码
// 插入写屏障示例
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
}

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

浮动垃圾示例

go 复制代码
func main() {
    obj1 := &Object{}
    obj2 := &Object{}
    
    // obj1引用obj2
    obj1.AddReference(obj2)  // obj2被标记为灰色
    
    // 立即删除引用
    obj1.refs = nil
    
    // 此时obj2虽然已经不可达
    // 但因为已被标记为灰色,要等到下一轮GC才会被回收
}

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

删除写屏障

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

go 复制代码
// 删除写屏障示例
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 引入了混合写屏障,同时应用了插入写屏障和删除写屏障,结合了二者的优点:

go 复制代码
// 混合写屏障示例
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触发示例

go 复制代码
func main() {
    // 1. 内存分配达到阈值触发
    for i := 0; i < 1000000; i++ {
        _ = make([]byte, 1024) // 大量分配内存
    }
    
    // 2. 定期触发
    // Go运行时会自动触发GC
    
    // 3. 手动触发
    runtime.GC()
}

总结

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

性能优化建议

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

  1. 减少对象分配

    go 复制代码
    // 不好的做法
    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. 复用对象

    go 复制代码
    // 使用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. 使用合适的数据结构

    go 复制代码
    // 不好的做法:频繁扩容
    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. 控制内存使用量

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

参考资料:

相关推荐
Piper蛋窝3 小时前
深入 Go 语言垃圾回收:从原理到内建类型 Slice、Map 的陷阱以及为何需要 strings.Builder
后端·go
六毛的毛5 小时前
Springboot开发常见注解一览
java·spring boot·后端
AntBlack5 小时前
拖了五个月 ,不当韭菜体验版算是正式发布了
前端·后端·python
31535669135 小时前
一个简单的脚本,让pdf开启夜间模式
前端·后端
uzong5 小时前
curl案例讲解
后端
一只叫煤球的猫6 小时前
真实事故复盘:Redis分布式锁居然失效了?公司十年老程序员踩的坑
java·redis·后端
大鸡腿同学7 小时前
身弱武修法:玄之又玄,奇妙之门
后端
DemonAvenger7 小时前
高性能 TCP 服务器的 Go 语言实现技巧:从原理到实践
网络协议·架构·go
Code季风8 小时前
深入理解微服务中的服务注册与发现(Consul)
java·运维·微服务·zookeeper·架构·go·consul
轻语呢喃9 小时前
JavaScript :字符串模板——优雅编程的基石
前端·javascript·后端