这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天
一、本堂课重点内容
- 常见 GC 算法
- Tracing garbage collection
- Generational GC
- Reference counting
- Go 的分配策略
- 编译器优化
二、详细知识点介绍:
自动分配内存
- 保证安全性和正确性
- 避免 double-free
- 避免使用释放后的内存
实现的功能:
- 为新对象分配空间
- 找到存活对象
- 回收死亡对象的内存空间
相关概念
- Mutator:业务线程,分配新对象,修改对象指向关系
- Collector:GC 线程,找到存活对象,回收死亡对象的内存空间
- Serial GC:只有一个 collector
- Parallel GC:支持多个 collectors 同时回收的GC 算法
- Concurrent GC: mutator(s) 和 collector(s)可以同时执行
GC 的挑战:
- 被标记完的对象又引用了新对象, 这种变化难以感知(三色标记写屏障)
追踪垃圾回收
-
对象被回收的条件:指针指向关系不可达的对象
-
标记根对象
- 静态变量、全局变量、常量、线程栈等
-
标记:找到可达对象
- 求指针指向关系的传递闭包:从根对象出发,找到所有可达对象
-
清理:所有不可达对象
-
将存活对象复制到另外的内存空间 (Copying GC)
-
将死亡对象的内存标记为"可分配” (Mark-sweep GC)
-
移动并整理存活对象 (Mark-compact GC)
-
-
根据对象的生命周期,使用不同的标记和清理策略
分代 GC
- 分代假说 (Generational hypothesis): most objects die young
- Intuition:很多对象在分配出来后很快就不再使用了
- 每个对象都有年龄:经历过 GC 的次数
- 目的:对年轻和老年的对象,制定不同的 GC 策略,降低整体内存管理的开销
- 不同年龄的对象处于 heap 的不同区域
为不同对象的生命周期使用不同的策略:
- 年轻代 (Young generation)
- 常规的对象分配
- 由于存活对象很少,可以采用 copying collection
- GC吞叶率很高
- 老年代 (Old generation)
- 对象趋向于一直活著,反复复制开销较大
- 可以采用 mark-sweep collection
引用计数
-
每个对象都有一个与之关联的引用数目
-
对象存活的条件:当且仅当子 1 用数大于。
-
优点
- 内存管理的操作被平摊到程序执行过程中
- 内存管理不需要了解 runtime 的实现细节:C++ 智能指针 (smart pointer)
-
缺点
- 维护引用计数的开销较大:通过原子操作保证对引用计数操作的原子性和可见性
- 无法回收环形数据结构 (weak reference)
- 内存开销:每个对象都引入的额外内存空间存储引用数目
- 回收内存时依然可能引发暂停(回收大型数据结构)
Go 内存分配
分块
目标:为对象在 heap 上分配内存
策略: 提前将内存分块, 根据对象的大小, 选择最合适的块返回
- 调用系统调用
mmap()向 OS 申请一大块内存,例如 4 MB - 先将内存划分成大块,例如 8KB,称作
mspan - 再将大块继续划分成特定大小的小块,用于对象分配
noscan mspan: 分配不包含指针的对象 - GC 不需要扫描scan mspan: 分配包含指针的对象 — GC 需要扫描
下图的三个都是大块, 根据需要分成不同的小块
缓存
借鉴了 TCMalloc: thread caching
- G - Goroutine,协程,是参与调度与执行的最小单位
- M - Machine,系统级线程
- P - Processor,逻辑处理器,关联了 Goroutine 队列。
- 每个 p 包含一个 mcache 用于快速分配,用于为绑定于 p 上的 g 分配对象
- mcache 管理一组 mspan
- 当 mcache 中的 mspan 分配完毕,向 mcentral 申请带有末分配块的 mspan
- 当 mspan 中没有分配的对象,mspan 会被缓存在 mcentral 中,而不是立刻释放井归还给 OS
编译器优化
逃逸分析
逃逸分析:分析代码中指针的动态作用域:指针在何处可以被访问
大致思路, 从对象分配处出发,沿着控制流,观察对象的数据流
若发现指针 p 在当前作用域 s:
- 作为参数传递给其他函数
- 传递给全局变量
- 传递给其他的 goroutine
- 传递给已逃逸的指针指向的对象
则指针p指向的对象逃逸出 S,反之则没有逃逸出 S
三、实践练习例子:
Balance GC
- 对象分配是非常高频的操作:每秒分配 GB 级别的内存
- 小对象占比较高
- Go 内存分配比较耗时
- 分配路径长:g -> m -> p -> mcache -> mspan -> memory block -> return pointer
- pprof:通过 pprof 也能发现, 对象分配的函数是最频繁调用的函数之一
Balance GC 的优化方案:
- 每个 g 都鄉绑定一大块内存(1KB),称作 goroutine allocation buffer (GAB)
- GAB 用于 noscan 类型的小对象分配:
< 128 B - 使用三个指针维护 GAB: base, end, top
- Bump pointer(指针碰撞)风格对象分配
- 无须和其他分配请求互斥
- 分配动作简单高效
类似于每个 Goroutine 单独的栈
if top + size <= end {
addr := top
top += size
return addr
}
-
GAB 对于 Go 内存管理来说是一个大对象
-
本质:将多个小对象的分配合井成一次大对象的分配
-
问题:GAB 的对象分配方式会导致内存被延迟释放
-
方案:移动 GAB 中存活的对象
- 当 GAB 总大小超过一定國值时,将 GAB 中存活的对象复制到另外分配的 GAB 中
- 原先的 GAB 可以释放,避免内存泄漏
- 本质:用copying GC 的算法管理小对象
四、课后个人总结:
- 优化一定要以数据驱动
- GMP 模型, 内存分配 还要找其他资料
五、引用参考:
- medium.com/a-journey-w…
- go-memory-allocation-pt1 andrestc.com/post/go-mem…
- GopherCon UK 2018: Andre Carvalho - Understanding Go's Memory Allocator www.youtube.com/watch?v=3CR…