1. go gc 是怎么实现的?(必问)#
分析
go 语言的 gc 经历了多个版本的迭代和逐步优化,在回答的时候突出三色标记法配合混合写屏障技术就可以了,但是对于问题的追问,其中涉及到的细节要做到心中有数。
回答
go 语言的 gc 策略是采用三色标记法。但是单纯的三色标记会带来 STW,导致执行效率不高,所以在 1.8 版本之后,采用了三色标记法配合混合写屏障技术来实现 gc。
问题追问
Go gc 经历了那几个版本?#
- 1.3 版本前:普通标记清除法,整个 gc 过程需要启动 STW,效率极低。
- 1.5 版本:三色标记法,堆空间启动写屏障,栈空间不启动,全部扫描之后,需要重新扫描一次栈(需要 STW),效率普通。
- 1.8 版本:三色标记法,混合写屏障机制。栈空间不启动写屏障,根节点可达对象和新加入的对象全部标记成黑色,堆空间启用写屏障,整个扫描过程不需要 STW,效率高。
三色标记法过程是怎样的?有什么问题?#
第一步: 应用程序开始运行时,所有对象默认标记为白色。
第二步: 从根节点开始遍历,把遍历到的对象标记为灰色,放到灰色标记表中。
第三步: 遍历灰度集合,将灰色对象标记为黑色,并由灰色标记表移动到黑色标记表中;将黑色对象引用的白色对象标记为灰色,放到灰色标记表中。
第四步: 重复第三步,直到灰色标记表为空。
三色标记的整个过程都是跟业务逻辑并行的,这样就会带来一定的问题,可能会修改已标记为黑色对象的引用关系,比如会让黑色对象指向一个白色对象,所以标记过程中还是需要 STW,会影响程序的性能。
针对三色标记的 STW,是怎么解决的?#
引入了屏障技术来解决 STW 的问题。屏障技术主要分为插入写屏障和删除写屏障,在 Go 1.8 之后采用二者的一个综合,用混合写屏障来处理。
什么是插入写屏障?用插入写屏障解决 STW 会有什么问题?#
插入写屏障主要是针对插入新对象,或者说是添加对象之间的引用关系。被插入的对象或者被引用指向的对象会被标记为灰色。
但是插入写屏障只能在堆上操作,不能在栈上操作。这是因为 Go 在并发运行时,大部分操作都发生在栈上,函数调用会非常频繁,数十万 goroutine 的栈都进行屏障保护会有严重的性能问题。
删除写屏障是怎么工作的?#
删除写屏障主要是断开引用关系,被断开连接的下一个对象直接标记为灰色。
混合写屏障是怎么工作的?#
在 GC 刚开始的时候,会将栈上的可达对象全部标记为黑色。GC 过程中任何在栈上新创建的对象,均标记为黑色。这样就可以保证三色标记流程结束后,不需要再对栈上重新进行一次 rescan。
在堆上的操作时:堆上被删除的对象标记为灰色,堆上新添加的对象标记为灰色。
2. GC 中 STW 时机,各个阶段是如何解决的?#
分析
go 语言的整个 gc 流程大致可以分为下面 5 个步骤:
| 阶段 | 描述 | 赋值器状态 |
|---|---|---|
| SweepTermination | 清除终止阶段,为下一个阶段的并发标记做准备工作,启动写屏障 | STW |
| Mark | 扫描标记阶段,与赋值器并发执行,写屏障开启状态 | 并发 |
| MarkTermination | 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 | STW |
| GCoff | 内存清除阶段,将需要回收的内存归还到堆中,写屏障关闭状态 | 并发 |
| GCoff | 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭状态 | 并发 |
回答
虽然有了混合写屏障技术,go 语言的整个 gc 过程中还是有两次 STW。因为写屏障需要开启和关闭,在整个标记过程开始之前需要 STW,用于开启写屏障,为标记做准备;在标记终止阶段同样需要短暂的 STW 来暂停写屏障。
3. GC 的触发时机?#
分析
gc 的触发分为手动和被动两种。
- 主动触发:通过调用
runtime.GC()来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。 - 被动触发,分为两种方式:
- go 后台有一系统监控线程,当超过两分钟没有产生任何 GC 时,强制触发 GC。
- 内存使用增长一定比例时有可能会触发。每次内存分配时检查当前内存分配量是否已达到阈值(环境变量
GOGC),默认 100%,即当内存扩大一倍时启用 GC。- 我们可以通过
debug.SetGCPercent(500)来修改步调,这里表示,如果当前堆大小超过了上次标记的堆大小的 500%,就会触发。 - 而第一次 GC 的触发的临界值是 4MB。
- 我们可以通过
回答
- gc 可以在代码中通过调用
runtime.GC()手动触发。 - 也可以由系统被动触发,当超过两分钟没有 gc,或者是内存分配达到了一定阈值的时候就会强制触发 gc。
4. GC 扫描的根节点有哪些?#
分析
gc 的标记是从根节点开始的,扫描的对象是在堆上的,所以要明确堆上的对象是怎么建立关联的。
举个例子:我们在程序中一般创建对象,假设创建在堆上,然后我们在函数内去操作这个对象,这里我们是通过在函数中的局部变量去操作这个堆上的对象的。所以这种情况下堆上对象一定是和局部变量相关联的,局部变量是保存在栈上的,所以要找到所有堆中可达对象,栈上的对象可以作为根节点全部扫描一遍。
还有一种情况,对象不是在函数内部创建的,而是以全局变量创建的。这种情况下,全局对象是不是也可以作为根节点呢?
所以在回答的时候要思考和联想堆中的对象是怎么关联的,来明确根节点有哪些。
回答
根节点包括:
- 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。
- 执行栈上的对象或指针:每个 goroutine 都包含自己的执行栈,这些执行栈上的对象包含栈上的变量及指向分配的堆内存区块的指针。
- 寄存器中的变量:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。