欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

go语言-内存回收机制GC

程序员文章站 2022-07-12 22:58:43
...

前言

文章有点长,如果是刚接触GC建议耐心看完

所谓内存回收,便是指当前内存使用完毕,释放当前存储器,以供后续继续使用,如果没有进行及时的释放,则会造成内存泄漏

常见的GC方式有如下三种

  1. 引用计数:对每一个对象维护一个引用计数,当引用该对象的对象被销毁的时候,引用计数减1,当引用计数为0的时候,怎回收该对象,比如c++的shared_ptr
    优点 :对象可以很快的被回收,不会出现内存耗尽或达到某个阀值时才回收。
    缺点:不能很好的处理循环引用,而且实时维护引用计数,有也一定的代价。
    代表语言:Python,PHP,Swift
  2. 标记-清除:从根变量开始遍历所有引用的对象,引用的对象标记为"被引用",没有被标记的进行回收。
    优点:解决引用计数的缺点
    缺点:需要STW(stop the word),即要暂停程序运行
    代表语言:Golang,不过目前最新版的golang采用的是三色标注法
  3. 分代收集:按照生命周期进行划分不同的代空间,生命周期长的放入老年代,短的放入新生代,新生代的回收频率高于老年代的频率
    代表语言:JAVA

Golang回收机制

标记-清楚法

span是内存管理的最小单位,所以猜测gc的粒度也是span。

type mspan struct {
    // allocBits and gcmarkBits hold pointers to a span's mark and
    // allocation bits. The pointers are 8 byte aligned.
    // There are three arenas where this data is held.
    // free: Dirty arenas that are no longer accessed
    //       and can be reused.
    // next: Holds information to be used in the next GC cycle.
    // current: Information being used during this GC cycle.
    // previous: Information being used during the last GC cycle.
    // A new GC cycle starts with the call to finishsweep_m.
    // finishsweep_m moves the previous arena to the free arena,
    // the current arena to the previous arena, and
    // the next arena to the current arena.
    // The next arena is populated as the spans request
    // memory to hold gcmarkBits for the next GC cycle as well
    // as allocBits for newly allocated spans.
    //
    // The pointer arithmetic is done "by hand" instead of using
    // arrays to avoid bounds checks along critical performance
    // paths.
    // The sweep will free the old allocBits and set allocBits to the
    // gcmarkBits. The gcmarkBits are replaced with a fresh zeroed
    // out memory.
    allocBits  *gcBits
    gcmarkBits *gcBits
}

span中维护了一个内存块,并由一个位图allocBits表示每个内存块的分配情况,在span数据机构中还有另个一个位图gcmarkBits用于标注内存块被引用情况
go语言-内存回收机制GC
如上图所示,allocBits记录了每块内存分配的情况,而gcmarkBits记录了每块内存的引用情况,标记阶段对每块内存进行标记,有对象引用的内存标记为1,没有的标记为0
这两个位图的数据结构是完全一致的,标记结束则进行内存回收,回收的时候,将allocBits指向gcmarkBits,标记过的则存在,未进行标记的则进行回收

三色标记法

其实实际应用场景是没有三色这个概念,这只是为了人们方便理解这种,抽象出来的一种说法而已,这里的三色对应的就是垃圾回收的三种状态

1、白色:初始状态下所有的对象都是白色,gcmarkBits对应的位为0(该对象会被清理)
2、灰色:对象被进行标记,但是这个对象的子对象(也就是它引用的对象)未被进行标记
3、黑色:对象被标记同时这个对象引用的子对象也被进行标记gcmarkBits对应的位为1(该对象不会被清理)

引用网上的一个例子解释三者的关系:
当前内存中有A~F一共6个对象,根对象a,b本身为栈上分配的局部变量,根对象a、b分别引用了对象A、B, 而B对象又引用了对象D,则GC开始前各对象的状态如下图所示:
go语言-内存回收机制GC
初始状态下所有对象都是白色的。
接着开始扫描根对象a、b:
go语言-内存回收机制GC
由于根对象引用了对象A、B,那么A、B变为灰色对象,接下来就开始分析灰色对象,分析A时,A没有引用其他对象很快就转入黑色,B引用了D,则B转入黑色的同时还需要将D转为灰色,进行接下来的分析。如下图所示:
go语言-内存回收机制GC
上图中灰色对象只有D,由于D没有引用其他对象,所以D转入黑色。标记过程结束:
go语言-内存回收机制GC
最终,黑色的对象会被保留下来,白色对象会被回收掉。

Go GC工作流程

Golang GC的大部分处理是和用户代码并行的

1、Mark: 包含两部分:
a、Mark Prepare: 初始化GC任务,包括开启写屏障(write barrier)和辅助GC(mutator assist),统计root对象的任务数量等。这个过程需要STW
b、GC Drains: 扫描所有root对象,包括全局指针和goroutine(G)栈上的指针(扫描对应G栈时需停止该G),将其加入标记队列(灰色队列),并循环处理灰色队列的对象,直到灰色队列为空。该过程后台并行执行
2、Mark Termination: 完成标记工作,重新扫描(re-scan)全局指针和栈。因为Mark和用户程序是并行的,所以在Mark过程中可能会有新的对象分配和指针赋值,这个时候就需要通过写屏障(write barrier)记录下来,re-scan 再检查一下。这个过程也是会STW的。
3、Sweep: 按照标记结果回收所有的白色对象,该过程后台并行执行
4、Sweep Termination: 对未清扫的span进行清扫, 只有上一轮的GC的清扫工作完成才可以开始新一轮的GC。

写屏障(Write Barrier)

因为go支持并行GC, GC的扫描和go代码可以同时运行, 这样带来的问题是GC扫描的过程中go代码有可能改变了对象的依赖树。

例如开始扫描时发现根对象A和B, B拥有C的指针。

GC先扫描A,A放入黑色
B把C的指针交给A
GC再扫描B,B放入黑色
C在白色,会回收;但是A其实引用了C。
为了避免这个问题, go在GC的标记阶段会启用写屏障(Write Barrier).

启用了写屏障(Write Barrier)后,在GC第三轮rescan阶段,根据写屏障标记将C放入灰色,防止C丢失。

辅助GC

如果GC回收的速度跟不上用户代码分配对象的速度呢?
Go 语⾔如果发现扫描后回收的速度跟不上分配的速度它依然会把⽤户逻辑暂停,⽤户逻辑暂停了以后也就意味着不会有新的对象出现,同时会把⽤户线程抢过来加⼊到垃圾回收⾥⾯加快垃圾回收的速度。这样⼀来原来的并发还是变成了STW,还是得把⽤户线程暂停掉,要不然扫描和回收没完没了了停不下来,因为新分配对象⽐回收快,所以这种东⻄叫做辅助回收。

垃圾回收触发机制

1、内存分配量达到阀值触发GC
每次内存分配时都会检查当前内存分配量是否已达到阀值,如果达到阀值则立即启动GC。
阀值 = 上次GC内存分配量 * 内存增长率
内存增长率由环境变量GOGC控制,默认为100,即每当内存扩大一倍时启动GC。

2、定期触发GC
默认情况下,最长2分钟触发一次GC,这个间隔在src/runtime/proc.go:forcegcperiod变量中被声明:

// forcegcperiod is the maximum time in nanoseconds between garbage
// collections. If we go this long without a garbage collection, one
// is forced to run.
//
// This is a variable for testing purposes. It normally doesn't change.
var forcegcperiod int64 = 2 * 60 * 1e9

3、手动触发
程序代码中也可以使用runtime.GC()来手动触发GC。这主要用于GC性能测试和统计。

如何进行GC调优

减少对象的分配,合理重复利用; 避免string与[]byte转化;

两者发生转换的时候,底层数据结结构会进行复制,因此导致 gc 效率会变低。

少量使用+连接 string;

Go里面string是最基础的类型,是一个只读类型,针对他的每一个操作都会创建一个新的string。 如果是少量小文本拼接,用 “+” 就好;如果是大量小文本拼接,用 strings.Join;如果是大量大文本拼接,用 bytes.Buffer。

go程序内存占用大的问题

这个问题在我们对后台服务进行压力测试时发现,我们模拟大量的用户请求访问后台服务,这时各服务模块能观察到明显的内存占用上升。但是当停止压测时,内存占用并未发生明显的下降。花了很长时间定位问题,使用gprof等各种方法,依然没有发现原因。最后发现原来这时正常的…主要的原因有两个,

一是go的垃圾回收有个触发阈值,这个阈值会随着每次内存使用变大而逐渐增大(如初始阈值是10MB则下一次就是20MB,再下一次就成为了40MB…),如果长时间没有触发gc go会主动触发一次(2min)。高峰时内存使用量上去后,除非持续申请内存,靠阈值触发gc已经基本不可能,而是要等最多2min主动gc开始才能触发gc.

第二个原因是go语言在向系统交还内存时只是告诉系统这些内存不需要使用了,可以回收;同时操作系统会采取“拖延症”策略,并不是立即回收,而是等到系统内存紧张时才会开始回收这样该程序又重新申请内存时就可以获得极快的分配速度。

相关标签: golang 学习