3678 words
18 minutes
Go语言相关知识

Go的垃圾回收机制#

一、这部分笔记的大纲#

  1. 为什么程序需要垃圾回收(GC)
  2. Go 为什么选择自动内存管理
  3. Go 的内存分区基础:栈、堆、全局区
  4. 栈上分配和堆上分配:逃逸分析的作用
  5. Go GC 的核心思路:可达性分析 + 标记清扫
  6. 三色标记法到底在解决什么问题
  7. 并发 GC、STW(Stop The World)与写屏障
  8. Go 为什么不采用传统分代 GC
  9. Go GC 的整体执行流程
  10. 结合伪代码理解一次完整回收
  11. Go 这套设计的优点、代价与适用场景

二、为什么需要垃圾回收#

程序运行时会不断申请内存。如果一块内存已经没有任何变量再使用它,但程序又没有把它释放掉,这块内存就会一直占着空间,这就是“垃圾”。

如果没有垃圾回收,主要会出现两个问题:

  • 内存泄漏:不用的对象还留在内存里,越积越多
  • 管理复杂:程序员需要手动 malloc/freenew/delete,很容易释放早了、释放晚了,甚至重复释放

从语言设计角度看,内存管理本质上是在平衡三件事:

  • 性能
  • 易用性
  • 安全性

C/C++ 更偏向性能和控制权,所以交给程序员自己管理。Go 更强调工程效率、并发编程体验和服务端稳定性,因此选择自动垃圾回收。

三、Go 为什么选择自动内存管理#

Go 主要面向的是服务器程序、网络服务、中间件、云原生基础设施。这类程序的特点是:

  • 生命周期长,不是“运行一下就退出”
  • 并发多,协程数量大
  • 对延迟敏感,但也要求开发效率
  • 代码维护者很多,出错成本高

如果仍然使用手动内存管理,会出现两个明显问题:

  1. 开发心智负担过高
    并发场景下,一个对象可能被多个 goroutine 间接引用,谁来释放、什么时候释放,判断非常麻烦。

  2. 稳定性风险高
    手动释放容易导致悬挂指针、野指针、双重释放等问题,而这些问题在服务端通常很难排查。

所以 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
}

这里 xy 一般都可以放在栈上,因为它们的生命周期只在当前函数内。

这体现了 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 扫描出来的结果可能不一致。

这就引出了三色标记和写屏障。

九、三色标记法在解决什么问题#

三色标记是为了在“程序继续运行”的同时,仍然能正确完成标记。

可以把对象分成三类:

  • 白色:还没被扫描到,默认认为可能是垃圾
  • 灰色:已经发现这个对象了,但它引用的子对象还没扫描完
  • 黑色:这个对象和它的子对象都扫描处理过了

初始时:

  • 根对象先变灰
  • 其余对象先看作白色

处理过程:

  1. 取出一个灰色对象
  2. 扫描它指向的所有对象
  3. 白色子对象变成灰色
  4. 当前对象自己变成黑色

直到没有灰色对象,标记阶段结束。此时仍然是白色的对象,就是不可达对象。

伪代码如下:

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 还没被扫描,还是白色。

这时用户程序做了两件事:

  1. A -> B 这条引用删掉
  2. 又把某个已扫描对象到 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,建议按下面这条主线理解:

  1. 先理解为什么要区分栈和堆
  2. 再理解逃逸分析为什么能减少 GC 压力
  3. 再理解 GC Root 和可达性分析
  4. 再理解标记清扫为什么需要三色标记
  5. 最后理解写屏障为什么让并发 GC 成为可能

真正把这条链路串起来之后,你会发现 Go GC 的核心不是某个单独算法名词,而是一整套相互配合的设计:

  • 编译器尽量把对象留在栈上
  • 运行时负责堆对象的并发回收
  • 写屏障保证并发标记正确
  • STW 只保留在必要的同步点

十七、一句话总结#

Go 的垃圾回收设计思想可以概括为:

先通过逃逸分析减少进入堆的对象,再对堆对象使用并发三色标记清扫,在保证正确性的前提下尽量降低 STW 和程序员的心智负担。

Go语言相关知识
https://jinliye.github.io/Blog/posts/go/pms-project/
Author
JinLiye
Published at
2026-04-09
License
CC BY-NC-SA 4.0