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 流程

  1. Stack scan: 收集根对象(全局变量和goroutine栈上的变量),该阶段会开启写屏障(Write Barrier)
  2. Mark: 标记对象,直到标记完所有的根对象和根可达对象,此时写屏障会记录所有指针的更改(通过mutator)
  3. Mark Termination: 重新扫描部分全局变量和发生更改的栈变量,完成标记,该阶段会STW,造成go程序停顿的主要阶段
  4. Sweep:并发清除未标记的对象

Golang GC

2.2 三色标记:在Mark阶段

  • 白色: 未标记对象,gc开始时所有对象都为白色,当gc结束时,如果仍为白色,说明对象不可达,在sweep阶段会被清除
  • 灰色:被黑色对象引用到的对象,但其引用的自对象还未被扫描,灰色为标记过程的中间状态,当灰色对象全部被标记完成,代表本次标记阶段结束
  • 黑色:已标记的对象,表示对象是根对象可达的

2.3 三色标记的主要过程

  1. 开始时所有对象为白色

  2. 将所有根对象标记为灰色,放入队列

  3. 遍历灰色对象,将其标记为黑色,并将它们引用的对象标记为灰色,放入队列

  4. 重复步骤3持续遍历灰色对象,直至队列为空

  5. 此时只剩下黑色和白色对象,白色对象即为需要sweep的对象

Golang GC

Golang GC

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

  1. 栈区:

    空间较小,数据读写性能高,数据存放时间短

    由编译器主动分配和释放,存放函数的参数值、函数调用流程方法地址、局部变量等(局部变量如果产生逃逸现象,可能会挂在堆区)

  2. 堆区:

    空间充溢,数据存放时间较久

    一般由使用者分配和释放,Golang由GC清除机制自动回收

  3. 全局区:

    • 静态全局变量区:全局变量对外完全可见,即作用域在全部代码中,必须使用var来声明

    • 常量区:常量不可修改,不可获取地址,用const来声明

  4. 代码区:

    存放代码逻辑的内存

5.1 栈和堆比较

栈:一般函数内部执行中声明的变量,函数返回直接释放,不会引起垃圾回收,对性能无影响

堆:有引用到的内存空间,靠GC回收,会影响程序进程

栈和堆是在内存上2块不同功能的区域:

  • 栈在高地址,从高地址向低地址增长
  • 堆在低地址,从低地址向高地址增长

栈和堆相比优势:

  • 栈的内存管理简单,分配比堆快

  • 栈的内存不需要回收,但堆需要,无论是主动free,还是被动的垃圾回收,都需要消耗额外的CPU

  • 栈内存由更好的局部性,堆内存访问就不那么友好了,CPU访问的2块数据可能在不同的页上,这更耗时

5.2 Golang 内存管理

主要是指堆内存管理,因为栈内存不需要程序去操心

堆内存管理主要是三部分:

  • 分配内存块
  • 回收内存块
  • 组织内存块

一个内存块,包含三类信息:

  • 元数据
  • 用户数据
  • 对齐字段,内存对齐时为了提高访问效率

释放内存:实质是把已使用的内存块,从链表中取出来,标记为未使用,当分配内存块时,可以从未使用内存块中有先查找大小相近的内存块,如果找不到,再从未分配的内存中分配内存。

6. 内存逃逸

逃逸分析:由编译器决定内存分配的位置,不需要程序员指定,及编译器决定新申请的对象放堆上还是栈上

逃逸分析场景:

  • 指针逃逸:

    函数内定义的变量返回到函数外,会将本该分配到栈上的内存分配到了堆上

  • 栈空间不足逃逸:

    栈空间不在或无法判断当前切片长度时,会将对象分配到堆上

  • 动态类型逃逸:

    当函数参数为interface类型,编译期间无法确定参数的具体的类型,也可能会产生逃逸

内存逃逸的五种情况:

  1. 发送指针到channel中。由于在编译阶段无法确定其作用域,所以一般会逃逸到堆上分配
  2. slice中包含指针元素。即使slice的底层数组仍在栈上,但数据引用也会转移到堆中
  3. slice由于append操作导致扩容。编译时,slice初始容量已知的情况下,在栈上分配内存,但slice扩容时,则在堆上分配
  4. 调用接口类型的方法。接口类型方法的调用是动态的,即运行时确定。例如一个接口类型为io.Reader的变量r,对r.Read(b)的调用将导致r的值和byte数组b的后续转义到分配在堆上
  5. 尽管能够分配在栈上,但编译时无法确定其大小的情况,也会分配到堆上