快速搞懂 Go 垃圾回收
3

快速搞懂 Go 垃圾回收

1. GC的历史演进

Go 语言的垃圾回收器发展经历了四个主要阶段,每一次演进都致力于减少 STW(Stop-The-World)时间,提升性能:

1.0-1.2:完全 STW 标记-清除

  • 最原始的 GC 方案,全程需要暂停所有用户 Goroutine
  • 标记和清扫阶段完全串行执行
  • 对于大堆内存应用,STW 时间可达数百毫秒甚至秒级,无法满足实时性要求

1.3-1.4:并行 STW 标记-清除

  • 将标记和清扫工作并行化,利用多核 CPU 加速
  • 减少了 STW 的绝对时间,但程序仍然需要完全暂停
  • 属于"治标不治本"的优化

1.5:并发标记

  • 引入三色标记法写屏障技术,实现标记阶段的并发执行
  • STW 时间从百毫秒级大幅降低到几毫秒到几十毫秒级别
  • 但栈重新扫描仍需要额外的 STW 时间

1.8+:混合写屏障

  • 采用混合写屏障技术,彻底消除栈重新扫描的 STW
  • STW 时间稳定控制在 100 微秒以下
  • 成为现代 Go 低延迟 GC 的基石

2. 三色标记+混合写屏障

2.1 基本概念

三色抽象模型:

  • 白色对象:尚未被 GC 扫描的对象(可能是垃圾)
  • 灰色对象:已被扫描但引用的对象还未扫描(待扫描队列)
  • 黑色对象:已扫描完且所有引用对象也全部扫描(存活对象)

混合写屏障规则:

  • 在指针被覆盖前,将被覆盖的旧指针指向的对象着色为灰色(Yuasa 删除屏障)
  • 将新指针指向的对象着色为灰色(Dijkstra 插入屏障)
  • GC 开始时将所有栈对象永久标记为黑色,之后不再重新扫描栈

2.2 核心状态与阶段

GC 的周期分为四个核心阶段:

GCoff:未进行 GC 的状态,程序正常执行

GCmark:标记阶段

  • 开启写屏障(极短 STW)
  • 并发标记所有可达对象
  • 从根对象(栈、全局变量)开始遍历

GCmarktermination:标记终止阶段

  • 短暂 STW 处理残余灰色对象
  • 确保标记完全完成

GCsweep:清扫阶段

  • 增量回收未标记对象
  • 与内存分配过程交织进行

3. GC怎么工作的

3.1 触发时机

GC 不是定期执行,而是在满足以下条件时触发:

内存分配阈值:堆内存达到上一次 GC 后存活对象大小的特定比例,由 GOGC 环境变量控制(默认 100%)

定时强制触发:运行时保证每 2 分钟至少触发一次 GC,防止长时间不回收导致延迟飙升

手动触发:通过调用 runtime.GC() 强制触发,主要用于测试和调试

3.2 并发标记

  1. STW 准备:开启写屏障(耗时极短)
  2. 标记根对象:扫描所有栈和全局变量,放入灰色队列
  3. 并发标记:后台 Mark Worker Goroutines 并发处理灰色队列
  4. 标记终止:短暂 STW 确保标记完全完成

标记过程中,GC 与用户程序并发执行,通过写屏障保证正确性。

3.3 增量清扫策略

增量清扫是Go GC实现低延迟的关键设计,其核心思想是将清扫工作分散化、渐进式完成,避免集中清扫导致的长时间STW

3.3.1 内存管理基础单元span的清扫机制

  • Go运行时将堆内存划分为多个span(连续内存块),每个span维护自己的分配状态位图。
  • 清扫过程不是一次性处理整个堆,而是以span为粒度逐个处理。
  • 每个span都有一个needSweep状态标志,标记该span是否需要清扫。

3.3.2 触发时机与执行流程

  • 清扫工作主要在两种场景下触发:内存分配时和后台清扫。
  • 内存分配清扫
    • 当程序申请内存时,如果目标span需要清扫,分配器会先执行该span的清扫操作,然后再进行分配。
    • 这种"按需清扫"策略将清扫成本直接分摊到内存分配过程中。
  • 后台清扫器
    • 运行时维护了专用的后台清扫goroutine,在系统空闲时主动执行清扫任务。
    • 清扫器会从全局队列中获取需要清扫的span,逐步处理。
    • 这种设计充分利用了CPU空闲时间,为后续的内存分配提前做好准备。

3.3.3 渐进式内存回收

  • 增量式清扫,内存不会在GC完成后立即释放,而是随着程序的运行逐步回收。
  • 这会导致内存占用在一段时间内缓慢下降,但换来了更平滑的性能表现和极短的STW时间。

3.4 写屏障技术

写屏障是保证并发标记正确性的核心技术,Go的混合写屏障结合了DijkstraYuasa两种方案的优点,实现了高效且正确的并发标记。

3.4.1 混合写屏障的双重保护机制

  • 首先,在指针被覆盖前,将被覆盖的旧指针指向的对象标记为灰色(Yuasa删除屏障),这防止了因为指针覆盖而导致的存活对象丢失。
  • 其次,将新指针指向的对象标记为灰色(Dijkstra插入屏障),确保新引用的对象不会被错误回收。

3.4.2 栈永久黑色

  • GC开始时将所有栈对象标记为黑色,并承诺之后不再重新扫描栈
  • 这看似冒险的设计实际上是通过写屏障来保证正确性的。
    • 栈被永久视为黑色后,任何从栈出发的指针修改都会触发写屏障,确保相关对象得到正确标记。

3.4.3 栈上对象的回收

  • 需要明确的是:栈上对象不是由垃圾回收器回收的。
  • 栈内存的管理完全独立于GC系统。
    • 每个Goroutine都有自己的栈空间,栈上对象的生命周期由函数调用栈管理。
    • 当函数返回时,其栈帧被弹出,局部变量占用的空间自然释放
    • 当栈空间使用率过低时,运行时还会进行栈缩容,将整块内存交还系统。

3.4.4 写屏障与栈管理的协同

  • 虽然GC不直接回收栈内存,但必须保护栈上指针引用的堆对象。
    • 确保任何从栈(黑色区域)出发的指针修改都不会导致堆上的存活对象被遗漏。
    • 无论用户代码如何修改栈上的指针,写屏障都能保证被引用的堆对象得到正确标记。

4. 面试题速查

4.1 Go GC 是引用计数吗?

  • 不是。Go 采用追踪式 GC(三色标记),而非引用计数。
  • 引用计数无法处理循环引用,且每次赋值都有计数更新开销。

4.2 为什么 Go GC 延迟低?

关键在并发标记和增量清扫。大部分标记工作与用户程序并发执行,清扫成本被分摊到内存分配中,极大减少 STW 时间。

4.3 写屏障有什么作用?

保证并发标记期间指针修改的正确性,防止存活对象被错误回收。混合写屏障消除了栈重新扫描的需要。

4.4 Goroutine 泄漏为什么会导致内存泄漏?

因为泄露的 Goroutine 栈被视为永久根对象,其引用的所有堆对象都无法被回收。这比栈内存本身泄漏更严重。

4.5 如何监控和调试 GC 行为?

设置 GODEBUG=gctrace=1 环境变量,查看详细 GC 日志。使用 pprof 分析 Goroutine 和内存使用情况。

4.6 如何优化 GC 性能?

  • 合理设置 GOGC 参数
  • 使用 sync.Pool 重用对象
  • 避免不必要的指针
  • 减少堆内存分配

4.7 混合写屏障相比之前方案的优势?

消除了栈重新扫描的 STW,将暂停时间稳定在亚毫秒级别,同时保证正确性。

4.8 GC 何时会触发扩容?

Go 的 GC 本身不触发扩容,但内存分配器可能会因堆增长而扩容堆空间。

4.9 如何手动控制 GC?

调用 runtime.GC() 手动触发,通过 debug.SetGCPercent() 调整 GC 触发频率。

快速搞懂 Go 垃圾回收
http://blog.allons.cn/archives/%E5%BF%AB%E9%80%9F%E6%90%9E%E6%87%82-go-%E5%9E%83%E5%9C%BE%E5%9B%9E%E6%94%B6
作者
cheivin
发布于
更新于
许可