本文档主要记录自己对Go GC的一些学习笔记,以及学习逻辑,参考资料在文末的资料来源。
本文档将会学到
GC,Garbage Collection的缩写,自动内存管理机制。程序创建对象等引用类型实体时会在虚拟内存中分配给它们一块内存空间,如果该内存空间不再被任何引用变量引用时就成为需要被回收的垃圾。
垃圾回收器分为两个半独立的组件
用户程序Mutator通过内存分配器Allocator在堆Heap上申请内存,垃圾回收器Collector会定时清理堆上的内存。
从原理上而言,所有的语言都能够自行实现 GC。从语言诞生之初就提供 GC 的语言,例如:
而不以 GC 为目标,被直接设计为手动管理内存、但可以自行实现 GC 的语言有:
也有一些语言可以在编译期,依靠编译器插入清理代码的方式,实现精准的清理,例如:
垃圾回收使程序员无需手动处理内存释放,从而能够消除一些需要手动管理内存才会出现的运行时错误:
当然,垃圾回收也会伴随一些缺陷,这也就造就了没有 GC 的一些优势:
常见GC分为两大类:
标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象。然后,在清除阶段,清除所有未被标记的对象。这也是GO在GC算法的基础。
标记清扫优点:
标记清扫缺点:
它在标记-清除算法的基础上做了一些优化。和标记-清除算法一样,标记-压缩算法也首先需要从根节点开始,对所有可达对象做一次标记。但之后,它并不简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。
标记压缩优点:
标记-压缩算法适合用于存活对象较多的场合,如老年代
降低堆内存碎片化问题
标记压缩缺点:
将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。
标记复制优点:
与标记-清除算法相比,复制算法是一种相对高效的回收方法(默认应用于青年代)
效率较高
标记复制缺点:
对于一个大型的系统,当创建的对象和方法变量比较多时,堆内存中的对象也会比较多,如果逐一分析对象是否该回收,那么势必造成效率低下。分代收集算法是基于这样一个事实:不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 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为引用计数器,当一个对象有新的引用时计数器增一,当引用它的对象被删除时计数器减一。
引用计数法优点包括:
缺点包括:
主要分为主动触发和被动触发
无分代、不整理、并发的三色标记法
对象整理目的:
mark and sweep分为2个步骤
可以看到一次GC里面需要一次STW,在没有优化之前,STW的时间是比较久的。只有到清扫完成之后才停止STW,因此GO 1.3进行了一个优化,就是讲停止STW改到清扫之前。
步骤一、将程序以根节点展开
步骤二、所有对象放入白色集合
步骤三、根节点开始遍历,遍历到的标为灰色
步骤四、遍历灰色,将遍历到的放入灰色,原本灰色的放入黑色
步骤五、重复上一步直到无灰色对象
步骤六、回收白色对象
基于标记清扫的例子,看一下三色并发标记法是如何实现的,记住GC开始时还是需要STW
根据步骤一,将会变成如下:
将程序以根节点展开
根据步骤二,我们会建立白色对象,灰色对象和黑色对象集合。首先的是将所有对象先放到白色对象,最后剩下的白色对象,也就是要回收的对象。
步骤三,遍历白色对象内的根对象,也就是对象1(dx1)和对象4。将遍历到的对象,由白色标记为灰色。
步骤四,遍历灰色,将遍历到的放入灰色,原本灰色的放入黑色。以对象1为例,对象2会从白色标机为灰色,而对象1会被标记为黑色。
步骤五,循环第四步,直到没有灰色对象为止。
步骤六,当没有灰色对象后,回收白色对象
看下图,要扫描对象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的时候才会销毁。
可以看到之前的插入屏障和删除屏障有明显的自身缺陷:
混合写屏障,就是结合两者优势,又中和两者的劣势。混合写屏障减少STW,并且减少了扫描栈对象的时间。混合写屏障会做如下操作:
遍历对象4时,删除对对象5的引用,对象1创建对对象5的引用,对象5被标记为灰色。
在栈上创建对象7,对象7会被标记为黑色。同时,对象7创建对对象6的引用,对象2删除对对象6的引用。由于都在栈上操作,因此没有标记色动作。
遍历对象4时,删除对对象5的引用。同时,对象7创建对对象5的引用。对象5被标记为灰色,避免被错误回收。
遍历对象4时,删除对对象5的引用。同时,对象4创建对对象2的引用。对象5会被标记为灰色,为了保证下游对象不被误回收。
Go GC对应5个流程分别是:
标记准备阶段,为并发标记做准备工作,包括开启写屏障(write barrier)和辅助GC(mutator assist),统计root对象的任务数量等 --> STW
扫描标记阶段,扫描所有root对象,包括全局指针和goroutine(G)栈上的指针(扫描对应G栈时需停止该G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空 --> 后台并发
标记终止阶段,保证一个周期内标记任务完成,停止写屏障。同时由于Mark和用户代码是并发执行,所以需要重新扫描(re-scan)全局指针和栈。 --> STW
内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 --> 后台并发
内存归还阶段,将过多的内存归还给操作系统,写屏障关闭 --> 后台并发
下面通过此代码尝试观察gc
package main
func allocate() {
_ = make([]byte, 1<<20)
}
func main() {
for n := 1; n < 100000; n++ {
allocate()
}
}
通过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: 多核优势不明显
将上述代码块改为如下代码
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分配的内存
绿色为用户代码运行在系统线程上
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
}
只有多写,多靠生产积累经验
若有出处未贴出,请私信,马上补上