Go语言和C/C++语言的一个显著的特点是Go中对象内存空间的回收是通过GC机制来完成的,不需要像C++一样通过程序员的手动申请和释放,所以Go中相对不容易出现内存泄漏,不过也不是绝对的(后面会在写一篇文章来说明Go中的内存泄漏和Gorotinue泄漏,以及Go的内存逃逸),今天我们就来详细聊聊Go中的GC机制。首先要先说一下GC,GC机制是在Java语言被广泛使用之后所火起来的,像后来的脚本语言Python都支持GC,GO也支持GC。GC是对内存空间的自动释放,那我们首先说说如果对内存空间管理和分配。
1、内存空间的管理
内存空间的管理主要就是包括:内存空间的分配、释放。
2、内存空间的分配
要对内存进行分配,首先要讲内存组织好,有两种组织内存的方式,线性和链式。也对应线性分配内存和链式分配内存,而分配内存也就是要分配空间内存。
2.1、线性分配
线性分配是将空闲的内存顺序的组织起来,当需要申请一块内存的时候,从free的内存地址分配一块内存,然后返回该内存的首地址就可以了。
看起来简单,但是存在问题:当已经分配的内存在释放之后,如何使用这块内存是个问题。此时这块内存是不能使用的。
分配之后如下:
所以我们必须能够回收这块内存。
2.2、链式分配
链式分配是将空闲的地址块用指针链接起来,这样当需要分配内存的时候,顺序遍历并找到一块合适的内存来进行分配。
而寻找一块可用的内存并进行分配有以下几种方式:
首次适应算法(First-Fit)— 从链表头开始遍历,选择第一个大小大于申请内存的内存块;
循环首次适应算法(Next-Fit)— 从上次遍历的结束位置开始遍历,选择第一个大小大于申请内存的内存块;
最优适应算法(Best-Fit)— 从链表头遍历整个链表,选择最合适的内存块;
最坏适应算法(Worst Fit):该算法按大小递减的顺序形成空闲区链,分配时直接从空闲区链的第一个空闲区中分配
隔离适应算法(Segregated-Fit)— 将内存分割成多个链表,每个链表中的内存块大小相同,申请内存时先找到满足条件的链表,再从链表中选择合适的内存块;
2.3、垃圾回收,也就是内存空间的释放有以下几种方式:
引用计数、标记清除、复制清除、分代回收、三色标记算法,Go的GC是三色标记+写屏障+GC辅助
三色标记算法:
首先由三个概念:
白色对象,最终被回收的对象
灰色对象,活跃的对象,但后期会寻找该类对象是否有引用其他对象
黑色对象,活跃的对象,不会被回收的对象
算法的执行流程是:
1、所有的对象在最开始初始化的时候都是白色对象
2、首先从根对象开始,扫描所有根对象,然后标记该对象为灰色对象。这里的根对象是什么对象呢?因为我们有一个后台的垃圾回收线程处在运行状态,当前的根对象指的是当前的全部全局变量以及所有Gorotinue中的栈中的对象。
3、从灰色对象队列中取出一个对象,看这个对象是否有引用其他的对象,如果没有引用其他的对象,则将该对象标记为黑色对象,放入黑色对象队列。如果引用了其他的对象,则被引用的对象被标记为灰色,同时该引用对象标记为黑色,并放入黑色对象队列。总的来说也就是取出来的灰色对象不管有没有引用其他的对象,都会被标记为黑色对象,并放入到黑色对象队列,只不过如果有引用其他对象,就把其他对象标记为灰色,然后放入到灰色的队列而已。
4、如果灰色队列不空,则继续步骤3,最终只剩下黑色对象和白色对象,白色对象就是被清理的对象。
好,到了这里三色标记算法已经明白了,那我们看一下Go的GC具体是如何执行的?
1、Mark
首先是标记(mark)对象,但是Go将标记过程进行了一个划分,这样就能尽可能的减少STW的时间,也就能进一步提升GC的性能。GO的标记阶段分为:Mark Perpare和GC Drains两部分。
Mark Prepare:
初始化GC任务,同时开启写屏障(write barrier)和辅助GC(mutator assist),统计根对象的个数,这个过程是需要STW的。
GC Drains:
扫描所有root对象,也就是扫描全局对象和Gorotine上的栈对象(扫描对应Gorotinue的栈时需停止该Gorotinue),将其加入灰色队列,并循环处理灰色队列的对象,直到灰色队列为空。该过程后台并行执行
好,以上是标记阶段,下面是标记阶段结束之后的操作。
2、Mark Termination
完成标记工作,重新扫描(re-scan)全局指针和栈。因为Mark和用户程序是并行的,所以在Mark过程中可能会有新的对象分配和指针赋值,这个时候就需要通过写屏障(write barrier)记录下来,re-scan 再检查一下。这个过程也是会STW的。
3、Sweep
按照标记结果回收所有的白色对象,该过程后台并行执行
写屏障
写屏障:该屏障之前的写操作和之后的写操作相比,先被系统其它组件感知。 好难懂哦,结合上面GC工作的完整流程就好理解了,就是在每一轮GC开始时会初始化一个叫做“屏障”的东西,然后由它记录第一次scan时各个对象的状态,以便和第二次re-scan进行比对,引用状态变化的对象被标记为灰色以防止丢失,将屏障前后状态未变化对象继续处理。
辅助GC
从上面的GC工作的完整流程可以看出Golang GC实际上把单次暂停时间分散掉了,本来程序执⾏可能是“⽤户代码-->⼤段GC-->⽤户代码”,那么分散以后实际上变成了“⽤户代码-->⼩段 GC-->⽤户代码-->⼩段GC-->⽤户代码”这样。如果GC回收的速度跟不上用户代码分配对象的速度呢? Go 语⾔如果发现扫描后回收的速度跟不上分配的速度它依然会把⽤户逻辑暂停,⽤户逻辑暂停了以后也就意味着不会有新的对象出现,同时会把⽤户线程抢过来加⼊到垃圾回收⾥⾯加快垃圾回收的速度。这样⼀来原来的并发还是变成了STW,还是得把⽤户线程暂停掉,要不然扫描和回收没完没了了停不下来,因为新分配对象⽐回收快,所以这种东⻄叫做辅助回收。
GC调优
要进行GC调优还得从代码角度处理。
1、减少对象的分配,合理重复利用; 避免string与[]byte转化;两者发生转换的时候,底层数据结结构会进行复制,因此导致 gc 效率会变低。
2、少量使用+连接 string;Go里面string是最基础的类型,是一个只读类型,针对他的每一个操作都会创建一个新的string。 如果是少量小文本拼接,用 “+” 就好;如果是大量小文本拼接,用 strings.Join;如果是大量大文本拼接,用 bytes.Buffer。
GC触发条件
自动垃圾回收的触发条件有两个:
- 超过内存大小阈值
- 达到定时时间 阈值是由一个gcpercent的变量控制的,当新分配的内存占已在使用中的内存的比例超过gcprecent时就会触发。比如一次回收完毕后,内存的使用量为5M,那么下次回收的时机则是内存分配达到10M的时候。也就是说,并不是内存分配越多,垃圾回收频率越高。 如果一直达不到内存大小的阈值呢?这个时候GC就会被定时时间触发,比如一直达不到10M,那就定时(默认2min触发一次)触发一次GC保证资源的回收。