Golang GC
Golang 标准库 GC
1. 垃圾回收方法
-
引用计数 (reference counting)
-
标记-清除 (mark & sweep)
-
节点复制 (copying garbage collection)
-
分代搜集 (generational garbage collection)
1.1 引用计数
对每个对象维护一个引用计数
- 当有引用该对象的对象被销毁或更新时,引用计数自动减1;
- 当引用对象被创建或被赋值给其他对象时,引用计数自动加1;
- 当引用计数为0时,对象会被回收
缺点:
- 频繁更新引用计数降低性能
- 循环引用导致对象无法被释放
1.2 标记-清除
从根变量开始迭代的遍历所有被引用对象,能能够通过遍历访问的对象都标记为“被引用”;标记完成后进行清除操作,对未被标记的内存进行回收
缺点:每次启动垃圾回收都会暂停当前所有正常运行的代码执行,导致系统响应能力大大降低
1.3 节点复制
将整个堆分两个半区 (semi-space),一个包含现有数据,另一个包含已被废弃的数据
1.4 分代搜集
将堆划分为两个或多个称为代(generation)的空间,新创建的对象存放在新生代中,随着垃圾回收的重复执行,生命周期较长的对象会被提升(promotion)到老年代中。
新生代垃圾回收的速度非常快,回收频率更高;而老生代垃圾回收频率较低
2. Golang的GC
“非分代的、非移动的、并发的、三色的”标记清除垃圾回收算法
2.1 GC 流程
- Stack scan: 收集根对象(全局变量和goroutine栈上的变量),该阶段会开启写屏障(Write Barrier)
- Mark: 标记对象,直到标记完所有的根对象和根可达对象,此时写屏障会记录所有指针的更改(通过mutator)
- Mark Termination: 重新扫描部分全局变量和发生更改的栈变量,完成标记,该阶段会STW,造成go程序停顿的主要阶段
- Sweep:并发清除未标记的对象
2.2 三色标记:在Mark阶段
- 白色: 未标记对象,gc开始时所有对象都为白色,当gc结束时,如果仍为白色,说明对象不可达,在sweep阶段会被清除
- 灰色:被黑色对象引用到的对象,但其引用的自对象还未被扫描,灰色为标记过程的中间状态,当灰色对象全部被标记完成,代表本次标记阶段结束
- 黑色:已标记的对象,表示对象是根对象可达的
2.3 三色标记的主要过程
-
开始时所有对象为白色
-
将所有根对象标记为灰色,放入队列
-
遍历灰色对象,将其标记为黑色,并将它们引用的对象标记为灰色,放入队列
-
重复步骤3持续遍历灰色对象,直至队列为空
-
此时只剩下黑色和白色对象,白色对象即为需要sweep的对象
2.4 STW: Stop The World
为防止在标记过程中,对象引用发生变化,导致清除仍在使用的对象。
三色标记过程中,由于引入了灰色对象这个中间状态,标记过程和用户的golang代码并发执行,不需要STW,极大减少了应用的停顿时间
STW永远是带有GC语言的痛
1.5+版本,STW已从以前的数秒降低到1ms以内
1.6+版本,会根据实际使用情况平衡下延迟和吞吐量。没有STW也是可以的,但吞吐量会进一步下降,未被是好选择
WB: Write Barrier,把全局变量,以及每个goroutine中的root对象收集起来,Root对象是标记扫描的源头。避免在标记过程中应用对象的改变
go的对象大小定义:
- 大对象:> 32KB
- 小对象:16KB ~ 32KB
- Tiny对象:1Byte ~ 16KB,不包含指针对象
3. 触发GC的两种方式
- 主动触发:调用
runtime.GC()
阻塞式地强制启动一轮GC - 被动触发:
- 系统监控:当超过2分钟没进行GC时,会触发一轮GC
- 步调(Pacing)算法:判断当前内存的增长比例是否已达到触发一轮GC的阀值,超过阀值,启动一轮GC
4. GC参数调节
GOGC:范围0~100, 默认100. GOGC=off 代表关闭GC;GOGC=0代表持续进行GC,只能用于调试
假如当前heap占有内存4MB,GOGC=75:
4 * (1 + 75%) = 7MB
当heap占用达到7MB时会触发一轮GC
5. 内存分配
-
栈区:
空间较小,数据读写性能高,数据存放时间短
由编译器主动分配和释放,存放函数的参数值、函数调用流程方法地址、局部变量等(局部变量如果产生逃逸现象,可能会挂在堆区)
-
堆区:
空间充溢,数据存放时间较久
一般由使用者分配和释放,Golang由GC清除机制自动回收
-
全局区:
-
静态全局变量区:全局变量对外完全可见,即作用域在全部代码中,必须使用var来声明
-
常量区:常量不可修改,不可获取地址,用const来声明
-
-
代码区:
存放代码逻辑的内存
5.1 栈和堆比较
栈:一般函数内部执行中声明的变量,函数返回直接释放,不会引起垃圾回收,对性能无影响
堆:有引用到的内存空间,靠GC回收,会影响程序进程
栈和堆是在内存上2块不同功能的区域:
- 栈在高地址,从高地址向低地址增长
- 堆在低地址,从低地址向高地址增长
栈和堆相比优势:
栈的内存管理简单,分配比堆快
栈的内存不需要回收,但堆需要,无论是主动free,还是被动的垃圾回收,都需要消耗额外的CPU
栈内存由更好的局部性,堆内存访问就不那么友好了,CPU访问的2块数据可能在不同的页上,这更耗时
5.2 Golang 内存管理
主要是指堆内存管理,因为栈内存不需要程序去操心
堆内存管理主要是三部分:
- 分配内存块
- 回收内存块
- 组织内存块
一个内存块,包含三类信息:
- 元数据
- 用户数据
- 对齐字段,内存对齐时为了提高访问效率
释放内存:实质是把已使用的内存块,从链表中取出来,标记为未使用,当分配内存块时,可以从未使用内存块中有先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。
6. 内存逃逸
逃逸分析:由编译器决定内存分配的位置,不需要程序员指定,及编译器决定新申请的对象放堆上还是栈上
逃逸分析场景:
-
指针逃逸:
函数内定义的变量返回到函数外,会将本该分配到栈上的内存分配到了堆上
-
栈空间不足逃逸:
栈空间不在或无法判断当前切片长度时,会将对象分配到堆上
-
动态类型逃逸:
当函数参数为interface类型,编译期间无法确定参数的具体的类型,也可能会产生逃逸
内存逃逸的五种情况:
- 发送指针到channel中。由于在编译阶段无法确定其作用域,所以一般会逃逸到堆上分配
- slice中包含指针元素。即使slice的底层数组仍在栈上,但数据引用也会转移到堆中
- slice由于append操作导致扩容。编译时,slice初始容量已知的情况下,在栈上分配内存,但slice扩容时,则在堆上分配
- 调用接口类型的方法。接口类型方法的调用是动态的,即运行时确定。例如一个接口类型为io.Reader的变量r,对r.Read(b)的调用将导致r的值和byte数组b的后续转义到分配在堆上
- 尽管能够分配在栈上,但编译时无法确定其大小的情况,也会分配到堆上