golang系列之-垃圾回收
go的垃圾回收原理官方说法是是三色标记法+混合写屏障,但是,理论上怎么说是一回事,具体实现又是另一回事了,或者说实现已经偏离文档描述。总的来说,GC这部份的内容要比GMP跟内存分配都要复杂的多且出人意料。当前go版本:1.24
前言
目标对象
Go的GC并不扫描整个内存,只关注以下区域:
- heap上分配的对象(在mspan管理范围)
- data/bss段的全局变量
- 栈上的引用(扫描stackRoots)
触发类型
触发类型 | 备注 |
---|---|
gcTriggerHeap | 堆大小触发,heap内存达到一个临界点触发(最常用) |
gcTriggerTime | 时间触发,超时2min未执行GC则强制执行 |
gcTriggerCycle | 手动触发,用户调用runtime.GC() |
角色/分工
角色 | 作用 | 工作阶段 | 备注 |
---|---|---|---|
Mark Worker(标记工作线程) | 遍历对象图,标记存活对象,防止被错误回收 | 标记阶段(Marking) | 运行时创建多个并发 Mark Worker,加快标记速度 |
Sweeper(清扫器) | 清理未标记的对象,将其内存释放回空闲列表(mheap.free) | 清扫阶段(Sweeping) | 逐步清理,避免一次性 STW(Stop The World) |
Scavenger(内存回收器) | 释放长期未使用的堆内存,归还给OS以减少RSS | 后台运行(定期触发) | 主要针对大对象或空闲mspans,减少物理内存占用 |
标记工作线程
其中,标记工作线程的会根据工作模式进一步区分,如下
标记工作线程模式 | 备注 |
---|---|
gcMarkWorkerNotWorker | 默认,未运行 |
gcMarkWorkerDedicatedMode | 专用标记任务,最高优先级,非抢占 |
gcMarkWorkerFractionalMode | 比例标记任务,跟其他g共享时间,可被抢占 |
gcMarkWorkerIdleMode | 空闲时执行的低优先级标记任务,需要p空闲 |
CPU限制
标记工作线程的CPU使用率被限制在25%(GOGC=100时),假设系统使用一个6核CPU,那么GC在标记阶段大约会使用1.5个CPU资源:
- 1个CPU由Dedicated(专用模式)的标记工作线程持续占用,该线程不允许抢占,直到标记任务完成
- 0.5个CPU由Fractional(比例模式)的标记工作线程使用,该线程仅在由额外CPU资源可用时运行,并会根据系统负责动态调整自身的CPU使用率
完整运行流程
Sweep Termination(清理终止)
- STW(Stop The World),确保所有P都达到GC安全点
- 完成上一轮GC未完成的sweep(清扫),回收剩余的的mspan
- 准备GC统计数据,为新一轮的GC计算目标heap大小、触发阈值等
Mark(标记)
- STW,切换GC状态
- gcphase从_GCoff切换到_GCmark
- 开启写屏障(write barrier),允许Mutator协助GC标记,以维护三色标记不变性
- 启动GC后台线程,执行并发标记任务
- 根对象入队(包括栈、全局变量)
- 恢复世界(Start The World),GC线程进入并发标记阶段
- 从根对象开始标记,遍历所有可达对象
- 扫描灰色对象(已发现但未完全扫描的对象)并进行扫描,将其置黑,并将其引用的对象入队为灰色
- 混合写屏障(Hybrid Write Barrier)确保一致性
- 完成标记
- STW,切换GC状态
Mark Termination(标记终止)
- STW,切换GC状态
- gcphase从_GCmark切换到_GCmarktermination
- 停止并发标记任务
- 执行终结器finalizer,如果有的话
- 清理mcache以确保没有悬挂对象
- STW,切换GC状态
Sweep(清理)
- 切换GC状态
- gcphase从_GCmarktermination切换回_GCoff
- 关闭写屏障(Mutator不再协助GC标记)
- 恢复世界(Start The World),进入并发清扫阶段
- 清理未被标记的对象
- 回收mspan到mheap或mcentral,部份回收到mcache
- Mutator分配内存时,可能会触发增量清扫(Incremental Sweeping),加快回收过程
- 切换GC状态
满足触发条件,启动下一轮GC
简单的说,标记然后清扫
数据结构
如果没有特别说明的话,下面的耗时字段都指一个GC周期内的
workType
workType类似GMP里的全局调度器schedt,负责GC的任务调度与阶段状态,包括管理GC周期中的各类任务、调度器状态、根对象索引、全局任务队列、并发控制等
1 | // 世界停止前获取,恢复世界后释放。确保同一时间只有一个GC流程在运行 |
gcControllerState
gcControllerState-垃圾回收节奏控制(pacing),保持系统平稳运行。负责内存使用与触发GC的策略控制,包括控制GC何时触发,计算下一次触发时机、控制GC比例(目标)、动态调整参数(如GOGC)等
1 | var gcController gcControllerState |
gcCPULimiterState
CPU限制器,负责限制GC对CPU的占用
1 | var gcCPULimiter gcCPULimiterState |
清扫器
sweeper-清扫器,负责将内存回收到mheap
1 | var sweep sweepdata |
内存回收器
内存回收器负责将内存归还OS,其中
- scavenge-负责回收目标计算、回收时机判断
- scavenger-节奏调度器
- 当前目标释放速率(sleepRatio, targetCPUFraction)
- 实际是否要执行(shouldStop)
- 具体怎么执行(scavenge回调函数由pageAlloc注入)
- 用PI控制器sleepController平滑地控制回收速率:更类似GC的pacer
1 | // 回收目标计算、回收时机判断 |
缓冲区
这几个缓冲区都放在P内部,作为本地缓冲区,全局缓冲区放在workType
- workbuf-任务缓冲区
- wbBuf-写屏障缓冲区
1 | // 工作队列 |
其他
- gcTrigger-触发类型
- gcBgMarkWorkerNode-标记工作线程
1 | // 触发类型 |
触发方式
时间触发
时间触发的GC注册在init函数,由runtime.main负责启动,如果检查到2min内没有执行过任何GC,则触发运行
1 | // src/runtime/proc.go |
堆大小触发
每次分配内存时,判断heap内存大小是否达到临界点,达到临界点则触发运行
1 | if t := (gcTrigger{kind: gcTriggerHeap}); t.test() { |
手动触发
由用户调用runtime.GC()手动触发,大概的逻辑为:
- 等待当前周期结束
- 执行新周期的GC
- 等待标记、清扫完全结束
- 更新heap统计数据
该操作会阻塞当前线程,甚至是阻塞整个程序
1 | func GC() { |
完整流程
第1阶段:Sweep Termination(清理终止)
大概流程如下
- guard,检查函数运行的前提条件
- 协助sweeper清理剩余的mspan
- 创建标记工作线程(这个阶段前,将世界停止)
- 重置GC状态
- 停止所有p
- 清扫、回收
- 开始启动GC(设置各种状态、纪录快照等,开启写屏障)
- 一切都准备好了,可以并发标记了(这个阶段后,恢复世界)
1 | func gcStart(trigger gcTrigger) { |
第2阶段:Mark(标记)
大概流程如下
- 挂起等待调度器唤醒
- 根据模式设置标志位,运行标记任务
- 初始化参数
- 扫描根对象
- 从队列获取灰色对象标记
- 重置相关字段、纪录耗时,如果是最后一个标记工作线程,启动第3阶段:Mark Termination(标记终止)
- STW,确定已经没有标记任务
- 根据统计数据调整栈的初始大小
- 唤醒所有因为辅助标记、weak->strong转换挂起的g
- 运行user类型的g运行
- 计算目标heap大小和并发标记进度
1 | // 标记工作线程,负责第2阶段:Mark(标记) |
第3阶段:Mark Termination(标记终止)
大概流程如下
- 设置各种状态
- gcphase切换到_GCmarktermination,继续开启写屏障
- m禁止抢占,g切换到_Gwaiting,可抢占
- 清空allgs快照、gcw/mcache等残留处理
- gcphase切换到_GCoff,关闭写屏障,开启第4阶段:Sweep(清理)
- 统计信息并计算pacing参数,重置scavenger状态
- 同步heap内存使用量
- 更新memstats、cpuStats耗时信息
- 设置GC启动阈值、计算目标heap大小和跑道大小
- 计算辅助GC的工作量转换参数
- 计算GC触发阈值和目标heap大小
- 更新sweeper、scavenger的pacing参数
- 重置scavenge状态、更新scavengeIndex状态
- 收尾/清理
- g切换到_Grunning,m允许抢占
- 将sweepWaiters放进本地/全局队列,并尝试唤醒m处理
- epoll轮询、p数量调整、唤醒sysmon、唤醒m绑定p执行任务、统计等
- 清空empty链表,将busy链表数据搬到free链表
- stackpool内所有allocCount为0的mspan以及stackLarge所有mspan全部释放
- mcache清理
- alloc列表mspan放到partial或full链表、tiny区域清空
- 清空stackcache,如果是_GCoff阶段,将空的mspan释放回mheap
- pageCache清空
- 如果目标heap大小超过1GB,尝试开启大页支持
到这里,世界开始恢复
1 | func gcMarkTermination(stw worldStop) { |
第4阶段:Sweep(清理)
大概流程如下
- 重置mheap、sweep相关状态
- 非并发清理模式或手动强制GC
- mcache清理
- alloc列表mspan放到partial或full链表、tiny区域清空
- 清空stackcache,如果是_GCoff阶段,将空的mspan释放回mheap
- 不断的逐个清理mspan
- 遍历272个mcentral,清扫每个mspan
- 用gcmarkBits覆盖allocBits
- 所有对象都被释放则释放整个mspan
- 累计各种统计数据
- 如果清扫完毕则唤醒scavenger
- 遍历272个mcentral,清扫每个mspan
- 清空empty链表,将busy链表数据搬到free链表
- wbuf的free链表清理64个mspan、mspan内存释放回mheap
- mcache清理
- 并发清理模式(默认)
- 唤醒后台清扫器-sweeper清扫
- 不断的逐个清理mspan
- 遍历272个mcentral,清扫每个mspan
- 用gcmarkBits覆盖allocBits
- 所有对象都被释放则释放整个mspan
- 累计各种统计数据
- 如果清扫完毕则唤醒scavenger
- 遍历272个mcentral,清扫每个mspan
- 每完成10次mspan清扫,执行一次goschedIfBusy,当可抢占时让出CPU
- wbuf的free链表清理64个mspan、mspan内存释放回mheap,当可抢占时让出CPU
- 还有sweeper在运行时,继续清理mspan,否则挂起休眠
- 清理mspan时,如果判断已经没有更多的mspan需要清理则设置完结标记,最后一个sweeper通知sysmon唤醒scavenger
- 不断的逐个清理mspan
- 唤醒后台清扫器-sweeper清扫
当scavenger被唤醒后,大概逻辑如下
- scavenger开始回收,运行时间限制1ms
- 如果heap内存没有超过GC触发临界点且总内存没有超过限制,退出
- 执行mheap_.pages.scavenge,回收64KB字节数量的内存,统计累计耗时
- 根据searchAddr从高地址chunk往低地址chunk扫描
- 如果chunk已使用页数<496则不需要回收,否则返回chunk索引、页位置
- searchAddr定位到chunk内最高页(chunk内从低到高扫描)
- 在指定chunk内回收一定数量的页(最低16个页)
- 先标记为已分配,释放回系统,最后标记为释放
- 根据searchAddr从高地址chunk往低地址chunk扫描
- 如果回收字节数不足64KB,说明没有更多内存需要回收,退出
- 如果累计耗时超过1ms,退出
- 如果释放的字节数为0,挂起休眠,唤醒后重试
- 累计释放字节数,让出CPU挂起一段时间,更新sleepRatio等信息
1 | // 启动清扫 |
混合写屏障
不知道怎么说,从源代码看,跟设计思路对不上
设计思路伪代码:
1 | // writePointer(slot, ptr): |
实际:
在开启写屏障后,一些内存复制函数会将源内存区域跟目标内存区域内的指针对象收集起来,放入写屏障缓冲区,交给标记工作线程标记。涉及到slice、map、channel等组件,相关内存处理函数如下所示:
- typedmemmove
- typedslicecopy
- typedmemclr
- memclrHasPointers
- makeslicecopy
- growslice
- reflect.typedmemclr
- reflect.typedmemclrpartial
- reflect.typedarrayclear
其中,typedmemmove源码如下
1 | // 从src复制数据到dst |
相关依赖函数
GC初始化
1 | // GC初始化,调度器初始化时调用 |
gcTrigger-GC触发
1 | // 判断是否满足条件运行GC |
世界停止/恢复
1 | // 使其他p中断/停止执行g进入安全点 |
标记线程相关
1 | // 获取快照,计算各个根对象的块数量、基地址(索引) |
清扫器相关
1 | // 计数器加1,返回mheap_.sweepgen及sweepDrainedMask标记是否已设置 |
内存回收器相关
scavengerState-节奏调度器
1 | // scavenger初始化,绑定g |
scavengeIndex-回收索引
1 | // 初始化 |
内存管理相关
1 | // 从0到272获取mcentral,再把所有未清扫的mspan一个个返回 |
1 | // 回收指定字节数量的内存,先标记为已分配,释放回系统,最后标记为释放(扫描时是从高地址向低地址进行搜索) |
缓冲区
任务缓冲区
1 | // 初始化wbuf1、wbuf2 |
写屏障缓冲区
1 | // 重置wbBuf的next、end指针 |
gcControllerState-垃圾回收节奏控制
1 | // 设置GC启动阈值、计算目标heap大小和跑道大小,计算辅助GC的工作量转换参数,计算GC触发阈值和目标heap大小,更新sweeper、scavenger的pacing参数 |
gcCPULimiterState-CPU限制器
1 | // enabled为true => 限制GC运行 |
GMP调度相关
1 | // 发起GC时,已经有其他G运行(同一个周期),挂起等待(_GCmarktermination时唤醒) |
栈相关
1 | // 释放栈 |
bitmap操作相关
1 | // 从gcBitsArenas分配足以容纳nelems个位的内存(64的倍数向上取整) |
参考文档
Memory Optimization and Garbage Collector Management in Go
A Guide to the Go Garbage Collector
Go: How Does the Garbage Collector Mark the Memory?
Go Does Not Need a Java Style GC
Go 语言的 GC 实现分析
一文弄懂 Golang GC、三色标记、混合写屏障机制
Golang垃圾回收(GC)介绍
How does go GC identify GC roots
What is a garbage collection (GC) root?
关于Golang GC的一些误解–真的比Java算法更领先吗?
golang 垃圾回收(五)混合写屏障