gc也就是垃圾回收。最近写的项目,pprof查看性能,发现在gc的消耗非常大,发现gc的cpu占用已经到了30%。在oom的时候更是要2分钟才开始进行gc。
因此想着深入调研一下go的gc模式,了解在写程序时保持哪些好的代码习惯,才能最大的减少程序的gc调用。
目录
gc是什么
gc的主流算法
三色标记法
三色标记法的算法流程
三色标记法的问题
多标的例子
多标的发生条件
漏标的例子
漏标的发生条件
解决漏标问题的方法
三色标记法的缺陷
go的内存逃逸
能引起变量逃逸到堆上的典型情况
go的习惯,如何尽可能避免gc的性能影响
go的gc性能调优工具
参考资料:
了解的同学可以直接跳过。
GC Garbage Collection。直译过来就是垃圾回收。想要进行程序调优,是肯定避不开这个环节的,gc管理不好,很容易造成程序的内存无限增长,然后被系统杀掉,上线的项目发生内存泄漏,肯定是p0级别的问题了。
一般写的程序中会用到两种内存,堆内存和栈内存。堆内存就是堆状数据结构存储的内存,不连续,动态分配,存取慢,系统不会自动帮你释放;栈内存就是连续的内存存储结构,先进后出,存取速度快,仅次于寄存器,但是数据大小和生命周期确定,系统会自动释放的。
像函数中定义的一些局部变量,外部没有办法访问,用完就释放掉,这些栈内存中的数据管理起来相对简单,不需要人工的干预释放,所以GC是不会管栈中的内存的,GC负责清理的只有在堆中的内存。
在C等比较早期的语言中是没有堆内存管理的,在堆中申请和释放内存都需要手动执行,这样很容易出现忘记释放内存的情况,导致内存泄漏。所以诞生了一个更人性化的工具,就是GC,它可以自动管理内存的申请和释放,避免造成内存泄漏。当然,有得必有失,有了GC工具,也就需要额外性能或者内存的开支。
gc有两种主流的算法,一个是 【引用计数】,一个是【可达性分析】。只是大类,具体实现的算法就有很多了。
【引用计数】顾名思义,就是对每个使用的变量进行计数统计,每被引用一次,那么计数+1,否则计数-1,到0就可以回收了。
【可达性分析】通过引用的链路来判断是否可以回收,能访问到的就是正在使用的,不能回首;所有不能访问到的,是可以回收的。
当前可达性分析要更主流一些。Go、Java、.Net等都是如此。因为【引用计数】虽然更简单,实时性更好,但是有个很严重的问题,是无法处理循环引用的,比如A->B,B->C,C->A。这种所有的引用均为1,除非主动断开其中一条链,否则不会触发回收。所以【引用计数】的方式一般会和【可达性分析】一起使用。
go的内存回收算法,三色标记法,也是属于可达性分析算法的一种。
先了解一个算法Mark-And-Sweep(标记清扫),这个算法就是严格按照追踪式算法的思路来实现的。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是 0,如果发现对象是可达的就会置为 1,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位设置成 0 方便下次清理。
这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步进行 GC 操作。因为在不同阶段标记清扫法的标志位 0 和 1 有不同的含义,那么新增的对象无论标记为什么都有可能意外删除这个对象。
对实时性要求高的系统来说,Mark-And-Sweep这种需要长时间挂起的标记清扫法是不可接受的。所以就需要一个算法来解决 GC 运行时程序长时间挂起的问题,三色标记法就是干这个的。
三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。
注意,三色标记法虽然是异步的,但还是会有中断的时间。
垃圾回收器的工作流程大体如下:
无论使用哪种算法,标记总是必要的一步。而三色标记法的中断时间就在于刚开始标记的时候。所以需要知道一个概念,叫「Stop The World 」,简称「STW」。
把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:
假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
当Stop The World (以下简称 STW)时,对象间的引用 是不会发生变化的,可以轻松完成标记。
而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。
多标会有本轮垃圾没有被清理的情况。
假设已经遍历到E(变为灰色了),此时应用执行了 objD.fieldE = null
:
此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存。
这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
上面的例子属于引用变化或者删掉引用的情况,会在黑色的对象断开对其他对象的引用时发生。
另外还有新建内存的情况,GC开始并发标记后,对于新建的内存,会直接放到黑色标记中,不进行清理,这部分数据也可能会成为“浮动垃圾”。
假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了:
var G = objE.fieldG;
objE.fieldG = null; // 灰色E 断开引用 白色G
objD.fieldG = G; // 黑色D 引用 白色G
E > G 断开,D引用 G
此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。
不难分析,漏标只有同时满足以下两个条件时才会发生:
条件一:灰色对象 断开了 白色对象的引用(直接或间接的引用);即灰色对象 原来成员变量的引用 发生了变化。
条件二:黑色对象 重新引用了 该白色对象;即黑色对象 成员变量增加了 新的引用。
传统的解决这多标漏标两个问题的思路有两个:
第一个思路专业叫法是「写屏障」,第二个是「读屏障」。其实名字就是噱头,就是在修改和读取之前做一些操作。读写屏障的目的就是在读写前后记录修改的对象。
基于「读屏障」的方案是:在「黑色」对象重新建立「白色」对象的引用前,将这个白色对象记录下来,避免被回收掉。这个动作在「读取操作前」进行,JVM 中的 ZGC 垃圾回收器就是这个思路。这种做法是保守的,但也是安全的。
基于「写屏障」,可以延伸出两个方案:
■ 增量更新(Incremental Update)。针对新增的引用,将其记录下来等待重新遍历。这个操作在「修改操作后」进行,JVM 中的 CMS 垃圾回收器就是这个思路。
■ 原始快照(Snapshot At The Beginning,SATB)。当某个时刻 的 GC Roots 确定后,当时的对象图就已经确定了。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。这个操作在「修改操作前」进行,JVM中 的 G1 垃圾回收器用的就是这个思路。理论上,配合 「Remembered Set」,SATB 的效率是比增量更新要高的,不过会消耗更多的内存。
在 Golang(1.8版本之后)里,用的是一种新的机制,称之为「混合写屏障」机制。就是在 GC 期间,在堆上被删除或者添加的对象都标记为灰色。后续继续扫描。
程序中的垃圾产生的速度大于垃圾收集的速度,这样会导致程序中的垃圾越来越多无法被收集掉。
go的垃圾回收有个触发阈值,这个阈值会随着每次内存使用变大而逐渐增大(如初始阈值是10MB则下一次就是 20MB,再下一次就成为了40MB...),如果长时间没有触发gc,go会主动触发一次(2min)。高峰时内存使用量上 去后,除非持续申请内存,靠阈值触发gc已经基本不可能,而是要等最多2min主动gc开始才能触发gc。
go语言在向系统交还内存时只是告诉系统这些内存不需要使用了,可以回收;同时操作系统会采取“拖延症”策略, 并不是立即回收,而是等到系统内存紧张时才会开始回收这样该程序又重新申请内存时就可以获得极快的分配速度。
表面上,指针参数的性能要更好一些,但是实际上具体分析,被复制的指针会延长目标对象的生命周期,还可能会 导致他被分配到堆上去,那么其性能消耗就得加上堆内存分配和垃圾回收的成本。
golang中内存分配方式:
主要是堆(heap)和栈(stack)分配两种。
栈分配廉价,堆分配昂贵。
栈分配:对于栈的操作只有入栈和出栈两种指令,属于静态资源分配。
堆分配:堆中分配的空间,在结束使用之后需要垃圾回收器进行闲置空间回收,属于动态资源分配。
使用栈分配:函数的内部中不对外开放的局部变量,只作用于函数中。
使用堆分配:1.函数的内部中对外开放的局部变量。2.变量所需内存超出栈所提供最大容量。
golang程序变量
会携带有一组校验数据,用来证明它的整个生命周期是否在运行时完全可知。如果变量通过了这些校验,它就可以在栈上
分配。否则就说它 逃逸
了,必须在堆上分配
。
1.在方法内把局部变量指针返回
局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则溢出。
2.发送指针或带有指针的值到 channel 中
在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
3.在一个切片上存储指针或带指针的值
一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
4.slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )
slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
5.在 interface 类型上调用方法
在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。
其实重点围绕的就是两方面:
尽量减少对象创建,能复用的就复用
尽量避免内存逃逸,能在栈解决的不要扔到堆上
1.根本上解决,避免在堆上申请内存,能用函数内变量搞定的,不要用外部变量
2.尽量不要创建大量对象,也尽量不要频繁创建对象,创建的对象越多,gc负担越重。具体分析例子可以看看:Go --- GC优化经验 - ma_fighting - 博客园,写的很具体。
3.大对象用sync.pool优化,尽量做到内存复用
4.尽量避免内存逃逸到堆,这样会增大变量的生存周期。但是做到这点很难,需要丰富的经验,go的编译器是很智能的,即使你用了new,也有可能在栈上分配;你直接赋值,后面有函数调用,也会从栈转到堆,所以只能说尽量避免。
5.对切片数据,优先分配需要的内存,一个是避免超过cup逃逸到堆,一个是避免重新分配内存。
6.减少小数据指针的调用,直接用值虽然会增加内存使用,但是会减少创建的对象数量,对gc有好处,需要平衡使用内存和对象数量的关系。
一是使用cpupprof,可以统计出一段时间内哪些函数最耗费cpu。
pprof 的使用 - 简书
二是设置go的GOGCTRACE变量,查看打印的gc信息
Go -- 中开启gctrace_weixin_30246221的博客-CSDN博客
三是设置go的gc参数,可以通过条件gc参数的值来选出最适合程序的gc数值
Go 垃圾回收(一)——为什么要学习 GC ? - 知乎
举例:go程序例子
func doAllocate(nKB int, wg *sync.WaitGroup) {
var slice []byte
for i := 0; i < nKB; i++ {
t := make([]byte, 1024) // 1KB
slice = append(slice, t...)
}
wg.Done()
}
func main() {
t0 := time.Now()
n := 10
wg := new(sync.WaitGroup)
wg.Add(n)
for i := 0; i < n; i++ {
go doAllocate(50*1024, wg) // 程序运行时最多分配 50MB-100MB 内存, 防止影响宿主机
}
wg.Wait()
println("time used", time.Since(t0).Milliseconds(), "ms")
}
shell调优脚本:
data=( -1 10 50 100 200 400 800 1600 3200)
for i in ${data[@]} ; do
echo "==== start", GOGC=$i "===="
GOGC=$i go run main.go
echo
done
运行结果
==== start, GOGC=-1 ====
time used 1000 ms
==== start, GOGC=10 ====
time used 706 ms
==== start, GOGC=50 ====
time used 708 ms
==== start, GOGC=100 ====
time used 650 ms
==== start, GOGC=200 ====
time used 752 ms
==== start, GOGC=400 ====
time used 712 ms
==== start, GOGC=800 ====
time used 785 ms
==== start, GOGC=1600 ====
time used 903 ms
==== start, GOGC=3200 ====
time used 784 ms
基础知识篇——堆内存和栈内存_WaitFoF-CSDN博客_堆内存和栈内存
Go 垃圾回收(二)——垃圾回收是什么? - 知乎
聊聊Go的三色标记法 - 简书
三色标记法与读写屏障 - 简书
Golang 三色标记法 - 翊仰 - 博客园
Go --- GC优化经验 - ma_fighting - 博客园