image

​ Golang作为比较热门的一个编程语言,或者说高级语言,很重要的特性之一是他的垃圾收集。垃圾收集回收掉不需要的内存占用,使我们的程序变得更轻量。

​ 熟悉GC首先需要先了解它的内存分配,因为GC的主要发生地是堆。对于栈来说,它的大小往往是固定的,编译器会将可预测生命周期的小对象放在栈上,使用完毕后直接回收掉。对于程序是否产生内存逃逸可以使用go build -gcflags '-m -l'来确定。

​ 对于Go的GC来说,它并没有像Java一样的分代收集,而是一种较为简单的标记-清除算法。这种算法类似于Java的CMS垃圾收集算法。标记-清除算法和它的名称一样,分为标记和清除两部。

三色标记法

Go的标记使用的是三色标记法,一个非常好的动图实例可以参考 三色标记法。具体的流程如下:

  1. 首先在开始时,所有的对象都会被标记为白色。
  2. 然后我们从根节点开始标记,如果此节点为白色,则会被标记为灰色。如果该节点为灰色则将其标记为黑色。
  3. 当标记完成,所有白色的即为需要被回收的对象。

Tips:在标记时产生的对象会直接被标记为灰色(即使这样也不会导致内存泄漏,因为它将有机会在下一轮标记中被标记为白色)

tri-color-marking

GC过程

​ 对于一轮GC来说,执行顺序为清除结束、标记、标记结束和清除。

清除结束(aka:sweep termination)

​ 此过程会阻塞掉调用的协程(即使用gopark来让该协程进入wait状态),直到它完成上一轮的标记。我们首先需要保证执行本协程的M未被抢占和加锁,否则结束本轮GC过程。然后我们将调用sweepone方法来清理掉剩余的需要清除的内存。对于这种情况来说,在一般情况下(即我们把GC完全交给runtime来处理),这种情况不应该发生,因为每一轮的垃圾都会在其GC过程中被清理。但当我们调用runtime.GC时,便有可能会发生这种情况。

标记

​ 此时我们开启新一轮的GC过程,启动和P一样数量的mark worker协程以保证每个P都有一个后台GC的Go协程。它们不会立刻开始工作直到被gcController.findRunnableGCWorker方法唤醒,然后我们获取堆的锁并将堆中所有arena的标记都置为0。

​ 现在我们将通过sema信号量来STW并将GC状态置为_GCmark状态。此时我们可以开始对每个P的mcache的tiny对象做标记。完成后我们置gcBlackenEnabled为1,这样可以将标记的压力给其他工作协程来分担。结束后我们将start the world并使用gosched来启动goroutine调度。

​ 现在我们进入了一个并发标记的过程,runtime在schedule时会启动对应的后台标记协程。大概如下图所示:

标记协程状态

​ 对于执行标记的协程来说,首先它会先开始标记root对象,对于mcache,它会将mcache直接释放掉。对于mheap的arena,它会每次标记一个shard。对于goroutine的栈它不但会扫描栈对象,如果占用小于1/4,它将收缩该栈。最后我们消费gcwork来标记对象。

标记结束

​ 我们等待本轮标记结束。

清除

​ 我们调用sweepone来清除mspan内的对象。对于mspan里的对象,我们获取它对应的内部元素大小,并调用finalizer并释放掉内存占用。

一些想法

​ 对于golang的gc来说,它所追求的是性能,所以它不像java那样做分代收集,也没有提供多种GC算法给用户选择。golang的设计追求的是不断优化GC对性能造成的影响,也意味着对于内存碎片的处理以及内存的增长的规则都是比较糙的。同时对于这种算法,它对于在内存占用较大时的影响也会比较负面 - 因为你需要标记的对象更多了,这代表你将花更多的时间在gc上。

​ 对于GC的这一话题,其实没有一种完美的算法能cover everything,但是我们需要辩证的看待golang的gc。同时,gc是一个比较庞大的话题,是从1970年开始的古老算法,它值得我们做更深入的探究!

参考


<
Previous Post
MySQL随笔 - 总结
>
Next Post
浅谈Golang内存分配