Go GC 非玄学,而是 CPU 和内存的权衡

0 阅读12分钟

本博客内容基于 Go 官方 GC 指南与官方文档进行创作(Go): 官方把(Go)文章《A Guide to the Go Garbage Collector》,定位成一篇性能认知 + 调优指南文档。

若你熟悉 Go 基础语法,只是想要更好的服务自己的项目, 那本篇文章就是为你量身定做的( •̀ ω •́ )✧。 本篇文章会带你逐个打破 GC、GOGC、GOMEMLIMIT、heap、mark-sweep 这些术语的梦魇。 并把 Go GC 这件事,用清晰易懂的方式讲明白。


一、先别急着聊 GC,先分清:咱们的内存到底在哪放

很多人一提到内存,就下意识觉得都归 GC 管。

其实不是。

在 Go 里,值大致会放在两个地方:

  • 栈(stack)
  • 堆(heap)

你先记住一句最重要的话:

GC 主要处理的是堆内存,不是所有内存。

1. 栈是什么

栈上的数据,通常生命周期比较清楚。

比如函数里的局部变量,编译器能提前看明白:
“这个变量只在这个函数里用,用完就没了。”

那这种值就很适合放在栈上。函数结束,栈帧弹出,内存自然就回收了。
这种回收方式非常快,也不需要 GC 参与。

2. 堆是什么

堆上的数据,通常生命周期没那么好判断。

比如一个对象会不会在函数外继续被引用?
一个切片底层数组到底要活多久?
一个值是不是被别的结构持有了?

如果编译器没法明确判断它何时结束生命周期,就只能把它放到堆上。

堆上的内存,才是 GC 重点管理的对象

3. 什么叫逃逸

你可能经常会听到:对象逃逸到堆上了。

这个“逃逸”,本质上就是:

本来有机会放栈上的值,因为生命周期变复杂了,最后被分配到了堆上。

来看一个很典型的例子:

package main

func newUser() *int {
	x := 10
	return &x
}

func main() {
	_ = newUser()
}

这里 x 是函数内的局部变量。 但你把 x 的地址返回出去了,那它显然不能随着函数结束一起销毁。 所以编译器会让它分配到堆上。

这就是最常见的逃逸场景之一。

4. 为什么这件事重要

因为很多时候,你以为自己在优化 GC,实际上你真正该优化的是:

为什么程序里有这么多值逃逸到了堆上。

所以说:

不是所有内存问题都是 GC 问题,很多时候是堆分配太多的问题。


二、GC 到底在干嘛?别把它想的太玄

可以这么说:

GC 就是在一堆堆内存对象里,找出哪些还在用,哪些已经没人用了,然后把没用的回收掉。

Go 使用的是追踪式垃圾回收

可以说,记住术语,是远没有理解它背后的运行过程重要:

1. 从谁活着开始找

比如:

  • 全局变量
  • 当前 goroutine 栈上的局部变量
  • 这些变量引用到的对象

GC 会从这些“根”开始,一层层顺着指针往下找。

只要某个对象还能被一路找到,它就算“活着”。

2. 找得到的叫 live,找不到的就是垃圾

GC 不是“看到一个对象就判断要不要删”。 也就是:

  • 先把所有能到达的对象标出来
  • 最后统一清理那些没有被标记的对象

就像我们常听到的:

  • 标记(mark)
  • 清扫(sweep)

三、Go GC 最核心的不是算法细节,而是“成本”

别一学 GC,就急着去背什么三色标记、写屏障、STW。

非不重要,而是第一次系统接触 GC时,更建议先抓住主线:

GC 不是免费的。GC 会消耗资源。

而且主要消耗两样:

  • CPU
  • 内存

1. 为什么 GC 会吃 CPU

因为 GC 要做“找活对象”这件事。

它要扫描对象、遍历引用、标记存活对象。 这些动作本质上都要 CPU 时间。

堆对象越多、引用关系越复杂、分配越频繁,GC 的 CPU 成本通常就越高。

2. 为什么 GC 还会影响内存

因为 GC 不是每分配一点就立刻回收一次。

它要攒到某个时机才开始回收。 所以在真正开始回收之前,新分配的对象会继续堆积。

这意味着:

  • GC 跑得没那么勤时,内存占用会更高
  • GC 跑得更勤时,内存占用会更低

3. 何为 GC 的权衡

调优过程就是在权衡?

GC 调优,本质上是在用 CPU 换内存,或者用内存换 CPU。

说白了:

  • GC 更勤快

    • 内存更省
    • CPU 更累
  • GC 更偷懒

    • CPU 更省
    • 内存更高

这就是 Go GC 最核心的思想之一。

后面你看到的 GOGCGOMEMLIMIT、profiling,本质上都在围绕这个权衡展开。


四、GOGC:Go GC 中当之无愧的最重要参数

如果你只打算先记一个参数,那就记 GOGC

1. GOGC 控制的到底是什么

很多人总说:

“GOGC 是垃圾回收阈值。” 确实是这样的,因为:

GOGC 控制 Go 等堆涨到什么程度,再触发下一轮 GC。

默认值通常是:

GOGC=100

GOGC 的值是 Go 运行时的 GC 目标百分比参数。 他控制的是:上一轮 GC 结束后,还活着的堆内存(live heap),允许再增长多少百分比,才触发下一轮 GC。

举个直观的例子:

  • 假设上一轮 GC 结束后,还活着的堆内存是 100MB
  • 如果 GOGC=100
  • 那么 Go 会允许堆再分配大约 100MB
  • 也就是堆目标大致到 200MB 左右时,触发下一轮 GC。

2. 它怎么影响程序

调小:

  • GOGC=50:更早触发 GC,更省内存,但 GC 更频繁,CPU 压力通常更大

调大:

  • GOGC=200:更晚触发 GC,GC 频率更低,CPU 压力通常更小,但内存会更高

3. 用代码也能动态调

除了环境变量,还可以在代码里调:

package main

import (
	"fmt"
	"runtime/debug"
)

func main() {
	old := debug.SetGCPercent(200)
	fmt.Println("old GOGC =", old)
}

4. “GOGC 是什么?”

经过上方的讲解,可以发现:

GOGC 控制的是 GC 的激进程度。值越大,Go 会允许堆增长得更多一些再触发 GC,这样 GC 次数更少、CPU 更省,但内存占用更高;值越小,GC 会更早介入,内存更省,但 CPU 开销更大。


五、GOMEMLIMIT:不是让你替代 GOGC,而是多一个“天花板”

之所以存在comemlimit,是因为:

GOGC 是“百分比节奏控制”,GOMEMLIMIT 是“具体数值上限控制”。

想象你把 Go 服务跑在:

  • 容器里

这时候你会遇到一个问题:

如果某一段时间堆突然涨得很高怎么办?这时GOGC不一定触发,

这就是 GOMEMLIMIT 的意义。

1. 它是什么

GOMEMLIMIT 可以理解成:

给 Go runtime 设一个软内存上限。

注意,是软上限,不是“绝对不允许超过”的硬限制。

它的作用不是替你决定所有 GC 策略, 而是告诉运行时:

“别吃得太离谱,快接近这条线时,要更积极一点。”

2. 你可以这样理解它和 GOGC 的关系

  • GOGC:平时按什么节奏收
  • GOMEMLIMIT:最多别冲太高

也就是说:

GOGC 决定日常节奏,GOMEMLIMIT 决定上限边界。

3. 代码怎么写

package main

import (
	"fmt"
	"runtime/debug"
)

func main() {
	old := debug.SetMemoryLimit(512 << 20) // 512 MiB
	fmt.Println("old memory limit =", old)
}

4. 它不是越小越好

“你可能认为卡小一点,就会更省内存?”

这其实很危险。

因为如果你把限制压的太低,GC 可能会进入一种很难受的状态:

  • 程序刚分配一点
  • GC 就赶紧回收
  • 回收完继续分配
  • 刚分配又继续回收

程序大量时间都耗在 GC 上,业务逻辑推进得很慢。

这种现象有个名字,叫:

thrashing(抖动 / 疯狂回收)

切记:

GOMEMLIMIT 是拿来防止失控的,不是拿来逼死程序的。

5. 很有用的小案例:

如果你的 Go 服务跑在容器里,比如容器限额 1GiB, 那通常可以考虑给 Go runtime 留一点缓冲,而不是直接卡死到 1GiB。

比如:

  • 容器 1GiB
  • Go runtime 配到 850MiB ~ 950MiB 这个量级,再结合实际压测看效果

这里不要死背具体数值,重点是要有这个意识:

要给运行时和其他开销留空间。


六、别一上来就调参数,先学会查:GC 问题到底怎么定位

看 GC 日志、抓 profile、看逃逸分析

很多人一发现程序内存高,第一反应就是:

  • 调小 GOGC
  • 设置 GOMEMLIMIT
  • 强制 runtime.GC()

这种做法,运气好可能有效,运气不好就是瞎调。

所以应该: 先定位,再优化。

1. 看 GC 日志:gctrace

最简单粗暴的办法:

GODEBUG=gctrace=1 go run main.go

或者你跑服务时加环境变量:

export GODEBUG=gctrace=1

它会把每轮 GC 的摘要打印出来。通过此来判断:

  • GC 触发频率是不是很高
  • 堆是不是涨得很快
  • 暂停时间是不是有明显异常

2. 看堆分配:heap profile

如果你的问题是:

  • 内存为什么高
  • GC 为什么这么频繁

那很多时候关键不是“GC 算法”,而是:

到底是谁在疯狂分配堆内存?

这个时候就该上 heap profile。

如果你服务接了 net/http/pprof,就可以抓 profile 看热点分配位置。

你真正要找的是:

  • 哪些函数分配特别多
  • 哪些对象生命周期明明很短,却老是在堆上
  • 哪些结构体/切片/map 在高频路径里疯狂创建

3. 看 CPU profile

如果你的问题是:

  • CPU 飙高
  • 延迟增加
  • 怀疑 GC 很重

那就看 CPU profile。

你会经常看到 runtime 里的这些符号:

  • runtime.gcBgMarkWorker
  • runtime.mallocgc
  • runtime.gcAssistAlloc

你现在不用死背每个函数细节,先知道它们大致意味着什么就行:

  • gcBgMarkWorker 多,说明 GC 标记工作很活跃
  • mallocgc 多,说明堆分配很多
  • gcAssistAlloc 多,说明业务 goroutine 已经在帮 GC 打工了

这通常说明:

你的分配速度,已经把 GC 压得比较辛苦。

4. 最后再做一件事:看逃逸分析

如果你已经找到了热点函数,就可以进一步看逃逸分析:

go build -gcflags=-m=3 ./...

这一步不是让你看个热闹。 你要重点观察:

  • 哪些值逃逸到了堆上
  • 为什么逃逸
  • 有没有机会改写代码,减少堆分配

很多时候,真正有效的优化不是“调 GC 参数”,而是:

让本来上堆的值,重新回到栈上。


七、初学 GC 时,最容易犯的 5 个误区

误区 1:GC 就是性能差的根源

错。

很多时候真正的问题是:

  • 分配太多
  • 对象活得太久
  • 数据结构引用关系太复杂

GC 只是结果,不一定是起点。

误区 2:内存高,就该把 GOGC 调小

不一定。

调小 GOGC 的确可能降低内存占用, 但它也可能把 CPU 压力抬上去。

如果你服务本来 CPU 就很紧,再调小,很可能副作用更明显。

误区 3:有了 GOMEMLIMIT,就不用管 GOGC 了

也错。

GOMEMLIMIT 更像保险杠。 GOGC 仍然是你日常控制 GC 节奏的核心旋钮。

二者不是替代关系,而是互补关系。

误区 4:runtime.GC() 是优化手段

绝大多数业务代码里,不建议把它当常规优化手段。

它更像调试工具,或者极少数特殊场景下的主动提示。 你如果把它当成“内存一高就手动收一下”的通用解法,通常会把问题带偏。

误区 5:GC 只要懂概念就够了

也不对。

如果你想把 GC 真正讲到后端面试能过、项目里能用, 你至少得把下面这条链路打通:

  • 栈 vs 堆
  • 逃逸分析
  • GC 的 CPU/内存权衡
  • GOGC
  • GOMEMLIMIT
  • gctrace / pprof / heap profile

这几样一旦串起来,你对 GC 的理解就不再停留在“八股背诵”层面了。


八、如果面试官问你 Go GC,可以这样解答

如果是第一次答,不要一上来扔术语。

你可以按这个顺序讲:

1. 先讲它管什么

Go 不是所有内存都归 GC 管,主要是堆内存才归 GC 管。能明确生命周期的值尽量走栈,不需要 GC 参与;生命周期不明确、发生逃逸的值通常上堆。

2. 再讲它怎么回收

Go 的 GC 可以简单理解为追踪式的标记-清扫。它从根对象开始扫描,找到还活着的对象并标记,最后回收那些不可达的对象。

3. 然后讲它的成本

GC 主要有两类成本:CPU 和内存。GC 跑得更勤,内存占用通常更低,但 CPU 更高;GC 跑得更少,CPU 更省,但内存占用会更高。

4. 最后讲调优

Go 里最关键的 GC 参数是 GOGC,它控制 GC 的激进程度;Go 1.19 之后还有 GOMEMLIMIT,可以给 runtime 设置软内存上限。真遇到 GC 问题时,不应该先瞎调参数,而是先看 gctrace、heap profile、CPU profile 和逃逸分析。


九、后续学习方向

第一阶段:先建立直觉

先真正理解这 4 句话:

  1. GC 主要回收堆内存
  2. 逃逸会让值上堆
  3. GC 会消耗 CPU,也影响内存
  4. GC 调优是在 CPU 和内存之间做权衡

第二阶段:把两个参数吃透

重点掌握:

  • GOGC
  • GOMEMLIMIT

没必要一开始去啃一堆 runtime 源码,先把这两个参数吃透。

第三阶段:学会看工具

最低也要掌握的工具:

  • GODEBUG=gctrace=1
  • heap profile
  • CPU profile
  • go build -gcflags=-m=3

这一步会直接把你从“只会背概念”拉到“能看现场”。

第四阶段:再去理解更底层的东西

比如:

  • 三色标记
  • 写屏障
  • 并发标记
  • STW 在 Go 里的真实位置
  • pacer

结尾

很多人学 Go,一开始就只盯着 语法、框架....

可随着后端服务逐渐变重,GC 迟早会成为绕不过去的话题:

  • 为什么服务内存越来越高?
  • 为什么压测一上来延迟就抖?
  • 为什么 CPU profile 里 runtime 占比不低?
  • 为什么明明业务逻辑不复杂,GC 却这么忙?

这些问题,最后都会指向同一个方向:

你是否真正理解 Go 的内存模型和 GC 成本。