Go的垃圾回收机制
一、这部分笔记的大纲
- 为什么程序需要垃圾回收(GC)
- Go 为什么选择自动内存管理
- Go 的内存分区基础:栈、堆、全局区
- 栈上分配和堆上分配:逃逸分析的作用
- Go GC 的核心思路:可达性分析 + 标记清扫
- 三色标记法到底在解决什么问题
- 并发 GC、STW(Stop The World)与写屏障
- Go 为什么不采用传统分代 GC
- Go GC 的整体执行流程
- 结合伪代码理解一次完整回收
- Go 这套设计的优点、代价与适用场景
二、为什么需要垃圾回收
程序运行时会不断申请内存。如果一块内存已经没有任何变量再使用它,但程序又没有把它释放掉,这块内存就会一直占着空间,这就是“垃圾”。
如果没有垃圾回收,主要会出现两个问题:
- 内存泄漏:不用的对象还留在内存里,越积越多
- 管理复杂:程序员需要手动
malloc/free或new/delete,很容易释放早了、释放晚了,甚至重复释放
从语言设计角度看,内存管理本质上是在平衡三件事:
- 性能
- 易用性
- 安全性
C/C++ 更偏向性能和控制权,所以交给程序员自己管理。Go 更强调工程效率、并发编程体验和服务端稳定性,因此选择自动垃圾回收。
三、Go 为什么选择自动内存管理
Go 主要面向的是服务器程序、网络服务、中间件、云原生基础设施。这类程序的特点是:
- 生命周期长,不是“运行一下就退出”
- 并发多,协程数量大
- 对延迟敏感,但也要求开发效率
- 代码维护者很多,出错成本高
如果仍然使用手动内存管理,会出现两个明显问题:
-
开发心智负担过高
并发场景下,一个对象可能被多个 goroutine 间接引用,谁来释放、什么时候释放,判断非常麻烦。 -
稳定性风险高
手动释放容易导致悬挂指针、野指针、双重释放等问题,而这些问题在服务端通常很难排查。
所以 Go 的设计选择不是“追求绝对最快”,而是“在足够高性能的前提下,把内存安全和开发效率做成默认能力”。
四、Go 的内存分区:栈、堆、全局区
理解 GC 前,先要知道对象可能放在哪里。
1. 栈(stack)
栈一般存放:
- 函数参数
- 局部变量
- 返回地址
- 调用帧相关信息
栈的特点是:
- 分配和释放极快
- 生命周期跟函数调用强绑定
- 不需要 GC 单独回收
函数返回后,这一帧栈空间整体失效,所以栈内存的管理成本非常低。
2. 堆(heap)
堆一般存放:
- 生命周期超过当前函数的对象
- 大对象
- 不能确定何时释放的对象
- 被多个地方共享引用的对象
堆的特点是:
- 生命周期不规则
- 分配比栈复杂
- 必须靠 GC 判断“谁还活着,谁已经死了”
3. 全局区 / 静态数据区
这里通常存放:
- 全局变量
- 常量相关数据
- 程序运行期间长期存在的数据
这部分一般也会作为 GC 的根对象来源之一。
五、Go 如何决定对象放栈上还是堆上
这一步靠的是逃逸分析(escape analysis)。
核心判断可以简单理解为:
- 如果一个对象只在当前函数内部使用,且不会在函数返回后继续被引用,那么尽量放栈上
- 如果一个对象会“逃出”当前函数作用域,那么放到堆上
例如:
func f() *int { x := 10 return &x}这里 x 不能放在栈上,因为函数返回后外部还要继续使用它,所以它会逃逸到堆上。
再看一个不逃逸的例子:
func sum() int { x := 10 y := 20 return x + y}这里 x 和 y 一般都可以放在栈上,因为它们的生命周期只在当前函数内。
这体现了 Go 的一个重要设计思想:
能不用 GC 的地方,尽量不要让 GC 介入。
也就是说,Go 并不是“所有对象都扔给垃圾回收器”,而是先用编译器尽量把对象留在栈上,把 GC 压力降下来。
六、Go GC 的核心目标
GC 的本质问题只有一句话:
如何找出“还活着的对象”,然后回收其余对象。
Go 主要采用的是:
- 可达性分析(reachability analysis)
- 标记清扫(mark-sweep)
- 三色标记(tri-color marking)
- 并发回收(concurrent GC)
它不是简单的“引用计数”。
因为引用计数虽然直观,但有明显问题:
- 循环引用难处理
- 每次赋值都要更新计数,运行期开销大
Go 选择的是从一组“根对象”出发,沿着引用关系向下遍历:
- 能走到的对象,说明还活着
- 走不到的对象,说明已经不可达,可以回收
七、什么是 GC Root
GC 不是从整个内存胡乱扫描,而是从一批根对象开始。
常见根对象包括:
- 当前 goroutine 栈上的活动变量
- 全局变量
- 寄存器中的引用
- 运行时自己维护的重要对象
这些根对象相当于“已知还活着的起点”。
从这些起点出发,所有能顺着指针找到的对象都算存活对象。
八、Go 的基本回收算法:标记清扫
最直观的思路分两步:
1. 标记(Mark)
从 GC Root 开始遍历对象图,把所有还能到达的对象打上“存活”标记。
2. 清扫(Sweep)
遍历堆,把没有被标记的对象回收掉,把空间重新挂回空闲链表或者归还给分配器使用。
伪代码可以写成:
mark(rootSet): worklist = rootSet while worklist not empty: obj = worklist.pop() if obj.marked: continue obj.marked = true for each child in obj.references: worklist.push(child)
sweep(heap): for each obj in heap: if obj.marked: obj.marked = false else: free(obj)这个思路已经能工作,但有一个现实问题:
如果程序一边运行,一边修改对象引用关系,那 GC 扫描出来的结果可能不一致。
这就引出了三色标记和写屏障。
九、三色标记法在解决什么问题
三色标记是为了在“程序继续运行”的同时,仍然能正确完成标记。
可以把对象分成三类:
- 白色:还没被扫描到,默认认为可能是垃圾
- 灰色:已经发现这个对象了,但它引用的子对象还没扫描完
- 黑色:这个对象和它的子对象都扫描处理过了
初始时:
- 根对象先变灰
- 其余对象先看作白色
处理过程:
- 取出一个灰色对象
- 扫描它指向的所有对象
- 白色子对象变成灰色
- 当前对象自己变成黑色
直到没有灰色对象,标记阶段结束。此时仍然是白色的对象,就是不可达对象。
伪代码如下:
init: for obj in heap: obj.color = white for root in roots: shade(root) // root -> gray
mark: while grayQueue not empty: obj = grayQueue.pop() for child in obj.references: if child.color == white: shade(child) obj.color = black其中:
shade(obj): if obj.color == white: obj.color = gray grayQueue.push(obj)十、为什么并发标记会出错
假设 GC 正在执行,某个对象 A 已经被扫描完,变成黑色;另一个对象 B 还没被扫描,还是白色。
这时用户程序做了两件事:
- 把
A -> B这条引用删掉 - 又把某个已扫描对象到
B的唯一可达路径打断
如果 GC 没注意到这个变化,B 可能仍然是白色,最后被错误回收,但程序其实后面还可能会用到它。
更抽象地说,并发标记最怕破坏这个不变量:
黑色对象不能直接指向白色对象。
一旦出现“黑指向白”,就可能漏标。
十一、Go 如何解决并发标记问题:写屏障
写屏障(write barrier)本质上是在“指针写入”这一刻,顺手通知 GC。
例如程序执行:
p.next = q在普通赋值之外,运行时会额外做一些标记辅助逻辑,避免 q 被漏掉。
可以用简化伪代码表示:
writePointer(slot, newObj): if gcMarking: shade(newObj) *slot = newObj真实实现更复杂,但核心思想就是:
- 用户程序改引用关系时
- GC 也同步得到信息
- 从而维持三色标记的正确性
Go 使用写屏障的目的,不是让 GC 更“聪明”,而是让 GC 可以和用户程序并发运行,同时把 STW 时间压缩到很短。
十二、STW 是什么,Go 为什么还需要它
STW(Stop The World)就是暂停用户程序,让 GC 在一个相对稳定的状态下做某些关键操作。
很多人会误以为“并发 GC 就完全不会暂停程序”,这不对。更准确的说法是:
Go 的目标不是消灭暂停,而是把暂停压缩到尽可能短。
Go 仍然会有一些短暂停顿,常见用途包括:
- 启动标记阶段前做状态切换
- 重新扫描某些根对象
- 结束标记时完成收尾同步
设计思想很明确:
- 大部分重活并发做
- 少量必须一致性的步骤用短 STW 完成
这是一种非常工程化的取舍。
十三、Go 为什么不强依赖传统分代 GC
很多语言会使用“分代 GC”:
- 新生代:对象大多朝生夕死,频繁回收
- 老年代:存活更久,较少回收
这是因为大量应用符合“多数对象很快死亡”的经验规律。
但 Go 长期没有把传统分代 GC 作为核心方案,背后有几层现实考虑:
1. Go 的对象分配速度本来就很快
Go 运行时对小对象分配做了大量优化,很多短命对象即使频繁创建,成本也未必高到需要典型分代结构。
2. Go 强调低延迟
分代 GC 往往意味着更多屏障、更复杂的代际管理和对象晋升逻辑。Go 更希望先把并发标记清扫做扎实,把暂停时间控制好。
3. Go 有大量栈对象和短生命周期协程
很多临时数据压根不会进堆,而是通过逃逸分析留在栈上。这样一来,堆上的“年轻垃圾”压力本身就被削弱了。
所以 Go 的路线可以理解为:
- 先减少必须进入 GC 的对象数量
- 再把堆回收设计成并发、低暂停
- 用简单且稳定的模型服务工程实践
十四、Go GC 的完整流程可以怎么理解
可以把一次 GC 粗略理解成下面几个阶段:
1. 准备阶段
- 打开 GC 周期
- 开启写屏障
- 进行短暂 STW,确保状态切换一致
2. 并发标记阶段
- 从根对象出发扫描
- 工作线程不断处理灰色对象
- 用户程序继续运行
- 新产生的引用变更由写屏障辅助修正
3. 标记终止阶段
- 再次短暂 STW
- 处理剩余必须同步完成的工作
- 确认标记结束
4. 清扫阶段
- 回收未标记对象
- 把可复用内存交还分配器
- 为下一轮分配服务
如果写成更完整一点的伪代码:
gcCycle(): stopTheWorld() enableWriteBarrier() scanRootsAndShade() startWorld()
while grayQueue not empty: obj = grayQueue.pop() scan(obj)
stopTheWorld() flushRemainingWork() disableWriteBarrierWhenSafe() startWorld()
sweepUnmarkedObjects()其中 scan(obj) 可以进一步理解为:
scan(obj): for each child in obj.references: shade(child) obj.color = black十五、从工程视角理解 Go GC 的设计思想
Go 的 GC 不是“理论上最优”,而是“工程上足够稳、足够快、足够简单”。
可以总结成几条主线:
1. 优先减少 GC 必须处理的对象
通过逃逸分析,让尽可能多的对象留在栈上。
也就是说,Go 不是先想着“怎么把垃圾回收做得更猛”,而是先想着“能不能别产生那么多需要 GC 的堆对象”。
2. 堆对象一旦需要回收,就用可达性分析保证正确性
不靠程序员手动释放,也不依赖脆弱的引用计数,而是从根出发扫描对象图。
3. 采用三色标记,是为了支持并发
并发标记的关键不是“边扫边跑”这么简单,而是必须维持正确性。三色模型让运行时有办法描述对象状态,并配合写屏障修复并发期间的引用变化。
4. 接受少量 STW,但严格压缩它
Go 并不追求“零暂停神话”,而是承认有些阶段必须全局同步,然后把暂停控制在很短时间。
5. 选择非移动、低侵入、可预测的方案
Go 的堆对象通常不会像某些压缩型 GC 那样频繁搬家。这让运行时、指针语义、与底层系统交互的复杂度更容易控制。
这也是 Go 很典型的风格:
- 不追求最花哨
- 先保证行为稳定
- 再在实现细节上不断优化
十六、学习这部分时应该抓住什么主线
如果你现在是第一次系统学 Go GC,建议按下面这条主线理解:
- 先理解为什么要区分栈和堆
- 再理解逃逸分析为什么能减少 GC 压力
- 再理解 GC Root 和可达性分析
- 再理解标记清扫为什么需要三色标记
- 最后理解写屏障为什么让并发 GC 成为可能
真正把这条链路串起来之后,你会发现 Go GC 的核心不是某个单独算法名词,而是一整套相互配合的设计:
- 编译器尽量把对象留在栈上
- 运行时负责堆对象的并发回收
- 写屏障保证并发标记正确
- STW 只保留在必要的同步点
十七、一句话总结
Go 的垃圾回收设计思想可以概括为:
先通过逃逸分析减少进入堆的对象,再对堆对象使用并发三色标记清扫,在保证正确性的前提下尽量降低 STW 和程序员的心智负担。