前言
Google 搜索 Golang GC 排名靠前的文章都讲的不错,从设计到实现,从演进到源码,一应俱全。但是庞杂的信息会给人一种恐惧感,让人望而却步。
本文尝试使用较为简单易懂的语言和图像,讲解 Golang 的垃圾回收机制。
垃圾回收算法
目前比较常见的垃圾回收算法有三种:
- 引用计数:为每个对象维护一个引用计数,当引用该对象的对象销毁时,引用计数 -1,当对象引用计数为 0 时回收该对象。
代表语言:Python、PHP、Swift
优点:对象回收快,不会出现内存耗尽或达到某个阈值时才回收。
缺点:不能很好的处理循环引用,而实时维护引用计数也是有损耗的。 - 标记-清除:从根变量开始遍历所有引用的对象,标记引用的对象,没有被标记的进行回收。
代表语言:Golang(三色标记法)
优点:解决了引用计数的缺点。
缺点:需要 STW,暂时停掉程序运行。 - 分代收集:按照对象生命周期长短划分不同的代空间,生命周期长的放入老年代,短的放入新生代,不同代有不同的回收算法和回收频率。
代表语言:Java
优点:回收性能好
缺点:算法复杂
Golang 垃圾回收
跳过原理,我们先来介绍 Golang 的三色标记法。
三色标记法
1 三色含义
- 白色对象 — 潜在的垃圾,其内存可能会被垃圾收集器回收;
- 黑色对象 — 活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象;
- 灰色对象 — 活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象;
2 根对象
垃圾回收中最先检测的对象,包括以下三种对象
- 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
- 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。
- 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。
3 工作原理
在垃圾收集器开始工作时,程序中不存在任何的黑色对象,垃圾收集的根对象会被标记成灰色,垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束
具体流程如下图:
回收原理
通过上图,应该对三色标记法有了一个比较直观的了解,那么我们现在来讲讲原理。简单的讲,就是标记内存中那些还在使用中(即被引用了)的部分,而内存中不再使用(即未被引用)的部分,就是要回收的垃圾,需要将其回收,以供后续内存分配使用。
上图中的 A、B、D 就是被引用正在使用的内存,而 C、F、E 曾经被使用过,但现在没有任何对象引用,就需要被回收掉。
而 Root 区域主要是程序运行到当前时刻的栈和全局数据区域,是实时正在使用到的内存,当然应该优先标记。而考虑到内存块中存放的可能是指针,所以还需要递归的进行标记,待全部标记完后,就会对未被标记的内存进行回收。
内存标记
allocBitsgcmarkBitsallocBitsgcmarkBits在标记阶段会对每块内存进行标记,有对象引用的内存标记为 1,没有对象引用的为 0。
allocBitsgcmarkBitsallocBitsgcmarkBitsgcmarkBits三.详细介绍
1 golang 三色标记法
golang 的垃圾回收采用的是 标记-清理(Mark-and-Sweep) 算法就是先标记出需要回收的内存对象快,然后清理,使用就是三色标记法。
2 gc 内存泄漏
根本原因
预期的能很快被释放的内存由于附着在了长期存活的内存上,或生命期意外的被延长,导致预计能够立即回收的内存长时间得不到回收(由于 goroutine 还有多种形式)
预期能被快速释放的内存因被根对象引用而没有得到迅速释放
当有一个全局对象时,可能不经意间将某个变量附着在其上,且忽略的将其进行释放,则该内存永远不会得到释放
goroutine 泄露
Goroutine 作为一种逻辑上理解的轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。
因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象,
这种形式的 goroutine 泄漏还可能由 channel 泄漏导致。而 channel 的泄漏本质上与 goroutine 泄漏存在直接联系。
Channel 作为一种同步原语,会连接两个不同的 goroutine,如果一个 goroutine 尝试向一个没有接收方的无缓冲 channel 发送消息,则该 goroutine 会被永久的休眠,整个 goroutine 及其执行栈都得不到释放,
gc 标记流程
流程介绍
gcphase 的三个状态
1 GC 执行扫描和终止
a. 暂停整个程序(stop the world),等待所有 goroutine 到达 GC 安全点(程序执行期间的一个点,在此点上所有 GC 根都是已知的,并且所有堆对象内容都是一致的。从全局的角度来看,所有线程都必须在安全点阻塞,然后 GC 才能运行。)
b.清除任何未经清除的 span,只有在预期时间之前强制执行此 GC 周期时,才会有未清除的 span。
2 GC 的标记阶段
a.为了标记阶段将 gcphase 从_GCoff 设置成_GCmark,开启写屏障(详情见备注),启用 mutator assist,将根标记任务放入队列。通过 STW 保证没有对象会被扫描,直到所有协程(Ps)启用写屏障。
b.唤醒程序(start the world),从此开始,GC 的工作由调度程序启用的 mark workers 和 allocation 一部分的 assists performed 执行,写屏障将任何指针指向的新指针和覆盖指针都标记为灰,新申请的对象立即判为黑色。
c.gc 执行根标记任务,这包括扫描所有的栈,为所有全局变量标灰色,以及对堆外运行时数据结构中的任何堆指针进行标灰色。扫描栈会停止 goroutine,为 goroutine 指针指向的所有栈着灰色,然后再重启 goroutine
d.GC 排出灰色对象的工作队列,将每个灰色对象扫描为黑色,并对在该对象中找到的所有指针标记为灰色(这反过来又可能将这些指针添加到工作队列中)。
e.由于 GC work 分散在本地缓存中,因此 GC 使用分布式终止算法来检测何时不再有根标记作业或灰色对象(参见 gcMarkDone 函数)。此时,GC 状态转换到标记终止(gcMarkTermination)。
3 GC 执行标记终止
a.暂停程序(stop the world)
b.设置 gcphase 状态到_GCmarktermination,停止 workers 和 assists
c.清理工作,如回收 mcaches 内存
4 GC 执行清除阶
a.设置 gcphase 到_GCoff,设置清除状态并禁止写屏障。
b.唤醒程序(start the world),从此时开始,新申请的对象为白色,若必要可以在使用前清扫 spans。
c.gc 在后台执行回收白色对象并响应内存的分配
5 当内存分配足够多,则循环开始
6 总结
golang 的 gc 一共是用了两次 STW
第一次目的是标记准备阶段,为并发标记做准备工作,启动写屏障
第二次目的是标记终止阶段,保证一个周期内标记任务完成,停止写屏障
1.gc 关闭状态
2.stw,等待程序到达稳定态(从全局变量和 goroutine 堆栈中收集指针。在抢占点扫描的堆栈),开启写屏障
3.标记对象并跟随指针直到指针队列为空,写屏障通过 mutator 跟踪指针变化
4.swt,重新扫描全局变量/更改的堆栈、完成标记、缩小堆栈
5.根据需要回收未标记的对象。为下一个周期调整 gc pacing
6.重复此流程
gc 触发时机
Go 语言中对 GC 的触发时机存在两种形式:
- 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
- 被动触发,分为两种方式:
- 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。
- 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。