Golang 垃圾回收
本文对 Go 语言垃圾回收(GC)的整体流程进行一次清晰的总结。
1 GC
Go 的 GC 设计核心目标是实现极低的延迟,尤其是要最大限度地缩短“Stop-The-World”(STW,即程序完全暂停)的时间。为了达到这个目标,它采用的是并发三色标记清除(Concurrent Tri-color Mark-and-Sweep)算法,和混合写屏障(Hybrid Write Barrier)。
一个完整的 GC 周期可以分为以下几个主要阶段:
1.1 0. GC 触发时机
在进入 GC 周期前,首先需要被触发。主要有三种触发方式:
-
堆内存分配阈值:这是最主要的触发方式。当程序新分配的内存达到一个阈值时,GC 会被触发。这个阈值由环境变量
GOGC
控制(默认为100),计算公式为:下次触发GC的堆大小 = 上次GC后的存活堆大小 * (1 + GOGC/100)
。例如,如果GOGC=100
,则当堆大小翻倍时触发 GC。 -
定时触发:Go 运行时会保证即使没有达到内存分配阈值,最长也会在约 2 分钟内强制触发一次 GC,以防止程序长时间不回收内存。
-
手动触发:通过调用
runtime.GC()
可以手动强制执行一次 GC 周期,这通常只在测试或特殊场景下使用。
1.2 GC 周期整体流程
一旦被触发,GC 周期将按以下四个阶段进行。其中,最耗时的标记和清理阶段都是与用户代码并发执行的。
1.2.1 阶段一:标记准备 (Mark Setup) - STW
这是 GC 周期的第一个、也是一个非常短暂的 Stop-The-World (STW) 暂停。
-
目的:为即将到来的并发标记阶段做准备。
-
核心操作:
-
开启混合写屏障 (Enable Write Barrier):这是此阶段最重要的任务。写屏障是一种机制,它会“监视”在并发标记期间用户 Goroutine 对堆上指针的修改操作。一旦开启,任何“黑”对象指向“白”对象的操作都会被写屏障拦截并妥善处理,从而保证不错过任何存活的对象。(屏障机制类似于一个回调保护机制,指的是在完成某个特定动作前,会先完成屏障成设置的内容.)
-
暂停所有正在运行的用户 Goroutine。
-
准备根对象的扫描(如全局变量、每个 Goroutine 栈上的指针等)。
-
这个 STW 暂停通常非常快,在微秒级别。完成后,用户 Goroutine 会被恢复。
1.2.2 阶段二:并发标记 (Concurrent Marking)
这是 GC 中最耗时的阶段,但它是与用户 Goroutine 并发执行的,这也是 Go GC 低延迟的关键。
-
目的:找出所有存活的对象。
-
核心操作:
-
GC 的后台工作 Goroutine 开始从根对象(全局变量、栈)出发,遍历整个对象图。
-
它使用三色标记法:
-
白色 (White):对象是潜在的垃圾。初始时所有对象都是白色。
-
灰色 (Gray):对象是存活的,但其引用的其他对象尚未被扫描。
-
黑色 (Black):对象是存活的,并且它引用的所有对象都已被完全扫描。
-
-
GC worker 不断从灰色对象队列中取出对象,扫描它引用的指针,将被引用的白色对象标记为灰色,然后将自身标记为黑色。
-
写屏障在此阶段持续工作:如果用户代码在此期间修改了对象引用(例如
blackObj.field = whiteObj
),写屏障会确保这个whiteObj
被标记为灰色,防止它被漏标。需要注意的是,基于性能考量,混合写屏障仅仅适用于堆上 -
GC 辅助 (GC Assist):为了确保标记工作能及时完成,当用户 Goroutine 分配新内存时,可能会被要求“帮助” GC 完成一部分标记工作。
-
其他:并发标记阶段新增的栈上对象会直接标黑
-
1.2.3 阶段三:标记终止 (Mark Termination) - STW
这是第二个,也是最后一个非常短暂的 Stop-The-World (STW) 暂停。
-
目的:完成所有标记工作,确保没有遗漏,并为清理阶段做准备。
-
核心操作:
-
再次暂停所有用户 Goroutine。
-
关闭写屏障。
-
对所有 Goroutine 的栈进行快速的重新扫描:这是处理“栈上指针写入无写屏障”问题的关键安全网。此过程会检查在并发标记期间,栈上是否出现了新的指向白色对象的指针,如果有,则将对应的白色对象标记为灰色。
-
排空所有剩余的灰色对象队列,直到没有灰色对象为止。此时,所有可达对象都是黑色的。
-
得益于混合写屏障,这个 STW 阶段也极快,通常在亚毫秒级。完成后,用户 Goroutine 会被恢复。
1.2.4 阶段四:并发清理 (Concurrent Sweeping)
此阶段也是与用户 Goroutine 并发执行的。
-
目的:回收所有未被标记(即仍然是白色)的对象所占用的内存。
-
核心操作:
-
标记工作完成后,内存中的对象要么是黑色(存活),要么是白色(垃圾)。
-
GC worker 开始遍历整个堆,将所有白色对象的内存空间回收,并归还给内存分配器,以便后续的内存分配请求使用。
-
用户 Goroutine 在分配新内存时,也可能会触发一小部分的清理工作。
-
2 Go GC 三色标记算法流程图解
- 红色框代表 Stop-The-World (STW) 阶段,程序会短暂暂停。
- 绿色框代表并发阶段,GC 工作与用户代码同时运行。
2.1 GC 整体流程图
graph TD A[GC 触发] --> B[阶段一: 标记准备 STW] B --> |开启写屏障, 准备根| C[阶段二: 并发标记] C --> |扫描对象图, 写屏障活跃| D[阶段三: 标记终止 STW] D --> |关闭写屏障, 重扫栈| E[阶段四: 并发清理] E --> |回收白色对象| F[GC 周期结束] classDef stw fill:#ff9999,stroke:#333,stroke-width:2px classDef concurrent fill:#99ff99,stroke:#333,stroke-width:2px class B,D stw class C,E concurrent
2.2 写屏障与栈扫描的关系
graph TD A[栈上引用修改] --> B{是否在标记阶段?} B --> |否| C[正常赋值操作] B --> |是| D{引用类型?} D --> |栈到栈| E[无写屏障保护] D --> |栈到堆| F[可能需要记录] D --> |堆到堆| G[写屏障保护] E --> H[标记终止时重扫栈] F --> H G --> I[立即标记灰色] H --> J[发现新引用] I --> K[对象安全] J --> K classDef dangerous fill:#ffcccc,stroke:#ff0000,stroke-width:2px classDef safe fill:#ccffcc,stroke:#00ff00,stroke-width:2px class E,F dangerous class G,I,K safe
2.3 三色标记状态转换
stateDiagram-v2 [*] --> 白色: 对象创建 白色 --> 灰色: 被根对象引用/写屏障触发 灰色 --> 黑色: 扫描完成 黑色 --> [*]: GC清理(如果白色)/保留(如果黑色) 白色 --> [*]: 被回收 note right of 灰色: 待扫描队列中 note right of 黑色: 确认存活 note right of 白色: 可能被回收
2.4 漏标问题场景分析(为什么需要第二个stw)
考虑这样一个问题
背景: 存在栈上对象A,白色(未扫描,这是因为对应的栈还未开始扫描); 存在栈上对象B,黑色(已完成扫描,说明对应的栈均已完成扫描); 存在堆上对象C,被栈上对象A引用,白色(未被扫描) 并且A.ref=C
• moment1:B建立对C的引用; • moment2:A删除对C的引用.
分析Golang GC, 次情景下如何保证不漏标,需要注意的是混合写屏障不适用栈对象
sequenceDiagram participant A as 栈对象A(白) participant B as 栈对象B(黑) participant C as 堆对象C(白) participant GC as GC扫描器 Note over A,C: 初始状态: A -> C B->>C: moment1: 建立引用 B -> C Note right of B: 栈操作,无写屏障 A->>A: moment2: 删除引用 A -> nil Note right of A: 栈操作,无写屏障 Note over B,C: 现在只有 B(黑) -> C(白) Note over B,C: B已扫描完,C仍为白色 GC->>GC: 标记终止阶段 GC->>B: 重新扫描B所在栈 GC->>C: 发现 B -> C 引用 C->>C: 标记为灰色/黑色 Note over C: C被保护,不会被回收
3 GC 优化
3.0.1 GOGC
:核心调优参数
GOGC
是控制 GC 频率最重要的环境变量。它的默认值是 100
。 它的含义是:当新分配的内存达到上次 GC 结束后存活内存的 GOGC
% 时,触发下一次 GC。
GOGC = 100
(默认): 意味着当堆内存增长到上一次的两倍时,触发 GC。这是一个在 CPU 开销和内存占用之间的平衡选择。GOGC < 100
: 会让 GC 更频繁地触发。这会增加 CPU 的消耗,但能更有效地控制内存占用,适合内存敏感的应用。GOGC > 100
: 会让 GC 不那么频繁。这会减少 CPU 的消耗,但会允许程序占用更多内存。GOGC = off
: 关闭 GC。极不推荐,除非是短时运行、内存占用明确的特殊程序。
你也可以在代码中通过 debug.SetGCPercent()
来动态设置。
3.0.2 GOMEMLIMIT
:软内存限制
从 Go 1.19 开始,引入了 GOMEMLIMIT
环境变量。它提供了一个软内存限制。当程序的总内存使用接近这个限制时,GC 会被更积极地触发,以尝试将内存维持在这个限制之下。这对于在有严格内存限制的容器环境中运行 Go 程序非常有用。
3.0.3 优化方法
- 内存分配优化:最高效的 GC 就是不发生 GC。通过优化代码,减少不必要的临时对象分配,是性能调优的第一步。
- 可以使用
sync.Pool
来复用对象。 - 尽量预分配内存,减少动态扩容
- 可以使用
- 避免指针:减少指针数量可降低GC扫描成本,GC 只关心指针指向的对象。如果你的数据结构中不包含指针(或
slice
,map
,chan
等),GC 的扫描工作量就会大大减少。 - 合理设置
GOGC
:通过性能分析(pprof),观察应用的 GC 行为和内存增长情况,调整GOGC
以在 CPU 和内存之间找到最佳平衡点。