前言
早期的程序需要手动管理内存,比如c,c++这种,十分费劲。非常容易泄露。
后面出现的编程语言,基本都是自动回收内存,有go java这种追踪式的,还是有python这种引用计数方式的。
现在,甚至出现了rust这种,用所有权+栈式的内存管理。
不过今天来看一下golang的垃圾回收机制,有没有它吹得那么牛。
原理
垃圾回收原理已经有很多文章表述了,且写的都非常好,这里只做基本解释。
详细过程推荐这篇博文:community.apinto.com/d/34057-gol...
标记清除 < 1.5
go的早期版本(<1.5)采用的是标记清除方式。
gc开始时先stw,然后从根节点开始扫描资源,找到所有可达资源节点 并标记。
停止stw,开始清理未被标记的资源。
- stw: Stop The World 暂停业务程序。
这种方式清理内存,问题显而易见,标记时stw,这会导致业务程序长时间不能运行,有极大的性能损耗。
三色标记算法
为了解决上面的问题,go从1.5改用了三色标记算法回收内存。
三色标记法:
- 开始扫描前,将所有节点标记为白色
- gc开始,将根节点置为灰色
- 向根节点的下一个依赖节点进行扫描,将其标记为灰色,自身标记为黑色,类似下图
- 循环上个操作,直到标记全部可达节点为黑色,然后回收白色节点。
因为扫描标记过程和业务程序运行时同步的,所以会产生这样一种情况:
黑色节点引用了一个白色节点,白色节点和前面的灰色节点的引用关系又被破坏,这就会导致这个白色节点存在引用,但依然会被回收。如下图
产生这种情况,需要满足两个条件
- 条件一:一个黑色节点引用了一个白色节点
- 条件二:白色节点前面到灰色节点的引用关系遭到破坏
强三色不变式
为避免引用内存被回收,破坏条件一的方式 称为强三色不变式。
规则:不允许黑色对象引用白色对象
弱三色不变式
根据破坏条件二的处理方式 称为弱三色不变式
规则:黑色对象可以引用白色对象,但是白色对象的上游必须存在灰色对象
插入写屏障
满足强三色不变式
规则:当一个对象引用另外一个对象时,将另外一个对象标记为灰色。
go在开始使用三色标记法的时候,就是采用的插入写屏障。但这种保护只对堆内存资源生效。
原因是go的逃逸分析更倾向将资源分配到栈上,在大量goroution调度时,栈上的资源被频繁调度,如果存在插入写屏障,会产生极大的性能消耗。
go的做法是,在一次正常的三色标记流程之后,stw,然后对栈上的资源进行rescan一次。
当然stw+rescan这种重新扫描的操作,依然有极大的成本。
删除写屏障
满足弱三色不变式
规则:在删除引用时,如果被删除引用的对象自身为灰色或者白色,那么被标记为灰色。
这种方式会导致回收精度下降,很多应该回收的资源,会存活到下一轮gc。
混合写屏障
go在1.18之后,不再使用插入写屏障,而是自研了一种混合写屏障,避免了rescan问题,也不会降低回收精度。
规则:
- GC刚开始的时候,会将栈上的可达对象全部标记为黑色。
- GC期间,任何在栈上新创建的对象,均为黑色。
- 堆上被删除的对象标记为灰色
- 堆上新添加的对象标记为灰色
源码解读
这里依然是mac+go版本1.18.4
gc触发方式
方式一: 定时触发,runtime/proc.go
golang
func init() {
go forcegchelper()
}
func forcegchelper() {
...
for {
...
// Time-triggered, fully concurrent.
// 在gcStart 函数中会计算,如果上次gc在两分钟之前则会触发gc
gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
}
}
方式二: 内存分配阈值
在程序分配新的内存,函数runtime/malloc.go
->mallocgc
时,内存分配量超过一定的阈值时,垃圾回收器会被触发。
方式三: 主动触发,在业务代码中主动调起gc
scss
runtime.GC()
gc步骤
从上面三色标记法里可以看出来 gc大概有这样三个阶段
- 标记(Mark): 在这个阶段,垃圾回收器会标记所有仍然被引用的对象,以区分出哪些对象是存活的。
- 清除(Sweep): 在这个阶段,垃圾回收器会遍历堆上的对象,将那些被标记为不再被引用的对象释放掉,从而回收内存。
- 回收(Reclaim): 在清除阶段完成后,垃圾回收器会将回收的内存重新添加到空闲列表,以备后续的内存分配使用。
开始标记过程 gcStart
gc的主要过程在runtime/mgc.go
下的gcStart
中
go
func gcStart(trigger gcTrigger) {
//gc依赖malloc,malloc可能存在多个调用。如果它处于被抢占的情况下,则不开始gc
mp := acquirem()
if gp := getg(); gp == mp.g0 || mp.locks > 1 || mp.preemptoff != "" {
releasem(mp)
return
}
releasem(mp)
mp = nil
//清理标记位,为下一轮gc做准备
for trigger.test() && sweepone() != ^uintptr(0) {
sweep.nbgsweep++
}
//卡住 直到获取startSema信号,相当于加锁 只允许一个线程来操作
semacquire(&work.startSema)
//检查gc条件是否满足,
if !trigger.test() {
semrelease(&work.startSema) //不满足和释放信号量 并返回
return
}
//检查gc是否被强制执行,如果是用户主动gc,那么久不应该参考统计数据
work.userForced = trigger.kind == gcTriggerCycle
//设置gc模式,是否以debug设置的模式运行
mode := gcBackgroundMode
if debug.gcstoptheworld == 1 {
mode = gcForceMode
} else if debug.gcstoptheworld == 2 {
mode = gcForceBlockMode
}
//加锁 stw相关
semacquire(&gcsema)
semacquire(&worldsema)
//如果启用了全局跟踪事件,那发送一个traceEvGCStart事件 表示gc开始了
if trace.enabled {
traceGCStart()
}
// 检查所有的p是否完成了mcache的清理
for _, p := range allp {
if fg := atomic.Load(&p.mcache.flushGen); fg != mheap_.sweepgen {
println("runtime: p", p.id, "flushGen", fg, "!= sweepgen", mheap_.sweepgen)
throw("p mcache not flushed")
}
}
//启动开始后台标记的goroution,需要持有 worldsema
gcBgMarkStartWorkers()
//在标记开始之前重置全局状态,并重置所有 G 的堆栈扫描状态,并且这个过程运行在操作系统的堆栈上,防止污染
systemstack(gcResetMarkState)
//计算相关数据,防止gc过长
work.stwprocs, work.maxprocs = gomaxprocs, gomaxprocs
if work.stwprocs > ncpu {
work.stwprocs = ncpu
}
work.heap0 = atomic.Load64(&gcController.heapLive)
work.pauseNS = 0
work.mode = mode
now := nanotime()
work.tSweepTerm = now
work.pauseStart = now
if trace.enabled {
traceGCSTWStart(1) //同理,表示开始stw
}
// stw
systemstack(stopTheWorldWithSema)
// 在开始扫描之前确保清理完成
systemstack(func() {
finishsweep_m()
})
//顾名思义 清理 pools
clearpools()
work.cycles++
// 重置 GC 控制器的状态并计算新 GC 周期的估计值
gcController.startCycle(now, int(gomaxprocs))
work.heapGoal = gcController.heapGoal
// gc模式为gcBackgroundMode时,禁用g的调度
if mode != gcBackgroundMode {
schedEnableUser(false)
}
// 设置gc状态是_GCmark,相当于启动了写屏障
setGCPhase(_GCmark)
// gc开始标记前的一些设置
gcBgMarkPrepare() // Must happen before assist enable.
// 开始标记前,初始化根节点(堆栈 全局资源 杂项)
gcMarkRootPrepare()
// 标记微对象相关
gcMarkTinyAllocs()
// 设置gcBlackenEnabled=_GCmark 写屏障相关
atomic.Store(&gcBlackenEnabled, 1)
// 获取m
mp = acquirem()
// 停止stw,设置gc标记时的相关参数
systemstack(func() {
now = startTheWorldWithSema(trace.enabled)
work.pauseNS += now - work.pauseStart
work.tMark = now
memstats.gcPauseDist.record(now - work.pauseStart)
})
// 释放 worldsema ,释放m
semrelease(&worldsema)
releasem(mp)
// 启动g的调度
if mode != gcBackgroundMode {
Gosched()
}
//释放startSema
semrelease(&work.startSema)
}
- sweepone 函数:扫描
runtime.mheap_.mcentral
清理标记,释放那些不被引用的资源对象。具体的回收工作是在其里面的runtime.mspan.sweep函数中完成。 - gcBgMarkStartWorkers 会启动
GOMAXPROCS
个goroution进行标记工作,相当于每个处理器上一个。 - trigger.test 函数:用于检查gc条件是否满足,上面我们的定时gc就是在这里判断时间是否超过预定值
forcegcperiod
。 - stopTheWorldWithSema :是stopTheWorld的核心实现,需持有
worldsema
并禁用全局抢占 - Gosched 让出当前cpu,会在未来的某个时间再次执行
清理回收
清理回收主要还是靠sweepone
,类似下面的代码,和上文是一致的。
go
for ... && sweepone() != ^uintptr(0) {
...
}
分代的垃圾回收策略
相信聪明的你 已经发现,本轮gc标记的垃圾,会在下一轮次才能回收。go将对象分为,新生代(young generation)和老年代(old generation)。这个分代策略在一定程度上确保了高效的内存回收。
当然这也不绝对,比如runtime.GC()
会等待标记完成,清理一轮后才会进行函数返回。
尾语
go的垃圾回收,其实并不复杂,结合之前的go内存管理,启动流程一起看效果更佳。
虽然还有很多地方没有解读,比如写屏障的部分,标记的部分,以及回收的部分。
不过 相关小伙伴们在看过这三篇博文之后,整体脉络是清晰的,顺着这个思路往下捋,细枝末节也是可以捋清楚的。