Go 垃圾回收
区分规范不规定具体 GC 算法与当前实现的并发标记清扫;理解可达性、分配压力、GOGC/GOMEMLIMIT 与工程降分配策略。
[!info] related notes
Go 垃圾回收
这个概念为什么会出现
手动 malloc/free 把生命周期错误变成 use-after-free 与泄漏两类日常事故。Go 选择自动内存管理:堆上不再可达的对象由运行时回收。
但这不意味着“内存免费”:
- 分配本身有 CPU 与缓存成本
- GC 要扫描/标记可达对象,带来 CPU 与偶尔的延迟毛刺
- 错误地长时间持有指针 = 逻辑泄漏
服务性能问题经常落在分配速率、存活集合大小、缓存策略,而不是某个微观语法。
[!abstract] 一句话理解 GC 自动回收堆上不可达对象以降低手动释放负担;你仍需管理分配速率与对象寿命。具体算法与调参是实现细节,可达性语义才是可依赖的心智模型。
最小可运行示例
先把示例放进可观察实验,再看代码:
场景:观测「图片缩略图临时缓冲」制造的分配与回收
本例分配 8 块 1MiB 缓冲,先保留引用采样,再释放引用并强制 GC 后采样。它演示“仍存活”与“可回收”的区别,不依赖无用分配恰好没被优化掉,也不证明 RSS 会立即下降。
package main
import (
"fmt"
"runtime"
)
// allocThumbBuffers 模拟 N 次缩略请求各自申请 1MiB 临时缓冲。
//
// 业务意图:请求级短命大块,用完不挂到全局。
// 返回缓冲,让调用方显式控制保活窗口。
func allocThumbBuffers(n int) [][]byte {
out := make([][]byte, n)
for i := range out {
out[i] = make([]byte, 1<<20)
out[i][0] = byte(i)
}
return out
}
func main() {
var before, live, after runtime.MemStats
// 观察窗口 1:压测分配前的堆占用与已发生 GC 次数。
runtime.ReadMemStats(&before)
buffers := allocThumbBuffers(8)
runtime.GC()
runtime.ReadMemStats(&live)
runtime.KeepAlive(buffers) // 让以上 GC/采样期间的引用确实存活
buffers = nil
// 仅实验:强制完成一轮 GC。生产服务不要用 GC() 当吞吐旋钮。
runtime.GC()
// 观察窗口 2:回收后对比 HeapAlloc / NumGC。
runtime.ReadMemStats(&after)
fmt.Printf("HeapAlloc before=%d live=%d after=%d\n", before.HeapAlloc, live.HeapAlloc, after.HeapAlloc)
fmt.Printf("NumGC %d -> %d\n", before.NumGC, after.NumGC)
}
保存为新模块中的 main.go(先 go mod init example.com/gc-demo),然后运行:
go run .
期望输出形态(具体数字随机器/实现波动,关注相对关系):
HeapAlloc before=... live=... after=...
NumGC N -> N+2 或更大
解读:两次显式 GC 使 NumGC 至少增加 2;live 采样时 8 块缓冲仍存活,释放后 after 通常下降。HeapAlloc 是全进程指标,差值不是固定 8MiB;GC 回收对象也不保证对应页立即归还 OS。
结合场景再看三个关注点
-
可达才存活
缩略缓冲若不挂全局,GC 后不应再被业务逻辑引用;逻辑泄漏来自「以为扔了其实还被 map/全局钉住」。 -
runtime.GC是诊断探针
可在实验里对齐观测点;线上应靠分配速率与GOGC/GOMEMLIMIT等策略,而不是请求里手动 GC。 -
MemStats要配合 pprof
这里只证明「能观察」;谁在分配、谁在持有,仍看 alloc/inuse。
核心概念与准确模型
规范说了什么 / 没说什么
| 层面 | 内容 |
|---|---|
| 语言/可依赖语义 | 有垃圾回收;程序员通常不显式释放;对象在仍被引用时可认为存活 |
| 未在规范钉死 | 具体 GC 算法、分代与否、触发阈值精确公式、STW 上限绝对保证 |
| 当前实现(gc 工具链,会变) | 非分代、并发标记清扫(concurrent mark-sweep)为主的演进实现;混合写屏障等 |
写笔记与调优时必须口头禅:算法细节 = 实现;调参与版本相关;用观测验证。
可达性
从根出发沿活跃引用可到达的对象视为存活;仅仅“变量仍在词法作用域内”不保证保活,编译器可在最后一次使用后将引用视为不再存活。需要延长特定观察窗口时用 runtime.KeepAlive,外部资源仍需显式管理。
根 → 对象 A → 对象 B(可达)
对象 C(从任何根都无路径到达时,才可回收)
逻辑泄漏:map/slice/全局缓存仍引用“以为扔掉”的数据 → GC 无法收。
分配与压力
GC 频率与成本 Roughly 随:
- 分配速率(单位时间新对象)
- 存活堆大小(扫描工作量)
优化顺序通常是:
- 少分配(复用缓冲、原地处理、避免不必要拷贝)
- 缩短不必要存活(别钉住大底层数组,见 go-slices)
- 再谈
GOGC/GOMEMLIMIT等旋钮
常见实现向旋钮(非规范)
| 旋钮 | 直觉 |
|---|---|
GOGC | 调整堆增长目标;off 不代表有内存限制时 GC 也绝不会运行 |
GOMEMLIMIT | Go 1.19+ 的运行时软内存限制,不是进程 RSS 硬上限 |
runtime/debug.SetGCPercent | 程序内调 GOGC |
runtime/debug.SetMemoryLimit | 程序内内存限制 |
GOGC 默认 100;Go 1.18+ 的目标模型包含存活堆及根扫描量,不应把它当成只看 HeapAlloc 的精确触发公式。GOMEMLIMIT 对应运行时管理的 Sys - HeapReleased,排除 C 分配、独立 mmap 等;太低可能让 GC 持续忙碌,且软限制不能保证不超限或不被容器 OOM kill。即使 GOGC=off,内存限制仍可驱动 GC。
与 pprof 的关系
alloc_space/alloc_objects:谁在分配- inuse_space:当前存活对象的分配栈,不是持有这些对象的完整引用链
- CPU profile:GC 标记是否显著
见 go-pprof。
边界情况与反直觉行为
1. 有 GC 仍会 OOM
分配快过回收、存活集过大、cgo 外部内存、goroutine 泄漏持有栈/堆引用。
2. 子切片/子字符串钉死大底层
短生命周期视图挂到长生命周期变量 → 整块底层数组存活。
3. finalizer 不是析构函数
runtime.SetFinalizer 执行时机不确定,不能当及时 Close。资源用 defer Close。
4. 强制 GC 的错觉
循环里 runtime.GC() “降内存”往往增延迟、掩问题。
5. sync.Pool
复用减轻分配,但对象可能随时被回收;逻辑必须正确处理“拿不到复用”的路径。属库语义 + 实现协作。
常见误区
[!warning] 常见误区:背诵某版本 GC 论文细节当语言保证 错误:假设永远非分代、某固定 STW 上限写进业务 SLA。
正确:依赖可达性与观测;SLA 用测试与监控证明。
[!warning] 常见误区:内存涨了就怪 GC 有 bug 错误:不查引用链。
正确:heap profile 定位存活分配来源,再审查缓存、引用链与生命周期;仅凭分配栈不能直接确定持有者。
[!warning] 常见误区:过早 micro-optimize 每个 alloc 错误:未 profile 先对象池化一切。
正确:热点证明后再改。
[!warning] 常见误区:GOGC=off 当生产默认 错误:关掉 GC 冲吞吐。
正确:几乎总是错;用 limit 与降分配。
工程实践
- 指标:
go_memstats_*、容器 RSS、GC 暂停相关 metric(按运行时导出)。 - 降分配:预分配 slice cap、流式处理、避免
[]byte↔string热路径来回。 - 缓存有上限:LRU/TTL,防逻辑泄漏。
- API 生命周期:文档说明是否保留调用方 buffer 引用。
- 负载下验证:bench + pprof + 真实流量。
- 版本升级:GC 实现演进时用回归基准看延迟/内存。
// 独立小切片,避免钉死 big
func clone(b []byte) []byte {
out := make([]byte, len(b))
copy(out, b)
return out
}
可验证实验
实验 1:短命分配
运行上面的保活/释放两阶段示例,观察 HeapAlloc 和 NumGC;不要用丢弃结果的 make 循环推断真实分配量。
实验 2:钉住大数组
在独立实验中先把 big[:16] 存入全局,再清掉其他引用并 GC;对照另一次运行只保存 clone(big[:16]) 后 GC。确保采样时全局引用仍在,避免用不同保活条件比较。
实验 3:GOGC 感性
同程序不同 GOGC=50/200 看 GC 次数与 RSS(实现向实验)。
实验 4:pprof heap
go tool pprof 看 inuse_space 头部。
本节总结
- 可依赖:自动回收不可达堆对象;持有引用则不收。
- 实现向:并发标记清扫等细节、GOGC 行为随版本变。
- 工程核心:分配速率、存活集、泄漏式引用、观测驱动。
- 下一步:并发下“何时看见写入”见 go-memory-model——与 GC 是不同问题。
自测题
概念题
- 规范保证具体 GC 算法吗?
- 什么是逻辑泄漏?
- 为何 finalizer 不能替代 Close?
代码推理题
var cache [][]byte
func handler(buf []byte) {
cache = append(cache, buf[:10])
}
长期运行的风险?
工程思考题
服务 RSS 持续爬升但 HeapAlloc 波动正常。还可能查什么?
参考答案
展开
- 不保证;算法属实现。
- 仍被引用以致不能回收的“无用”数据。
- 时机不确定,不能做及时资源释放。
代码题:buf[:10] 要求 cap(buf) 至少 10,并可能保留整个底层数组;cache 无限增长,多请求并发还会竞态。应按契约校验输入、同步访问、限容,并按需复制需要保留的数据。 工程题:goroutine 泄漏、cgo/堆外、OS 线程、映射文件、未归还的进程级资源;用合适 profile 与runtime.NumGoroutine等。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| A Guide to the Go Garbage Collector | 官方文档 | 调优与心智(实现向权威入口) |
| GOGC / memory limit 相关 release notes | 发行说明 | GOMEMLIMIT 等(按版本核对) |
| Package runtime | 标准库 | MemStats、GC |
| Package runtime/debug | 标准库 | SetGCPercent、SetMemoryLimit |
| Profiling Go Programs | 博客 | 观测 |
笔记元信息
- 建议文件名:
go-garbage-collection.md - 所属阶段:阶段六
- 学习顺序:性能与运行时
- 建议下一篇:内存模型
- 本篇状态:已深化(明确规范 vs 实现;工程分配策略)