Skip to content

Go 的垃圾回收器(GC,Garbage Collector)是并发的标记-清除回收器:标记和清除都与用户代码同时跑,只在必要时短暂停住程序。

Go 垃圾回收器的工作原理 ​

一轮 GC 分标记、清除两步,两个阶段都尽可能并发执行。

1. 标记阶段(Marking Phase) ​

在标记阶段,GC 会遍历所有活跃的对象,并标记它们为“活跃”。这个阶段会从一组称为根(roots)的对象开始,这些根对象包括全局变量、栈上的局部变量以及当前正在被使用的寄存器中的变量。GC 会递归地遍历所有从根对象可达的对象,并将这些对象标记为“活跃”。

2. 清除阶段(Sweeping Phase) ​

在清除阶段,GC 会遍历堆内存,并清除所有未标记为“活跃”的对象。这些对象被认为是不再使用的,因此可以被回收并重新分配。

3. 并发执行 ​

Go 的垃圾回收器是一个并发标记-清除回收器,这意味着它会尽可能并发地执行,以减少对程序执行的影响。标记阶段和清除阶段可以在多个线程上并发执行,减少了垃圾回收暂停程序的时间。GC 还引入了一种称为“三色标记”的算法,用于在并发标记过程中保持一致性。

三色标记算法 ​

三色标记算法将对象分为三类:

  1. 白色(White):未访问的对象
  2. 灰色(Gray):已访问但其子对象未全部访问的对象
  3. 黑色(Black):已访问且其子对象已全部访问的对象

标记过程如下:

  1. 所有对象初始为白色。
  2. 根对象被标记为灰色,并放入标记队列。
  3. 逐个处理灰色对象,将其标记为黑色,并将其引用的对象标记为灰色。
  4. 当没有灰色对象时,标记过程结束,所有未标记为黑色的对象即为白色(不可达)对象,可被清除。

垃圾回收触发机制 ​

Go 的垃圾回收器会根据内存分配量、内存使用率和手动触发等条件启动垃圾回收过程。通常,当堆内存增长到一定程度时,GC 会自动运行。

调优垃圾回收 ​

Go 提供了一些方法和参数来调优垃圾回收器,以适应不同的应用场景。例如:

  • GOGC 环境变量:控制垃圾回收的触发频率。默认值是 100,表示堆内存每增长 100% 会触发一次垃圾回收。降低这个值可以更频繁地触发垃圾回收,但会增加 GC 开销;增加这个值则会减少垃圾回收频率,但可能会导致内存使用增加。
  • runtime.GC() 函数:手动触发垃圾回收。
  • runtime.ReadMemStats() 函数:获取垃圾回收统计信息。

代码示例 ​

手动触发垃圾回收并读取统计信息:

go
package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    // 启动一个 goroutine 分配大量内存
    go func() {
        for i := 0; i < 10; i++ {
            _ = make([]byte, 10*1024*1024) // 分配 10 MB
            time.Sleep(1 * time.Second)
        }
    }()

    // 手动触发垃圾回收
    runtime.GC()

    // 获取垃圾回收统计信息
    var memStats runtime.MemStats
    runtime.ReadMemStats(&memStats)
    fmt.Printf("Allocated: %v MB\n", memStats.Alloc/1024/1024)
    fmt.Printf("Total Allocated: %v MB\n", memStats.TotalAlloc/1024/1024)
    fmt.Printf("Heap Allocated: %v MB\n", memStats.HeapAlloc/1024/1024)
    fmt.Printf("Number of GC: %v\n", memStats.NumGC)

    // 让程序运行一段时间以观察 GC
    time.Sleep(20 * time.Second)
}

例子启动一个 goroutine,每秒分配 10 MB 来逼出垃圾回收,主流程手动调 runtime.GC(),再用 runtime.ReadMemStats 打印已分配字节、累计分配字节和 GC 次数。

GC影响性能的情况 ​

下面几种情况会让 GC 影响程序性能:

  1. 频繁的内存分配和回收:

    • 当程序频繁进行小对象的内存分配和回收时,垃圾收集器可能会频繁触发,导致额外的CPU消耗和暂停程序执行的停顿(goroutine 的暂停)。
    • 解决方案:可以通过对象池(sync.Pool)来复用对象,减少垃圾收集的压力,或者考虑减少频繁分配内存的操作。
  2. 大对象的分配和释放:

    • 当程序中出现大对象的频繁分配和释放时,尤其是大于大对象阈值(32 KB,超过该阈值的对象直接从堆上分配)的对象,会产生更大的堆内存压力,使垃圾收集器更频繁地触发。
    • 解决方案:可以考虑复用大对象;也可以通过 GOGC 环境变量或 debug.SetMemoryLimit(GOMEMLIMIT)调整触发 GC 的阈值(使用 GODEBUG=gctrace=1 可以查看 GC 的调试信息)。
  3. 长时间的停顿时间:

    • Go语言的垃圾收集器是并发执行的,但仍可能导致一些特定情况下的长时间停顿(stop-the-world pauses),特别是在进行全局垃圾收集时。
    • 解决方案:可以通过调整GC的参数来尝试减少长时间停顿,例如使用环境变量 GOGC 调整触发GC的阈值或者使用 GODEBUG=gctrace=1 查看详细的GC信息以优化代码。
  4. 内存泄漏:

    • 如果程序中存在内存泄漏(例如,未被使用但仍占用内存的对象),垃圾收集器可能无法及时回收这些内存,导致程序整体内存占用增加,影响性能。
    • 解决方案:可以使用工具(如pprof)来识别和分析内存泄漏,及时修复代码中的问题。

GC 本身很少成为性能瓶颈,但小对象分配太勤、大对象反复创建释放、长时间停顿、内存泄漏这四种情况会让它显形,各自的解决办法已经写在上面。

Go GC的优化历史 ​

Go 的历次 GC 优化,主线是缩短 Stop-The-World(STW)停顿。各版本的改动:

  • Go 1.3:引入了并行清扫,标记过程需要STW,停顿时间在约几百毫秒。
  • Go 1.5:实现了并发标记清扫,停顿时间减少到一百毫秒以内。
  • Go 1.6:使用bitmap记录回收内存的位置,优化了垃圾回收器自身消耗的内存,停顿时间在十毫秒以内。
  • Go 1.7:进一步优化,将停顿时间控制在两毫秒以内。
  • Go 1.8:引入了混合写屏障机制,显著减少了STW的时间,停顿时间在半个毫秒左右。
  • Go 1.9:彻底移除了栈的重扫描过程,进一步提升了GC效率。
  • Go 1.14:引入了异步抢占,解决了由于密集循环导致的STW时间过长的问题。

特别地,在Go 1.18版本中,引入了debug.SetMemoryLimit函数,通过这个内置函数可以调整触发GC的堆内存目标值,从而减少GC次数,降低GC时CPU占用的目的。这个优化实际上是对gcTriggerHeap策略的改进,通过代码调用可以知道在gcControllerState.heapGoalInternal计算HeapGoal的时候使用了两种方式,一种是通过GOGC值计算,另一种是通过memoryLimit值计算,然后取它们两个中小的值作为HeapGoal。

STW 停顿从 Go 1.3 的几百毫秒降到 Go 1.8 的半个毫秒,这条曲线的每一段都对应上面某个版本的改动。

总结 ​

Go 的 GC 用三色标记完成并发标记,标记一致后统一清除白色对象。GOGC 控制触发频率,默认 100;debug.SetMemoryLimit 给堆目标加了第二道限制,两者取小作为 HeapGoal。

参考资料 ​

相关页面 ​

Hello Golang