Go 垃圾回收

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

#type / concept #status / growing #tech / dev #resource / go

[!info] 关联笔记

Go 垃圾回收

这个概念为什么会出现

手动 malloc/free 把生命周期错误变成 use-after-free 与泄漏两类日常事故。Go 选择自动内存管理:堆上不再可达的对象由运行时回收。

但这不意味着“内存免费”:

  • 分配本身有 CPU 与缓存成本
  • GC 要扫描/标记可达对象,带来 CPU 与偶尔的延迟毛刺
  • 错误地长时间持有指针 = 逻辑泄漏

服务性能问题经常落在分配速率、存活集合大小、缓存策略,而不是某个微观语法。

[!abstract] 一句话理解 GC 自动回收堆上不可达对象以降低手动释放负担;你仍需管理分配速率与对象寿命。具体算法与调参是实现细节,可达性语义才是可依赖的心智模型。

最小可运行示例

先把示例放进可观察实验,再看代码:

场景:观测「图片缩略图临时缓冲」制造的分配与回收

图片服务为每次缩略请求 make 约 1MiB 的短命缓冲,用完即丢。
排障时想确认:高峰分配后,强制一次 GC,HeapAlloc / NumGC 是否按「可达性」方向变化——这是可观察实验,不是去证明某个绝对堆公式。

package main

import (
	"fmt"
	"runtime"
)

// allocThumbBuffers 模拟 N 次缩略请求各自申请 1MiB 临时缓冲。
//
// 业务意图:请求级短命大块,用完不挂到全局。
// 教学点:无引用后对象可被回收;本函数返回后切片不可达。
func allocThumbBuffers() {
	for i := 0; i < 1000; i++ {
		// 1MiB;是否一定上堆由编译逃逸分析决定(实现细节)。
		buf := make([]byte, 1<<20)
		// 假装写入了缩略像素后丢弃:不把 buf 存进全局/闭包。
		_ = buf[0]
	}
}

func main() {
	var before, after runtime.MemStats

	// 观察窗口 1:压测分配前的堆占用与已发生 GC 次数。
	runtime.ReadMemStats(&before)

	allocThumbBuffers()

	// 仅实验:强制完成一轮 GC。生产服务不要用 GC() 当吞吐旋钮。
	runtime.GC()

	// 观察窗口 2:回收后对比 HeapAlloc / NumGC。
	runtime.ReadMemStats(&after)

	fmt.Printf("HeapAlloc before=%d after=%d\n", before.HeapAlloc, after.HeapAlloc)
	fmt.Printf("NumGC %d -> %d\n", before.NumGC, after.NumGC)
}

建议运行:

go run .

期望输出形态(具体数字随机器/实现波动,关注相对关系):

HeapAlloc before=... after=...
NumGC N -> N+1 或更大

解读:NumGC 应前进;after.HeapAlloc 通常不再持有那 1000 块仍可达的 1MiB 视图(若你把切片存进全局,after 会明显抬高——可自行改一版对照)。

结合场景再看三个关注点

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

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

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

核心概念与准确模型

规范说了什么 / 没说什么

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

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

可达性

从一组根(栈、全局、寄存器等,实现定义)出发,沿指针可到达的对象视为存活;不可达对象可被回收。

根 → 对象 A → 对象 B
         ↘ 对象 C(若无其他引用,C 不可达则可回收)

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

分配与压力

GC 频率与成本 Roughly 随:

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

优化顺序通常是:

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

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

旋钮直觉
GOGC相对目标:堆相对存活集增长百分比触发;off 关闭(危险)
GOMEMLIMIT软内存上限,协助在限制下更积极回收(Go 1.19+ 叙事)
runtime/debug.SetGCPercent程序内调 GOGC
runtime/debug.SetMemoryLimit程序内内存限制

具体默认值与交互以当前版本文档为准。

与 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、流式处理、避免 []bytestring 热路径来回。
  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:短命分配

循环 make([]byte, 1<<20) 观察 NumGC 与 CPU。

实验 2:钉住大数组

big := make([]byte, 1<<24); small := big[:16]; runtime.GC() 后经全局保存 small,对比保存 clone(small) 的 heap。

实验 3:GOGC 感性

同程序不同 GOGC=50/200 看 GC 次数与 RSS(实现向实验)。

实验 4:pprof heap

go tool pprofinuse_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] 可能钉死整个传入 buffer 底层数组;cache 无限增长。应 copy 并限容。
    工程题: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/7/15