一、前言
1、垃圾回收背景
编程语言通常会使用手动和自动两种方式管理内存,C、C++ 以及 Rust 等编程语言使用手动的方式管理内存,工程师需要主动申请或者释放内存;而 Python、Ruby、Java 和 Go 等语言使用自动的内存管理系统,一般都是垃圾收集机制。这是Go语言成为高生产力语言的原因之一。将开发者从内存管理中释放出来,让开发者有更多的精力去关注软件设计,而不是底层的内存问题。
学习java都知道,对垃圾收集器的印象都是暂停程序(Stop the world,STW),随着用户程序申请越来越多的内存,系统中的垃圾也逐渐增多;当程序的内存占用达到一定阈值时,整个应用程序就会全部暂停,垃圾收集器会扫描已经分配的所有对象并回收不再使用的内存空间,当这个过程结束后,用户程序才可以继续执行,Go 语言在早期也使用这种策略实现垃圾收集,但是今天的实现已经复杂了很多。
2、什么是垃圾回收
垃圾回收也称为GC (Garbage Collection),是一种自动内存管理机制
在应用程序中会使用到两种内存,分别为堆(Heap)和栈(Stack) , GC负责回收堆内存,而不负责回收栈中的内存:栈是线程的专用内存,专门为了函数执行而准备的,存储着函数中的局部变量以及调用栈,函数执行完后,编译器可以将栈上分配的内存可以直接释放,不需要通过GC来回收。堆是程序共享的内存,需要GC进行回收在堆上分配的内存。
3、内存管理组件
垃圾回收器的执行过程被划分为两个半独立的组件:
1.赋值器(Mutator)∶这一名称本质上是在指代用户态的代码。因为对垃圾回收器而言,用户态的代码仅仅只是在修改对象之间的引用关系,也就是在对象图(对象之间引用关系的一个有向图)上进行操作。
2.回收器(Collector):负责执行垃圾回收的代码。
用户程序(Mutator)会通过内存分配器(Allocator)在堆上申请内存,而垃圾收集器(Collector)负责回收堆上的内存空间,内存分配器和垃圾收集器共同管理着程序中的堆内存空间。
4、Go垃圾回收发展史
- go1.1,提高效率和垃圾回收精确度。
- go.13,提高了垃圾回收的精确度。
- go1.4,之前版本的runtime大部分是使用C写的,这个版本大量使用Go进行了重写,让GC有了扫描stack的能力,进一步提高了垃圾回收的精确度。
- go1.5,目标是降低GC延迟,采用了并发标记和并发清除,三色标记,write barrier写屏障,以及实现了更好的回收器调度,设计文档1,文档2,以及这个版本的[Go talk]。
- go1.6,小优化,当程序使用大量内存时,GC暂停时间有所降低。
- go1.7,小优化,当程序有大量空闲goroutine,stack大小波动比较大时,GC暂停时间有显著降低。
- go1.8,write barrier写屏障切换到hybrid write barrier混合写屏障,以消除STW中的re-scan,把STW的最差情况降低到50us,设计文档。
- go1.9,提升指标比较多,1)过去 runtime.GC, debug.SetGCPercent, 和 debug.FreeOSMemory都不能触发并发GC,他们触发的GC都是阻塞的,go1.9可以了,变成了在垃圾回收之前只阻塞调用GC的goroutine。2)debug.SetGCPercent只在有必要的情况下才会触发GC。
- go.1.10,小优化,加速了GC,程序应当运行更快一点点。
- go1.12,显著提高了堆内存存在大碎片情况下的sweeping性能,能够降低GC后立即分配内存的延迟。
- 比较注意的是从go1.5开始使用的并发标记和三色标记法、写屏障为主要,再有就是从1.8之后的混合写屏障是比较重大的改动。
二、垃圾回收常见算法
引用计数:
每个对象维护一个引用计数,当被引用对象被创建或被赋值给其他对象时引用计数自动 +1。如果这个对象被销毁,那么计数-1,当计数为0时,回收该对象。
代表语言:Python、PHP、Swift
优点:对象可以很快被回收,不会出现内存耗尽或者达到阈值才回收。
缺点:不能很好的处理循环引用。
标记-清除:
从根变量开始遍历所有引用的对象,引用的对象标记“被引用”,没有标记的则进行回收。
代表语言:Golang(三色标记法) 。
优点:解决了引用计数的缺点。
缺点:需要 STW(stop the world),暂时停止程序运行。
分代收集:
按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。
代表语言:Java。
优点:回收性能好
缺点:算法复杂
1、go1.3使用的是标记清除法
标记清除(Mark-Sweep)算法是最常见的垃圾收集算法,标记清除收集器是跟踪式垃圾收集器,其执行过程可以分成标记(Mark)和清除(Sweep)两个阶段:
标记阶段 — 从根对象出发查找并标记堆中所有存活的对象;
清除阶段 — 遍历堆中的全部对象,回收未被标记的垃圾对象并将回收的内存加入空闲链表;
主要流程:
- 进行STW(stop the worl即暂停程序业务逻辑),然后从main函数开始找到不可达的内存占用和可达的内存占用
- 开始标记,程序找出可达内存占用并做标记
- 标记结束清除未标记的内存占用
- 结束STW停止暂停,让程序继续运行,循环该过程直到main生命周期结束。
用户程序在垃圾收集需要STW 。
2、三色标记法(go1.5垃圾回收原理)
1)为什么需要三色标记?
三色标记的目的,主要是利用Tracing GC做增量式垃圾回收,降低最大暂停时间。原生Tracing GC只有黑色和白色,没有中间的状态,这就要求GC扫描过程必须一次性完成,得到最后的黑色和白色对象。在前面增量式GC中介绍到了,这种方式会存在较大的暂停时间。
三色标记增加了中间状态灰色,增量式GC运行过程中,应用线程的运行可能改变了对象引用树,只要让黑色对象直接引用白色对象,GC就可以增量式的运行,减少停顿时间。
2)、什么是三色标记?
黑色 Black:表示对象是可达的,即使用中的对象,黑色是已经被扫描的对象。
灰色 Gary:表示被黑色对象直接引用的对象,但还没对它进行扫描。
白色 White:白色是对象的初始颜色,如果扫描完成后,对象依然还是白色的,说明此对象是垃圾对象。
三色标记规则:黑色不能指向白色对象。即黑色可以指向灰色,灰色可以指向白色。
3)三色标记的主要流程:
①、标记灰色对象:在垃圾收集器开始工作时,初始所有对象被标记为白色,寻找所有的Root根对象(比如被线程直接引用的对象)并标记成灰色。
垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束。
三色标记垃圾收集器的工作原理很简单,我们可以将其归纳成以下几个步骤:
② 标记黑色、从灰色对象的集合中选择一个灰色对象并将其标记成黑色;
③标记灰色、将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收;
④重复 、重复上述两个步骤直到对象图中不存在灰色对象;
当三色的标记清除的标记阶段结束之后,应用程序的堆中就不存在任何的灰色对象,我们只能看到黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾,下面是使用三色标记垃圾收集器执行标记后的堆内存,堆中只有对象 D 为待回收的垃圾:
因为用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或者增量执行的,它仍然需要 STW,在如下所示的三色标记过程中,用户程序建立了从 A 对象到 D 对象的引用,但是因为程序中已经不存在灰色对象了,所以 D 对象会被垃圾收集器错误地回收。
本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误,我们将这种错误称为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性, 想要并发或者增量地标记对象还是需要使用屏障技术。
4)、写屏障(屏障机制分为插入屏障和删除屏障)
插入屏障实现的是强三色不变式,
删除屏障则实现了弱三色不变式。
值得注意的是为了保证栈的运行效率,屏障只对堆上的内存对象启用,栈上的内存会在GC结束后启用STW重新扫描。
我们结合一段用户代码介绍写屏障,也是对上述Bug的一种代码表示:
A.Next = B
A.Next = &C{}
三色标记的扫描线程是跟用户线程并发执行的,考虑这种情况:
用户线程执行完 A.Next = B 后,扫描线程把A标记为黑色,B标记为灰色,用户线程执行 A.Next = &C{} ,C是新对象,被标记为白色,由于A已经被扫描,不会重复扫描,所以C不会被标记为灰色,造成了黑色对象指向白色对象的情况,这种三色标记中是不允许的,结果是C被认为是垃圾对象,最终被清扫掉,当访问C时会造成非法内存访问而Panic。
写屏障可以解决这个问题,当对象引用树发生改变时,即对象指向关系发生变化时,将被指向的对 象标记为灰色,维护了三色标记的约束:黑色对象不能直接引用白色对象,这避免了使用中的对象被释放。
有写屏障后,用户线程执行 A.Next = &C{} 后,写屏障把C标记为灰色。
4、增量收集器:Go1.8三色标记 + 混合写屏障
基于插入写屏障和删除写屏障在结束时需要STW来重新扫描栈,所带来的性能瓶颈,Go在1.8引入了混合写屏障的方式实现了弱三色不变式的设计方式,混合写屏障分下面四步
- GC开始时将栈上可达对象全部标记为黑色(不需要二次扫描,无需STW)
- GC期间,任何栈上创建的新对象均为黑色
- 被删除引用的对象标记为灰色
- 被添加引用的对象标记为灰色
增量垃圾收集器:
增量式(Incremental)的垃圾收集是减少程序最长暂停时间的一种方案,它可以将原本时间较长的暂停时间切分成多个更小的 GC 时间片,虽然从垃圾收集开始到结束的时间更长了,但是这也减少了应用程序暂停的最大时间:
需要注意的是,增量式的垃圾收集需要与三色标记法一起使用,为了保证垃圾收集的正确性,我们需要在垃圾收集开始前打开写屏障,这样用户程序修改内存都会先经过写屏障的处理,保证了堆内存中对象关系的强三色不变性或者弱三色不变性。虽然增量式的垃圾收集能够减少最大的程序暂停时间,但是增量式收集也会增加一次 GC 循环的总时间,在垃圾收集期间,因为写屏障的影响用户程序也需要承担额外的计算开销,所以增量式的垃圾收集也不是只带来好处的,但是总体来说还是利大于弊。
并发垃圾收集器
并发(Concurrent)的垃圾收集不仅能够减少程序的最长暂停时间,还能减少整个垃圾收集阶段的时间,通过开启读写屏障、利用多核优势与用户程序并行执行,并发垃圾收集器确实能够减少垃圾收集对应用程序的影响:
虽然并发收集器能够与用户程序一起运行,但是并不是所有阶段都可以与用户程序一起运行,部分阶段还是需要暂停用户程序的,不过与传统的算法相比,并发的垃圾收集可以将能够并发执行的工作尽量并发执行;当然,因为读写屏障的引入,并发的垃圾收集器也一定会带来额外开销,不仅会增加垃圾收集的总时间,还会影响用户程序,这是我们在设计垃圾收集策略时必须要注意的。
三、GC触发和参数调节
1、触发GC(三种方式)
辅助GC
在分配内存时,会判断当前的Heap(堆内存)内存分配量是否达到了触发一轮GC的阈值(每轮GC完成后,该阈值会被动态设置,一般是之后的堆内存达到上一次垃圾收集的2倍时才会触发GC),如果超过阈值,则会启动一轮GC。
调用runtime.GC()强制启动一轮GC
sysmon是运行时的守护进程,当超过runtime.forcegcperiod(默认值是2分钟)没有运行GC会启动一轮GC
2、GC调节参数
Go垃圾回收不像Java垃圾回收那样,有很多参数可供调节,Go为了保证使用GC的简洁性,只提供了 一个参数GOGC。
GOGC代表了占用中的内存增长比率,达到该比率时应当触发1次GC,该参数可以通过环境变量设置。
该参数取值范围为0~100,默认值是100,单位是百分比。
假如当前的heap占用内存时为3MB,GOGC = 75
5 * (1 + 75%) = 8.75MB
等heap占用内存大小达到8.75MB会触发1轮GC。
GOGC还有两个特殊值:
1、“off”:代表关闭GC。
2、0:代表持续进行垃圾回收,只用于调试。
GOGC 设置很大,有的时候又容易触发 OOM
设置 GOGC 的弊端
设置 GOGC 基本上我们比较常用的 Go GC 调优的方式,大部分情况下其实我们并不需要调整 GOGC 就可以,一方面是不涉及内存密集型的程序本身对内存敏感程度太低,另外就是 GOGC 这种设置比率的方式不精确,我们很难精确的控制我们想要的触发的垃圾回收的阈值。
GOGC 设置的非常小,会频繁触发 GC 导致太多无效的 CPU 浪费,反应到程序的表现就会特别明显。举个例子,对于 API 接口来说,导致的结果的就是接口周期性的耗时变化。这个时候你抓取 CPU profile 来看,大部分的耗时都集中在 GC 的相关处理上。
如上图,这是一次 prometheus 的查询操作,我们看到大部分的 CPU 都消耗在 GC 的操作上。这也是生产环境遇到的,由于 GOGC 设置的过小,导致过多的消耗都耗费在 GC 上。
对 API 接口耗时比较敏感的业务,如果 GOGC 置默认值的时候,也可能也会遇到接口的周期性的耗时波动。这是为什么呢?
因为这种接口本身占用内存比较低,每次 GC 之后本身占的内存比较低,如果按照上次 GC 后的 heap 的一倍的 GC 步调来设置 GOGC 的话,这个阈值其实是很容易就能够触发,于是就很容出现接口因为 GC 的触发导致额外的消耗。
那如何调整呢?是不是把 GOGC 设置的越大越好呢?这样确实能够降低 GC 的触发频率,但是这个值需要设置特别大才有效果。这样带来的问题,GOGC 设置的过大,如果这些接口突然接受到一大波流量,由于长时间无法触发 GC 可能导致 OOM。
四、GC调优
GC 调优,主要是两方面:一方面减少用户代码分配内存的数量(即对程序的代码行为进行调优),另一方面最小化 Go 的 GC 对 CPU 的使用率(即调整 GOGC)。
GC 的调优是在特定场景下产生的,并非所有程序都需要针对 GC 进行调优。只有那些对执行延迟非常敏感、当 GC 的开销成为程序性能瓶颈的程序,才需要针对 GC 进行性能调优,几乎不存在于实际开发中 99% 的情况。
1.控制内存分配的速度,限制Goroutine的数量,提高赋值器mutator的CPU利用率(降低GC的CPU利用率)
2.少量使用+连接string,避免重复扩容!
3.slice提前分配足够的内存来降低扩容带来的拷贝
4.避免map key对象过多,导致扫描时间增加
5.变量复用,减少对象分配,例如使用sync.Pool来复用需要频繁创建临时对象、使用全局变量等
6.增大GOGC的值,降低GC的运行频率