清扫与归还
1. sweep 在干什么
标记结束时仍未被标到的堆对象当作本轮垃圾;清扫阶段把这些对象占的槽位回收给分配器,必要时整 span 归还,并清好 GC 位图,保证下一轮标记前堆里状态一致。此时 gcphase 已是 _GCoff,写屏障关闭,新对象又是「白」的起点。
2. runtime 里两类 reclaim(文件头注释)
mcentral:按对象大小类挂链表的「中心仓库」,分配器从这儿 cacheSpan 取 mspan。sweepone:异步从各 mcentral 的待扫链里揪一个 span 清一遍。
先把「清扫时机」放在前面看,会更容易理解后面的源码:
- 后台清扫:
bgsweep线程会循环调用sweepone,持续吃全局未扫队列。 - GC 启动前补尾巴:
gcStart里在真正 STW 前会for trigger.test() && sweepone()...,尽量把上一轮没扫完的尾巴补掉。 - 分配路径同步清扫(懒惰清扫):
mcentral.cacheSpan取货时,可能直接对 unswept span 做s.sweep(true);谁急着要 span,谁顺手先扫。 - 分配前按页补回收:
mheap.alloc里如果!isSweepDone()会先h.reclaim(npages),避免一边还有大量未扫页,一边继续猛涨堆。
一句话:清扫不是只在一个后台点发生,而是后台持续扫 + 前台按需帮扫 + 新一轮前补扫尾巴三条线并行。
runtime/mgcsweep.go 开头概括了分工:
// The sweeper consists of two different algorithms:
//
// * The object reclaimer finds and frees unmarked slots in spans. It
// can free a whole span if none of the objects are marked, but that
// isn't its goal. This can be driven either synchronously by
// mcentral.cacheSpan for mcentral spans, or asynchronously by
// sweepone, which looks at all the mcentral lists.
//
// * The span reclaimer looks for spans that contain no marked objects
// and frees whole spans. This is a separate algorithm because
// freeing whole spans is the hardest task for the object reclaimer,
// but is critical when allocating new spans. The entry point for
// this is mheap_.reclaim and it's driven by a sequential scan of
// the page marks bitmap in the heap arenas.
//
// Both algorithms ultimately call mspan.sweep, which sweeps a single
// heap span.
译文:
清扫器由两种不同算法组成:
-
对象回收器:在 span 里查找并释放未被标记的槽位。若某个 span 里对象都未标记,也可以释放整段 span,但那不是它的主要目标。对 mcentral 管理的 span,它可以由 mcentral.cacheSpan 同步驱动;也可以由 sweepone 异步驱动,后者会查看所有 mcentral 链表。
-
span 回收器:寻找没有任何被标记对象的 span,并整段释放这些 span。单独做成一套算法,是因为「释放整 span」对对象回收器来说是最难的一类工作,但在需要分配新 span时又非常关键。入口是 mheap_.reclaim,通过顺序扫描堆 arena 中的 page marks 位图来驱动。
两种算法最终都会调用 mspan.sweep,即清扫单个堆 span。
一句话:按 span 扫未标槽、按堆元数据找可扔的整 span,最后都落到 mspan.sweep。
3. sweepone:异步地扫一个 span
后台 bgsweep 和 gcStart 里 for 循环的尾巴都会反复调用 sweepone。它从 central 的「未扫」列表里拿 span,加锁后 sweep,返回本轮释放的页数或 ^uintptr(0) 表示暂时没有要扫的(runtime/mgcsweep.go):
// sweepone sweeps some unswept heap span and returns the number of pages returned
// to the heap, or ^uintptr(0) if there was nothing to sweep.
func sweepone() uintptr {
// ...
for {
s := mheap_.nextSpanForSweep()
// 当前这一轮里已经没有任何待扫的 span
if s == nil {
// 标记「清扫工作基本耗尽」
noMoreWork = sweep.active.markDrained()
break
}
// ...
if s, ok := sl.tryAcquire(s); ok {
npages = s.npages
if s.sweep(false) {
mheap_.reclaimCredit.Add(npages)
} else {
npages = 0
}
break
}
}
// ...
}
// nextSpanForSweep:从各尺寸档位的 mcentral 里「未扫」链表中 pop 一个 span,交给 sweepone;没有则返回 nil。
// numSweepClasses = 每种 spanClass 对应两条扫表索引(full / partial 未扫链表),见 sweepClass.split。
func (h *mheap) nextSpanForSweep() *mspan {
sg := h.sweepgen // 当前代 sweepgen,未扫链表按代区分
// centralIndex:全局游标,记录上次找到 span 的位置;从此处往后扫,减少每次都从 0 空转。
for sc := sweep.centralIndex.load(); sc < numSweepClasses; sc++ {
spc, full := sc.split() // spc=尺寸档;full=true 从「满」未扫链取,false 从「不满」未扫链取
c := &h.central[spc].mcentral
var s *mspan
if full {
s = c.fullUnswept(sg).pop()
} else {
s = c.partialUnswept(sg).pop()
}
if s != nil {
// 记下当前 sc,后续 sweeper 可从附近接着找(索引单调前移)
sweep.centralIndex.update(sc)
return s
}
// 本档本条链表空,sc++ 试下一档或另一条 full/partial 组合
}
// 所有组合都空:标记扫表走完
sweep.centralIndex.update(sweepClassDone)
return nil
}
4. (*sweepLocked).sweep:按标记位回收槽位(runtime/mgcsweep.go)
nextSpanForSweep 只负责取出待扫 span;「哪些槽是本轮垃圾」在这里处理:allocBits 表示哪些槽在本轮已分配,gcmarkBits(经 markBitsForIndex)表示是否被标活;未标记且确为已分配的对象会释放,最后 gcmarkBits 与 allocBits 交换角色,为下一轮标记清好位图。
// preserve 表示:扫完这个 span 之后,要不要由 sweep 自己负责「挂回 mcentral / 还给堆」。
func (sl *sweepLocked) sweep(preserve bool) bool {
// --- 前置:须在不可抢占上下文;取出 mspan;preserve 时保留 sl.mspan 供调用方 ---
...
s := sl.mspan
if !preserve {
sl.mspan = nil
}
sweepgen := mheap_.sweepgen
...
// 1. Specials(finalizer / weak / reachable 等)
// 未标记:有 finalizer → 复活 mark + freeSpecial 入队;无 finalizer → 清掉 specials。
// 已标记:specialReachable → 设 reachable + freeSpecial;其它 kind 保留链表节点。
hadSpecials := s.specials != nil
siter := newSpecialsIter(s)
for siter.valid() {
...
}
if hadSpecials && s.specials == nil {
spanHasNoSpecials(s)
}
...
// 2. Zombie:gcmark &^ alloc 非 0 → alloc 认为 free 但 mark 仍标存活 → reportZombies
if s.freeindex < s.nelems {
obj := uintptr(s.freeindex)
if (*s.gcmarkBits.bytep(obj / 8)&^*s.allocBits.bytep(obj / 8))>>(obj%8) != 0 {
s.reportZombies()
}
// Check remaining bytes.
for i := obj/8 + 1; i < divRoundUp(uintptr(s.nelems), 8); i++ {
if *s.gcmarkBits.bytep(i)&^*s.allocBits.bytep(i) != 0 {
s.reportZombies()
}
}
}
// 3. 统计空闲对象、更新 span 元数据
nalloc := uint16(s.countAlloc())
nfreed := s.allocCount - nalloc
if nalloc > s.allocCount {
throw("sweep increased allocation count")
}
s.allocCount = nalloc
s.freeindex = 0
s.freeIndexForScan = 0
// 4. 位图交接 + sweepgen
// 旧 gcmarkBits → 新 allocBits;新分配全 0 的 gcmarkBits 给下一轮标记。
s.allocBits = s.gcmarkBits
s.gcmarkBits = newMarkBits(uintptr(s.nelems))
if s.pinnerBits != nil {
s.refreshPinnerBits()
}
s.refillAllocCache(0)
// 再次校验 span 状态未被并发破坏
...
atomic.Store(&s.sweepgen, sweepgen)
// 5. 小对象 / 大对象 / user arena / preserve
// user arena 跟着 arena 自己的生命周期(隔离区、回收、复用)
if s.isUserArenaChunk {
// arena chunk 不允许 preserve:不能仿 cacheSpan 扫完再交给调用方挂链
if preserve {
throw("sweep: tried to preserve a user arena span")
}
if nalloc > 0 {
// chunk 里仍有对象:还不能回收整块,先挂 fullSwept 等后续周期
mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
return false // 非普通 freeSpan,返回 false
}
// nalloc==0:chunk 已空,可交给 arena 运行时回收;不再走 mcentral 常规路径
mheap_.pagesInUse.Add(-s.npages) // 统计上不再占用这些页
s.state.set(mSpanDead)
systemstack(func() {
// quarantineList → readyList,供 arena 复用,不进普通 sweep 链
...
})
return false
}
if spc.sizeclass() != 0 {
// 小对象 span
if nfreed > 0 {
s.needzero = 1 // 释放过槽位,后续分配可能需清零
// memstats / gcController.totalFree ...
...
}
if !preserve {
// preserve==false:由本函数负责挂链或还堆
if nalloc == 0 {
// 槽全空:整段还给堆(sweepone 里对应 reclaimCredit 那类)
mheap_.freeSpan(s)
return true
}
if nalloc == s.nelems {
// 全满,无空闲槽
mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
} else {
// 仍有空闲槽
mheap_.central[spc].mcentral.partialSwept(sweepgen).push(s)
}
}
// preserve==true:调用方(如 cacheSpan)自己拿 span 去分配或再 push
} else if !preserve {
// 大对象 span(sizeclass==0);preserve 时同样不在这里挂链
if nfreed != 0 {
// 大对象已死:largeFree 统计;efence 时 sysFault 否则 freeSpan
...
return true
}
// 大对象仍活:整段还在用,挂 fullSwept
mheap_.central[spc].mcentral.fullSwept(sweepgen).push(s)
}
return false // 未整段 freeSpan 还给堆,或仅挂回 swept 链
}
- 参数preserve
- preserve == true:不要在 sweep 里把 span freeSpan 还给堆,也不要按惯例 fullSwept / partialSwept 挂链——调用方会接着处理这个 span。
- preserve == false:正常清扫路径(例如 sweepone),sweep 里要根据 nalloc / nfreed 自己决定 freeSpan 或 推到哪个 swept 链表。
- Specials(finalizer 等) 遍历 specials:对 未标记 的对象,若有 finalizer 则 复活标记 并入队 finalizer;否则 freeSpecial 清掉各类 special。 已标记 的对象上处理 specialReachable 等。
**finalizer(终结器)**指的是:你给某个堆对象绑定的、在它被 GC 真正回收之前(对象已经「不可达」之后)由 runtime 异步调用一次的函数。
在扫 span 时,先把 finalizer / weak / reachable 等特殊语义处理完,避免和后面的批量释放逻辑打架
-
Zombie 检查 检查 「已 free 但 mark 位仍标成存活」 的不一致,有问题则 reportZombies。
-
统计空闲对象、更新 span 元数据 countAlloc() 得到当前存活数,更新 allocCount、freeindex,统计 nfreed。
-
位图交接(核心)
当前 gcmarkBits 变成 allocBits(表示哪些 slot 已占用); 分配新的 gcmarkBits 供 下一轮 标记使用。 也就是说:上一轮 GC 的 mark 位图,清扫后变成 分配器用的 alloc 位图;新的 mark 位图 全清等待下次 GC。
- 小对象 / 大对象 / user arena / preserve 分支
- 小对象:needzero、统计 free;若 !preserve 且 nalloc==0 则 mheap_.freeSpan(返回 true);否则 fullSwept / partialSwept。
- 大对象:若释放了大对象则 freeSpan(返回 true);否则 fullSwept。
- User arena:有单独分支(quarantine/ready 等)。
分配路径在 mcentral.cacheSpan 等函数里也会同步扫、保证用到的 span 已是 swept 代,这就是懒惰清扫:不全靠后台 goroutine,谁要领内存谁可能先帮忙扫。
5. 比例清扫与「下一轮」
控制器按 GOGC 等算「这一轮该回收多少、下一轮何时再涨 trigger」,清扫进度与分配挂钩(比例 sweep),避免堆上攒大量未扫 span 还去要新内存。gcStart 里在进入新一轮 STW 前 for trigger.test() && sweepone() ...,是在补强制 GC 或误差下还没扫完的尾巴,和本章「阶段 I」叙述一致。
下面为 src/runtime 中的对应摘录。
GOGC 与「下一轮」堆目标(runtime/mgc.go 文件顶部注释;具体 heapGoal 等在 gcController、runtime/mgcpacer.go):
// GC rate.
// Next GC is after we've allocated an extra amount of memory proportional to
// the amount already in use. The proportion is controlled by GOGC environment variable
// (100 by default). If GOGC=100 and we're using 4M, we'll GC again when we get to 8M
// (this mark is computed by the gcController.heapGoal method). This keeps the GC cost in
// linear proportion to the allocation cost. Adjusting GOGC just changes the linear constant
// (and also the amount of extra memory used).
分配 n 页前先 reclaim(比例清扫与分配挂钩)(runtime/mheap.go,(*mheap).alloc):
func (h *mheap) alloc(npages uintptr, spanclass spanClass) *mspan {
var s *mspan
systemstack(func() {
// To prevent excessive heap growth, before allocating n pages
// we need to sweep and reclaim at least n pages.
if !isSweepDone() {
h.reclaim(npages)
}
s = h.allocSpan(npages, spanAllocHeap, spanclass)
})
return s
}
gcStart:进入本轮 GC、真正 STW 之前,先把剩余未扫 span 尽量扫掉(runtime/mgc.go,gcStart):
// Pick up the remaining unswept/not being swept spans concurrently
//
// This shouldn't happen if we're being invoked in background
// mode since proportional sweep should have just finished
// sweeping everything, but rounding errors, etc, may leave a
// few spans unswept. In forced mode, this is necessary since
// GC can be forced at any point in the sweeping cycle.
//
// We check the transition condition continuously here in case
// this G gets delayed in to the next GC cycle.
for trigger.test() && sweepone() != ^uintptr(0) {
}
6. 小结
标记决定谁该活;清扫把死对象的空间还回去;_GCoff 下并发 sweep 与 mutator 并行,直到全局未扫队列耗尽,周期在逻辑上收尾,等待下次触发再进 gcStart。