大纲
扫描的目的
扫描的实现
编译阶段
运行期内存分配
运行扫描阶段
总结
之前阐述了 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)。