引言
Go 的垃圾回收(GC)是语言运行时的核心部件,直接影响程序的性能和内存使用。它的设计目标是低延迟、高吞吐,同时尽量简单易用。从早期的 Mark-and-Sweep 到现在的三色标记法,Go 的 GC 一直在演进,平衡着并发执行和内存回收的需求。
这篇文章会从底层讲起,拆解 Mark-and-Sweep 和三色标记法的工作原理,再聊聊怎么优化内存使用。内容会尽量深入,但用大白话讲明白,方便理解和上手。
Mark-and-Sweep:最基础的垃圾回收
Mark-and-Sweep(标记-清扫)是 GC 的经典算法,Go 早期(1.3 之前)用的是它的一个变种。原理很简单,分两步走:
-
标记(Mark)
从“根对象”(比如全局变量、栈上的指针)出发,遍历所有能访问到的对象,把它们标记为“活的”。没标记上的就是“死的”,也就是垃圾。 -
清扫(Sweep)
扫描整个堆,把没标记的对象回收,释放内存。
怎么实现的
在 Go 里,运行时有个内存分配器,管理的堆内存分成一块块(叫 span)。标记时,GC 从根开始,顺着指针走,标记每个 span 里的活对象。清扫时,直接遍历所有 span,把没标记的内存放回空闲列表。
问题在哪儿
- STW(Stop The World):标记和清扫时,程序得停下来等着,延迟高得让人头疼。早期 Go 的 GC 一个大对象多的程序,暂停能到秒级。
- 效率低:标记要遍历所有对象,清扫要扫全堆,堆越大越慢。
Go 1.3 那会儿,Mark-and-Sweep 已经跟不上并发程序的需求了,得升级。
三色标记法:现代 Go 的 GC 核心
从 Go 1.5 开始,引入了三色标记法,结合并发和增量回收,极大降低了延迟。它是 Mark-and-Sweep 的进化版,核心是把对象分成三种“颜色”:
- 白色:还没检查的对象,可能是垃圾。
- 灰色:活的,但还有指针没检查完。
- 黑色:活的,所有指针都检查过了。
工作流程
-
初始化
所有对象先标成白色,根对象(栈、全局变量里的指针)标成灰色。 -
标记过程
- 挑一个灰色对象,检查它指向的所有对象。
- 把这些被指向的对象从白色变成灰色,自己变成黑色。
- 重复,直到灰色对象没了。
-
清扫
剩下的白色对象就是垃圾,直接回收。
并发怎么搞
三色标记法厉害的地方是它能跟程序并发跑,不用完全停下来:
- 标记并发:Go 1.5 开始,标记阶段由 GC 线程和应用程序线程一起干。应用程序跑时,GC 在后台标记,遇到灰色对象就推给 GC 线程。
- 写屏障(Write Barrier):程序跑着可能会新建对象或改指针。为了不漏标记,写屏障会在指针变动时把相关对象标灰,确保 GC 能找到。
- STW 优化:只有初始化(标记根)和标记结束(检查漏网之鱼)需要短暂 STW,其他时间程序照跑。
从源码看细节
在 runtime/mgc.go 里,gcDrain 函数负责标记,gcSweep 负责清扫。写屏障的实现(runtime.writebarrierptr)保证指针更新时不会丢活对象。整个过程靠 gcController 调度,动态调整 GC 的工作量。
三色标记的好处
- 低延迟:Go 1.8 后,STW 时间压到毫秒级甚至更低。
- 并发性:程序和 GC 一起跑,吞吐量不崩。
但也有代价:写屏障有开销,GC 频率高了可能影响内存使用效率。
内存优化技巧
理解了 GC 原理,优化内存就更有底了。下面是几招实用的:
1. 减少分配,复用对象
每次分配内存都会增加 GC 的负担。能复用就别老是 new 或 make。
go
代码解读
复制代码
// 坏例子:每次都分配 func process() []int { return make([]int, 100) } // 好例子:复用缓冲区 var buf = make([]int, 0, 100) func process() []int { buf = buf[:0] // 清空但保留容量 for i := 0; i < 100; i++ { buf = append(buf, i) } return buf }
效果:少分配,GC 扫描的堆变小,标记和清扫都快。
2. 控制指针使用
指针多了,GC 标记时要追的引用就多,耗时长。能用值就别用指针。
go
代码解读
复制代码
// 指针多 type Node struct { Next *Node Val int } // 值少指针 type Node struct { Next Node // 嵌套值 Val int }
效果:减少指针,标记路径变短,GC 负担轻。
3. 小对象合并
小对象太多,分配和回收开销大。可以把它们塞进结构体。
go
代码解读
复制代码
// 坏例子:一堆小对象 for i := 0; i < 1000; i++ { ch <- &Data{val: i} } // 好例子:合并成大块 type Batch struct { Vals [1000]int } ch <- &Batch{...}
效果:减少 span 数量,GC 扫描更快。
4. 调整 GOGC
GOGC 控制 GC 触发频率,默认 100(堆增长 100% 时触发)。延迟敏感的程序可以调低(比如 50),内存敏感的可以调高(比如 200)。
go
代码解读
复制代码
import "runtime/debug" func main() { debug.SetGCPercent(50) // 更频繁 GC,延迟更低 // ... }
效果:根据场景平衡内存和性能。
5. 用 pprof 找问题
GC 优化离不开数据。用 pprof 看堆分配和 GC 统计,找到内存热点。
bash
代码解读
复制代码
go tool pprof http://localhost:6060/debug/pprof/heap
效果:精准定位,优化有的放矢。
总结
Go 的 GC 从 Mark-and-Sweep 进化到三色标记法,核心是降低延迟、支持并发。Mark-and-Sweep 简单但 STW 太狠,三色标记用颜色分状态、写屏障保安全,把暂停时间压到最低。
优化内存的关键是少分配、控指针、合并对象,再加上 GOGC 和 pprof 的辅助。写代码时多想想 GC 的感受,别光顾着功能,性能自然就上来了。Go 的 GC 设计挺实在,理解了这些,内存管理就不慌了。
750

被折叠的 条评论
为什么被折叠?



