Go 垃圾回收
垃圾回收(Garbage Collection,GC)自动找出不再需要保留的堆对象,并回收它们占用的存储。Go 1.22.12 使用并发标记清扫:大部分标记和清扫工作可以与业务代码一起推进,但阶段切换仍需要暂停业务执行。
并发回收的关键问题,是业务代码还在修改引用时,怎样避免漏掉存活对象。三色标记描述扫描进度,写屏障维护引用变化,暂停则帮助运行时统一切换状态;这些机制也决定了 CPU 与内存之间的取舍。对象怎样获得存储空间见 Go 内存分配;本文按 Go 1.22.12 解释回收过程。
1. 面试核心
先记住这几条,再读正文- GC 判断的是对象是否可达,不是业务上是否有用。根包括 goroutine 栈中需要保留的引用、全局变量中的引用和运行时持有的引用;孤立循环引用可以回收,仍被缓存或 goroutine 引用的对象不能仅因业务不用了就回收。卡 · 可达性
- 白、灰、黑描述本轮标记的进度:白色尚未发现,灰色已发现但引用尚未扫描完,黑色已扫描完。标记进行到一半时,白色不等于垃圾。卡 · 三种颜色
- 写屏障保护并发标记期间的引用变化。Go 1.22.12 的混合写屏障兼顾被覆盖的旧引用和写入的新引用,防止业务把对象移到标记器已经检查过的位置后造成漏标。卡 · 混合写屏障
- 并发 GC 仍有 STW,也会消耗业务执行所需的 CPU。开始标记前要统一开启屏障等状态,标记结束时要完成终止与阶段切换;分配较快的 goroutine 还可能承担辅助标记。卡 · 可达性
- GOGC 主要影响堆增长目标,触发阈值与完成目标不是同一个数。运行时会提前启动标记,为并发执行预留空间,还支持时间条件和显式
runtime.GC()。卡 · GOGC - GOMEMLIMIT 是 Go 运行时管理内存的软限制,不是进程 RSS 或容器内存硬上限。它仍可在
GOGC=off时推动 GC;存活对象本身已经超限时,GC 不能把它们强行回收。卡 · GOMEMLIMIT - 比较 Go 与 Java GC,需要指定实现。Go 1.22.12 不分代,也不通过移动堆对象整理空间;Java 可以选择不同收集器,不能概括成一种算法或固定的停顿表现。卡 · 与 Java GC 比较
2. 可达性
2.1 从根出发
根是 GC 查找对象的起点。运行时先扫描需要保留的栈引用、全局引用和自身维护的引用,再沿对象中的指针继续查找。只要还有一条有效路径从根到达某个对象,它就需要被保留。
这与统计引用次数不同。两个对象互相引用,只要整个环已经无法从根到达,就不需要仅因为“彼此还有一个引用”而继续保留。
package main
import (
"fmt"
"runtime"
)
type Node struct{ next *Node }
var root *Node
func makeCycle() {
left, right := new(Node), new(Node)
left.next, right.next = right, left
root = left
}
func main() {
makeCycle()
runtime.GC()
fmt.Println(root.next.next == root)
root = nil
runtime.GC()
}
程序输出 true。第一次 GC 时,root 仍指向这个环,两次访问 next 能回到同一个对象。把 root 设为 nil 后,如果没有其他引用,环中的两个对象就满足回收条件。nil 赋值本身只是在解除这一条引用,不会像手动释放函数一样立即释放对象。
这里讨论的是没有 finalizer 等特殊处理的普通对象。也不要把变量的源码作用域当作精确的保留期限:编译器会提供活跃引用信息,不再使用的局部变量未必一直保留对象到右花括号。需要向运行时明确延长某个值的存活期时有 runtime.KeepAlive,但它不能代替锁、资源关闭或业务生命周期管理。
2.2 GC 解决不了哪些保留问题
GC 无法判断一个仍然可达的对象是否已经失去业务价值。过期条目没有从全局 map 删除,等待中的 goroutine 长期持有请求对象,或小切片一直引用大数组,都会让对象继续存活。
因此,局部变量来自哪个函数,只是排查的起点。真正要找的是谁还持有它、这条引用何时解除。goroutine 的退出责任见 Go 并发诊断,底层数组的保留见 Go 切片。文件和网络连接还需要按契约显式关闭,不能把它们的及时释放交给 GC。
3. 标记与清扫
3.1 三种颜色
三色标记是一种描述对象遍历进度的模型。颜色属于当前这一轮回收,不是对象从出生到销毁都不变的属性,也不要求每个对象真的存放一个三值颜色字段。
| 颜色 | 当前含义 | 后续工作 |
|---|---|---|
| 白色 | 本轮尚未发现它需要保留 | 仍可能通过未扫描的引用找到它 |
| 灰色 | 已发现对象,但它的引用还需要处理 | 扫描对象中的指针,发现更多对象 |
| 黑色 | 对象中的相关引用已扫描完成 | 不需要仅因这次遍历再次扫描它 |
从根发现一个对象后,把它标记为需要保留,并安排扫描。扫描灰色对象时,把新发现的白色对象加入待处理工作;当前对象扫描完后成为黑色。Go 运行时通过标记位、工作队列和类型提供的指针信息完成这些工作。
只有当这一轮的根、对象和写屏障产生的标记工作都正确收尾,仍未标记的普通对象才可以进入回收范围。扫描还在继续时,一个白色对象可能只是“还没轮到”,不能直接删除。
3.2 一轮 GC 的阶段
STW(Stop The World)是运行时为统一状态而暂停普通 Go 代码执行的阶段。它不表示整个 GC 周期都停着,也不表示操作系统里所有进程和线程都停止。
| 阶段 | 主要工作 | 与业务的关系 |
|---|---|---|
| 清扫终止与标记准备 | 完成必要的上轮清扫,切换阶段,开启写屏障与辅助标记,准备根任务 | STW |
| 并发标记 | 扫描根和对象,处理写屏障产生的工作,判断是否还存在待处理任务 | 大部分工作与业务并发 |
| 标记终止 | 确认标记收尾,完成必要的运行时状态处理,准备进入清扫 | STW |
| 并发清扫 | 按 span 回收未标记对象,使槽位和页能够复用 | 后台与分配路径共同推进 |
开始标记前的暂停,保证所有相关执行资源都已经启用写屏障,再让标记和业务同时运行。否则标记器可能已经开始扫描,部分业务代码却还在无保护地修改指针。
并发标记也不是任何时候都完全不影响单个 goroutine。扫描一个 goroutine 的栈时,要先使它处于可安全扫描的状态,暂时停止它对这个栈的修改,再继续执行;这和把整个并发标记阶段都算作全局 STW 不同。
辅助标记(mutator assist)是分配内存的业务 goroutine 帮助 GC 完成一部分标记工作。分配持续推进、标记却跟不上时,不能无限允许堆继续增长,所以业务分配可能需要先偿还对应的标记工作量。这也是“STW 很短”不能直接推出“GC 对延迟没有影响”的原因。
清扫让空间重新可分配,与把空闲页归还操作系统是不同工作。后者由运行时的内存归还机制等路径处理。一次 GC 后进程 RSS 未立刻下降,并不能单独证明对象没有回收,相关指标见 Go 内存分配。
4. 混合写屏障
4.1 为什么并发修改引用会漏标
写屏障是在必要的指针写入周围,由编译器和运行时插入的额外处理,用来维护 GC 所需的信息。它保护的是“标记器与业务同时修改、查看对象图”这件事。
假设 A 已经扫描完,是黑色;B 已发现但还没扫描,是灰色;C 还没发现,是白色。最初 B.next 指向 C。
| 业务操作 | 如果完全没有写屏障,会发生什么 |
|---|---|
把 B.next 复制到 A.next | A 已扫描过,标记器不一定再沿 A.next 找到 C |
把 B.next 设为 nil | 原本还能通过灰色 B 找到 C 的路径消失 |
继续扫描 B | 看不到 C,但业务仍可通过 A.next 使用它 |
这说明不能把“已经扫描过”理解为“以后再也不会增加引用”。若没有配合引用变化的机制,C 可能被错误地当作垃圾。
4.2 旧引用与新引用
Go 1.22.12 的混合写屏障结合了删除屏障和插入屏障的思路。
- 删除屏障关注将被覆盖的旧引用:业务把对象从尚未扫描的堆位置移到已经扫描过的栈上,再删掉原引用时,旧引用仍会帮助 GC 发现它。
- 插入屏障关注写入的新引用:业务把尚未扫描栈上的对象引用放进已经扫描过的堆对象时,新引用仍会被记录。
源码中的概念模型是:覆盖一个指针槽位前,先对旧引用做标记保护;当前 goroutine 栈尚未完成扫描时,也保护新引用;随后才更新槽位。这里的保护是把需要处理的对象标记、加入相应工作,不是立即递归扫描完整对象图。
实际实现不必逐条照这个模型检查栈颜色。Go 1.22.12 通常把新旧指针写入屏障缓冲区,再批量处理;为了简化运行路径,可以保守地处理两个指针。编译器确认某些写入不需要屏障,或确认旧值为零等条件时,也可能省掉对应操作。不能把屏障伪代码当作每一次指针赋值都会执行的固定指令序列。
4.3 写屏障没有取消所有暂停
混合写屏障的一个作用,是让已经扫描过的栈不必仅为追回这类引用移动,在标记终止时统一重新扫描。它依赖整个标记过程的配合,包括标记期间新分配对象直接按黑色处理、根扫描和标记终止检查。
这不意味着整个 GC 没有 STW,也不意味着每轮都会把所有刚变成垃圾的对象立刻清掉。保守标记可能让一些本轮后半段已不可达的对象继续保留到后续周期。
对当前栈帧中能确定位置的写入,编译器可以省去写屏障;通过传入指针写入、不能证明目标只在当前栈帧的情况,则不能一概说“栈写入都没有屏障”。全局变量写入堆指针也需要相应保护。
GC 写屏障同样不提供业务数据同步。两个 goroutine 无同步地修改同一个指针字段,仍可能产生数据竞争;锁、channel 和原子操作承担的是另一份契约,见 Go 内存模型。
5. 触发与内存目标
5.1 GOGC
GOGC控制运行时允许堆相对上一轮存活数据增长多少,默认值为 100。在不受内存软限制等条件约束时,Go 1.22.12 根据以下关系计算百分比对应的堆目标:
目标 = 上轮标记存活堆 + (上轮标记存活堆 + 栈扫描量 + 全局扫描量) × GOGC / 100
例如,存活堆为 100 MiB、相关根扫描量合计 10 MiB,GOGC=100 时,这个计算得到 210 MiB;GOGC=50 时得到 155 MiB。实际目标还受最小堆、清扫空间和 GOMEMLIMIT 等条件影响。这是帮助理解增长预算的计算,不是下一次一定在某个精确字节数触发的保证。
增大 GOGC,通常允许堆增长更多,以更大的空间换取较低回收频率;减小它,通常更早进行回收,但增加 GC 工作占比的可能性也更高。实际效果还取决于分配速度、存活对象和扫描成本。
下面单独运行程序,先排除内存软限制的影响,再观察百分比变化怎样调整堆目标:
package main
import (
"fmt"
"math"
"runtime"
"runtime/debug"
)
func main() {
oldLimit := debug.SetMemoryLimit(math.MaxInt64)
defer debug.SetMemoryLimit(oldLimit)
oldPercent := debug.SetGCPercent(100)
defer debug.SetGCPercent(oldPercent)
held := make([]byte, 8<<20)
held[0] = 42
runtime.GC()
var at100, at50 runtime.MemStats
runtime.ReadMemStats(&at100)
debug.SetGCPercent(50)
runtime.ReadMemStats(&at50)
fmt.Println(at50.NextGC < at100.NextGC)
fmt.Println(held[0])
runtime.KeepAlive(held)
}
在 Go 1.22.12 的本例中输出 true、42。我们保留 8 MiB 数组,使实验不只是在最小堆附近观察默认值。NextGC 表示当前的堆目标,不能把它当作某次分配越过后立即执行 GC 的精确触发点;这个程序也没有比较实际业务的性能。
5.2 触发阈值与周期
运行时需要在堆到达目标前启动并发标记,因为业务还会继续分配。GC pacer就是决定何时开始、怎样让标记进度跟上分配速度的控制机制。它会结合历史扫描工作、分配与标记速度等信息计算触发位置,而不是简单等到整个堆“用满”。
Go 1.22.12 的触发路径包括堆增长条件、距离上一轮 GC 的时间条件,以及显式周期请求。runtime.GC() 会阻塞调用方,直到相应完整回收完成;其他 goroutine 仍会经历其中的并发阶段与必要暂停。
各阶段有统一的运行时状态。新的标记周期开始前,需要处理上一轮尚未完成的清扫,不会让两套独立标记清扫状态任意交叉、同时改写同一批回收位图。“GC 和业务并发”不能解释成“任意多个 GC 周期都能各自独立并发执行”。
5.3 GOMEMLIMIT
GOMEMLIMIT设置 Go 运行时管理内存的软限制,debug.SetMemoryLimit 可以在运行时调整它。它约束的内存口径可用以下表达式观察: runtime.MemStats.Sys - runtime.MemStats.HeapReleased,也就是运行时管理、尚未归还操作系统的内存,而不仅是业务堆对象。
这个口径包含栈、运行时元数据等开销,但不包含 Go 二进制本身、C 代码分配的内存或应用自行通过 syscall.Mmap 映射的空间。因此,把它设成容器的全部内存预算,并不能保证容器不发生 OOM;需要为这些额外来源和波动留出空间。
运行时会结合软限制调整堆目标,更积极地回收和归还空间。GOGC=off 或 debug.SetGCPercent(-1) 关闭的是百分比驱动的自动回收,有限的 GOMEMLIMIT 仍可能推动 GC,显式 runtime.GC() 也仍然有效。
软限制不是分配失败阈值。如果存活对象已经超过预算,GC 无法靠反复扫描把仍然可达的对象删除。为了避免几乎所有 CPU 都花在无效回收上,运行时还有 GC CPU 限制机制,必要时允许超过内存目标,让业务有机会继续推进。因此,GOMEMLIMIT 不能替代业务并发上限、缓存容量控制或操作系统的硬限制。
6. 诊断与比较
6.1 先区分分配、保留与暂停
GC 调整应从现象和测量出发。相同的“内存高”,可能是快速创建短命对象,也可能是少量大对象长期被引用;只改一个百分比无法同时解决这些问题。
| 观察对象 | 它回答什么 | 不能单独推出什么 |
|---|---|---|
| 累计分配字节与分配次数 | 多长时间里创建了多少堆对象 | 这些对象现在都还活着 |
| GC 后存活堆与引用路径 | 哪些对象持续被保留 | 只靠一次快照就能判断合理容量 |
| GC 暂停与标记 CPU、辅助标记 | GC 是否占用执行时间或影响请求推进 | STW 短就没有 GC 成本 |
| 运行时内存与 RSS | 运行时管理空间和进程占用的差异 | RSS 未降就等于泄漏 |
GODEBUG=gctrace=1 可以打印每轮 GC 摘要。Go 1.22.12 的时钟时间包含清扫终止暂停、并发标记、标记终止暂停;不能把整行的并发标记时长都算成 STW。runtime/metrics 中的 /gc/cycles/total:gc-cycles、/gc/heap/live:bytes、/gc/heap/goal:bytes 和 GC 暂停分布,可以帮助把频率、存活量与暂停分开观察。
如果主要问题是存活量过高,先找仍在保留对象的引用;如果主要问题是高频短命分配,再检查中间对象、缓冲区复用和批处理等选择。降低分配次数也要同时检查保留容量,不能为了复用而把偶发大缓冲长期留在池中,见 sync.Pool。
6.2 与 Java GC 比较
应当比较具体的运行时与收集器,而不是比较两个语言名称。以 Go 1.22.12 和 Java 17、21 的 HotSpot G1 为例:
| 维度 | Go 1.22.12 | HotSpot G1 |
|---|---|---|
| 堆组织与回收范围 | 不分年轻代、老年代 | 保留分代,按 Region 组织 |
| 存活对象处理 | 不通过移动堆对象进行压缩整理 | 回收选中区域时复制存活对象 |
| 并发与暂停 | 并发标记清扫配合必要 STW | 同样同时涉及并发工作和暂停阶段 |
| 调整重点 | GOGC、运行时内存软限制及实际分配/存活负载 | 收集器配置、区域与回收集合等条件 |
表中的“不移动”只针对这个版本的堆回收,不能扩展成“Go 的所有地址永远不变”:goroutine 栈增长和缩小会移动栈,见 Go 内存分配。G1 的分区、记忆集和复制机制见 G1。Java 还有其他收集器,不能据此断言 Go 一定更快、Java 一定暂停更久。
7 · 面试题卡片
本页题目与参考回答
7.1 Go 的 GC 怎样回收对象,为什么仍有 STW?
考察重点:根与可达性、并发标记清扫的阶段、暂停的必要性,以及辅助标记的成本。
参考回答
Go 1.22.12 使用并发标记清扫,按根到对象的可达路径保留对象,但阶段切换仍需要 STW。
开始标记前,要完成必要的上轮清扫并统一开启屏障等状态;恢复业务执行后,扫描根和对象,处理业务修改指针产生的标记工作;标记收尾时再次暂停,完成阶段切换,随后并发清扫未标记的普通对象。
GC 能处理孤立循环引用,但不能回收仍被业务缓存或 goroutine 持有的对象。并发阶段也会使用 CPU,分配较快的 goroutine 可能承担辅助标记,所以短暂停不等于没有成本。
7.2 三色标记中的白色、灰色、黑色分别表示什么?
考察重点:发现对象与完成扫描的区别;标记进度和回收资格的区别。
相关内容:三种颜色
参考回答
白色表示本轮尚未发现,灰色表示已经发现但引用尚未扫描完,黑色表示已经完成扫描。
扫描灰色对象时,会发现它指向的其他对象并继续安排扫描,逐步完成根能到达的对象图。并发标记还要配合写屏障,避免业务修改引用后漏掉对象。
标记未结束时,白色可能只是尚未扫描到的存活对象。只有本轮标记正确完成后,仍未标记的普通对象才可以回收;颜色不是对象永久属性。
7.3 混合写屏障解决什么问题?为什么要关注新旧引用?
考察重点:并发引用变化造成的漏标、删除与插入保护、栈扫描与暂停的关系。
相关内容:混合写屏障
参考回答
混合写屏障防止业务在并发标记期间转移引用,把仍然可达的对象藏到标记器已经检查过的位置。
保护被覆盖的旧引用,可以避免把唯一引用从堆移到已扫描栈后漏标;保护写入的新引用,可以避免从尚未扫描的栈把对象放入已扫描堆对象后漏标。Go 1.22.12 实现通过屏障缓冲区等机制处理指针,并允许编译器省掉能证明不必要的工作。
它帮助避免标记终止时为这类引用转移重新扫描所有栈,但没有取消阶段切换的 STW。GC 写屏障也不是业务同步机制,不能代替锁或原子操作。
7.4 Go GC 在什么条件下触发?多个周期会任意重叠吗?
考察重点:堆增长、时间与显式请求;触发阈值、堆目标和回收阶段的协调。
参考回答
Go 1.22.12 有堆增长、时间条件和显式周期请求等触发路径,运行时统一协调各阶段。
GOGC 参与计算堆增长目标,GOMEMLIMIT 可能进一步压低目标。pacer 会为并发标记预留空间,在达到目标前计算合适的触发位置,不能把 GOGC=100 简化为“精确增长到两倍才开始回收”。
runtime.GC() 请求并等待完整回收。新标记周期开始前要处理尚未完成的上轮清扫;并发 GC 指的是与业务并发,不是任意多个独立周期同时改写回收状态。
7.5 GOGC=off 后,GOMEMLIMIT 还起作用吗?能保证进程不超限吗?
考察重点:百分比目标与软限制的分工、运行时管理内存的口径、存活对象与 GC CPU 的边界。
相关内容:GOMEMLIMIT
参考回答
有限的 GOMEMLIMIT 仍会起作用,但它不是进程内存硬上限。 GOGC=off 关闭百分比驱动的自动回收,内存限制仍可能推动 GC,显式 runtime.GC() 也可以执行。
限制口径是运行时管理且未归还系统的内存,可用 Sys - HeapReleased 观察,不包括 C 分配、应用自行 mmap 等全部进程开销。存活对象无法强行回收,运行时也可能为了避免 GC 耗尽 CPU 而容许超过目标。因此需要独立控制对象保留、并发量和容器预算。
7.6 Go 与 Java 的垃圾回收有什么区别?
考察重点:指定版本和收集器后比较分代、对象移动、并发工作与暂停;避免用语言名称推断性能。
相关内容:与 Java GC 比较
参考回答
先指定比较对象:Go 1.22.12 的堆 GC 不分代、不移动堆对象,Java 则有多种可选收集器。
例如 HotSpot G1 保留年轻代和老年代,以 Region 组织堆,在回收选中区域时复制存活对象。Go 使用并发标记清扫,两者都要处理并发标记与必要暂停,但具体路径和调节手段不同。
不能据此断言某种语言一定更快。实际分配速度、存活量、对象图和服务延迟要求都会影响结果;Go 的堆不移动,也不代表它的 goroutine 栈不移动。
8. 参考资料
- Go 1.22.12:GC 阶段与显式回收
- Go 1.22.12:标记与根扫描
- Go 1.22.12:混合写屏障
- Go 1.22.12:写屏障缓冲区
- Go 1.22.12:编译器的写屏障处理
- Go 1.22.12:GC pacer 与内存目标
- Go 1.22.12:GC CPU 限制
- Go 1.22.12:SetGCPercent 与 SetMemoryLimit
- Go 1.22.12:运行时指标定义
- Go 1.22.12:GODEBUG 与 GC 日志
- Java 17:G1 Garbage Collector
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明