← [ BACK_TO_NOTES ]

Go 垃圾回收

区分规范不规定具体 GC 算法与当前实现的并发标记清扫;理解可达性、分配压力、GOGC/GOMEMLIMIT 与工程降分配策略。

#concept #growing #dev #go

[!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。

结合场景再看三个关注点

  1. 可达才存活
    缩略缓冲若不挂全局,GC 后不应再被业务逻辑引用;逻辑泄漏来自「以为扔了其实还被 map/全局钉住」。

  2. runtime.GC 是诊断探针
    可在实验里对齐观测点;线上应靠分配速率与 GOGC/GOMEMLIMIT 等策略,而不是请求里手动 GC。

  3. MemStats 要配合 pprof
    这里只证明「能观察」;谁在分配、谁在持有,仍看 alloc/inuse。

核心概念与准确模型

规范说了什么 / 没说什么

层面内容
语言/可依赖语义有垃圾回收;程序员通常不显式释放;对象在仍被引用时可认为存活
未在规范钉死具体 GC 算法、分代与否、触发阈值精确公式、STW 上限绝对保证
当前实现(gc 工具链,会变)非分代、并发标记清扫(concurrent mark-sweep)为主的演进实现;混合写屏障等

写笔记与调优时必须口头禅:算法细节 = 实现;调参与版本相关;用观测验证。

可达性

从根出发沿活跃引用可到达的对象视为存活;仅仅“变量仍在词法作用域内”不保证保活,编译器可在最后一次使用后将引用视为不再存活。需要延长特定观察窗口时用 runtime.KeepAlive,外部资源仍需显式管理。

根 → 对象 A → 对象 B(可达)

对象 C(从任何根都无路径到达时,才可回收)

逻辑泄漏:map/slice/全局缓存仍引用“以为扔掉”的数据 → GC 无法收。

分配与压力

GC 频率与成本 Roughly 随:

  • 分配速率(单位时间新对象)
  • 存活堆大小(扫描工作量)

优化顺序通常是:

  1. 少分配(复用缓冲、原地处理、避免不必要拷贝)
  2. 缩短不必要存活(别钉住大底层数组,见 go-slices)
  3. 再谈 GOGC / GOMEMLIMIT 等旋钮

常见实现向旋钮(非规范)

旋钮直觉
GOGC调整堆增长目标;off 不代表有内存限制时 GC 也绝不会运行
GOMEMLIMITGo 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 与降分配。

工程实践

  1. 指标:go_memstats_*、容器 RSS、GC 暂停相关 metric(按运行时导出)。
  2. 降分配:预分配 slice cap、流式处理、避免 []byte↔string 热路径来回。
  3. 缓存有上限:LRU/TTL,防逻辑泄漏。
  4. API 生命周期:文档说明是否保留调用方 buffer 引用。
  5. 负载下验证:bench + pprof + 真实流量。
  6. 版本升级: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 是不同问题。

自测题

概念题

  1. 规范保证具体 GC 算法吗?
  2. 什么是逻辑泄漏?
  3. 为何 finalizer 不能替代 Close?

代码推理题

var cache [][]byte
func handler(buf []byte) {
	cache = append(cache, buf[:10])
}

长期运行的风险?

工程思考题

服务 RSS 持续爬升但 HeapAlloc 波动正常。还可能查什么?

参考答案

展开
  1. 不保证;算法属实现。
  2. 仍被引用以致不能回收的“无用”数据。
  3. 时机不确定,不能做及时资源释放。
    代码题: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 实现;工程分配策略)
创建于 2026/6/20 更新于 2026/10/7
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本