layout: post
title: “深度剖析 Golang 的 GC 扫描对象实现”
date: 2020-7-31 1:44:09 +0800
categories: golang GC 扫描对象
文章目录
之前阐述了 golang 垃圾回收通过保证三色不变式来保证回收的正确性,通过写屏障来实现业务赋值器和 gc 回收器正确的并发的逻辑。其中高概率的提到了“扫描队列”和“扫描对象”。队列这个逻辑非常容易理解,那么”扫描对象“ 这个你理解了吗?有直观的感受吗?这篇文章就是要把这个扫描的过程深入剖析下。
- 扫描的东西是啥?形象化描述下
- 怎么去做的扫描?形象化描述下
我们就是要把这两个抽象的概念搞懂,不能停留在语言级别浅层面,要知其然知其所以然。
扫描的目的
扫描到底是为了什么?
之前的文章我们深入剖析了垃圾回收的理论和实现,可以总结这么节点:
- 垃圾回收的根本目的是:“回收那些业务永远都不会再使用的内存块”;
- 扫描的目的则是:“把这些不再使用的内存块找出来”;
我们通过地毯式的扫描,从一些 root 起点开始,不断推进搜索,最终形成了一张有向可达的网,那些不在网里的就是没有被引用到的,也就是可回收的内存。
扫描的实现
扫描对象代码逻辑其实不简单,但主体线索很清晰,可以分为三部分:
- 编译阶段:编译期是非常重要的一环,针对静态类型做好标记准备(旁白:原则上编译期能做的绝对不留到运行期);
- 运行阶段:赋值器分配内存的时候,根据编译阶段的 type 标示,会为分配的对象内存设置好一个对应的指针标示的 bitmap;
- 扫描阶段:根据指针的 bitmap 标示,地毯式扫描;
编译阶段
结构体对齐
要理解编译阶段做的事情,那么首先要理解结构体对齐的基础知识。这个和 C 语言类似,golang 的结构体是有对齐规则的,也就是说,必要的时候可能会填充一些内存空间来满足对齐的要求。总结来说两条规则:
- 长度要对齐
- 地址要对齐
“长度要对齐”怎么理解?
结构体的长度要至少是内部最长的基础字段的整数倍。
举例:
type TestStruct struct {
ptr uintptr // 8
f1 uint32 // 4
f2 uint8 // 1
}
这个结构体内存占用 size 多大?
答案是:16个字节,因为字段 ptr 是 uintptr 类型,占 8 字节,是内部字段最大的,TestStruct 整体长度要和 8 字节对齐。那么就是 16 字节了,而不是有些人想的 13 字节(8+4+1)。
dlv 调试如下:
(dlv) p typ
*runtime._type {
size: 16,
...
字节示意图:
|--8 Byte--|--4 Byte--|--4 Byte--|
“地址要对齐”怎么理解?
字段的地址偏移要是自身长度的整数倍。
举例:
type TestStruct struct {
ptr uintptr // 8
f1 uint8 // 1
f2 uint32 // 4
}
假设 new
一个 TestStruct
结构体 a 的地址是 0xc00008a010
,那么 &a.ptr 是 0xc00008a010
(= a + 0),&a.f1 是 0xc00008a018
(= a + 8) ,&a.f2 是 0xc00008a01c
(= a + 8 + 4) 。
dlv 调试如下:
(dlv) p &a.ptr
(*uintptr)(0xc00008a010)
(dlv) p &a.f1
(*uint8)(0xc00008a018)
(dlv) p &a.f2
(*uint32)(0xc00008a01c)
假设 TestStruct 分配对象 a 的地址是 0xc00008a010
,解释如下:
- ptr 是第一个字段,当然和结构体本身地址一样,相对偏移是 0,所以地址是
0xc00008a010 == 0xc00008a010 + 0
; - f1 是第二个字段,由于前一个字段 ptr 是 uintptr 类型(8字节),并且由于 f1 本身是 uint8 类型(1字节),所以 f1 从 8 偏移开始没毛病,所以 f1 的偏移地址从
0xc00008a018 == 0xc00008a010 + 8
; - f2 是第三个字段,由于前一个字段 f1 是 uint8(1字节),所以表面上看好像 f2 要接着
0xc00008a019
(= 0xc00008a018 +1) 这个地址才对,但是 f2 本身是 uint32 (4字节的类型),所以 f2 地址偏移至少要是 4 的倍数,所以 f2 的地址要从0xc00008a01c
(0xc00008a018 + 4)这个地址开始才对。也就是说,f1 到 f2 之间填充了一些不用的空间,为了地址对齐。
所以这样算下来,整个 TestStruct
的占用空间长度是 16字节 (8+1+3+4)。
指针位标记
golang 的所有类型都对应一个 _type
结构,可以在 runtime/type.go
里面找到,定义如下:
type _type struct {
size uintptr
ptrdata uintptr // size of memory prefix holding all pointers
hash uint32
tflag tflag
align uint8
fieldalign uint8
kind uint8
alg *typeAlg
// gcdata stores the GC type data for the garbage collector.
// If the KindGCProg bit is set in kind, gcdata is a GC program.
// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
gcdata *byte
str nameOff
ptrToThis typeOff
}
比如我们定义了一个 Struct 如下:
type TestStruct struct {
ptr uintptr
f1 uint8
f2 *uint8
f3 uint32
f4 *uint64
f5 uint64
}
该结构 dlv 调试如下:
(dlv) p typ
*runtime._type {
size: 48,
ptrdata: 40,
hash: 4075663022,
tflag: tflagUncommon|tflagExtraStar|tflagNamed (7),
align: 8,
fieldalign: 8,
kind: 25,
alg: *runtime.typeAlg {hash: type..hash.main.TestStruct, equal: type..eq.main.TestStruct},
gcdata: *20,
str: 28887,
ptrToThis: 49504,}
在编译期间,编译器就会在内部生成一个 _type
结构体与之对应。_type
里面重点解释几个和本次扫描主题相关的字段:
- size:类型长度,我们上面这个类型长度应该是 32 字节;
- 这里理解要应用上上面讲的结构体字节对齐的知识,这里就不再复述;
- ptrdata:指针截止的长度位置,我们 f4 是指针,所以包含指针的字段最多也就到 40 字节的位置,ptrdata==40;
- 要理解字节对齐哈;
- kind:表明类型,我们是自定义struct类型,所以 kind == 25
- kind 枚举定义在
runtime/typekind.go
文件里;
- kind 枚举定义在
- gcdata:这个就重要了,这个就是指针的 bitmap,因为编译器他在编译分析的时候,肯定就知道了所有的类型结构,那么自然知道所有的指针位置。gcdata 是
*byte
类型(byte 数组),当前值是 20,20 转换成二进制数据就是00010100
,这个眼熟不?这个你要从右往左看就是00101000
(从低 bit 往高 bit 看),这个不就是刚好是TestStruct
的指针 bitmap 嘛,每个 bit 表示一个指针大小(8 字节)的内存,00101000
第 3 个 bit 和第 5 个 bit 是 1,表示 第 3 个字段(第 3 个 8 字节的位置)和第 5 个字段(第 5 个 8 字节的位置)是存储的是指针类型,这里刚好就和TestStruct.f2
和TestStruct.f4
对应起来。
划重点:这里重点回顾一下 uintptr 类型的问题,这里注意到,第一个字段 ptr(uintptr 类型)在指针的 bitmap 上是没有标记成指针类型的,这里一定要注意了,uintptr 是数值类型,非指针类型,用这个存储指针是无法保护对象的(扫描的时候 uintptr 指向的对象不会被扫描),这里就是实锤了。
小结:
编译阶段给每个类型生成 _type
类型,内部对类型字段生成指针的 bitmap,这个是后面扫描行为的基础依据。
思考题:是否可以不用 bitmap,其实有个最简单最笨拙的扫描方式,我们可以不搞这个指针的 bitmap,我上来就直接扫描,每 8 字节的读取内存,然后去看这个内存块存储的值是否指向了一个对象?如果是我就保护起来。
这个实现理论上可以满足,但是有两个不能接受的缺陷:
- 精度太低,你编译期间不做准备,那运行期间就要来偿还这部分损耗,你无法判断是不是指针,所以只要指向了一个有效内存地址,就得无脑保护,这样就保护了很多不需要保护的内存块;
- 扫描太低效,必须全地址扫描,因为你没有 bitmap,无法识别是否有指针。也无法做优化,比如我们程序里面可能 一半以上的类型内是不包含指针的,这种根本就不需要扫描;
运行期内存分配
下一步就是赋值器的做的事情,也就是业务运行的过程中分配内存。分配内存的时候肯定要指定类型,调用 runtime.newobject
函数进行分配,本质上调用 mallocgc 函数来操作。mallocgc 函数做几件事情:
- 分配内存
- 内存采样
- gc 标记准备
我们这里重点分析给 gc 做扫描做的准备。在分配完堆内存之后,会调用一