Go 知识垃圾回收

2024/05/10 go

Go 知识垃圾回收

所谓垃圾回收就是不在需要的内存块,这些垃圾如果不清理就没办法再次被分配使用, 在不支持垃圾回收的编程语言里,这些垃圾内存就是泄露的内存。

1. 垃圾回收算法

常见的垃圾回收算法有以下几种:

  • 引用计数: 对每个对象维护一个引用计数,当引用该对象的对象被销毁时,引用计数减1,当引用计数器为0的时候回收该对象:
    优点:对象可以很快地被回收,不会出现内存耗尽或达到某个阈值时才回收。
    缺点:不能很好地处理循环引用,而且实时维护引用计数也有一定的代价。
    语言:Python, PHP, Swift
  • 标记-清除:从根变量开始遍历所有引用的对象,引用的对象标记为“被引用”,没有标记的对象被回收:
    优点:解决了引用计数的缺点
    缺点:需要STW(stop the world)
    语言:Go(三色标记)
  • 分代收集:按照对象声明周期的长短划分不同的代空间,声明周期长的放入老年代,而短的放入新生代,不同代有不同的回收算法和回收频率:
    优点:回收性能好
    缺点:算法复杂
    语言:Java

2. Go 垃圾回收

2.1 垃圾回收原理

垃圾回收的核心就是标记出哪些内存还在使用中(即被引用到),哪些内存不在使用了(即未被引用),把未被引用的内存回收,以供后续内存分配时使用。
img.png

上图中,内存的1、2、4号位上的内存块已经被分配(数字1表示已被分配,0表示未分配)。 变量a,b未指针,指向内存的1、2号位。 内存块的4号位置曾经被使用过,但现在没有任何对象引用了,就需要被回收。
垃圾回收开始时从root对象扫描,把root对象引用的内存标记为“被引用”,考虑到内存中存放的可能是指针,还需要递归的进行标记,全部标记完成后,只保留被标记的内存, 未被标记的内存全部标记为未分配,这样就完成了回收。

2.2 内存标记

在Go的内存分配中(https://blog.csdn.net/a18792721831/article/details/137244768),在 span 中维护了一个个内存块,并由一个位图 allocBits 表示每个内存块的分配情况。 在 span 的数据结构中还有另一个位图 gcmarkBits , 用于标记内存块被引用的情况。
runtime/mheap.go中:
img_1.png

img_2.png

allocBits 记录了每块内存的分配情况,而 gcmarkBits 记录了每块内存的标记情况。 标记阶段对每块内存进行标记,有对象引用的内存标记为1,没有引用到的内存保持为0.
allocBits 和 gcmarkBits 的数据结构是完全一样的,标记结束就是内存回收,回收时将 allocBits 指向 gcmarkBits, 代表标记过的内存才是存活的,gcmarkBits 则会在下次标记时重新分配内存。

2.3 三色标记法

在内存标记中,allocBits 记录内存是否分配,gcmarkBits 标记内存是否被引用。除了这两个,还有一个标记队列存放待标记的对象,也就是本次还未处理的对象的队列。
简单理解就是把对象从标记队中取出,然后将对象的引用状态标记在 span 的 gcmarkBits 中,把对象引用到其他对象在放入待标记队列。
三色只是为了便于理解,虚拟出来的状态:

  • 灰色: 待处理,对象还在标记队列中等待
  • 黑色: 引用中,对象被标记, gcmarkBits 为1。该对象还存在引用,不会在本次GC中被清理。
  • 白色: 未使用,对象未标记, gcmarkBits 为0。该对象没有引用,本次GC将会清理。

img_3.png

初始状态下所有对象都是白色的。
接着扫描根对象 a,b
img_4.png

由于根对象引用了对象A,B ,那么 A, B 变为灰色,进入等待队列。
img_5.png

接着分析灰色对象,分析A,由于A没有引用其他对象,那么很快就转为黑色对象,B引用了D,B进入黑色,同时D转为灰色,进入等待队列。
img_6.png

由于灰色对象D没有引用其他对象,所以D转为黑色对象。
最后,黑色对象保留,白色对象被回收。 C,E,F 就被清理了。

2.4. STW(stop the world)

对于垃圾回收来说,在回收过程中需要控制内存的变化,否则在回收过程中指针传递灰引起内存引用关系变化,如果错误的回收了还在使用的内存,那么程序就会出现不可预知的错误。
Go 中的 STW 就是停止所有的 goroutine, 专心做内存垃圾回收,待垃圾回收结束后,在恢复goroutine.
STW 时间的长短直接影响了应用的执行,时间过长对于一些web应用等来说是不可接受的。 Java中也存在因GC引发的STW。

3. 垃圾回收优化

STW时间越短,对程序的影响就越小。所以对垃圾回收进行了很多的优化:

3.1 写屏障(Write Barrier)

STW的目的是防止GC扫描时,内存变化而停止goroutine,写屏障就是让goroutine和GC可以同时运行的一种手段。 虽然写屏障不能完全消除STW,但是可以大大缩短STW的时间。
写屏障类似一种开关,在GC的特定时机开启,开启后指针传递时会标记指针,即本轮不回收,下次GC时在确定。
GC过程中欣分配的内存会被立即标记,用的就是写屏障技术,即GC过程中分配的内存不会在本轮GC中回收。
写屏障(Write Barrier)是一种用于垃圾收集优化的技术,主要用于减少停止世界(Stop The World,STW)的时间。在 Go 的垃圾收集器中,写屏障是用于实现并发标记阶段的。
在 Go 的垃圾收集过程中,有一个并发标记阶段,这个阶段的目的是找出所有的可达对象。在这个阶段,应用程序的线程和垃圾收集器的线程是并发运行的。这就带来了一个问题:如果在并发标记阶段,应用程序修改了一个已经被标记为可达的对象的指针字段,那么这个修改可能会导致一些原本可达的对象变得不可达,或者一些原本不可达的对象变得可达。为了解决这个问题,Go 引入了写屏障。
写屏障的工作原理是:在并发标记阶段,每当应用程序要修改一个对象的指针字段时,都要先检查写屏障。写屏障会确保新指向的对象(如果它还没有被标记为可达的话)被标记为可达,同时也会确保旧指向的对象(如果它已经被标记为可达的话)仍然被标记为可达。这样,就可以保证在并发标记阶段,应用程序的修改不会影响到垃圾收集器的标记结果。
写屏障的引入,使得 Go 的垃圾收集器可以在并发标记阶段减少 STW 的时间,从而提高了应用程序的性能。但是,写屏障也会带来一些额外的开销,因为每次修改指针字段时都需要检查写屏障。因此,Go 的垃圾收集器会在并发标记阶段结束后关闭写屏障,以减少这部分开销。
写屏障的相关代码主要在 runtime/mbarrier.go 文件中。这个文件定义了写屏障的实现,包括启用和禁用写屏障,以及写屏障的检查函数。

3.2 辅助GC(Mutator Assist)

为了防止内存分配过快,在GC执行过程中,如果goroutine需要分配内存,那么该goroutine会参与一部分GC的工作,即帮助GC做一部分工作,这个机制就是辅助GC.
辅助垃圾收集(Assist GC)是 Go 语言中的另一种垃圾收集优化策略。它的主要目标是在垃圾收集过程中,平衡应用的执行时间和垃圾收集的执行时间,以达到更好的性能。
在 Go 的垃圾收集过程中,有一个并发标记阶段,这个阶段的目的是找出所有的可达对象。在这个阶段,应用程序的线程和垃圾收集器的线程是并发运行的。然而,如果应用程序的执行速度过快,可能会产生大量的新的垃圾对象,这会使得垃圾收集器无法跟上应用程序的速度。为了解决这个问题,Go 引入了辅助垃圾收集。
辅助垃圾收集的工作原理是:在并发标记阶段,每当应用程序要分配一个新的对象时,都要先检查是否需要进行辅助垃圾收集。如果需要,那么应用程序的线程会暂停自己的执行,转而帮助垃圾收集器进行标记工作,直到完成一定量的标记工作后,才会继续执行应用程序。这样,就可以保证垃圾收集器能够跟上应用程序的速度。
辅助垃圾收集的引入,使得 Go 的垃圾收集器可以在并发标记阶段更好地平衡应用的执行时间和垃圾收集的执行时间,从而提高了应用程序的性能。但是,辅助垃圾收集也会带来一些额外的开销,因为每次分配新的对象时都需要检查是否需要进行辅助垃圾收集。因此,Go 的垃圾收集器会根据应用程序的执行情况和垃圾收集的进度,动态地调整辅助垃圾收集的频率和量,以达到最佳的性能。
辅助垃圾收集的相关代码主要在 runtime/mgcmark.go 文件中。这个文件定义了并发标记阶段的实现,包括辅助垃圾收集的触发条件和执行过程。

4. 垃圾回收的触发时机

4.1 内存分配量达到阈值触发GC

每次内存分配时都会检查当前内存分配量是否已达到阈值,如果达到阈值则立即启动GC。
阈值 = 上次 GC 内存分配量 * 内存增长率
内存增长率由环境变量GOGC控制,默认为100,即每当聂村扩大一倍时启动GC.
Go 语言的垃圾收集(GC)是由内存分配量达到一定阈值时触发的。这个阈值被称为 GC 触发器(GC Trigger)。当 Go 程序的堆内存分配量达到这个阈值时,就会启动一次新的垃圾收集。
GC 触发器的计算方法是基于上一次垃圾收集后的存活对象的大小。具体来说,Go 语言的垃圾收集器会在每次垃圾收集结束后,计算所有存活对象的大小总和,然后将这个总和乘以一个因子(默认是 100%),得到的结果就是下一次垃圾收集的触发器。这个因子可以通过 GOGC 环境变量来调整。
例如,如果上一次垃圾收集后,存活对象的大小总和是 100MB,那么下一次垃圾收集的触发器就是 200MB(假设 GOGC 的值是默认的 100)。这意味着,当 Go 程序的堆内存分配量再次达到 200MB 时,就会启动一次新的垃圾收集。
这种基于存活对象大小的 GC 触发器计算方法,可以使 Go 的垃圾收集器根据程序的实际内存使用情况,动态地调整垃圾收集的频率,从而达到更好的性能。

4.2 定期触发GC

默认情况下,最长2分钟触发一次GC,这个间隔在runtime/proc.go#forcegcperiod变量中声明:
img_7.png

4.3 手动触发

程序代码中使用runtime.GC()手动触发GC.

5. 总结

GC性能与对象数量负相关,对象越多,GC性能越差,对程序影响越大。
GC性能优化的思路之一就是减少对象分配的个数,比如对象复用或者使用大对象组合多个小对象等等。
内存逃逸会产生一些隐式的内存分配,也有可能成为GC的负担。

Search

    Table of Contents