Golang GC

161 阅读14分钟

什么是垃圾回收

内存泄漏问题:指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果
过去的两种方法:

  • 内存泄漏检测工具,原理一般是静态代码扫描,通过扫描程序检测可能出现内存泄漏的代码段。
  • 智能指针 这是C++中引入的自动内存管理方法,通过拥有自动内存管理功能的指针对象来引用对象,使程序员不用太关注内存的释放。这种方法使采用最广泛的做法,但是对程序开发者有一定的学习成本,而且一旦有忘记使用的场景依然无法避免内存泄漏。
    为了解决这一问题,后来开发的所有新语言都引入了语言层面的自动内存管理,也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机或运行时自动进行管理。而这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。

常用的垃圾回收方法

引用计数

这是最简单的一种垃圾回收算法,和之前提到的指针异曲同工,对每个对象维护一个引用计数,当引用该对象的对象被销毁或更新时,该对象的引用计数自动减一,当引用该对象的对象被创建或赋值给其他对象时引用计数自动加一。当引用计数为0时即立即回收对象
优点: 实现简单,内存回收及时。这种算法在内存紧张和实时性比较高的系统使用广泛
缺点: 1.频繁更新引用计数并维护引用计数降低了性能和提高了内存占用
解决方法:
(1)编译器将相邻的引用计数更新操作合并到一次更新
(2)针对频繁发生的临时变量引用不进行计数,而是在引用达到0时通过扫描堆栈确认是否还有临时对象引用而决定是否释放

2.循环引用问题 当对象间发生循环引用时引用链中的对象都无法得到释放。最明显的解决方法是避免产生循环引用,或者系统检测循环引用并主动打破循环链,当然这也增加了垃圾回收的复杂度。

3.回收内存时可能会引发暂停

分代GC

分代假说

很多对象在分配出来后很快就不再使用了
每个对象的年龄:经历过GC的次数
目的: 对年轻和老年的对象,制定不同的GC策略,降低整体内存管理的开销
不同年龄的对象处于heap的不同区域

根据对象的生命周期,使用不同的标记和清理策略

三种方式,根据对象的生命周期,使用不同的标记和清理策略

1.标记-复制——将存活对象复制到另外的内存空间(Copying GC)

image.png 2.标记-清除——将死亡对象的内存标记为可分配(Mark-sweep GC):使用free-list管理空闲内存
标记-清除分为两步 (1) 标记从根变量开始迭代遍历的所有被引用的对象,对能够通过应用遍历访问到的对象都标记为“被引用”, (2) 标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作) image.png 3.标记-整理——移动并整理存活对象(Mark-compact GC):原地整理对象

image.png

年轻代

  • 常规的对象分配
  • 由于存活的对象很少,采用标记-复制的策略
  • GC吞吐率很高

老年代

  • 对象趋向于一直活着,反复复制开销较大,因此采用标记-清除的GC策略

追踪垃圾回收

对象被回收的条件:指针指向关系不可达的对象 步骤: 1.标记根对象(静态变量、全局变量、常量、线程栈等) 2.找到并标记所有可达对象 3.清理所有不可达对象

根对象是什么?

根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括:

  1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
  2. 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
  3. 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。

三色标记算法

三色标记算法是对标记阶段的改进,原理如下:

  • 起初所有对象都是白色
  • 从根节点出发扫描所有可达对象,标记为灰色,放入待处理队列。
  • 从队列取出灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色
  • 重复3,直到灰色对象队列为空,此时白色对象即为垃圾,进行回收。

注意事项:

  • 标记有两个过程。第一是从root开始遍历,标记为灰色。遍历灰色队列。第二重新扫描全局指针和栈,因为标记和用户程序是并行的,所以在过程1的时候可能会有新的对象分配,这个时候就需要写屏障记录下来。re-scan再完成检查标记。
  • STW有两个过程,第一个是GC将要开始的时候,这个时候主要是一些准备工作。比如写屏障。第二个就是上面提到的re-scan过程。如果这个时候没有STW,标记将无休止。

屏障机制

对于和用户程序并发运行的垃圾回收算法,用户程序会一直修改内存,我们需要记录下来以便重新扫描的时候将新增的对象标记为灰色。 好处:

  • GC线程可以感知对象指向关系的改变,使得用户程序和GC线程可以同时执行。
  • 利用Tracing GC做增量式垃圾回收,降低最大暂停时间。原生Tracing GC只有黑色和白色,没有中间的状态,这就要求GC扫描过程必须一次性完成,得到最后的黑色和白色对象。这种方式会存在较大的暂停时间。而三色标记增加了中间状态灰色,增量式GC运行过程中,应用线程的运行可能改变了对象引用树,只要让黑色对象直接引用白色对象,将新增的对象引用标记为灰色,GC就可以增量式的运行,减少停顿时间。

插入屏障

定义: 在黑色A对象新增引用B对象的时候,B对象必须被标记为灰色。 (强三色不变式,保证黑色对象不会引用白色对象) 需要注意的是插入屏障机制仅对堆空间对象的操作有效,在栈空间的对象操作中不适用。(原因:栈中的函数调用弹出频繁使用,对象引用状态变换频繁,使用插入屏障会大量增加写入指针的开销,导致栈的运行效率低下)所以在第一次三色标记扫描完成后(即没有灰色节点,标记结束)需要STW重新对栈空间进行三色标记。
在栈空间标记结束后,就可以停止STW对白色对象进行清除。

  • 优点: 通过减少堆空间扫描STW的时间至10~100ms之间
  • 缺点: 对栈空间的扫描仍然需要STW

删除屏障

定义: 在A对象删除引用B对象的时候,如果B对象自身为灰色或者白色,那么被标记为灰色。(弱三色不变式,保证灰色对象删除对白色对象引用后白色对象不会被垃圾回收)

  • 优点: 通过减少堆空间扫描STW的时间至10~100ms之间
  • 缺点:一个对象即使被删除了在本轮回收仍然会标记为可达,需要在下一轮GC才会被清理掉,对栈空间的扫描仍然需要STW ,对栈空间的扫描仍然需要STW

总结:只要实现了强三色不变式和弱三色不变式中的一种就可以实现一个只在栈空间扫描时STW的GC算法

混合写屏障

  1. 先将栈上的可达对象全部扫描并标记为黑色
  2. GC期间,任何创建的新对象,均为黑色
  3. 当一个堆对象被一个栈对象删除/增加引用,颜色不变。
  4. 当一个堆对象被一个堆对象删除/增加引用,标记为灰色
  5. 栈对象被删除/增加引用颜色不变

image.png

特殊情况

  1. 栈对象1 为黑色 栈对象2为灰色 堆对象3为白色 当前引用关系是:
  • A(黑) -> nil
  • B(灰) -> C(白)

现在应用程序赋值修改,把A指向C:

  • A(黑) -> C(白)
  • B(灰) -> nil 那么不就会出现黑对象引用白对象的情况?
    实际上,这种场景是不可能实现的。因为
  • A对象要引用C对象需要持有栈上的B对象或者C对象,而对于同一个栈上的对象要么全是黑要么全是白,所以A和B对象一定不在同一个栈上
  • A和B不在同一个栈上,A就无法访问B对象。因为不同 Goroutine 不能够互相访问彼此的栈空间
  • 所以这种场景是不会出现的。
  1. 堆对象A 为黑色 栈对象B为白色 当前引用关系是:
  • A(黑) -> nil
  • B(白) -> nil

现在应用程序赋值修改,把A指向C:

  • A(黑) -> B(白)
  • B(白) -> nil 那么不就会出现黑对象引用白对象的情况?
    实际上,这种场景也不可能实现的。因为
  • A对象要引用B对象需要持有的B对象,而B对象是白色说明在栈扫描的过程中,没有扫描出B对象,说明B对象没有被任何栈上的根对象引用,所以A对象就无法访问B对象
  • 所以这种场景是不会出现的。

GOLANG的垃圾回收机制

STW、开启写屏障->栈扫描->标记->标记结束

标记部分包含了栈扫描、标记和标记结束3个阶段。在栈扫描之前有两个重要的准备: **STW和开启写屏障 **

STW是Stop The World,指会暂停所有正在执行的用户线程/协程,进行垃圾回收的操作。在这之前会进行一些准备工作,比如开启写屏障、把全局变量和每个goroutine中的root对象收集起来,root对象是标记扫描的源头,可以从Root对象依次索引到使用中的对象。

每个P都有一个MCACHE,每个MCACHE都有一个SPAN用来存放TinyObject,TinyObject都是不包含指针的对象,所以这些对象可以直接标记为黑色,然后关闭STW

每个P都有一个进行扫描标记的goroutine,可以进行并发标记,关闭STW后,这些goroutine就变成可运行状态,接收Go Scheduler的调度,被调度时执行1轮标记,它负责第1部分任务:栈扫描、标记、标记结束。

栈扫描阶段就是把前面搜集的root对象找出来,标记为黑色,然后把它们引用的对象也找出来,标记为灰色,并且加入到gcWork队列,gcWork队列保存了灰色的对象,每个灰色的对象都是一个Work。

进入标记阶段,它是一个循环,不断地从gcWork队列中取出work,work所指向的对象标为黑色,该对象指向的对象标记为灰色,然后加入队列,直到队列为空,循环结束。

进入标记结束阶段,再次开启STW。不同的版本处理方式不同

  • 1.7版本是Dijkstra写屏障。只监控堆上指针数据的变动,由于成本原因,没有监控栈上指针的变动,由于应用gorountine和GC的标记goroutine都在运行,当栈上的指针指向的对象变更为白色对象时,这个白色对象应当标记为灰色,需要再次扫描全局变量和栈,以免释放这类不该释放的对象。
  • 1.8版本以后引入了混合写屏障,这个写屏障依然不监控栈上指针的变动,但是它的策略无需再次扫描栈和全局变量,但依然需要STW进行一些检查。

标记结束阶段最后:关闭写屏障,STW,唤醒负责清扫垃圾的goroutine

清扫goroutine是应用启动后立即创建的一个后台goroutine,它会立刻进入睡眠,等待被唤醒,然后执行垃圾清理:把白色对象挨个清理掉,清扫goroutine和应用goroutine是并发进行的。清扫完成之后,它再次进入睡眠状态,等待下次被唤醒。

最后执行一些数据统计和状态修改的工作,并且设置好触发下一轮GC的阈值,把GC状态设置为Off。

非增量式垃圾回收和增量式垃圾回收

  • 非增量式垃圾回收在STW期间完成所有垃圾对象的标记,STW结束后慢慢的执行垃圾对象的清理
  • 增量式垃圾回收在STW期间完成部分垃圾对象的标记,然后结束STW继续执行用户线程,一段时间后再次执行STW再标记部分垃圾对象,这个过程会多次重复执行,直到所有垃圾对象标记完成。
    GC算法有3大性能指标:吞吐量、最大暂停时间、内存占用率。增量式垃圾回收不能提高吞吐量,但和非增量式垃圾回收相比,每次STW的时间更短,能够降低最大暂停时间。

Golang GC STW的时候减少最大暂停时间还有一种思路:并发垃圾回收。(不是并行垃圾回收) 并行垃圾回收是每个核上都跑垃圾回收的线程,同时进行垃圾回收,这期间STW会暂停用户线程的执行
并发垃圾回收是先STW找到所有的Root对象,然后结束STW,让垃圾标记线程和用户线程并发执行,垃圾标记完成后,再次开启STW并扫描和标记,以免释放使用中的内存。

什么时候会触发GC?

在堆上分配大于32K byte对象的时候进行检测此时是否满足垃圾回收条件,如果满足则进行自动垃圾回收。
主动垃圾回收:通过调用runtime.GC(),这是阻塞式的。

GC触发条件

  1. 辅助GC 在分配内存时,会判断当前的heap内存分配量是否达到了触发一轮GC的阈值,如果超过阈值,则启动一轮GC
  2. 调用runtime.GC()强制启动一轮GC
  3. sysmon是运行时的守护进程,当超过forcegcperiod(2分钟)没有运行GC会启动一轮GC

golang为什么GC时要管理栈对象?

原因: golang在GC开始时会把栈上的变量也会认为是根对象。所以栈上的根变量对象开始也会被标记成灰色。