golang垃圾回机制(GC)

321 阅读10分钟

什么是GC

内存管理机制 我们在程序中定义一个变量,会在内存中开辟相应 内存空间 进行存储,当不需要此变量后,需要销毁此对象并释放内存。内存管理机制分为自动管理和手动管理,像C、C++ 等编程语言使用手动管理内存的方式,编写代码过程中需要自己主动申请或者释放内存;而Java、Go 等语言设计了自动的内存管理系统,由内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的 垃圾回收(GC),它有两方面的好处:

  • 避免手动内存管理,专注于实现业务逻辑
  • 保证内存使用的正确性和安全性

需要注意的一点是:

程序执行过程中会使用到两种内存,分别是堆(Heap)和栈(Stack),GC只负责回收堆内存,而不负责回收栈中的内存

栈是一块专门为了函数执行准备的专用内,存储着函数中的局部变量以及调用栈信息,用完就可以直接释放,所以栈中的数据可以通过简单的编译器指令自动清理,并不需要通过 GC 来回收。

常见的GC算法

一个GC算法的好坏通常有几种评价标准:

  • 安全性(Safety): 不能回收存活的对象,这也是基本要求;
  • 吞吐量(Throughput): 吞吐量是指应用程序在某个时间段内完成的工作量,好的GC算法应该在保证暂停时间可接受的情况下,尽可能地提高应用程序的吞吐量;
  • 暂停时间(Pause time): stop the world(STW)业务是否感知;
  • 内存占用(Memory Footprint): GC算法应该尽量减少垃圾回收器本身的内存占用;
  • 并发性(Concurrency): 允许垃圾回收器在执行回收时与应用程序并发执行;
  • ......

引用计数算法(reference counting)

通过在对象上增加自己被引用的次数(被其他对象引用时加1,引用自己的对象被回收时减1),引用数为0的对象即为可以被回收的对象

缺点:无法处理循环引用(如a.b=b; b.a=a),频繁更新引用计数降低了性能。

追踪式回收算法(Tracing)

追踪式算法(可达性分析)的核心思想是判断一个对象是否可达,如果这个对象一旦不可达就可以立刻被 GC 回收了,

  • 第一步从根节点开始找出所有的全局变量和当前函数栈里的变量,标记为可达。
  • 第二步,从已经标记的数据开始,进一步标记它们可访问的变量,
  • 以此类推,当追踪结束时,没有被打上标记的对象就被判定是不可达。

解决了循环引用的问题,但是无法立刻识别出垃圾对象,需要依赖 GC 线程,同时算法在标记时必须暂停整个程序,即STW(stop the world),否则其他线程有可能会修改对象的状态从而回收不该回收的对象

Go的垃圾回收算法

为了解决原始标记清除算法带来的长时间 STW,Go v1.5版本实现了 并发标记-清除(Concurrent Mark-Sweep,CMS)和三色标记法(Tricolor Marking)相结合,大幅度降低垃圾收集的延迟。在v1.8又使用 混合写屏障 将垃圾收集的时间缩短至0.5ms以内。

并发标记清除垃圾回收(Concurrent Mark and Sweep Garbage Collection)

并发标记(Concurrent Marking)

在Go的GC过程中,垃圾回收器会在后台线程中进行并发标记。它从根对象(包括全局变量、静态变量、常量、程序堆栈等)开始,递归地标记所有可达的对象。这意味着不会中断主程序的执行,垃圾回收过程和应用程序可以同时运行。标记阶段会识别出所有存活的对象,而未标记的对象则被视为垃圾。

三色标记法(Tricolor Marking) : 这是 并发标记 过程中使用的技术,将对象标记为三种不同的颜色:

  • 白色对象: 潜在的垃圾,表示还未搜索到的对象,其内存可能会被垃圾收集器回收
  • 黑色对象: 活跃的对象,表示搜索完成的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象
  • 灰色对象: 活跃的对象,表示正在搜索还未搜索完的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象

标记步骤:

  1. 开始所有对象都是白色的
  2. 从根对象出发,扫描所有可达对象,即根对象的引用对象,将其从白色标记为灰色,
  3. 遍历灰色集合,将灰色对象引用的对象从白色标记为灰色,之后将此灰色对象标记为黑色
  4. 重复第三步,直到没有灰色对象
  5. 此时剩下的所有白色对象都是垃圾对象,回收

并发清除(Concurrent Sweeping)

在标记阶段完成后,Go的垃圾回收器会进入并发清除阶段。这个阶段负责回收垃圾对象所占用的内存,并将这些内存释放回堆供后续使用。并发清除阶段同样是在后台线程中进行,并与应用程序并发执行,以降低对应用程序性能的影响。

写屏障

写屏障(Write Barrier)是在并发垃圾回收过程中用于保护内存一致性的一种机,使得垃圾回收器能够正确地识别所有的活跃对象,并且不会误判活跃对象为垃圾。

在并发垃圾回收中,程序的多个线程(Goroutines)可以同时访问和修改内存中的对象。当一个线程在并发标记阶段中遍历对象并对其进行标记时,其他线程可能在同时对这些对象进行修改。这样可能导致垃圾回收器遗漏或错误地标记一些对象,或者在清除阶段中错误地回收仍在使用的对象。

举个例子: 在一次GC进行的过程中,程序新建了对象 G,此时如果已经标记成黑的对象 P 引用了对象 G,那么在本次 GC 执行过程中因为黑色对象不会再次扫描,扫描结束 G 仍是白色的话,则会被回收掉,这显然是不允许的。

  • 插入写屏障:

    • 当一个对象引用另外一个对象时,将另外一个对象标记为灰色,以此满足强三色不变性,不会存在黑色对象引用白色对象,满足强三色不变式
    • 插入屏障仅会在堆内存中生效,不对栈内存空间生效,在一次正常的三色标记流程结束后,需要对栈上重新进行一次 STW,然后再扫描一遍。
  • 删除写屏障:

    • 在删除引用时,如果被删除引用的对象自身为灰色或者白色,那么被标记为灰色,其实就是快照保存旧的引用关系,这叫STAB(snapshot-at-the-beginning), 以此满足弱三色不变性
    • 缺点是一个对象的引用被删除后,即使没有其他存活的对象引用它,它仍然会活到下一轮。如此一来,会产生很多的冗余扫描成本,且降低了回收精度。

三色不变性

  • 强三色不变性: 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性: 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径

在V1.5的版本中采用的是插入写屏障实现,但是在运行时需要在几百个 goroutine 的栈上都开启写屏障,会带来巨大的额外开销,所以选择了在标记阶段完成时暂停程序、将所有栈对象标记为灰色并重新扫描,在活跃 goroutine 非常多的程序中,重新扫描的过程需要占用 10 ~ 100ms 的时间。

而在v1.8之后使用混合写屏障

  • GC刚开始的时候,会将栈上的可达对象全部标记为黑色

  • GC期间,任何在栈上新创建的对象,均为黑色

  • 堆上被删除的对象标记为灰色

  • 堆上新添加的对象标记为灰色

屏障限制只在堆内存中生效。避免了最后节点对栈进行STW的问题,提升了GC效率

一次完整的GC流程

  1. 标记准备阶段
  • 暂停程序,所有的处理器在这时会进入安全点(Safe point)
  1. 标记阶段
  • 将状态切换至 _GCmark、开启写屏障、用户程序协助(Mutator Assiste)并将根对象入队;
  • 恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象, 写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色
  • 开始扫描根对象,包括所有 goroutine 的栈、全局对象以及不在堆中的运行时数据结构,扫描 goroutine 栈期间会暂停当前处理器;
  • 依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;
  • 使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;

在标记开始的时候,收集器会默认抢占 25% 的 CPU 性能,剩下的75%会分配给程序执行。但是一旦收集器认为来不及进行标记任务了,就会改变这个 25% 的性能分配。这个时候收集器会抢占程序额外的 CPU,这部分被抢占 goroutine 有个名字叫 Mark Assist。而且因为抢占 CPU的目的主要是 GC 来不及标记新增的内存,那么抢占正在分配内存的 goroutine 效果会更加好,所以分配内存速度越快的 goroutine 就会被抢占越多的资源。 除此以外 GC 还有一个额外的优化,一旦某次 GC 中用到了 Mark Assist,下次 GC 就会提前开始,目的是尽量减少 Mark Assist 的使用,从而避免影响正常的程序执行。

  1. 标记终止阶段
  • 暂停程序、将状态切换至 _GCmarktermination 并关闭辅助标记的用户程序;
  • 清理处理器上的线程缓存;
  1. 清理阶段
  • 将状态切换至 _GCoff 开始清理阶段,初始化清理状态并关闭写屏障;
  • 恢复用户程序,所有新创建的对象会标记成白色;
  • 后台并发清理所有的内存管理单元,当 goroutine 申请新的内存管理单元时就会触发清理;

清理这个过程是并发进行的。清扫的开销会增加到分配堆内存的过程中,所以这个时间也是无感知的,不会与垃圾回收的延迟相关联。

GC触发时机

  • 主动触发: 调用runtime.GC
  • 被动触发: 一般两分钟内没有垃圾回收,会强制触发

GC调优

  • 减少堆内存的分配,合理重复利用对象

  • 避免 stringbyte[] 之间的转化等,两者发生转换的时候,底层数据结构会进行复制,因此导致 GC 效率会变低,

  • 针对string拼接, 如果是少量小文本用"+"; 如果是大量小文本拼接, 用strings.Join; 如果是大量大文本拼接,用 bytes.Buffer

  • slice 提前分配足够的内存来降低扩容带来的拷贝

  • 尽可能保持最小的堆内存,注意变量声明在堆上还是栈上

  • 最佳的 GC 频率