Go GC
本文档主要记录自己对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
垃圾回收使程序员无需手动处理内存释放,从而能够消除一些需要手动管理内存才会出现的运行时错误:
- 在仍然有指向内存区块的指针的情况下释放这块内存时,会产生悬挂指针,从而后续可能错误的访问已经用于他用的内存区域。
- 多重释放同一块申请的内存区域可能导致不可知的内存损坏。
当然,垃圾回收也会伴随一些缺陷,这也就造就了没有 GC 的一些优势:
- 没有额外的性能开销
- 精准的手动内存管理,极致的利用机器的性能
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调用完成
- 被动触发:
- 系统监控,超过2分钟没有引用就强制GC
- 步调(Pacing算法),控制内存增长的比例
GO使用的GC算法
无分代、不整理、并发的三色标记法
对象整理目的:
- 是解决内存碎片问题,但是Go给予tcmalloc分配算法,基本没有碎片问题。
另外顺序内存分配器在多线程并不适用,整理内存队tcmalloc分配没有实质提升 - 分代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 mainfunc 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 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 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 P
gc 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 调优
只有多写,多靠生产积累经验
资料来源
若有出处未贴出,请私信,马上补上
- https://www.jianshu.com/p/4c5a303af470
- https://zhuanlan.zhihu.com/p/150642189
- https://www.jianshu.com/p/3fc4450e1bbd
- https://www.jianshu.com/p/4a972dc959ca
- https://www.jianshu.com/p/4a972dc959ca
- https://www.cnblogs.com/dongl961230/p/13280415.html
- https://mp.weixin.qq.com/s/o2oMMh0PF5ZSoYD0XOBY2Q