Go GC 机制
Go 的 GC 经历了三个阶段:标记清除(全程 STW)→ 三色标记 + 插入/删除写屏障(并发标记)→ 三色标记 + 混合写屏障。下面按演进顺序讲,最后是现在的触发条件、完整流程和调参。
一段话背诵版
Go 的 GC 核心是并发标记清除。标记清除全程停世界,停顿随堆大小增长,所以改成三色标记做并发:白、灰、黑分别表示没扫到、在队列里等扫描、扫完,从根出发做 BFS,灰队列空了,仍是白色的就是垃圾。并发标记的问题在于程序边跑边改引用,黑色对象可能被写入指向白色对象的指针,白色就永远扫不到,所以堆指针写入要过写屏障:插入屏障把新值标灰,删除屏障把被覆盖的旧值标灰,宁可错救也不漏标,代价是被误救的浮动垃圾下一轮才收。混合写屏障把两者合并:覆盖堆引用时无条件标灰旧值,写入者的栈还没扫描时再标灰新值,栈就再也不用重扫。一轮 GC 只有标记准备和标记终止两次短 STW,主体是并发标记,清除惰性分摊。触发条件有四种:堆涨到 GOGC(默认 100,即堆翻倍)的阈值、sysmon 每 2 分钟兜底、runtime.GC() 手动触发、GOMEMLIMIT 软限制。调参先看 GODEBUG=gctrace=1,内存充足调大 GOGC,容器里设 GOMEMLIMIT。
阶段 1:标记清除(Go 1.3 及以前)
标记和清除都停世界(stop the world,暂停所有线程)。标记时程序必须停:程序一边跑一边改引用,标记结果不可信,可能把活对象当垃圾收掉。
停顿和堆大小、对象数量挂钩,堆越大停得越久。延迟敏感的程序扛不住,这是后面所有演进的根本原因。
阶段 2:三色标记 + 写屏障(Go 1.5 起)
三色标记
标记阶段把对象分成三种颜色:
- 白色:还没被扫描到,可能是垃圾。
- 灰色:被发现了,但指向它的引用还没扫描完,队列里等着扫。
- 黑色:扫描完了,它的所有引用都已处理,确定存活。
流程:从根对象(全局变量、栈、寄存器)出发,标记为灰色;然后不停从灰色队列取对象,把它的引用对象标灰,自己变黑;灰色队列空了,所有仍是白色的对象就是垃圾,回收掉。这个过程有点像图的广度优先遍历(BFS):灰色队列就是 BFS 的队列,黑色是已出队的节点。
flowchart LR
subgraph 根[根对象]
R[全局变量 / 栈 / 寄存器]
end
subgraph 堆[堆]
A["A(黑色)"]
B["B(灰色)"]
C["C(白色)"]
D["D(白色)"]
end
R --> A
A --> B
A -. 未扫描 .-> C
D -. 无引用,垃圾 .-> X[回收]
flowchart LR
subgraph 根[根对象]
R[全局变量 / 栈 / 寄存器]
end
subgraph 堆[堆]
A["A(黑色)"]
B["B(灰色)"]
C["C(白色)"]
D["D(白色)"]
end
R --> A
A --> B
A -. 未扫描 .-> C
D -. 无引用,垃圾 .-> X[回收]
flowchart LR
subgraph 根[根对象]
R[全局变量 / 栈 / 寄存器]
end
subgraph 堆[堆]
A["A(黑色)"]
B["B(灰色)"]
C["C(白色)"]
D["D(白色)"]
end
R --> A
A --> B
A -. 未扫描 .-> C
D -. 无引用,垃圾 .-> X[回收]
flowchart LR
subgraph 根[根对象]
R[全局变量 / 栈 / 寄存器]
end
subgraph 堆[堆]
A["A(黑色)"]
B["B(灰色)"]
C["C(白色)"]
D["D(白色)"]
end
R --> A
A --> B
A -. 未扫描 .-> C
D -. 无引用,垃圾 .-> X[回收]三色标记不是标记清除的替代品,是它的并发化版本:把「这个对象处理到哪一步」用颜色记下来,标记就能增量推进,扫完的变黑,待扫的保持灰;程序照常跑。清除阶段不变。
并发标记的问题:黑对象引用白对象
标记是并发的,程序一边跑一边标。某个黑色对象(已扫描完,确定存活)可能被程序写入一个新指针,指向还没扫到的白色对象。如果不处理,这个白色对象永远标不到,会被当成垃圾回收,程序之后再访问它就崩了。
解决办法是写屏障(write barrier):GC 期间,每次堆指针写入都拦截一下,把相关对象标灰。
强三色不变式和弱三色不变式
并发标记要防两种破坏:
- 黑色对象直接引用白色对象。白色挂在黑色下面,没有灰色对象可达它,永远不会被扫到。
- 灰色对象到白色对象的可达关系被破坏。灰色对象对白色的引用被覆盖掉,白色失去被发现的路径。
对应两个不变式:
- 强三色不变式:禁止黑色对象引用白色对象。破坏条件 1。
- 弱三色不变式:允许黑色对象引用白色对象,但前提是那个白色对象仍然被某个灰色对象可达。破坏条件 2。
白色对象怎么知道上游有没有黑色对象?不用它知道。不变式不是靠对象自查维持的,是写屏障在每次指针写入时保证的:黑色对象要拿到白色引用,屏障当场把白色标灰,「黑→白」还没成立就被拆掉了。
插入屏障(Dijkstra)
写指针时,把新指向的对象标灰。防「黑指向白」漏标。比如程序把新指针写进黑色对象,新目标还是白色的话,当场标灰,进灰色队列:
flowchart LR
B["B(黑色对象)"] -->|"写入新指针"| W["W(白色对象)"]
W -->|"插入屏障:标灰"| GQ["灰色队列"]
flowchart LR
B["B(黑色对象)"] -->|"写入新指针"| W["W(白色对象)"]
W -->|"插入屏障:标灰"| GQ["灰色队列"]
flowchart LR
B["B(黑色对象)"] -->|"写入新指针"| W["W(白色对象)"]
W -->|"插入屏障:标灰"| GQ["灰色队列"]
flowchart LR
B["B(黑色对象)"] -->|"写入新指针"| W["W(白色对象)"]
W -->|"插入屏障:标灰"| GQ["灰色队列"]Go 1.5-1.7 实际用的就是插入屏障,对堆指针写入无条件生效(shade(ptr) 后写槽位)。
为什么叫 Dijkstra?写屏障最早出现在 Dijkstra 1978 年的论文《On-the-fly garbage collection: an exercise in cooperation》——三色标记本身也是这篇论文提出的。屏障的规则以作者命名,就叫 Dijkstra 屏障。
删除屏障(Yuasa)
另一种经典屏障。写指针时,把被覆盖的旧引用指向的对象标灰。防「灰对象丢掉对白的引用后,白不再被扫描」:
flowchart LR
G["G(灰色对象)"] -->|"覆盖对 W 的引用"| NEW["新对象"]
G -.->|"旧引用指向"| W["W"]
W -->|"删除屏障:标灰"| GQ["灰色队列"]
flowchart LR
G["G(灰色对象)"] -->|"覆盖对 W 的引用"| NEW["新对象"]
G -.->|"旧引用指向"| W["W"]
W -->|"删除屏障:标灰"| GQ["灰色队列"]
flowchart LR
G["G(灰色对象)"] -->|"覆盖对 W 的引用"| NEW["新对象"]
G -.->|"旧引用指向"| W["W"]
W -->|"删除屏障:标灰"| GQ["灰色队列"]
flowchart LR
G["G(灰色对象)"] -->|"覆盖对 W 的引用"| NEW["新对象"]
G -.->|"旧引用指向"| W["W"]
W -->|"删除屏障:标灰"| GQ["灰色队列"]代价:屏障可能把已经没人引用的对象标灰救活,这一轮收不掉,下一轮才回收——浮动垃圾(floating garbage)。宁可错救,不可漏标。注意没有「对象被标记为要删除、下一轮再删」这回事:白色对象标记结束直接回收,会拖到下一轮的是被屏障误救的浮动垃圾。
为什么叫 Yuasa?这个屏障出自 Taiichi Yuasa 1990 年的论文《Real-time garbage collection on general-purpose machines》,同样以作者命名。Go 1.8 之前没用它,1.8 的混合屏障才把它的思想引进来。
栈为什么扫两遍
写屏障只插桩堆指针写入,栈上的指针写入不经过屏障,纯粹为了性能——栈写入太频繁,每次都拦扛不住。
但标记是并发的,程序照常跑。这就引出插入屏障时代的问题:栈不设防,怎么保证它不藏白色对象?
Go 的答案是扫两遍:
- 第一遍(标记启动时):把所有 goroutine 的栈扫一遍,栈引用全部标灰。
- 第二遍(标记终止 STW):重扫所有栈,把标记期间新出现的引用补标。
为什么扫过一遍的栈还要重扫?栈扫完会变黑,但那是「冻结状态」的黑——goroutine 一旦继续执行,往栈上写新的指针,栈的黑色就不可信了。没有栈写屏障记录这些写入,只能保守处理:扫描过的栈,只要它的 goroutine 又跑过,就变回灰色,等终止时重扫。重扫要遍历所有 goroutine 的栈,STW 时长和 goroutine 数量挂钩,几十到几百毫秒都可能——这是 Go 1.5-1.7 停顿的主要来源,也是阶段 3 要解决的问题。
flowchart LR
subgraph t1[标记启动]
A1[扫一遍所有栈
栈引用标灰] --> A2[栈暂时变黑]
end
subgraph t2[标记期间]
B1[goroutine 继续执行
栈上写入新引用] --> B2[栈变回灰色
无人记录新引用]
end
subgraph t3[标记终止 STW]
C1[重扫所有灰栈
补标新引用]
end
t1 --> t2 --> t3
flowchart LR
subgraph t1[标记启动]
A1[扫一遍所有栈
栈引用标灰] --> A2[栈暂时变黑]
end
subgraph t2[标记期间]
B1[goroutine 继续执行
栈上写入新引用] --> B2[栈变回灰色
无人记录新引用]
end
subgraph t3[标记终止 STW]
C1[重扫所有灰栈
补标新引用]
end
t1 --> t2 --> t3
flowchart LR
subgraph t1[标记启动]
A1[扫一遍所有栈
栈引用标灰] --> A2[栈暂时变黑]
end
subgraph t2[标记期间]
B1[goroutine 继续执行
栈上写入新引用] --> B2[栈变回灰色
无人记录新引用]
end
subgraph t3[标记终止 STW]
C1[重扫所有灰栈
补标新引用]
end
t1 --> t2 --> t3
flowchart LR
subgraph t1[标记启动]
A1[扫一遍所有栈<br>栈引用标灰] --> A2[栈暂时变黑]
end
subgraph t2[标记期间]
B1[goroutine 继续执行<br>栈上写入新引用] --> B2[栈变回灰色<br>无人记录新引用]
end
subgraph t3[标记终止 STW]
C1[重扫所有灰栈<br>补标新引用]
end
t1 --> t2 --> t3那能不能只在终止时扫一遍,省掉第一遍?不能。第一遍扫完,从栈出发的整片对象图进入灰色队列,在并发期被消化掉;省掉它,标记期间栈根缺席,所有栈可达对象都等到终止 STW 扫栈后才被发现,标记工作全部堆进暂停里,停顿不可控。终止的重扫是正确性兜底(栈只要跑过就变灰,终止必扫),但它兜不住性能——所以两遍都要。
注意标记进度在两次 STW 之间是延续的:灰队列、已标黑的对象都保留着,STW 只是暂停程序,世界恢复后从灰队列继续。第二遍扫栈补出的新引用并入已有的标记流程,不是从头重新标记。
阶段 3:三色标记 + 混合写屏障(Go 1.8+)
动机:去掉栈重扫
阶段 2 的栈重扫让 STW 随 goroutine 数量增长。Go 1.8 把插入屏障和删除屏障合并成混合写屏障(runtime/mbarrier.go 的伪代码):
writePointer(slot, ptr):
shade(*slot) # 旧值标灰,无条件
if 写入者的栈还没被扫描:
shade(ptr) # 新值标灰
*slot = ptr- 删除部分无条件:任何覆盖堆引用的写入都把旧对象标灰,防止程序把堆里唯一指针挪到栈上藏起来——想藏,先断开,断开的瞬间就被标灰。
- 插入部分有条件:写入者的栈还是灰色(没扫过)才需要标灰新值。
为什么栈还没扫时必须标灰新值?写入把新值装进了一个黑色对象——黑对象已经扫描完,之后不会再处理它的引用,这条边一旦建立就只能靠写屏障把关。栈虽然是灰色的、迟早要扫,但扫栈扫的是「扫描那一刻」栈上的引用:程序写完这个指针,转头就把栈上的副本覆盖掉(变量离开作用域、复用),扫栈时已经找不到它了。等扫栈再标灰,新值可能已经变成「黑对象里的白对象,没有任何灰色保护」——漏标。所以必须在写入的瞬间把新值标灰,送进灰色队列,不能等扫栈:
// 标记进行中,本 goroutine 的栈还没被扫描
var root *Obj // 黑色对象,已扫描完
func f(w *Obj) { // w 可能指向白色对象:栈还没扫过,引用未处理
root.field = w // 写入瞬间:把 w 标灰,进灰色队列
// 之后 w 离开作用域,栈上不再有它的引用
// 如果写入时没标灰,w 就只剩 root.field 一条引用,而 root 不会再被扫描 → 漏标
}那栈变黑后为什么可以省?关键看新值从哪来。
程序往堆里写指针,这个指针值不可能凭空生成,只能从已存在的地方读出来,最常见的是读堆对象的字段:
w := obj.field // w 的值从 obj.field 读出来obj 只有两种状态:
- obj 已经扫描(黑色):扫描 obj 时,它所有字段指向的对象都被标灰了。w 指向的对象必然已经标灰,写入无需处理。
- obj 还没扫描(灰色):obj 还在灰色队列里排队,扫描它会处理它的字段。w 指向的对象可能暂时还是白色,但它被队列里的 obj 引用着——等 obj 被扫时自然标灰,不会漏。
flowchart LR
subgraph caseA[情况 A:obj 已扫描]
A1["obj(黑)"] -->|"字段指向"| A2["目标(已标灰)"]
A3["w = obj.field"] -.->|"写进堆"| A2
end
subgraph caseB[情况 B:obj 未扫描]
B1["obj(灰,队列中)"] -->|"字段指向"| B2["目标(白)"]
B3["w = obj.field"] -.->|"写进堆"| B2
B2 -.->|"等 obj 被扫时标灰"| B4["不会漏"]
end
flowchart LR
subgraph caseA[情况 A:obj 已扫描]
A1["obj(黑)"] -->|"字段指向"| A2["目标(已标灰)"]
A3["w = obj.field"] -.->|"写进堆"| A2
end
subgraph caseB[情况 B:obj 未扫描]
B1["obj(灰,队列中)"] -->|"字段指向"| B2["目标(白)"]
B3["w = obj.field"] -.->|"写进堆"| B2
B2 -.->|"等 obj 被扫时标灰"| B4["不会漏"]
end
flowchart LR
subgraph caseA[情况 A:obj 已扫描]
A1["obj(黑)"] -->|"字段指向"| A2["目标(已标灰)"]
A3["w = obj.field"] -.->|"写进堆"| A2
end
subgraph caseB[情况 B:obj 未扫描]
B1["obj(灰,队列中)"] -->|"字段指向"| B2["目标(白)"]
B3["w = obj.field"] -.->|"写进堆"| B2
B2 -.->|"等 obj 被扫时标灰"| B4["不会漏"]
end
flowchart LR
subgraph caseA[情况 A:obj 已扫描]
A1["obj(黑)"] -->|"字段指向"| A2["目标(已标灰)"]
A3["w = obj.field"] -.->|"写进堆"| A2
end
subgraph caseB[情况 B:obj 未扫描]
B1["obj(灰,队列中)"] -->|"字段指向"| B2["目标(白)"]
B3["w = obj.field"] -.->|"写进堆"| B2
B2 -.->|"等 obj 被扫时标灰"| B4["不会漏"]
end所以从黑栈写进堆的新值,要么已经标灰,要么还挂在某个灰对象上等扫描——不可能是「彻底没人管的白色」。插入部分可以省掉。
效果:栈只扫一遍
标记开始不用再预扫栈,重扫也没了。栈在并发标记阶段逐个挂起 goroutine 扫描(scanstack),扫完变黑;标记终止的短 STW 只补扫还没扫完的栈。
并发标记期间,扫栈和扫堆是交错进行的,没有「先扫完栈再扫堆」的顺序:灰色队列里既有「扫描堆对象」的工作项,也有「扫描 goroutine 栈」的工作项,GC worker 取出哪个做哪个:
flowchart LR
S1["扫栈 A"] --> S2["扫堆对象 X"] --> S3["扫栈 B"] --> S4["扫堆对象 Y"] --> S5["扫栈 C"]
flowchart LR
S1["扫栈 A"] --> S2["扫堆对象 X"] --> S3["扫栈 B"] --> S4["扫堆对象 Y"] --> S5["扫栈 C"]
flowchart LR
S1["扫栈 A"] --> S2["扫堆对象 X"] --> S3["扫栈 B"] --> S4["扫堆对象 Y"] --> S5["扫栈 C"]
flowchart LR
S1["扫栈 A"] --> S2["扫堆对象 X"] --> S3["扫栈 B"] --> S4["扫堆对象 Y"] --> S5["扫栈 C"]扫过的栈和对象都变黑,不需要重扫——栈扫完变黑后,goroutine 继续执行产生的写入由混合屏障兜底(见上一节),这就是 Go 1.8 能把栈扫描彻底摊进并发期的原因。
GC 期间新分配的对象直接标黑(allocate black)。它们就算马上变成垃圾,本轮也不回收,下一轮才清掉——也算浮动垃圾。
两个场景:
- 程序往黑色对象里写一个刚拿到的指针,指向白色对象:写入者栈还没扫,插入部分把白色对象标灰,它进灰色队列,不会漏。
flowchart LR
S["栈(灰色,未扫描)"] -.->|"持有指针"| W["W(白色对象)"]
B["B(黑色对象)"] -->|"写入 B.slot = W"| W
W -.->|"插入部分触发:标灰"| GQ["灰色队列"]
flowchart LR
S["栈(灰色,未扫描)"] -.->|"持有指针"| W["W(白色对象)"]
B["B(黑色对象)"] -->|"写入 B.slot = W"| W
W -.->|"插入部分触发:标灰"| GQ["灰色队列"]
flowchart LR
S["栈(灰色,未扫描)"] -.->|"持有指针"| W["W(白色对象)"]
B["B(黑色对象)"] -->|"写入 B.slot = W"| W
W -.->|"插入部分触发:标灰"| GQ["灰色队列"]
flowchart LR
S["栈(灰色,未扫描)"] -.->|"持有指针"| W["W(白色对象)"]
B["B(黑色对象)"] -->|"写入 B.slot = W"| W
W -.->|"插入部分触发:标灰"| GQ["灰色队列"]- 程序覆盖一个堆引用,旧对象被无条件标灰:如果它还被别处引用,安全;如果已经没人引用,下一轮才被回收。这个动作和写入者栈的颜色无关,删除部分总是触发。
flowchart LR
B["B(黑色对象)"] -->|"覆盖 B.slot,指向新对象"| NEW["新对象"]
B -.->|"旧引用被断开"| W["W(白色对象)"]
W -->|"删除部分:无条件标灰"| GQ["灰色队列"]
flowchart LR
B["B(黑色对象)"] -->|"覆盖 B.slot,指向新对象"| NEW["新对象"]
B -.->|"旧引用被断开"| W["W(白色对象)"]
W -->|"删除部分:无条件标灰"| GQ["灰色队列"]
flowchart LR
B["B(黑色对象)"] -->|"覆盖 B.slot,指向新对象"| NEW["新对象"]
B -.->|"旧引用被断开"| W["W(白色对象)"]
W -->|"删除部分:无条件标灰"| GQ["灰色队列"]
flowchart LR
B["B(黑色对象)"] -->|"覆盖 B.slot,指向新对象"| NEW["新对象"]
B -.->|"旧引用被断开"| W["W(白色对象)"]
W -->|"删除部分:无条件标灰"| GQ["灰色队列"]触发条件
GC 由四种条件触发(gcTrigger 的四种方式):
- gcTriggerHeap(堆内存阈值):最主要的方式。堆上活跃对象增长到一定量就触发。阈值由 GOGC 控制,默认 100,意思是「上一次 GC 后堆涨了 100% 再触发」。比如上次 GC 后存活 4MB,那堆涨到 8MB 时触发下一次。GOGC 是环境变量,也可以在代码里用
debug.SetGCPercent改。 - gcTriggerTime(定时):
forcegcperiod是 2 分钟,由 sysmon 线程检查,超过 2 分钟没 GC 过就强制触发一次。纯空闲程序也会周期 GC。 - gcTriggerCycle(手动):代码里调
runtime.GC()触发。 - GOMEMLIMIT(内存软限制):Go 1.19+,设置后堆接近这个上限就提高 GC 频率,防止超限。软限制:可能短暂超过,但会尽力压住。默认不限制。
完整流程
一次 GC 分四个阶段(gcStart → gcMarkDone → gcSweep):
- Sweep(清理上一轮的残留):上一轮 GC 标记完的垃圾还没清完,这一轮开始前先清掉。清除是惰性的,
sweepone一次清一个 span(内存块),分摊到平时,不集中卡顿。 - Mark Setup(标记准备,短 STW):
stopTheWorld,所有 M 停到安全点(safe point,代码中可安全检查 GC 状态的位置),P 转 Pgcstop,gcphase 从 _GCoff 切到 _GCmark,开启写屏障。这个 STW 很短(微秒到亚毫秒级)。 - Marking(并发标记,主体):世界恢复运行,标记工作交给后台 GC worker(
gcBgMarkWorker,占用 P 的资源跑),程序继续执行。这期间写屏障拦截所有堆指针写入,把相关对象标灰。标记完(灰色队列空)进入下一步。 - Mark Termination(标记收尾,短 STW):再停一次世界,把还没扫完的栈扫掉,gcphase 切到 _GCmarktermination,最后关写屏障。然后世界恢复,进入下一轮的 Sweep 阶段。
整体上每轮 GC 只有两次很短的 STW,大头是并发标记,这就是 Go 1.5+ 以来 GC 停顿能压到毫秒级的原因。
调参
- GOGC:默认 100。调小:GC 更频繁、内存占用更低,代价是 CPU 开销更大;调大则反过来。内存充足、追求吞吐的场景适合调大。
- GOMEMLIMIT:Go 1.19+,
GOMEMLIMIT=500MiB或debug.SetMemoryLimit。给容器设置软内存上限,超过时提高 GC 频率压内存。 - GODEBUG=gctrace=1:打印每次 GC 的耗时、STW 时长、内存占用,观察 GC 行为用这个。
排查时先看 gctrace 输出,再决定调 GOGC 还是 GOMEMLIMIT,不要盲调。
面试题
Go GC 的实现经历了哪些主要阶段?
Go 1.3 及以前主要使用全程停世界的标记清除。程序暂停后完成标记和清除,停顿会随堆和对象数量增长。
Go 1.5 引入三色标记和并发标记,堆指针写入由插入屏障保护。Go 1.8 改用混合写屏障,把栈扫描放进并发阶段,每个栈只需扫描一次,标记终止时不再重扫全部栈。
三色标记的原理是什么?
- 白色对象还没被扫描,标记结束后仍为白色就会被回收。
- 灰色对象已经被发现,但它指向的对象还没处理完。
- 黑色对象已经扫描完成,它指向的对象都已进入标记流程。
GC 从全局变量、栈等根对象出发,把可达对象放入灰色队列。灰色队列清空后,剩余白色对象就是本轮要清理的垃圾。
写屏障有什么作用?混合写屏障做了什么?
写屏障(write barrier,指针写入时执行的额外逻辑)用于保护并发标记。没有它,程序可能让已扫描的黑色对象指向未标记的白色对象,GC 会漏标仍然存活的对象。
Go 1.8 之后的混合写屏障包含两部分:覆盖堆指针时,无条件标灰旧值;写入者的栈还没扫描时,再标灰新值。它保证对象不会被藏在未受保护的引用后面,也去掉了标记终止时的全栈重扫。
一轮 GC 中哪些阶段会发生 STW?
一轮 GC 有两次 STW(stop the world,暂停所有线程):
- 标记准备:切换到标记状态,开启写屏障,准备根扫描和后台标记工作。
- 标记终止:完成剩余标记工作,切换 GC 阶段,随后关闭写屏障。
并发标记是耗时主体,程序可以继续运行。清除通常惰性执行,不需要把整个清除阶段集中在一次 STW 中。
Go GC 会在什么情况下触发?
- 堆达到 GC 控制器计算的触发阈值时,触发
gcTriggerHeap。 - 距离上次 GC 超过 2 分钟时,后台监控触发
gcTriggerTime。GOGC=off时这个定时触发关闭。 - 调用
runtime.GC()时,通过gcTriggerCycle强制启动新一轮 GC。 - 设置 GOMEMLIMIT 后,运行时可能降低基于 GOGC 的堆目标,让 GC 更早运行。
GOGC=100 的准确含义是什么?
GOGC 控制下一轮 GC 的堆增长目标,默认值是 100。它表示新分配数据与上轮 GC 后存活数据的比例达到约 100% 时,应完成下一轮 GC;上轮存活堆为 4 MiB 时,目标大致是 8 MiB。
源码计算堆目标时还计入上轮的栈扫描量和全局变量扫描量:
heap goal = live heap + (live heap + stack scan + globals scan) × GOGC / 100GC 控制器会在堆目标之前计算实际启动阈值,给并发标记留出运行空间。因此 8 MiB 是近似完成目标,不是固定的启动点。负值或 GOGC=off 会关闭基于 GOGC 的触发,但内存软限制仍可触发 GC。
Go GC 应该怎样调优?
先用 GODEBUG=gctrace=1 或运行时指标观察 GC 频率、堆大小和暂停时间,再定位分配热点。频繁创建短命对象时,优先减少分配和复用缓冲区;不要先改 GOGC 掩盖问题。
内存充足且更看重吞吐时,可以调大 GOGC,减少 GC 次数。容器或内存受限服务应设置 GOMEMLIMIT,并给非 Go 堆内存和系统开销留余量。调小 GOGC 会降低堆占用,但会增加 GC 的 CPU 开销。
逃逸分析和 GC 有什么关系?
逃逸分析(escape analysis,编译器判断变量能否安全留在栈上的过程)决定部分对象分配在栈还是堆。栈上对象随函数返回自动失效,不需要 GC 单独回收;逃逸到堆上的对象会增加分配量和扫描工作。
减少不必要的逃逸通常能降低 GC 压力,但不能为了避免逃逸牺牲代码正确性。可以用 go build -gcflags='-m=2' 查看编译器的逃逸判断,再结合性能数据决定是否调整代码。