存一下, 以后有时间再看看源码重写一遍
runtime/
├── mgc.go # GC 启动入口、阶段调度
├── mgcphase.go # GC 各阶段常量、状态机
├── mgcpacer.go # GC 调度与 pacing 策略
├── mgcmark.go # 并发标记主流程
├── mgcmarktermination.go # 并发标记结束收尾逻辑
├── mbwbuf.go # 写屏障缓冲(barrier buffer)
├── mbitmap.go # 对象位图与标记集数据结构
├── msweep.go # 并发清扫流程
├── mgcscavenge.go # 堆碎片整理、堆切换逻辑
├── mheap.go # 堆管理:span 分配与回收
├── mspan.go # span 结构与对象布局
├── mcentral.go # central free list 管理
├── mcache.go # per-P mcache 快速小对象分配
旧
Go v1.3之前 清除标记算法
第一步, 暂停程序业务逻辑, 递归标记所有可达的堆对象, 做上标记 第二步 , 回收所有未被标记的对象
缺点
由于需要程序暂停, 会出现卡顿 标记的时候需要扫描整个heap
三色标记
本文的主要内容, 也就是三色标记法
先说下根对象是什么 根对象: 是指在GC中,作为起点的对象. 通常包括 全局变量, 当前栈上的变量, 寄存器的引用, runtime内部结构 等
步骤
- 新创建的对象, 默认都是白色
- 每次GC回收开始, 从根节点遍历所有对象, 把遍历到的对象从白色集合放入灰色集合
- 遍历灰色集合, 把灰色对象引用到的对象从白色放入灰色集合, 之后将灰色对象放入黑色集合
- 重复第三步, 直到灰色无任何对象
- 回收白色标记表里的对象
如果没有STW, 也没有屏障处理的话 标记阶段让线程并发修改对象引用, 会打破三色不变式
- 黑->白 边破坏: 标记过程中, 一个已经标记成黑色的A, 如果被应用线程写入了指向某个白色对象B的指针,由于A不会被扫描了, B永远无法从白->灰->黑的升级, 就会在清扫阶段被错误回收, 悬垂引用
- 灰->白 边破坏: 应用线程如果在B刚被放入灰色队列之后,就又把指向B的指针删除,让B既不在根集合, 又不被其他对象引用, 缺还在灰色队列里, 导致B不会到黑色 -> 既不会被正确扫描, 又不会被立即回收,造成内存泄露
屏蔽机制
强-弱 三色不变式
强三色不变式
强制性的不允许黑色对象引用白色对象
弱三色不变式
所有被黑色对象引用的白色对象,都处于灰色保护状态 黑色对象可以引用白色对象, 但必须由至少一个灰色或黑色对象以写屏障保证从根可达
写屏障
在A对象引用B对象的时候, B被标记成灰色
满足强三色不变式, 白色会被强制变成灰色
假设A已经是黑色, 此时程序执行A.field = B
- 写屏障拦截: 看到B还是白色, 立刻变成灰色
- 再把指针写入
A.field
, 此时状态是黑-> 灰 - 后续扫描, GC主线程从灰色里取B, 标成黑色, 扫描它所有引用
无论应用线程什么时候给一个已标记黑色的对象加上一条新指针, 都能保证不会出现 黑 -> 白, 因为在保证B写入前, 就确定已经变成了灰色
在 A.field = B
前, 检查 B 是否为白色, 若是则先染灰, 再写入
写屏障只插入在对堆上对象的指针写操作, 而不会影响栈上局部变量的赋值 栈上变量不参与后续三色流程, 只在 STW 根扫描时当作“根指针”一次性扫描, 并且占访问比堆更高, 如果每次都对栈进行写屏障检查, 会造成很大的性能损耗
为了保证栈上指向的白色对象不丢失引用, 会对栈启用STW暂停保护
删除屏障
golang并没有直接使用过删除写屏障, 但是混合写屏障里用到了删除写屏障的思路
GC 开始时stw做一次"快照", 把当时存活对象标记下来, 防止后续删除指针让它们进行误回收
Go v1.8混合写屏障
插入写屏障和删除写屏障的短板
- 插入: 结束前也要用STW扫描栈, 标记栈上引用的白色对象的存活
- 删除: 回收精度低, GC开始时STW扫描堆栈来记录初始快照, 这个过程会保护开始时刻的所有存活对象
混合写屏障规则
- STW 阶段: 扫描所有 goroutine 栈帧和全局变量,收集 root 引用的堆对象 → 把这些堆对象染成灰色
- 并发标记阶段: 恢复世界,逐步从灰队列拉出堆对象染黑、扫描它们的指针引用
- 并发写屏障: 在对堆对象的指针字段做写入时,先检测目标指针是否为白色, 若是则先染灰
相比于插入/删除屏障, 混合写屏障只在GC开始的时候对栈做预扫描,把所有栈上root引用的堆对象全部染成灰色, 后续全程不碰栈
一次GC的完整流程 (混合写屏障)
初始STW
暂停所有goruntine
也就是STW
开启混合写屏障机制
goruntine恢复执行的时候, 内存状态一致性会被打破, 所以STW的目的是为了顺利开启混合写屏障机制, 保证后续内存状态一致性
并发分批扫描 goroutine 栈并标记根集指向的堆
-
并发分批扫描所有被暂停的 goroutine 栈帧
- 每扫完一个 goroutine,就立即恢复它的运行,将 STW 时间降到最低。
- 扫描过程中收集所有栈内的指针,作为 GC 的根集
-
根据根集标记堆对象为灰色
- 将根集中引用的堆对象染成灰色
- 根本身不参与后续的三色染色流程,只负责引出堆对象
为什么不把根集对应的堆对象直接染成黑色?
这样做可以让初始 STW 阶段尽可能短暂,后续的并发标记阶段再按照“灰→黑”标准流程扫描堆对象,兼顾 GC 的准确性和性能。
并发标记
GC和应用程序在这个阶段并发运行
并发运行
应用程序可以正常执行操作, GC在后台进行标记操作, 互不阻塞
处理灰色对象
GC从队列中取出灰色对象, 把所有引用了的对象染灰, 并标记成黑色 之后递归处理灰色对象, 重复上述操作, 直到所有灰色对象都被处理完毕
混合写屏障
在这个阶段, 混合写屏障发生作用, 堆上新添加的对象被标记成灰色,将新引用的对象标记成灰色
标记完成后的STW
暂停所有goruntine, 确保标记操作全部完成. 在这个期间, GC处理完可能存在的延迟屏障记录,确保并发标记阶段所有引用修改都被正确记录和处理了
关闭写屏障, 意味着GC不需要记录引用修改了, 只存在白色和黑色两种颜色
恢复goruntine
并发清除
在这个阶段, 应用程序继续正常运行, GC后台进行清除操作
遍历堆内存, 清除所有白色对象, 释放内存, 更新内存管理的数据结构,代表这块空间已经被释放, 可以被应用程序重新分配使用
清扫完成(遍历完堆内存, 清除所有白色对象)后, 准备下一次GC
观察GC过程
环境变量
GODEBUG=gctrace=1 ./your_program
会输出以下格式日志
gc 1 @0.003s 0%: 0.1+0.7+0.005 ms clock, 0.4+0/0.24/0+0.015 ms cpu, 4->4->2 MB, 5 MB goal, 8 P
字段 | 含义 |
---|---|
gc 1 | 第几次 GC |
@0.003s | 程序启动至今的时间 |
0% | GC 占用总运行时间百分比 |
0.1+0.7+0.005 ms | GC 各阶段耗时 |
4->4->2 MB | GC 前 heap 分配、GC 中使用、GC 后存活 |
5 MB goal | 下次触发 GC 的堆使用目标 |
8 P | 调度器中启用了几个 P |
嵌入编程里
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Println("GC次数:", m.NumGC)
fmt.Println("上次GC耗时:", time.Duration(m.PauseNs[(m.NumGC+255)%256]))
fmt.Println("堆使用:", m.HeapAlloc/1024, "KB")
pprof
go tool pprof ./xxx gc.out
top
web
其他
内存泄露
在有GC的情况下,Go 语言中仍会发生内存泄漏
Go的GC是可达性分析, 并不判断是否还需要这个对象
- 全局变量, 缓存引用
- goruntine泄露
- map/slice 不断增长
sync.Pool
未清理- runtime注册的回调
都可能造成内存泄露, 越堆越大
内存分配速度超过了标记清除的速度
src/runtime/mgcpacer.go
如果GC跟不上, 临时放宽, 把heapGoal变成1.1倍
同时让goruntine本身部分来配合GC标记, 程序本身会慢, gc变快(谁丢的垃圾, 谁也来捡)