这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天
预备知识
内存管理
角色
- 用户程序
- 分配器
- 回收器
内存分配
线性分配器
使用一个指针指向未分配的地址
缺点:
- 内存回收后无法重用,指针一直在增长
- 多线程申请需要加锁,避免冲突
- 需要频繁通过(标记-整理,复制,分代回收)等回收算法,整理内存
空闲链表分配器
内部维护一个空闲内存块(指针)的链表
寻找内存块策略
- 首次适应:从表头开始
- 循环首次适应:从上次结束位置开始
- 最优适应:表头开始
- 隔离适应策略:将内存分割为多个链表(分箱)
内存回收GC
- 合理申请内存
- 及时清理内存
- c/c++:内存回收由用户程序决定
- Go/Java:语言层面实现垃圾收集器
垃圾识别
引用计数
没有再被任何对象引用的对象就是可回收垃圾
- 所有对象有一个计数器:记录引用次数
- 创建对象/引用对象:计数器+1
- 引用变更:计数器-1
- 计数器为0:回收垃圾
问题:循环引用A->B->A
解决:
- 垃圾收集算法,eg.py中的标记-清除算法
- 语言层面,eg.c++智能指针,weak_ptr
- Tracing GC,判断环是否被环以外的对象引用,没有则回收
Tracing GC
将程序的内存占用分为GC root和GC head两部分。以roots集合作为起始点进行图的遍历(顺着指针递归去找),如果从roots到某个对象是可达的,则该对象称为“可达对象”。否则就是不可达对象,其内存空间可以被回收(垃圾)。
垃圾收集
标记-清除算法(Mark-Sweep)
首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
缺点:产生内存碎片,导致大内存对象无法分配
复制算法(Coping)
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉,解决了内存碎片问题
缺点:可用内存减半、经常搬运长生命周期对象导致效率下降
标记-整理算法(Mark-Compact)
让所有存活的对象向一端移动,然后直接清理掉边界以外的内存
分代清理(Generational Collection)
GC分代的基本假设:绝大部分对象的生命周期都非常短暂,存活时间短。
将堆内存分为新生代和老年代(根据GC次数划分),根据各自特点选择收集算法。
Tips:Golang采用的是标记-清除算法,内存碎片的问题通过分配器分配解决。
Go内存管理
Tcmalloc算法(Thread-Caching Malloc),替代传统的malloc函数,适合多核,更好地支持并行。
核心理念:使用多级缓存将对象根据大小分类,不同类别实施不同的分配策略。
内存管理单元
- Page:默认8KB,由PageId,从0开始递增
- Span:连续N个Page,起始PageID,包含Page数量,prev和next指针,每个span都只服务于一种SpanClass
- Object:应用程序以Object作为整体再Page上分配内存
- SpanClass:不同大小的Object,微对象:<16B,小对象:16B->32KB,大对象:>32KB,int8:前7位存储size class信息,最后一位存储noscan,用于记录有无指针,1为无指针,0为有指针,有指针的时候需要参与到内存回收扫描过程
- Size Class:小对象分为68级(8*2^0,8*2^1...8*2^67),称为Size Class,向上取整
这里的计算是一个对象32B,一个page8KB,一个Span最小一个Page,即可以用放个对象
Golang复用tcmalloc三层框架
mcache
前端缓存,存放大量已分配或未分配的Span,基于各种SpanClass维护了一个span数组,68*2=136个(有无指针)。
用户程序申请小对象内存时,mcache查找自己管理的内存块,符合条件直接返回,否则向中端请求一批内存重新填充。
只让一个线程访问,不需要加锁。每个mcache都会与一个P进行绑定,只有这个P能访问mcache中的对象。
这部分还没有读懂。
mcentral
一个mcentral对应一个spanClass,系统中136个mcentral维护136个span
pzrial和full数组分为GC清理过和未清理过两部分
mheap
- 作为全局变量,管理所有从系统中申请到的堆内存
- 当无内存可用时,向操作系统申请内存
- 将不需要的内存返还给操作系统
线性管理内存
C、Go混用时:
- 分配的内存地址会冲突,导致堆的初始化和扩容失败
- 没被预留的大块内存分配给C语言,导致扩容后堆不连续
稀疏内存管理
通过一个人heapArena数组维护占用的部分区域内存
分配逻辑
微对象分配器
处理小于16B且没有指针的小对象,其处于macache上,主要用来分配较小的字符串和逃逸的临时变量,他可以将多个较小的内存分配合入同一个内存块中,只有当内存块中所有对象都要被回收时,整片内存才会被回收。
小对象分配
- 确定分配对象的大小和对应的SpanClass
- 从线程缓存mcache,中心缓存mcentral中获取Span,并从Span中通过allocCache和freeindex找到空闲的内存空间
- 清空空闲内存中的所有数据
大对象分配
对于大于32KB的大对象,Golang会单独处理,不会经过mcache和mcentral,直接从heap上分配对象
内存回收
Golang GC采用Tracing法来扫描对象,使用不分代(对象没有分代)、不整理(回收过程中不移动对象)、并发(与用户代码并发执行)的三色标记-清除算法来清理对象。
- 对象大小分级+多级缓存机制,基本不会出现碎片问题。Golang会对用户程序暴露指针,一旦整理对象就会导致指针异常。
- 分代GC依赖于分代假设,即GC主要回收目标应该是新创建的对象(存活时间短,更容易被回收)。但是Go编译器会通过逃逸分析将大部分新生对象直接存储在栈上,跟随栈的生命周期而被回收,只有需要长期存在的对象才会被分配到需要进行垃圾回收的堆中。因此分代也没有太多优势。
三色标记法
Golang GC会从根对象开始扫描,扫描过程中对象可能处于不同的状态,三色标记法将对象分为三类,并以不同颜色相称。
- 白色:未被回收器访问到的对象,扫描开始/结束后的不可达对象
- 灰色:已被回收器访问到的对象
- 黑色:已被回收器访问到的对象,并且该对象中所有的指针都已被扫描
Golang中,根对象
- 全局变量
- 执行栈
- 寄存器,可能是指向堆内存区块的指针
并发问题
标记过程中,对象之间的引用不能变,需要在标记过程中启动STW(stop the world,也就是将所有程序暂停运行)
STW有资源浪费,并且会影响用户程序
在三色标记法中,同时出现下面两种情况时,就会出现对象丢失现象:
- 一个白色对象被黑色对象引用
- 灰色对象与它指向的白色对象的关系遭到破坏
解决方法:屏障机制,破坏上述2个条件
屏障机制
三色不变式
- 强三色不变时:不允许黑色对象引用白色对象
- 弱三色不变式:所有被黑色对象引用的白色对象都处于灰色保护状态
插入屏障(Dijkstra屏障)
- 在A对象引用B对象时,将B对象标记为灰色
- 满足强三色不变式
缺点:
- 每次进行赋值操作都需要引入写屏障,这会增加大量性能开销
- 会将可能存活的对象标记为灰色以满足三色不变形
- 栈对象的变更无法保证,需要在标记终止后STW,对发生了变更的栈重新扫描
Tips:栈函数操作频繁及对速度要求高的特点,就没有将插入屏障在栈空间的对象操作中使用
删除屏障(Yuasa屏障)
- 被删除的对象,如果自身为灰色或者白色,那么被标记为灰色
- 满足弱三色不变式
问题:回收精度低,一个对象即使被删除了最后一个指向它的指针也依旧可以活过这一轮,需要在下一轮GC中被清理掉。
混合写屏障
Dijkstra屏障和Yuasa屏障组合而成的混合写屏障,避免了对栈重新重新扫描的过程,极大的减少了STW的时间。
Golang只需要在标记初始阶段,将栈上所有对象都标记为黑色,这个过程也不需要STW,因为后续栈上生成的对象都会是黑色的;后续栈上对象的指针变更,均不会使用屏障技术,因为要保证栈的运行效率。
执行过程
- 标记准备阶段:为了打开写屏障,停止每个goroutine,垃圾收集器会观察并等待每个goroutine进入安全点(Safe Point),然后停止程序,这时候进入STW状态
- 标记阶段:
- 切换GC状态为标记状态,开启写屏障
- 恢复用户程序,标记进程检查所有goroutine找堆栈根对象,将分配内存过快的goroutine,转去辅助标记对象
- 标记终止阶段:
- 暂停程序,进入STW,切换GC状态-标记终止
- 清理部分P上的线程缓存
- 清理阶段:
- 切换GC状态-关闭,关闭写屏障
- 恢复用户程序
- 后台并发清理所有Span
评价GC
- 安全性:不能回收存活对象
- 吞吐量:
- 暂停时间:STW
- 内存开销小 《The Garbage Collection HandBook》
编译器优化
函数内联
将被调用的函数体的副本替换到调用位置上,同时重写代码以反映参数的绑定
内存逃逸
- 将原本应该在栈上分配、需要在函数运行后使用的变量分配到堆上
- 将一些原本应分配到堆上、不需要在函数运行后使用的内存分配到栈上
逃逸分析
原则:
- 指向栈对象的指针不能存活在堆中
- 指向栈对象的指针不能在栈对象回收后还存活
# 查看逃逸分析结果
go build -gcflags="-m -m -l" test_go.go
# 注意move to heap 和 escape to heap的区别
- 闭包逃逸,函数是一个指针类型,匿名函数当作返回值时会发生逃逸
- 变量大小不确定及栈空间不足引发逃逸
# 查看操作系统栈空间
ulimit -a
实践优化
容器
- slice和map初始化,直接初始化最终大小,避免扩容
- 拼接字符串使用strings.Builder
- map当set,map[string]struct{}
内存逃逸优化
返回函数结果时,区分对象大小使用不同返回方式。小对象用值,大对象用指针(指针对象逃逸到堆上,加大GC负担)
并发
- 尽量使用atomic代替锁
- 尽量使用不带缓冲区的channel,所有通过channel传递的值都会被逃逸分析分配到堆上,而且带缓冲区的channel会在send时发生一次内存拷贝,不带缓冲区的不发生内存拷贝
- 使用goroutine池gopkg/gopool
- 使用并发安全的rand库gopkg/rand
GC
通过动态设置 GOGC 参数,使得服务更少的GC,释放计算资源。这个优化适用于空闲内存多,cpu占用高的服务。
静态分析
- 不执行代码,推导程序行为,分析程序本质
- 控制流:程序执行的流程
- 数据流:数据在控制流上的传递
通过分析控制流和数据流,了解关于程序的性质,据此优化代码
过程内分析
仅在函数内部进行分析
过程外分析
考虑过程调用时参数传递和返回值的数据流和控制流