如何观察Go语言的垃圾回收现象
方式1 :GODEBUG=gotrace=1
方式2 : go tool trace 的主要功能是,将统计而来的信息以一种可视化的方式展示给用户
方式3 : debug.ReadGCStats
方式4 :runtime.ReadMemStats
go内存泄漏场景
如果一个程序持续不断地产生新的goroutine,且不结束已经创建的
goroutine并复用这部分内存, 内存不会被回收,就会产生内存泄漏
标记清除算法的缺点
Mark-And-Sweep,这个算法就是严格按照追踪式算法的思路来实现的。这个垃圾回收算法的执行流程可以用下面这张图来表示
此算法主要有两个步骤:
- 暂停应用程序的执行, 从根对象出发标记出可达对象。
- 清除未标记的对象,恢复应用程序的执行。
这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步地进行垃圾回收,对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。所以就需要一个算法来解决 GC 运行时程序长时间挂起的问题。
Go语言的垃圾清理主要分为以下四个过程:
1.清理终止阶段
a.暂停程序,所有的处理器在这时会进入安全点(Safe point);
b.如果当前垃圾收集循环是强制触发的,我们还需要处理还未被清理的内存管理单元;
2.标记阶段
a.将状态切换至 _GCmark、开启写屏障、用户程序协助(Mutator Assists)并将根对象入队;
b.恢复执行程序,标记进程和用于协助的用户程序会开始并发标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色,而所有新创建的对象都会被直接标记成黑色;
c.开始扫描根对象,包括所有 Goroutine 的栈、全局对象以及不在堆中的运行时数据结构,扫描 Goroutine 栈期间会暂停当前处理器;
d.依次处理灰色队列中的对象,将对象标记成黑色并将它们指向的对象标记成灰色;
e.使用分布式的终止算法检查剩余的工作,发现标记阶段完成后进入标记终止阶段;
3.标记终止阶段
a.暂停程序、将状态切换至 _GCmarktermination 并关闭辅助标记的用户程序;
b.清理处理器上的线程缓存;
4.清理阶段
a.将状态切换至 _GCoff 开始清理阶段,初始化清理状态并关闭写屏障;
b.恢复用户程序,所有新创建的对象会标记成白色;
c.后台并发清理所有的内存管理单元,当 Goroutine 申请新的内存管理单元时就会触发清理。
Go的垃圾回收算法
Go的垃圾收集器从一开始到现在一直在演进,在v1.5版本开始三色标记法作为垃圾回收算法前使用Mark-And-Sweep(标记清除)算法。从v1.5版本Go实现了基于三色标记清除的并发垃圾收集器,大幅度降低垃圾收集的延迟从几百 ms 降低至 10ms 以下。在v1.8又使用混合写屏障将垃圾收集的时间缩短至 0.5ms 以内。
STW完成后,栈上分配的对象9,由白色变成了黑色
Golang 混合写屏障原理深入剖析
插入写屏障
流程:在A对象引用B对象的时候,B对象被标记为灰色。
- 纯粹的插入写屏障是满足强三色不变式的(永远不会出现黑色对象指向白色对象);
- 但是由于栈上对象无写屏障,那么导致黑色的栈可能指向白色的堆对象,必须 STW 重新扫描栈;
- STW 重新扫描栈再 goroutine 量大且活跃的场景,延迟不可控,经验值平均 10-100ms;
删除写屏障
流程:被删除引用的对象,如果为白色,那么被标记为灰色。
- 如果一个黑色对象添加一个向白色对象的引用,说明这个白色对象可达,受到别的对象的保护,不需要处理,只有在删除引用的时候,才需要处理,保证原先被保护的对象依然受到保护,保证弱三色不变式。
- 必须在起始时,STW 扫描所有goroutine的栈,全部扫黑,保证所有堆上在用的对象都处于灰色保护下。
- 由于起始快照也需要执行 STW,删除写屏障不适用于栈特别大的场景,栈越大,STW 扫描时间越长。
- 回收精度低。
混合写屏障
场景一: 对象被一个堆对象删除引用,成为栈对象的下游
对象4到对象7的引用被删除,所以标记被删除对象7为灰色
场景二: 对象被一个栈对象删除引用,成为另一个栈对象的下游
混合写屏障,新建对象9 标记为黑色
对象9添加引用到对象3,不启动屏障
对象2删除对象3的引用关系,直接删除,不启动写屏障
场景三:对象被一个堆对象删除引用,成为另一个堆对象的下游
对象10标记为黑
Golang中的混合写屏障满足`弱三色不变式`,结合了删除写屏障和插入写屏障的优点,只需要在开始时并发扫描各个goroutine的栈,使其变黑并一直保持,这个过程不需要STW,而标记结束后,因为栈在扫描后始终是黑色的,也无需再进行re-scan操作了,减少了STW的时间。
总结
以上便是Golang的GC全部的标记-清除逻辑及场景演示全过程。
GoV1.3- 普通标记清除法,整体过程需要启动STW,效率极低。
GoV1.5- 三色标记法, 堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要STW),效率普通
GoV1.8-三色标记法,混合写屏障机制, 栈空间不启动,堆空间启动。整个过程几乎不需要STW,效率较高。
Go和Java GC对比
gc对比 | golang | java |
GC算法 | 三色标记法 | 分代垃圾收集 |
对象标记 | 三色标记搭配混合屏障策略 | 根搜索算法 |
垃圾回收 | 并行垃圾回收 | 串行垃圾回收器、并行垃圾回收器、并发标记扫描垃圾回收器、G1垃圾回收器; |
回收算法 | 三色标记-清除,结合go语言的内存分配 | 标记-清除算法、标记-整理算法、复制算法以及适应性算法; |
GC过程 | 清理终止-标记阶段-标记终止-清理阶段 | 初始化标记-并发标记-并发预清理-可终止的并发预清理-重新标记-并发清理-并发重置(CMS) |
触发时机 | 1.申请内存时根据堆大小触发GC; 2.用户程序手动触发GC; 3.后台运行定时检查触发GC。 | Full.GC和Scavenge GC,当程序空闲时和堆内存不足时会触发GC |
GC效率 | 1.吞吐量下降 2.不可预测的堆空间扩大 | CMS和G1回收,缩短STW时间 |
Golang pprof
Golang pprof我们在程序中嵌入如下几行代码
http://ip:8899/debug/pprof/heap包括一些汇总信息,和各个go routine的内存开销,不过这里除了第一行信息比较直观,其他的信息太离散。可以看到当前使用的堆内存是1.58GB,总共分配过15.6GB。
go tool pprofbytes.makeSlice(*Loader).NextBinEntrylist可以直接看到这个函数在哪一行代码产生了多少的内存!不过如果是在可以方便导出文件的测试环境,推荐使用命令,
bytes.BuffergrowBuffer在空间不够时,申请一个当前空间2倍的byte数组,然后把老的copy到这里,这个峰值内存就是3倍的开销,如果value大小5GB,读到4GB空间不够,那么创建一个8GB的新buffer,那么峰值就是12GB了,此外Buffer的初始大小是64字节,在增长到4GB的过程中也会创建很多的临时byte数组,gc不及时也是额外的内存开销,所以4.5GB的RDB,在有大key的情况下,峰值内存用到15GB也就可以理解了。
这个问题的根本原因还是按key处理一次读的value太大,在碰到hash这种复杂数据类型时,其实我们可以分而治之,读了一部分value后,比如16MB就生成一个子hash,避免Buffer grow产生太大的临时对象。
此外,解析RDB时,受限于RDB的格式,只能单个go routine处理,但是回放时,是可以由多个go routine来并发处理多个子hash,写到目标实例的。每个子hash处理完,又可以被gc及时的清理掉。同时并发度上去了,同步的速度也有所提升(主要受限于目标Redis,因为Redis是单线程处理请求)。
Go的垃圾回收过程如何调优
1.合理化内存分配的速度,提高赋值器的CPU利用率
看一个例子 ,concat函数负责拼接一些长度不确定的字符串,并且为了快速完成任务,在两个嵌套的for循环中一口气创建了800个goroutine
通过观察发现,goroutine的执行时间占其生命周期总时间非常短的一部分,但大部分时间都花在调度器的等待上了,说明同时创建大量goroutine对调度器的压力确实不小,不妨将这一产生速率变慢,一批一批创建goroutine
2.降低并复用已经申请的内存
通过go tool pprof来查看是谁分配了大量内存(web指令来使用浏览器打开统计信息的可视化图形)
sync.Pool是内存复用的一个最为显著的例子;或者提前分配好足够的内存,再慢慢地填充,也是一种减少内存分配,复用内存形式的一种表现
3.调整GOGC
通过将GOGC的值设置得更大,让GC触发的时间变得更晚,从而减少其触发频率,进而增加用户代码对机器的使用率