写点什么

GO GC 知识点整理

用户头像
Jack Zheng
关注
发布于: 2021 年 03 月 14 日

本文档主要记录自己对 Go GC 的一些学习笔记,以及学习逻辑,参考资料在文末的资料来源。


本文档将会学到


  • 什么是 GC,GC 是来干嘛的,为了什么

  • 谁现在在用 GC

  • 区分 GC 好坏的指标是什么

  • GC 通常有什么算法

  • GC 在什么时候触发

  • GO 使用了什么 GC 算法,演变是怎样的

  • GO 中 GC 执行的流程是怎样的

  • GO GC 相关 API

  • 如何观察 GC

  • GC 如何调优



什么是 GC


Garbage Collection


GC,Garbage Collection 的缩写,自动内存管理机制。程序创建对象等引用类型实体时会在虚拟内存中分配给它们一块内存空间,如果该内存空间不再被任何引用变量引用时就成为需要被回收的垃圾。


垃圾回收器组件


垃圾回收器分为两个半独立的组件


  • 赋值器 Mutator:对用户态对象图进行操作(上色)

  • 回收器 Collector:负责执行垃圾回收(回收)


用户程序 Mutator 通过内存分配器 Allocator 在堆 Heap 上申请内存,垃圾回收器 Collector 会定时清理堆上的内存。



垃圾回收器的目标


  • 无内存泄漏:垃圾回收器最基本的目标就是减少防止程序员未及时释放导致的内存泄漏,垃圾回收器会识别并清理内存中的垃圾

  • 自动回收无用内存:垃圾回收器作为独立的子任务,不需要程序员显式调用即可自动清理内存垃圾

  • 内存整理:如果只是简单回收无用内存,那么堆上的内存空间会存在较多碎片而无法满足分配较大对象的需求,因此垃圾回收器需要重整内存空间,提高内存利用率



谁在用 GC


从原理上而言,所有的语言都能够自行实现 GC。从语言诞生之初就提供 GC 的语言,例如:


  • Python

  • JavaScript

  • Java

  • Objective-C

  • Swift


而不以 GC 为目标,被直接设计为手动管理内存、但可以自行实现 GC 的语言有:


  • C

  • C++


也有一些语言可以在编译期,依靠编译器插入清理代码的方式,实现精准的清理,例如:


  • Rust


垃圾回收使程序员无需手动处理内存释放,从而能够消除一些需要手动管理内存才会出现的运行时错误:


  1. 在仍然有指向内存区块的指针的情况下释放这块内存时,会产生悬挂指针,从而后续可能错误的访问已经用于他用的内存区域。

  2. 多重释放同一块申请的内存区域可能导致不可知的内存损坏。


当然,垃圾回收也会伴随一些缺陷,这也就造就了没有 GC 的一些优势:


  1. 没有额外的性能开销

  2. 精准的手动内存管理,极致的利用机器的性能



GC 指标


  • CPU 利用率:回收程序会占程序多少 CPU 来执行从而拖慢程序

  • GC 停顿时间:回收器占用多长时间

  • GC 停顿频率:回收器停顿频率为何

  • GC 可扩展性:当堆内存变大时,垃圾回收器性能如何



常见 GC 算法


常见 GC 分为两大类:


  • 追踪(tracing):从根对象出发,根据对象之间的引用信息,扫描,直到确定要保留的对象,回收无引用对象。一般是 GO,JAVA,V8 等

  • 引用计数式:每个对象自身包含一个计数器,当计数器归零的自动回收。一般是 Python,object-C 等


追踪式


标记清扫


标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。这也是 GO 在 GC 算法的基础。


标记清扫优点:


  • 可以解决循环调用问题


标记清扫缺点:


  • 效率较慢,因为在标记和清除时都要遍历所有的对象,并且在每次 GC 开始的时候都要 STW

  • 因为内存分配时的碎片化,导致在回收之后也会有不同程度的碎片化,导致内存利用率不高


标记压缩


它在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记。但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。



标记压缩优点:


  • 标记-压缩算法适合用于存活对象较多的场合,如老年代

  • 降低堆内存碎片化问题


标记压缩缺点:


  • 效率相较标记清扫更低了


标记复制


将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。



标记复制优点:


  • 与标记-清除算法相比,复制算法是一种相对高效的回收方法(默认应用于青年代)

  • 效率较高


标记复制缺点:


  • 浪费空间

  • 对可达对象存活周期长的效率不高


分代式


对于一个大型的系统,当创建的对象和方法变量比较多时,堆内存中的对象也会比较多,如果逐一分析对象是否该回收,那么势必造成效率低下。分代收集算法是基于这样一个事实:不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。当代商用虚拟机使用的都是分代收集算法:新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记清除算法或者标记整理算法。Java 堆内存一般可以分为新生代、老年代和永久代三个模块。


根据不同对象的存活周期分为不同的几块


  • 老年代:标记清扫 或 标记整理

  • 青年代:标记复制


引用计数法


引用计数 Reference counting 会为每个对象维护一个计数器,当该对象被其他对象引用时加一,引用失效时减一,当引用次数归零后即可回收对象。使用这类 GC 方法的语言包括 python、php、objective-C 和 C++标准库中的 std::shared_ptr 等。


以 python 为例,python 中的每个对象都包含如下结构:


 typedef struct_object {      int ob_refcnt;      struct_typeobject *ob_type;  }PyObject;
复制代码


其中 ob_refcnt 为引用计数器,当一个对象有新的引用时计数器增一,当引用它的对象被删除时计数器减一。


引用计数法优点包括:


  • 原理和实现都比较简单

  • 回收的即时性:当对象的引用计数为 0 时立即回收,不像其他 GC 机制需要等待特定时机再回收,提高了内存的利用率

  • 不需要暂停应用即可完成回收


缺点包括:


  • 无法解决循环引用的回收问题:当 ObjA 引用了 ObjB,ObjB 也引用 ObjA 时,这两个对象的引用次数使用大于 0,从而占用的内存无法被回收

  • 时间和空间成本较高:一方面是因为每个对象需要额外的空间存储引用计数器变量,另一方面是在栈上的赋值时修改引用次数时间成本较高(原本只需要修改寄存器中的值,现在计数器需要不断更新因此不是只读的,需要额外的原子操作来保证线程安全)

  • 引用计数是一种摊销算法,会将内存的回收分摊到整个程序的运行过程,但是当销毁一个很大的树形结构时无法保证响应时间



GC 触发机制


主要分为主动触发和被动触发


  • 主动触发:runtime GC,阻塞式等待当前 GC 调用完成

  • 被动触发:



GO 使用的 GC 算法


无分代、不整理、并发的三色标记法


对象整理目的:


  1. 是解决内存碎片问题,但是 Go 给予 tcmalloc 分配算法,基本没有碎片问题。另外顺序内存分配器在多线程并不适用,整理内存队 tcmalloc 分配没有实质提升

  2. 分代 GC 目标主要是针对新创建对象,不会频繁检查所有对象。但是 Go 会通过逃逸分析将大部分“新生”对象存储在栈上,需要长期保存的对象存在于堆中。栈会被回收,不需要 GC。Go 的 GC 更专注于如何让 GC 和用户代码并发执行


GO 1.3 mark and sweep


mark and sweep 分为 2 个步骤


  • 暂停业务逻辑(需要 STW),找到不可达对象并标记

  • 回收标记好的对象




可以看到一次 GC 里面需要一次 STW,在没有优化之前,STW 的时间是比较久的。只有到清扫完成之后才停止 STW,因此 GO 1.3 进行了一个优化,就是讲停止 STW 改到清扫之前。



GO 1.5 三色并发标记法


哪三色?


  • 白色对象:程序生成的对象都是白色对象,GC 结束后还是白色的会被回收

  • 灰色对象:可达对象,在 GC 遍历时遍历到的对象,GC 结束后不会回收,正常是变为黑色

  • 黑色对象:保留对象,在灰色遍历完之后,或者无接下去可达对象的灰色对象,最后都变为灰色对象


步骤


步骤一、将程序以根节点展开


步骤二、所有对象放入白色集合


步骤三、根节点开始遍历,遍历到的标为灰色


步骤四、遍历灰色,将遍历到的放入灰色,原本灰色的放入黑色


步骤五、重复上一步直到无灰色对象


步骤六、回收白色对象


基于标记清扫的例子,看一下三色并发标记法是如何实现的,记住 GC 开始时还是需要 STW


根据步骤一,将会变成如下:


将程序以根节点展开



根据步骤二,我们会建立白色对象,灰色对象和黑色对象集合。首先的是将所有对象先放到白色对象,最后剩下的白色对象,也就是要回收的对象。



步骤三,遍历白色对象内的根对象,也就是对象 1(dx1)和对象 4。将遍历到的对象,由白色标记为灰色。



步骤四,遍历灰色,将遍历到的放入灰色,原本灰色的放入黑色。以对象 1 为例,对象 2 会从白色标机为灰色,而对象 1 会被标记为黑色。



步骤五,循环第四步,直到没有灰色对象为止。


步骤六,当没有灰色对象后,回收白色对象


为何在 GC 一开始需要 STW


看下图,要扫描对象 2 的时候。对象 2 删除对对象 6 的引用,同时,对象 4 创建指针指向对象 6。此时继续标记,因为对象 4 不会再被扫描,所以,对象 6 会被当做回收对象。到最后就是,对象 4 指向的对象 6 会被清扫。因此,在没有 STW 的时候会发生如下两种情况:


  • 白对象被黑引用

  • 灰对象与可达对象失去关系


可见,其实 STW 是为了保护在程序执行过程中,由于额外的新增或删除导致不可控的对象丢失的一个做法。因为 STW 是需要暂定整个程序,所以 STW 的时间成为了一个指标,也上升为 GC 算法的一直指标,也是后面 GC 算法优化的一个点。


因此提出 2 个概念用于防止上述情况发生:


  • 强三色不变式:不允许黑色对象引用白色对象

  • 弱三色不变式:所有黑色对象引用的白色对象都必须在灰色对象保护链下


插入屏障


强三色不变式: 黑色引用白色对象后,白色对象变为灰色对象


如下图例子,遍历完对象 1 和对象 4 之后,分别引用对象 7 和对象 8。根据插入屏障强三式,此时对象 8 会标记为灰色,再下个循环标记的时候,对象 8 可以标记为黑色保留下来。



可以看到对象 7 其实是没有像对象 8 一样,立马被标记为灰色。这是因为,栈容量小,反应速度要求高,不能用插入屏障的机制。因此,在堆对象扫描完之后,会对栈对象 STW,然后通过三色并发标记清扫,完成 GC。即,此例中的对象 7 得以保留。


删除屏障


弱三色不变式:被删除对象为灰色或白色,则标记为灰色对象


如下,为了保证对象没有被误扫除,在扫描对象 1 和对象 4 时,对象 1 删除对对象 2 的指向后。根据删除屏障弱三式,对象 2 会被标记为灰色。按照三色并发标记,最终 GC 如下右侧图。不过可以看到,虽然在一定程度上降低了对象被误扫除的机率,但同时降低了 GC 的精度。例如对象 2 和对线 6 就需要在下次 GC 的时候才会销毁。


GO 1.8 混合写屏障


可以看到之前的插入屏障和删除屏障有明显的自身缺陷:


  • 插入屏障:需要对栈对象重新 STW 遍历

  • 删除屏障:回收精度低


混合写屏障,就是结合两者优势,又中和两者的劣势。混合写屏障减少 STW,并且减少了扫描栈对象的时间。混合写屏障会做如下操作:


  • GC 开始时,将栈全部标记为黑色

  • GC 期间,任何创建在栈上的对象都标记为黑色

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

  • 被添加的对象标记为灰色


栈对象引用堆对象


遍历对象 4 时,删除对对象 5 的引用,对象 1 创建对对象 5 的引用,对象 5 被标记为灰色。


某个栈对象引用另一个栈对象的引用


在栈上创建对象 7,对象 7 会被标记为黑色。同时,对象 7 创建对对象 6 的引用,对象 2 删除对对象 6 的引用。由于都在栈上操作,因此没有标记色动作。


某个堆对象引用另一个堆对象的引用


遍历对象 4 时,删除对对象 5 的引用。同时,对象 7 创建对对象 5 的引用。对象 5 被标记为灰色,避免被错误回收。



堆对象引用栈对象


遍历对象 4 时,删除对对象 5 的引用。同时,对象 4 创建对对象 2 的引用。对象 5 会被标记为灰色,为了保证下游对象不被误回收。




GO GC 流程


Go GC 对应 5 个流程分别是:


GC Mark Prepare


标记准备阶段,为并发标记做准备工作,包括开启写屏障(write barrier)和辅助 GC(mutator assist),统计 root 对象的任务数量等 --> STW


GC Mark


扫描标记阶段,扫描所有 root 对象,包括全局指针和 goroutine(G)栈上的指针(扫描对应 G 栈时需停止该 G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空 --> 后台并发


GC Mark Termination


标记终止阶段,保证一个周期内标记任务完成,停止写屏障。同时由于 Mark 和用户代码是并发执行,所以需要重新扫描(re-scan)全局指针和栈。 --> STW


GC Off SWEEP


内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 --> 后台并发


GCoff


内存归还阶段,将过多的内存归还给操作系统,写屏障关闭 --> 后台并发



GO GC API


  • runtime.GC:手动触发 GC

  • runtime.ReadMemStats:读取内存相关的统计信息,其中包含部分 GC 相关的统计信息

  • debug.FreeOSMemory:手动将内存归还给操作系统

  • debug.ReadGCStats:读取关于 GC 的相关统计信息

  • debug.SetGCPercent:设置 GOGC 调步变量

  • debug.SetMaxHeap:设置 GO 程序堆的上限值



观察 GC


下面通过此代码尝试观察 gc

package main
func allocate() { _ = make([]byte, 1<<20)}
func main() { for n := 1; n < 100000; n++ { allocate() }}
复制代码


GODEBUG=gctrace=1


通过 GODEBUG=gctrace=1 这个参数来观察 gc 的情况


GODEBUG=gctrace=1 ./main
复制代码


执行后部分结果如下

gc 1 @0.000s 0%: 0.007+0.22+0.002 ms clock, 0.042+0.059/0.051/0.009+0.015 ms cpu, 4->7->3 MB, 5 MB goal, 6 Pgc 2 @0.001s 3%: 0.023+0.29+0.10 ms clock, 0.13+0.043/0.021/0+0.61 ms cpu, 5->6->1 MB, 6 MB goal, 6 Pgc 3 @0.001s 4%: 0.062+0.41+0.003 ms clock, 0.37+0.041/0.17/0.018+0.018 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 4 @0.002s 4%: 0.025+0.22+0.002 ms clock, 0.15+0.046/0.018/0.001+0.013 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 5 @0.003s 4%: 0.008+0.38+0.002 ms clock, 0.051+0.053/0.097/0.016+0.017 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 6 @0.003s 4%: 0.010+0.22+0.002 ms clock, 0.063+0.044/0.021/0.015+0.017 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 7 @0.004s 5%: 0.050+0.089+0.002 ms clock, 0.30+0.040/0.028/0.002+0.013 ms cpu, 4->4->0 MB, 5 MB goal, 6 Pgc 8 @0.005s 5%: 0.009+0.34+0.003 ms clock, 0.055+0.042/0.067/0.016+0.019 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 9 @0.005s 6%: 0.008+0.21+0.095 ms clock, 0.053+0.047/0.020/0.001+0.57 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 10 @0.006s 5%: 0.007+0.27+0.002 ms clock, 0.047+0.039/0.073/0.014+0.015 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 11 @0.006s 6%: 0.081+0.33+0.003 ms clock, 0.48+0.040/0.093/0.013+0.018 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 12 @0.007s 6%: 0.008+0.20+0.002 ms clock, 0.052+0.045/0.035/0+0.015 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 13 @0.007s 6%: 0.008+0.27+0.003 ms clock, 0.049+0.041/0.080/0.011+0.018 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 14 @0.008s 6%: 0.007+0.25+0.002 ms clock, 0.047+0.040/0.020/0+0.015 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 15 @0.009s 6%: 0.080+0.24+0.003 ms clock, 0.48+0.042/0.067/0.012+0.019 ms cpu, 4->5->1 MB, 5 MB goal, 6 Pgc 16 @0.009s 7%: 0.066+0.11+0.001 ms clock, 0.40+0.040/0.018/0+0.011 ms cpu, 4->4->0 MB, 5 MB goal, 6 P
复制代码


gc 2 @0.001s 3%: 0.023+0.29+0.10 ms clock, 0.13+0.043/0.021/0+0.61 ms cpu, 5->6->1 MB, 6 MB goal, 6 P
复制代码


以此条结果讲解字段意思


字段含义

gc 2 第二个 GC 周期

0.001 程序开始后的 0.001 秒

3% 该 GC 周期中 CPU 的使用率

0.023 标记开始时, STW 所花费的时间(wall clock)

0.029 标记过程中,并发标记所花费的时间(wall clock)

0.10 标记终止时, STW 所花费的时间(wall clock)

0.13 标记开始时, STW 所花费的时间(cpu time)

0.043 标记过程中,标记辅助所花费的时间(cpu time)

0.021 标记过程中,并发标记所花费的时间(cpu time)

0.0 标记过程中,GC 空闲的时间(cpu time)

0.61 标记终止时, STW 所花费的时间(cpu time)

5 标记开始时,堆的大小的实际值

6 标记结束时,堆的大小的实际值

1 标记结束时,标记为存活的对象大小

6 标记结束时,堆的大小的预测值

6 P 的数量


可以这么理解,本次 GC 为第二次 GC 周期,占用了 3%的 CPU 来执行这次 GC。本次 GC 所花的 STW 扫描时间,STW 并发标记时间,即 STW 清扫的总时间为 0.10ms wall clock。然后,本次 GC 占用的 CPU 时间为 0.61ms。标记钱实际堆大小为 5MB,结束时为 6MB,清扫存活下来的为 1MB。此 GC 基于 6 核 CPU(约等于)。


wall clock 是指开始执行到完成所经历的实际时间,包括其他程序和本程序所消耗的时间;cpu time 是指特定程序使用 CPU 的时间;他们存在以下关系:

  • wall clock < cpu time: 充分利用多核

  • wall clock ≈ cpu time: 未并行执行

  • wall clock > cpu time: 多核优势不明显


go tool trace trace.out


将上述代码块改为如下代码

func main() {	f, _ := os.Create("trace.out")	defer f.Close()	trace.Start(f)	defer trace.Stop()
for n := 1; n < 1000; n++ { allocate() }}
func allocate() { _ = make([]byte, 1<<20)}
复制代码


通过 go tool 分析 trace.out,执行完代码后可以得到一个 trace.out,然后再执行 go tool trace trace.out,会启动一个 web server



网址输入后得到



点击第一个链接 view trace



蓝色区域就是背景 GC 在执行的时间


堆就是我们通过 allocate 分配的内存


绿色为用户代码运行在系统线程上


debug.ReadGCStats
func main() {	go printGCStats()	for n := 1; n < 1000; n++ {		allocate()	}	select {
}}
func printGCStats() { t := time.NewTicker(time.Second) s := debug.GCStats{} for { select { case <-t.C: debug.ReadGCStats(&s) fmt.Printf("gc %d last@%v, PauseTotal %v\n", s.NumGC, s.LastGC, s.PauseTotal) } }}
func allocate() { _ = make([]byte, 1<<20)}
复制代码


type GCStats struct {	LastGC         time.Time       // 上次一次收集时间	NumGC          int64           // gc总次数	PauseTotal     time.Duration   // 总共gc暂停的时间	Pause          []time.Duration // 暂停的时间切片,每次暂停的花费时间,倒叙	PauseEnd       []time.Time     // 暂停结束的时间点,倒叙	PauseQuantiles []time.Duration}
复制代码




GC 调优


只有多写,多靠生产积累经验



资料来源


若有出处未贴出,请私信,马上补上


  1. https://www.jianshu.com/p/4c5a303af470

  2. https://zhuanlan.zhihu.com/p/150642189

  3. https://www.jianshu.com/p/3fc4450e1bbd

  4. https://www.jianshu.com/p/4a972dc959ca

  5. https://www.jianshu.com/p/4a972dc959ca

  6. https://www.cnblogs.com/dongl961230/p/13280415.html

  7. https://mp.weixin.qq.com/s/o2oMMh0PF5ZSoYD0XOBY2Q


发布于: 2021 年 03 月 14 日阅读数: 23
用户头像

Jack Zheng

关注

no pain no gain 2019.04.10 加入

用我的勤奋,一点一点地努力,提升自己的能力,拓展自己的视野,提高自己的认知。

评论

发布
暂无评论
GO GC知识点整理