之前阐述了 golang 垃圾回收通过保证三色不变式来保证回收的正确性,通过写屏障来实现业务赋值器和 gc 回收器正确的并发的逻辑。其中高概率的提到了“扫描队列”和“扫描对象”。队列这个逻辑非常容易理解,那么”扫描对象“ 这个你理解了吗?有直观的感受吗?这篇文章就是要把这个扫描的过程深入剖析下。

  • 扫描的东西是啥?形象化描述下
  • 怎么去做的扫描?形象化描述下

我们就是要把这两个抽象的概念搞懂,不能停留在语言级别浅层面,要知其然知其所以然。

扫描的目的

扫描到底是为了什么?

之前的文章我们深入剖析了垃圾回收的理论和实现,可以总结这么节点:

  • 垃圾回收的根本目的是:“回收那些业务永远都不会再使用的内存块”;
  • 扫描的目的则是:“把这些不再使用的内存块找出来”;

我们通过地毯式的扫描,从一些 root 起点开始,不断推进搜索,最终形成了一张有向可达的网,那些不在网里的就是没有被引用到的,也就是可回收的内存。

扫描的实现

扫描对象代码逻辑其实不简单,但主体线索很清晰,可以分为三部分:

  • 编译阶段:编译期是非常重要的一环,针对静态类型做好标记准备(旁白:原则上编译期能做的绝对不留到运行期);
  • 运行阶段:赋值器分配内存的时候,根据编译阶段的 type 标示,会为分配的对象内存设置好一个对应的指针标示的 bitmap;
  • 扫描阶段:根据指针的 bitmap 标示,地毯式扫描;

编译阶段

结构体对齐

要理解编译阶段做的事情,那么首先要理解结构体对齐的基础知识。这个和 C 语言类似,golang 的结构体是有对齐规则的,也就是说,必要的时候可能会填充一些内存空间来满足对齐的要求。总结来说两条规则:

  • 长度要对齐
  • 地址要对齐

“长度要对齐”怎么理解?

结构体的长度要至少是内部最长的基础字段的整数倍。

举例:

这个结构体内存占用 size 多大?

答案是:16个字节,因为字段 ptr 是 uintptr 类型,占 8 字节,是内部字段最大的,TestStruct 整体长度要和 8 字节对齐。那么就是 16 字节了,而不是有些人想的 13 字节(8+4+1)。

dlv 调试如下:

字节示意图:

|--8 Byte--|--4 Byte--|--4 Byte--|

“地址要对齐”怎么理解?

字段的地址偏移要是自身长度的整数倍。

举例:

newTestStruct0xc00008a0100xc00008a0100xc00008a0180xc00008a01c

dlv 调试如下:

0xc00008a010
0xc00008a010 == 0xc00008a010 + 00xc00008a018 == 0xc00008a010 + 80xc00008a0190xc00008a01c
TestStruct

指针位标记

_typeruntime/type.go

比如我们定义了一个 Struct 如下:

该结构 dlv 调试如下:

_type_type

1.size:类型长度,我们上面这个类型长度应该是 32 字节;

这里理解要应用上上面讲的结构体字节对齐的知识,这里就不再复述;

2.ptrdata:指针截止的长度位置,我们 f4 是指针,所以包含指针的字段最多也就到 40 字节的位置,ptrdata==40;

要理解字节对齐哈;

3.kind:表明类型,我们是自定义struct类型,所以 kind == 25

runtime/typekind.go
*byte0001010000101000TestStruct00101000TestStruct.f2TestStruct.f4

划重点:这里重点回顾一下 uintptr 类型的问题,这里注意到,第一个字段 ptr(uintptr 类型)在指针的 bitmap 上是没有标记成指针类型的,这里一定要注意了,uintptr 是数值类型,非指针类型,用这个存储指针是无法保护对象的(扫描的时候 uintptr 指向的对象不会被扫描),这里就是实锤了。

小结

_type

思考题:是否可以不用 bitmap,其实有个最简单最笨拙的扫描方式,我们可以不搞这个指针的 bitmap,我上来就直接扫描,每 8 字节的读取内存,然后去看这个内存块存储的值是否指向了一个对象?如果是我就保护起来。

这个实现理论上可以满足,但是有两个不能接受的缺陷:

  • 精度太低,你编译期间不做准备,那运行期间就要来偿还这部分损耗,你无法判断是不是指针,所以只要指向了一个有效内存地址,就得无脑保护,这样就保护了很多不需要保护的内存块;
  • 扫描太低效,必须全地址扫描,因为你没有 bitmap,无法识别是否有指针。也无法做优化,比如我们程序里面可能 一半以上的类型内是不包含指针的,这种根本就不需要扫描;

运行期内存分配

runtime.newobject
  • 分配内存
  • 内存采样
  • gc 标记准备
heapBitsSetTypeheapBitsSetType
heapBitsSetType

所以,上面函数调用完,h.bitp 就给设置上了:

低字节 -> 高字节 [ 1101 0100 ], [ 0001 0001 ] |–前4*8字节–|–后4*8字节–|

这个就是 mallocgc 内存的时候做的事情。

总结就一句话:根据编译期间针对每个 struct 生成的 type 结构,来设置 gc 需要扫描的位图,也就是指针 bitmap。(旁白:每分配一块内存出去,我都会有一个 bitmap 对应到这个内存块,指明哪些地方有指针)。

运行扫描阶段

markrootscanstack
goroutinegcDrain
scanobject
scanstackscanobject

scanstack

这个函数是起点函数( 起始最原始的还是 markroot,但是我们这里梳理主线 ),该扫描栈上所有可达对象,因为栈是一个根,因为你做事情总要有个开始的地方,那么“栈”就是 golang 的起点。

小结:

  • 找到这个 goroutine 栈上的内存对象(一个个找,一个个处理);
  • 找到对象之后,获取到这个对象的 type 结构,然后取出 type.ptrdata, type.gcdata ,从而我们就知道扫描的内存范围,和内存块上指针的所在位置;
  • 调用 scanblock 扫描这个内存块;

scanblock

scanblock
0xc00007cf20scanblock ( 0xc00007cf20, 40, 20, xxx)

示意图如下:

type.ptrdata == 40

小结:

scanblock

scanobject

gcDrain 这个函数就是从队列里不断获取,处理这些对象,最重要的一个就是调用 scanobject 继续扫描对象。

markroot 从根(栈)扫描,把扫描到的对象投入扫描队列。gcDrain 等函数从里面不断获取,不断处理,并且扫描这些对象,进一步挖掘引用关系,当扫描结束之后,那些没有扫描到的就是垃圾了。

还是 TestStruct 举例:

TestStructtype.gcdata0001 0100TestStruct
scanobject

小结:

  • scanobject 的目的其实很简单:就是进一步发现引用关系,尽可能的把可达对象全覆盖;
  • 这个地方就没有直接使用到 type ,而是使用到 mallocgc 时候的准备成果( heapBitsSetType 设置),每个内存块都对应了一个指针的 bitmap;

总结

  • 要达到“正确并且高效的扫描”需要 编译期间运行分配期间扫描期间 三者配合处理;
  • 内存对齐是非常重要的一个前提条件;
  • 编译期间生成 type 类型,对用户定义的类型全方位分析,标记出所有的指针类型字段;
  • 运行期间,赋值器分配内存的时候,根据 type 结构,设置和对象内存一一对应的 bitmap,标明指针所在位置,以便后续 gc 扫描;
  • 回收器扫描期间,从根部开始扫描,遇到对象,则置灰,投入队列,并且不断的扫描这些对象指向的对象,直到结束。扫描的依据,就根据编译期间生成的 bitmap,分配期间设置的 bitmap 来识别哪些地方有指针,然后进一步处理;
  • 扫描只需要给个开始地址,然后每 8 字节推进就可以扫描了,为了加快效率我们才有了指针的 bitmap (所以这个是个优化项);
  • 再次强调下,定义的非指针类型不受保护,比如 uintptr 里面就算存储的是一个地址的值,也是不会被扫描到的;