垃圾回收和 Golang 内存管理

说在前面

陆老师今天的课程从 GC 的发展历程讲起,渐次讲解了垃圾回收的流派以及基础的垃圾回收方法,从而引出 Golang 的 GC,向我们讲述了 Golang 垃圾回收的特点与触发时机,最终为我们简述了一些编程过程中可能使用到的工具和指标,帮助我们更快的找到和分析问题。

GC 基本理论

GC 的发展从 1959 年的 Lisp 语言就开始了,他所做的事情其实就是把需要程序员手动申请和释放内存这件事情自动化了,解放了程序员的生产力,能够用更多的精力放在业务代码上而不是去关心内存,经历了几十年的发展,GC 也变得越来越完善,功能也变得越来越强大了。

GC 流派

引用计数

引用计数是最简单的垃圾回收算法,原理就是发生调用则计数器加一,不再调用了则计数器减一,计数器为零的对象就是垃圾可以直接回收,这个算法的特点是实现十分简单,但是缺点也很明显,就是无法解决循环引用的问题

可达性分析

可达性分析是最为常用的垃圾回收算法,java,python 和 Golang 都是基于这个方式来找到活对象的,原理就是从一个 GCROOT 出发,依次标记其引用的对象,标记为活对象,完成标记后清除掉所有的死对象,这个算法解决了循环引用的问题,但是缺点是耗费时间会比较长

垃圾回收方式

  1. Copying
    复制的算法需要有两块内存区域,假设为 A 和 B,此时对象都在 A 区域分配,发生回收时会标记 A 区域活着的对象,然后全部复制到 B 区域,然后将 A 区域清空,特点是速度比较快,且没有内存碎片,缺点是会比较浪费内存。java 的分代回收的年轻代就是用的这个回收方式

  2. Mark-sweep GC
    标记清除的算法是发生回收时标记存活的对象,然后把所有不存活的对象全部回收,特点是速度比较快,缺点是会产生大量的内存碎片

  3. Mark-compact GC
    标记整理的算法和标记清除类似,但是在清除之后多了一步压缩的步骤,就是把清理后活着的对象重新整理,移动到一块连续的内存区域,解决了标记清除内存碎片的问题,但是缺点就是速度会更慢

并发和并行

  1. 并发
    所谓并发其实就是标记程序和用户程序同时运行,标记时不会 Stop The World

  2. 并行
    并行时标记程序会 Stop The World,导致用户程序暂停

    GC 优化的思路大体上都是要减少 STW 的时间,减小对用户程序的影响

Go 内存管理

GC 的设计需要考虑的因素特别多,不同语言的 GC 侧重点也会有所区别,Golang
GC 设计的考量主要是减少暂停时间。

从上图可以看到从 1.4 到 1.5 GC 时间有一个质的飞跃,这是因为在 1.5 中实现了基于三色标记清扫的并发垃圾收集器,而 1.6 实现了去中心化的垃圾收集协调器,使用密集的位图替代空闲链表表示的堆内存,降低了清除阶段的 CPU 占用。

分配

Golang 使用 Tcmalloc (Thread Cache)风格的分配器,将内存空间按照尺寸划分成不同大小的格子,大对象优先在大格内存中进行分配,小对象优先在小格内存中进行分配

内存分配方式还分为内部分配和外部分配,内部分配是指对象不管是否占满一个内存格子都分配到一个格子里,缺点是分配时可能会有很多空间的浪费,但是回收之后会有连续的比较大的内存段,而外部分配则是分配时尽量让对象相连,好处是分配的时候没有浪费内存空间,但是回收之后却可能会产生内存碎片,两种方式各有优缺点

回收

Golang 的垃圾回收有以下特点:

  • 并发标记
  • 标记清除
  • 非分代

触发垃圾回收的时机如下:

  • 到达 GOGC threshold
  • 调用 runtime.GC()
  • 每 2min 自动调用的 runtime.forcegcperiod

其中 GOGC 的 threshold 是我们可以进行配置的

1
2
// 表示到达上一次垃圾回收内存的 1+GOGC% 倍容量的时候才触发 GC
export GOGC=100

进行标记时,Golang 是使用的三色标记法进行标记,具体的算法可以参考 Golang 三色标记 这篇文章,在标记时,是有三个过程的,先是短时间的 STW 从 GCROOT 出发标记出直接关联的节点,然后并发标记,在不影响用户程序的基础上找到所有活对象,最后再 STW 找到并发标记期间发生了改变的对象,这样就将所有活对象找到了。

编程者指南

线上诊断

如果发生了线上问题,我们可以通过几个手段来进行定位

  1. 监控系统,比如 Argos,Grafana 等等观察各类指标是否有异常
  2. pprof,通过 pprof 可以看到 Golang 程序运行过程中各类资源的分配和使用状况
  3. GC log,如果是内存问题,通过 GC log 我们可以找出异常的 GC 点,进而优化代码

那么如果是内存问题,我们如何导出 GC log 呢?

1
export GODEBUG=gctrace=1

通过上述的环境变量我们可以导出 Golang 程序运行的 GC log,其格式如下:

通过公式我们可以算出 GC 损耗比例

如果 GC 损耗比例过大,说明 GC 耗费时间过多,我们可以通过配置 GOGC 这个环境变量来解决这个问题,当我们把 GOGC 配置的大一些的时候,能够明显减少 GC 的次数,同时由于活对象的比例占所有对象的比例很小,每次回收的时间虽然会有所增加,但是也不会太多,收益还是会比较大的。

面向 GC 编程

如果内存分配能够不发生逃逸,而是就在栈上进行,就可以退栈即释放,不用进行垃圾回收,所以我们可以用以下方式改造我们的代码

  • 使用局部变量
  • 参数和返回值传递值
  • 制造 inline 机会
    通过 go build -gcflags='-m=1' 可以判断是否发生了逃逸

为了减少 GC 我们可以针对 GO GC 的特点针对性的改变我们的代码:

  • 促成内联
    • interface call
    • 循环
  • 避免逃逸,使用局部变量
  • 减少分配次数,提前为 slice 和 map 分配内存
  • 缓存对象

但是如果我们每次编程都要去考虑如何针对 GC 进行优化的话,其实又有点回到手动控制内存的老路上了,放弃了 GC 为我们带来的便利,增加了我们的工作量,所以专业的事情其实还是应该交给专业的人来做,GC 优化交给 Golang 自身的垃圾回收演进,我们专注于写好业务代码,如果发生了 GC 导致的问题再来进行对应的优化,不要过早优化,分散精力。

总结

本门课程陆老师主要讲解了以下内容

  1. 从 GC 的发展历程和基本的垃圾回收方式讲起,讲解了引用计数和可达性分析两种标记手段,并且讲解了对应的复制,标记清除,标记整理三种回收方式,并发标记和并行标记的区别
  2. 讲解了 Golang 的内存分配策略和垃圾回收方式
  3. 提出了一些面向 GC 编程的方法,但是我们还是应该专注于业务