3.1 内存分配机制
Go内存管理本质上是一个经过内部优化的内存池:自动伸缩内存池大小,合理的切割内存块。
分配逻辑:针对不同大小对象有不同的分配逻辑
- (0,16B)且不含指针的对象:Tiny分配
- (0,16B)且含指针的对象:正常分配
- [16B,32KB]对象:正常分配
- (32kB,-):大对象分配
内存分配流程图示
流程描述:
-
获取当前线程的私有缓存mcache【不存在线程竞争,提高内存并发申请效率】;
-
根据对象size计算出合适的classId;
-
基于classId从mcache的alloc[class]链表中查询可用的span;
-
若mcache中不存在对应的span,则从mcentral中申请新的span加入mcache中;
-
若mcentrral中不存在可用的span,则从mheap中申请新的span加入到mcentral中;
-
从该span中获取到空闲对象地址并返回。
3.2 垃圾回收机制
垃圾回收就是对程序中不再使用的内存资源进行自动回收的操作。
3.2.1 概述
1. 变更历史
GoV1.3- 普通标记清除法:整体过程需要启动 STW,效率极低。
GoV1.5- 三色标记法+写屏障:堆空间启动,栈空间不启动,全部扫描之后,需要重新扫描一次栈 (需要 STW),效率普通。
GoV1.8 - 三色标记法+混合写屏障机制:堆栈空间启动,整个过程几乎不需要 STW,效率较高。
2. GC触发条件
- 系统触发:当活跃对象达到堆内存的GC阈值(默认25%)时,将会触发 —设置环境变量GOGC调整阈值
- 系统监控:当超过两分钟没有产生任何GC时,强制触发GC
- 步调算法:控制内存增长的比例,当内存分配达到一定的比例则触发GC
- 手动触发:开发者在业务代码中自行调用runtime.GC方法来触发
3. 优化策略
1)避免循环引用:通过代码审查和工具分析来检查潜在的循环引用问题,并确保及时释放不再需要的对象
2)调整GC参数:在程序启动时通过设置环境变量 GOGC 来调整 GC 的触发阈值,降低GC频率
export GOGC=100
3)在程序开始时设置GC并发度
//设置GC并发度为X runtime.GOMAXPROCS(X)
4)pprof性能分析:在程序中开启 pprof 性能监控
import _ "net/http/pprof" go func() {log.Println(http.ListenAndServe("localhost:6060", nil)) }()
5)设置小堆空间(eg:512MB)
go build -ldflags "-H=windowsgui -X 'runtime/debug.MaxMemory=536870912'"
3.2.2 常用垃圾回收算法
1. 引用计数
原理:每个对象维护一个引用计数器,当对象被引用时计数自动+1,当引用失效时,计数自动-1,计数为0时回收该对象。
优点:对象可以很快被收回,不会出现内存耗尽/达到阈值才回收。
缺点:无法很好地处理循环引用。
2. 标记-清除
原理:从根节点开始遍历所有引用的对象,引用的对象被标记为“被引用”,没有被标记的则被回收。
优点:可以较好地处理循环引用。
缺点:
需要 STW(stop the world),暂时停止程序运行;
效率较低;
内存碎片化问题严重,无法为较大对象分配内存;
3. 复制算法
原理:按容量将内存等分为两块,每次只使用一块,当该内存满后,将该内存中存活的对象复制到另一块,并将已使用的内存清除。
缺点:
存活对象较多时,效率较低;
内存空间利用率低,只有原本的1/2;
4. 标记-整理
原理:标记出所有需要回收的对象,将存活的对象移动到内存一端,清除掉边界外的对象。
优点:解决内存碎片化问题
5. 分代收集
原理:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代使用不同的回收算法和频率。
优点:性能较好
缺点:算法复杂
3.2.3 Go垃圾回收机制
Go使用无分代,不整理和并发(与用户代码并发执行)的三色标记清除算法
TIPS:
不分代:Go编译器会通过逃逸分析将大部分新生对象存储在栈上,只有需要长时间存在的对象会被分配到堆中。
不整理:整理是为了解决内存碎片化问题,而Go内存分配器与tcmalloc类似,基本不存在内存碎片。
1. 三色标记清除算法
1)对象分类
- 灰色(波面):已被回收器访问的对象,但回收器需要对其中的一个或多个指针进行扫描,因为它们可能还指向白色对象。
- 黑色(确定存活):已被回收器访问的对象,其中所有字段都已被扫描,黑色对象中的任何一个指针都不可能直接指向白色对象。
- 白色(可能死亡):未被回收器访问到的对象。开始回收阶段对象均为白色,回收结束后白色对象不可达。
2)标记清除过程
- 第一步:初始状态下,所有对象均为白色对象;
- 第二步:从根节点开始遍历所有对象,把遍历到的对象标记为灰色对象,放入待处理队列;
- 第三步:从待处理队列取出灰色对象,将其引用的对象标记为灰色对象并放入待处理队列,并将其自身标记为黑色对象;
- 循环第三步,直到所有灰色对象变为黑色。
- 通过写屏障(write-barrier)检测到对象变化,重复以上操作
TIPS:如果对象的引用被用户修改了,那么之前的标记就无效了。因此Go采用了写屏障技术,当对象新增或者更新会将其着色为灰色
- 收集所有的白色对象(垃圾/不可达对象)
2. STW性能影响分析
1)STW(stop the world)是什么?
- 在 GC 的过程中,为了避免对象之间的引用关系发生新的变更导致GC结果发生错误【如GC过程中用户程序为"黑色对象"新增了一个引用,会导致该引用对象被误清理】,故GC过程中会停止所有运行的协程。
- STW过程中,CPU 不执行用户代码,全部用于垃圾回收。
- STW对性能有一些影响,Golang目前已经可以做到ms级别的STW。
2)如果取消STW会有什么问题?
- 问题分析
- 结论:不暂停程序时,如果用户程序在标记阶段改变了对象引用关系,会影响标记结果的正确性,可能会导致对象丢失。
-
发生条件:同时满足以下两个条件时,就会出现对象丢失。
条件 1: 一个白色对象被黑色对象引用 (白色被挂在黑色下)
条件 2: 灰色对象与该白色对象的可达关系遭到破坏 (灰色同时丢了该白色)
-
解决方案:可以利用屏障机制,尝试去破坏上面的两个必要条件。
3. Go GC性能优化原理
原理:可以通过屏障机制在保证对象不丢失的情况下,尽可能地缩短STW的时间,从而提高GC性能。
1)“强-弱”三色不变式
- 强三色不变式:不存在黑色对象引用到白色对象的指针。
- 弱三色不变式:所有被黑色对象引用的白色对象都处于灰色保护状态。
2)插入写屏障(遵循强三色不变式,堆空间启动)
操作:若对象A在堆区,在 A 对象引用 B 对象的时候,B 对象被标记为灰色。
缺点:结束时需要 STW 来重新扫描栈,标记栈上引用的白色对象的存活。
处理流程
- 第一步:所有对象标记为白色
-
第二步:遍历一次Root Set,将可达对象记录灰色
-
第三步:遍历灰色对象列表,标记其可达对象为灰色,并将遍历后的灰色标记为黑色
-
第四步:重点来了!!!! 由于并发特性,此刻用户协程向黑色对象1,4添加了白色对象9,8
tips:对象4在堆区,会立即触发插入屏障机制,而对象1在栈区,不会触发。
- 第五步:继续循环上述流程进行三色标记,直到不存在灰色对象
-
第六步:在垃圾回收之前,启动STW,重新扫描栈区对象进行三色标记,标记完成后,停止STW(约10~100ms)
- 第七步:清除堆&栈内存空间的白色对象,GC完成。
3)删除写屏障(遵循弱三色不变式,堆栈空间启动)
操作:被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。
缺点:回收精度低,GC 开始时 STW 扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。
核心处理流程
4)混合写屏障
实现原理:结合了插入和删除写屏障的优点。开始时并发扫描各个 Goroutine 的栈区,使其对象变黑并一直保持,这个过程不需要 STW;标记结束后,因为栈在扫描后始终是黑色的,也无需再进行 re-scan 操作了,减少了 STW 的时间。
处理流程
- 第一步:GC 开始将栈上的对象全部扫描并标记为黑色 (之后不再进行第二次重复扫描,无需 STW)
- 第二步:GC 期间,任何在栈上创建的新对象,均为黑色。
- 第三步:被删除/添加的对象均标记为灰色。
3.3 逃逸分析(Escape analysis)
3.3.1 概述
编译器决定对象分配的内存位置(堆/栈),逃逸分析在编译阶段完成。
函数申请一个新对象:
- 若分配在栈内存中,则函数执行结束后可自动回收内存;
- 若分配在堆内存中,则函数执行结束后交由GC处理;
3.3.2 逃逸策略
当函数申请新对象时,编译器会根据该对象是否被外部引用来决定是否逃逸
- 若不存在外部引用,优先放在栈内存中【注:若对象所需内存超过栈的存储能力,则会放在堆中】
- 若存在外部引用,必定放在堆内存
3.3.3 逃逸场景
主要有以下场景:指针逃逸,栈空间不足逃逸,动态类型逃逸,闭包对象引用逃逸
tips:通过以下命令行查看逃逸分析
## 查看编译过程中的逃逸分析
go build -gcflags=-m xxx.go## 分析结果:出现内存逃逸
"escapes to heap"
- 指针逃逸:函数内返回局部变量指针
type Student struct {Name stringAge int
}// PointerEscape 指针逃逸
func PointerEscape(name string, age int) *Student {s := new(Student) //局部变量逃逸到堆内存中s.Name = names.Age = agereturn s
}
- 栈空间不足逃逸:当栈空间不足以存放当前对象或者无法判断当前切片长度时会将对象分配到堆中
func Slice() {s := make([]int, 1000, 1000) //切片长度过大for index, _ := range s {s[index] = index}
}
- 动态类型逃逸:很多函数参数为interface类型,编译期间很难确定其参数的具体类型,也会产生逃逸
func DynamicTypeEscape() {str := "Escape"fmt.Println(str)
}
- 闭包引用对象逃逸:闭包中的局部变量会因为闭包的引用产生逃逸
func Fibonacci() func() int {a, b := 0, 1return func() int {a, b = b, a+b //会产生逃逸return a}
}
TIPS:函数传递指针真的比传值效率高吗?
传指针可以通过减少值拷贝过程来提高效率,但是指针传递可能会内存逃逸,增加GC负担,可能会降低效率。
参考文档:
Golang三色标记混合写屏障GC模式全分析 | Go 技术论坛
内存分配原理-地鼠文档