垃圾回收(GC)到底在干什么——从零开始讲清楚
这份文档从"为什么需要GC"讲起,一步步搭到"为什么并发GC需要写屏障和短暂STW",尽量把每个术语在第一次出现时就解释清楚。
1. 内存管理要解决的问题
程序运行时会不断申请内存(比如 new 一个对象、append 一个切片)。申请之后,早晚有一天这块内存不再需要了,就应该被收回,否则内存会一直涨,最终把机器耗尽。
回收内存的方式,历史上只有两条路:
手动管理(C/C++ 的
malloc/free):程序员自己决定什么时候释放。问题很直接——忘了释放 → 内存泄漏;释放早了 → 其他地方还在用这块内存,程序直接崩溃或读到脏数据(这类 bug 叫 use-after-free,是很多安全漏洞的根源)。自动管理(Go、Java、Python、JS……):语言运行时自己判断哪些内存还在用、哪些可以收回。这个"自动判断"的机制,就是 GC(Garbage Collector,垃圾回收器)。
GC 要解决的核心问题只有一个:在程序运行的过程中,怎么知道一块内存现在还有没有人在用?
2. GC 怎么判断"这块内存还有没有人用"
严格来说,"这个对象以后还会不会被用到"这个问题,在数学上是无法精确判定的(跟停机问题是一类问题)。所以所有实用的 GC 都退而求其次,换了一个可以被严格证明、计算代价也可控的替代问题:
不问"这个对象以后有没有用",只问"从程序当前能直接访问到的地方出发,沿着指针能不能走到这个对象"。
能走到,就叫可达(reachable),判定为存活;走不到,就判定为垃圾,可以回收。这个概念叫可达性分析(reachability analysis),是几乎所有追踪式 GC(Go、Java、C#、Python 用的都是这一类)共同的理论基础。
"程序当前能直接访问到的地方"叫根(roots),具体包括:
- 所有 goroutine 栈上的局部变量
- 全局变量
- CPU 寄存器里当前正持有的指针
下面这张图展示了可达性分析的基本样子:根对象出发,能顺着指针链条走到的 A、B、C 判定为存活;而 D 虽然也占着一块内存,但没有任何路径能从根走到它,所以判定为垃圾:
打个比方:这就像你搬家时清点东西——从"家门口"(根)出发,能摸得到的箱子都留下;至于某个箱子里的东西以后到底用不用得上,没法预知,但如果这个箱子已经完全脱离了家(没有任何路径能碰到它),那不管里面东西多好,都只能当成垃圾扔掉,因为你已经够不着它了。
这里有个不对称性很重要:GC 宁可"错判一点垃圾没收干净"(东西早就没用了但恰好还挂在某个引用链上),也绝对不能"错判一个存活对象为垃圾"(明明还够得着,却被当垃圾清掉,会直接导致程序崩溃)。后面第 5 节的"写屏障",本质上就是在保这条底线。
3. 最朴素的实现:标记-清除(Mark-Sweep)
有了"可达性"这个判定标准,最直接的实现方式分两步:
- 标记(Mark):从所有根出发,做一次图遍历(类似 BFS/DFS),把能走到的对象都打上"存活"标记。
- 清除(Sweep):把整个堆扫一遍,没被标记的对象,内存直接收回。
最朴素的版本会怎么做?——先把整个程序冻结住(不许任何 goroutine 继续跑,也就是 Stop The World,STW),保证标记的时候指针关系不会变,标记完、清除完,再放开程序继续跑。
这也是"GC = 卡顿"这个刻板印象的来源——早期的 GC(包括 Go 1.5 之前)确实是这么干的,堆越大、对象越多,标记遍历越久,STW 时间就越长,服务会明显卡一下。
4. 为什么现代 GC 不想一直 STW
对于跑在服务端、需要低延迟响应的程序(典型如 HTTP 服务),"每隔几秒钟卡住所有请求几十毫秒甚至几百毫秒"是完全不能接受的。所以 Go、Java 等语言这些年做的核心优化方向都是同一件事:能不能让 GC 的标记过程和程序的正常执行"同时"进行,而不是先停下来再标记?
这就是并发标记(concurrent mark)——GC 的标记线程和你的业务 goroutine 一起跑,谁也不等谁。
但这样做马上会引出一个新问题:标记的时候,程序还在改指针,会不会把一个"其实还活着"的对象漏标,误当成垃圾清掉?
答案是:会,如果什么都不做的话。
5. 并发标记的陷阱:对象丢失问题
要理解这个陷阱,需要先引入 三色标记法(tri-color marking)——它是把"标记"这个遍历过程拆成状态机的标准做法,把每个对象标成三种颜色之一:
- 白色:还没被发现可达(初始状态,遍历结束时仍是白色 = 判定为垃圾)
- 灰色:已经发现可达,但它自己指向的对象还没检查完(在待处理队列里)
- 黑色:已经发现可达,而且它指向的所有对象也都已经扔进了待处理队列
标记过程就是不断从灰色队列里取对象、检查它指向了谁、把新发现的对象染灰、自己变黑,直到灰色队列空了为止——队列空了,就意味着"从根出发能走到的对象,全都已经是黑色",遍历自然结束。
问题出在这里:如果标记进行到一半,业务代码(这里专门给它起个名字,叫 mutator,也就是会修改对象图的程序本体,跟负责标记的 collector 相对)同时做了两件事:
- 把一个已经扫描完的黑色对象,直接指向一个还没被碰过的白色对象(新增一条引用);
- 与此同时,把原来那条能发现这个白色对象的灰色路径给删掉了。
GC 不会再回头重新扫描已经变黑的对象,而原来那条能发现它的灰色路径又被删了——于是这个白色对象在这一轮标记里彻底"消失",即使它其实还活着,也会在 Sweep 阶段被当垃圾清掉。这就是并发 GC 理论里经典的 lost object(对象丢失)问题,本质是 mutator 和 collector 同时读写同一张对象图产生的竞态,不是"扫描得再快一点"能解决的。
6. 解决方案:写屏障(Write Barrier)
Go(以及绝大多数并发/增量 GC)用的解法是写屏障:在标记阶段开启之后,每一次指针写入(ptr.field = obj)都会被运行时拦截一下,顺带做点额外的事,防止上面那种丢失发生。Go 1.8 之后用的是"混合写屏障"(插入屏障+删除屏障的组合),效果可以简化理解成:只要有指针写入涉及到标记还没完成的对象,就顺手把相关对象染灰,保证它不会被漏检。
有了写屏障,第 5 节里那种"黑色对象直接指向白色对象、又没有其他路径能发现它"的情况就不会再发生——因为这次写入本身就会被拦截,顺手把白色对象染灰,它照样会被扫描到。
代价是什么? 每一次指针写入都多了一点点额外开销(判断要不要染色、要不要入队),这是用"写入变慢一点点"换来"标记阶段可以和程序并发运行、不用整体停下来",在绝大多数场景下这笔交易是划算的。
7. 即便有写屏障,为什么还要留两次短暂 STW
写屏障解决了标记过程中的竞态问题,但整个 GC 周期里,Go 仍然保留了两个必须暂停所有 goroutine 的极短时刻:
- Sweep Termination(标记开始前):需要把"写屏障"这个开关对所有 goroutine 原子性地统一打开——不能有的 goroutine 已经开了、有的还没开,这个切换瞬间必须全局一致,否则前面讲的竞态照样会发生。
- Mark Termination(标记结束时):做最后的确认——检查灰色队列是不是真的空了、有没有在临界窗口里溜进来的新工作,这个"收尾清点"也需要一个短暂的、一致的快照。
除了这两处,标记和清除的主体都是并发进行的,跟业务代码同时跑。完整的一轮 GC 周期长这样:
两段红色的 STW 通常在亚毫秒级,而且耗时主要跟当前 goroutine 数量有关,跟堆里对象数量、堆大小基本无关——这也是 Go GC 这些年一直在优化收窄的部分。
顺带澄清一个常见误解:并不是所有 GC 实现都必须留这两次 STW。业界确实存在完全零 STW 的方案(比如 Java 的 ZGC/Shenandoah、Azul 的 C4),它们用"染色指针 + 读屏障"把同步开销分摊到每一次指针读取上,彻底消灭了全局暂停。Go 选择"写屏障 + 两次极短 STW"这条路,是因为写屏障只在指针写入时触发(比读屏障便宜得多),日常运行的开销更低——这是工程取舍,不是"GC 理论上必须整体暂停"。
8. 回到 Go 的实际设计
结合前面的原理,Go GC 的几个特点就比较好理解了:
- 并发、非分代、非移动式:跟 Java 常见的分代 GC(新生代/老年代分开回收、还会挪动对象压缩内存)不同,Go 的对象一旦分配,内存地址就不会再变。这是因为 Go 允许指针直接指向堆内任意位置(cgo、unsafe.Pointer 等场景更是要求地址稳定),如果 GC 要挪动对象、还要在并发场景下同步更新所有指针,实现复杂度会陡增。代价是牺牲了分代 GC 在某些场景下的吞吐优势。
- 触发时机:由
GOGC(默认 100)控制——当前存活堆大小涨到相当于上一轮 GC 结束时的 2 倍左右就触发下一轮;Go 1.19 之后还可以用GOMEMLIMIT设置一个硬性的内存上限兜底。 - Pacer(步调控制器):自适应调节并发标记的 CPU 占用节奏(默认让标记工作占用大约 25% 的 CPU),目标是让 STW 尽量短、标记速度能跟上分配速度,避免堆无限膨胀。
9. 呼应最初的问题:GC 压力和 sync.Pool
回到我们最早聊的话题——new(gin.Context) 单次调用本身很便宜(前面实测大约几十纳秒),真正的成本在于:HTTP 服务器每秒处理成千上万次请求,意味着每秒都在往堆上扔新对象,这些对象迟早都要经历一遍上面讲的"标记-清除"全过程。分配得越多,并发标记阶段要追的"新增对象"就越多,Pacer 就得让标记跑得更勤、占用更多 CPU;而 sync.Pool 复用对象,相当于直接把这部分分配需求砍掉了,是从源头减少 GC 的工作量,而不是让单次分配变快。
附:常见误解速查
| 误解 | 更准确的说法 |
|---|---|
| GC 一定会把整个程序完全停住 | 现代并发 GC(包括 Go)只在标记开始/结束两个瞬间短暂 STW,其余时间和程序并发运行 |
| 白色对象 = 程序写错了 | 白色只是"遍历没找到通往它的路",绝大多数白色对象就是正常该被回收的垃圾 |
| GC 变慢 = 内存泄漏 | 更常见的原因是分配速率太高(短时间内 new 太多对象),不一定是"泄漏"(还被意外引用着回不去) |
new()/分配本身很"贵" |
单次分配是纳秒级的,真正的成本是高并发下大量分配的累积 GC 压力,而不是某一次分配很慢 |