Go 的垃圾回收器(GC,Garbage Collector)是并发的标记-清除回收器:标记和清除都与用户代码同时跑,只在必要时短暂停住程序。
Go 垃圾回收器的工作原理
一轮 GC 分标记、清除两步,两个阶段都尽可能并发执行。
1. 标记阶段(Marking Phase)
在标记阶段,GC 会遍历所有活跃的对象,并标记它们为“活跃”。这个阶段会从一组称为根(roots)的对象开始,这些根对象包括全局变量、栈上的局部变量以及当前正在被使用的寄存器中的变量。GC 会递归地遍历所有从根对象可达的对象,并将这些对象标记为“活跃”。
2. 清除阶段(Sweeping Phase)
在清除阶段,GC 会遍历堆内存,并清除所有未标记为“活跃”的对象。这些对象被认为是不再使用的,因此可以被回收并重新分配。
3. 并发执行
Go 的垃圾回收器是一个并发标记-清除回收器,这意味着它会尽可能并发地执行,以减少对程序执行的影响。标记阶段和清除阶段可以在多个线程上并发执行,减少了垃圾回收暂停程序的时间。GC 还引入了一种称为“三色标记”的算法,用于在并发标记过程中保持一致性。
三色标记算法
三色标记算法将对象分为三类:
- 白色(White):未访问的对象
- 灰色(Gray):已访问但其子对象未全部访问的对象
- 黑色(Black):已访问且其子对象已全部访问的对象
标记过程如下:
- 所有对象初始为白色。
- 根对象被标记为灰色,并放入标记队列。
- 逐个处理灰色对象,将其标记为黑色,并将其引用的对象标记为灰色。
- 当没有灰色对象时,标记过程结束,所有未标记为黑色的对象即为白色(不可达)对象,可被清除。
垃圾回收触发机制
Go 的垃圾回收器会根据内存分配量、内存使用率和手动触发等条件启动垃圾回收过程。通常,当堆内存增长到一定程度时,GC 会自动运行。
调优垃圾回收
Go 提供了一些方法和参数来调优垃圾回收器,以适应不同的应用场景。例如:
- GOGC 环境变量:控制垃圾回收的触发频率。默认值是 100,表示堆内存每增长 100% 会触发一次垃圾回收。降低这个值可以更频繁地触发垃圾回收,但会增加 GC 开销;增加这个值则会减少垃圾回收频率,但可能会导致内存使用增加。
- runtime.GC() 函数:手动触发垃圾回收。
- runtime.ReadMemStats() 函数:获取垃圾回收统计信息。
代码示例
手动触发垃圾回收并读取统计信息:
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 影响程序性能:
频繁的内存分配和回收:
- 当程序频繁进行小对象的内存分配和回收时,垃圾收集器可能会频繁触发,导致额外的CPU消耗和暂停程序执行的停顿(goroutine 的暂停)。
- 解决方案:可以通过对象池(sync.Pool)来复用对象,减少垃圾收集的压力,或者考虑减少频繁分配内存的操作。
大对象的分配和释放:
- 当程序中出现大对象的频繁分配和释放时,尤其是大于大对象阈值(32 KB,超过该阈值的对象直接从堆上分配)的对象,会产生更大的堆内存压力,使垃圾收集器更频繁地触发。
- 解决方案:可以考虑复用大对象;也可以通过
GOGC环境变量或debug.SetMemoryLimit(GOMEMLIMIT)调整触发 GC 的阈值(使用GODEBUG=gctrace=1可以查看 GC 的调试信息)。
长时间的停顿时间:
- Go语言的垃圾收集器是并发执行的,但仍可能导致一些特定情况下的长时间停顿(stop-the-world pauses),特别是在进行全局垃圾收集时。
- 解决方案:可以通过调整GC的参数来尝试减少长时间停顿,例如使用环境变量
GOGC调整触发GC的阈值或者使用GODEBUG=gctrace=1查看详细的GC信息以优化代码。
内存泄漏:
- 如果程序中存在内存泄漏(例如,未被使用但仍占用内存的对象),垃圾收集器可能无法及时回收这些内存,导致程序整体内存占用增加,影响性能。
- 解决方案:可以使用工具(如
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。
参考资料
- 官方 · A Guide to the Go Garbage Collector
- 官方博客 · Go GC: Prioritizing low latency and simplicity
- 官方博客 · Getting to Go(GC 演进)
- 官方博客 · The Green Tea Garbage Collector