转投go系列-go的垃圾回收和逃逸分析

什么是GC?

GC就是垃圾回收 “嘎壁纸” collection
golang不需要对内存进行手动的申请和释放操作,GC会帮我们搞定,对比C/C++优势所在。GC 对我们几乎不可见,但是程序需要进行特殊优化时,可通过调用API解决。

GC种类和Go用的哪个?

根对象

先解释下根对象,垃圾回收都是从根开始检查的,go里面都有哪些根呢?
寄存器:可以理解为一个指针,指针可能指向某个分配好的堆内存区块
执行栈:goroutine 包含自己的上下文,里面包含栈上的变量及指向分配的堆内存区块的指针
全局变量:这个不过多解释

GC种类

  • 追踪式 GC,从根对象出发一个个遍历扫描引用关系,回收不需要的对象。Go,java,nodejs都是这种方案。
  • 引用计数式GC,每个对象自身有个被引用计数器,计数器归零就回收。这种方法问题较多。Python、Obj-C都是这种方式。

Go用的哪个?

Go采用的是不分代、不整理、并发的执行三色标记清扫法。
1、不分代的原因是go在编译的时候会进行逃逸分析(后面讲)把大部分的新生对象放在栈上,栈上就直接回收了。
2、不整理因为go基于TCMalloc在多线程场景下分配内存,基本上很少有碎片问题。
3、并发执行GC减少STW(后面讲)时间。

Go有了GC为什么还泄露?

本想着被回收的内存,因为某种原因一直不被标记成可回收状态而导致长时间存活,也算是一种内存泄露。

1、例如goroutine泄露。程序一直创建新的goroutine,并且老的goroutine又不结束,其上下文一直占用内存,很快就达到内存瓶颈导致程序崩溃。
2、根对象引用了本应不需要的临时变量。例如一个全局变量,可能把某个临时变量附着到上面而被忽略的释放。

什么时候触发GC

可以理解为主动和被动两种
主动:

执行runtime.GC手动触发GC
执行runtime.ReadMemStats读取内存统计信息
执行debug.FreeOSMemory将内存归还给操作系统

被动:

双限制
Pacing算法,内存增长比例达到一定程度即触发GC
同时系统监控,2分钟没有任何GC时,强制触发GC

三色标记

简单说就是标记对象是否要被回收的三种阶段状态

  • 白色对象:未被回收器访问到的对象。在回收开始时,所有对象均为白色,当回收结束后,白色对象均是要被回收的对象。
  • 灰色对象:已被回收器访问到的对象,但回收器需要对其中的指针进行递归扫描,因为他们可能引用白色对象,防止误清理。
  • 黑色对象:已被回收器访问到的对象,其中所有字段都已被扫描,黑色对象中任何一个指针都不可能直接指向白色对象。

三色标记

STW

全称 Stop The World
因为在垃圾回收的过程中如果赋值器也在操作的话,会出现有些被标记为白色对象但在回收前一刻被赋值成黑色的引用对象,但被回收了的话,就出现了回收错误。所以要STW避免这个问题,停止用户的一切代码。

Go1.14以前会有个问题,就是STW无限制被延长,请看详情:
如何定位 golang 进程 hang 死的 bug
Go1.14之后,官方解释是有这种for{}的goroutine会被异步抢占,解决了这个问题。

混合写屏障

STW中也提到了,因为go的GC是和用户代码并发执行的。那么正好三色标记的时候赋值器修改对象引用导致某一黑色对象引用白色对象,同时从灰色对象出发到达白色对象的路径被赋值器破坏。这时候清理对象就真的会出现对象丢失了。
因此上面这2个条件不完全存在就没问题。

那么重点来了,很多人这块都解释不明白,我试着简单说说:

目前赋值器在赋值的时候会有2种情况:

把灰色或者黑色赋值给黑色成为“强三色不变性”,这种没问题,就是比较局限。

把白色赋值给黑色为“弱三色不变性”,这种有风险,所以接下来一定要控制赋值器不能破坏灰色对象到达该白色对象的路径,千万别“特寸进尺,让伤害加深”。

所以接下来要利用一个中间的辅助器来解决"得寸进尺"问题,那就是 Dijkstra 插入屏障和 Yuasa 删除屏障形成的“混合写屏障”。

插入屏障:插入的时候,如果是堆空间中的插入,在A对象引用B对象的时候,B对象被标记为灰色。 如果是栈空间,则不标记。当全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况,所以要对栈重新进行三色标记扫描, 但为了对象不丢失, 要对本次标记扫描启动STW暂停. 直到栈空间的三色标记结束.

删除屏障:删除的时候,如果自身为灰色或者白色,都被标记为灰色。这样可以保护灰色对象到白色对象的路径不会断。但回收精度低,GC开始时STW扫描堆栈来记录初始快照,这个过程会保护开始时刻的所有存活对象。

混合写屏障:Go1.8时出现的,避免了对栈re-scan的过程,极大的减少了STW的时间。结合了前两者的优点。GC开始将各goroutine栈上的对象全部扫描并标记为黑色,任何在栈上创建的新对象,均为黑色。被删除的对象标记为灰色。被添加的对象标记为灰色。

详细过程图片请看https://studygolang.com/articles/27243

逃逸分析

添加屏障的时候提到了栈和堆,那么什么时候是堆?什么时候是栈呢?这时候就用到了逃逸分析。

逃逸分析是Golang 在编译的时候通过分析用户源码,决定哪些变量应该在堆栈上分配,哪些变量应该逃逸到堆中。分析规则:

  1. 是否有在其他非局部地方被引用。只要有可能被引用了,那么它一定分配到堆上。否则分配到栈上。
  2. 即使没有被外部引用,但对象过大,无法存放在栈区上。依然有可能分配到堆上。
  3. 动态参数因为编译器难以确认其类型,安全起见都放在堆中。

为什么要这么做呢?

如果都分配到堆上,会有下面问题:
GC的压力会不断增大
申请、分配、回收内存的系统开销增大
动态分配产生一定量的内存碎片

如何肉眼观察呢?

通过gcflags
-m 会打印出逃逸分析的优化策略
-l 会禁用函数内联,禁用掉 inline 能更好的观察逃逸情况,减少干扰
例如编译时: go build -gcflags="-m -l" main.go

下面是我随便敲了几行代码的结果,有些在栈上,有些逃到的堆上。具体代码到时候可以具体分析。

# command-line-arguments
./main.go:28:8: func literal does not escape
./main.go:36:8: func literal does not escape
./main.go:41:13: ... argument does not escape
./main.go:41:14: "this is resp" escapes to heap
./main.go:30:15: ... argument does not escape
./main.go:30:16: "this is recover" escapes to heap
./main.go:32:14: ... argument does not escape
./main.go:32:15: "this is defer1" escapes to heap
./main.go:37:14: ... argument does not escape
./main.go:37:15: "this is defer2" escapes to heap
./main.go:13:5: func literal escapes to heap
./main.go:22:13: ... argument does not escape
./main.go:22:14: "done~" escapes to heap

所以说,总结两点点:

用指针传递并不一定是最好的,要合适的地方用
静态分配到栈上,一定比动态分配到堆上的性能好很多

猜你喜欢

转载自blog.csdn.net/lwcbest/article/details/120531228