Golang Memory Align
操作系统并非一个字节一个字节访问内存,而是按 2, 4, 8 这样的字长来访问。因此,当 CPU 从存储器读数据到寄存器,或者从寄存器写数据到存储器,IO 的数据长度通常是字长。如 32 位系统访问粒度是 4 字节,64 位系统的是 8 字节。
当被访问的数据长度为 n 字节且该数据地址为n字节对齐,那么操作系统就可以高效地一次定位到数据,无需多次读取、处理对齐运算等额外操作。
数据结构应该尽可能地在自然边界上对齐。如果访问未对齐的内存,CPU需要做两次内存访问。
下面是腾讯的面试题:
|
|
上面的 struct S,占用多大的内存?
|
|
首先,我们可以明确S的是8字节对齐的,因此我给出的答案是 32。但是很明显,答案并不是 32。先看下正确答案。终端输出:
|
|
可以看到,S.E 的偏移量 offset 是32,并且 size 确实是 0,但是 S 实例的 size 却是 40。说明 S.E 后面隐藏着一个 8 字节的 padding。
社区解答
那为什么需要这个 padding 呢?在 github 上面查到了相关的 issue,看见社区大佬的回复是这样的:
Trailing zero-sized struct fields are padded because if they weren’t, &C.E would point to an invalid memory location.
结构体尾部 size 为 0 的变量会被分配内存空间进行填充,原因是如果不给它分配内存,该变量指针将指向一个非法的内存空间。
并且还给了个相关的 issue 地址:
If a non-zero-size struct contains a final zero-size field f, the address &x.f may point beyond the allocation for the struct. This could cause a memory leak or a crash in the garbage collector (invalid pointer found).
一个非空结构体包含有尾部 size 为 0 的变量,如果不给它分配内存,那么该变量的指针地址将指向一个超出该结构体内存范围的内存空间。这可能会导致内存泄漏,或者在内存垃圾回收过程中,程序 crash 掉。
原理
为什么要对齐
操作系统并非一个字节一个字节访问内存,而是按 2, 4, 8 这样的字长来访问。因此,当 CPU 从存储器读数据到寄存器,或者从寄存器写数据到存储器,IO 的数据长度通常是字长。如 32 位系统访问粒度是 4 字节,64 位系统的是 8 字节。
当被访问的数据长度为 n 字节且该数据地址为n字节对齐,那么操作系统就可以高效地一次定位到数据,无需多次读取、处理对齐运算等额外操作。
数据结构应该尽可能地在自然边界上对齐。如果访问未对齐的内存,CPU需要做两次内存访问。
看下 Go 官方文档 Size and alignment guarantees 对于 Go 数据类型的大小保证和对齐保证:
在 Go 中,如果两个值的类型为同一种类的类型,并且它们的类型的种类不为接口、数组和结构体,则这两个值的尺寸总是相等的。
目前(Go 1.14),至少对于官方标准编译器来说,任何一个特定类型的所有值的尺寸都是相同的。所以我们也常说一个值的尺寸为此值的类型的尺寸。
下表列出了各种种类的类型的尺寸(对标准编译器 1.14 来说):
一个结构体类型的尺寸取决于它的各个字段的类型尺寸和这些字段的排列顺序。为了程序执行性能,编译器需要保证某些类型的值在内存中存放时必须满足特定的内存地址对齐要求。 地址对齐可能会造成相邻的两个字段之间在内存中被插入填充一些多余的字节。 所以,一个结构体类型的尺寸必定不小于(常常会大于)此结构体类型的各个字段的类型尺寸之和。
一个数组类型的尺寸取决于它的元素类型的尺寸和它的长度。它的尺寸为它的元素类型的尺寸和它的长度的乘积。
struct{} 和 [0]T{} 的大小为 0; 不同的大小为 0 的变量可能指向同一块地址。
Go 官方文档中的对对齐保证的要求只有如下解释:
对于任何类型的变量x,unsafe.Alignof(x) 的结果最小为 1。
对于一个结构体类型的变量 x,unsafe.Alignof(x) 的结果为x的所有字段的对齐保证 unsafe.Alignof(x.f) 中的最大值(但是最小为 1)。
对于一个数组类型的变量 x,unsafe.Alignof(x) 的结果和此数组的元素类型的一个变量的对齐保证相等。
类型对齐保证也称为值地址对齐保证。 如果一个类型T的对齐保证为N(一个正整数),则在运行时刻T类型的每个(可寻址的)值的地址都是N的倍数。 我们也可以说类型T的值的地址保证为N字节对齐的。
事实上,每个类型有两个对齐保证。当它被用做结构体类型的字段类型时的对齐保证称为此类型的字段对齐保证,其它情形的对齐保证称为此类型的一般对齐保证。
对于一个类型T,我们可以调用unsafe.Alignof(t)来获得它的一般对齐保证,其中t为一个T类型的非字段值, 也可以调用unsafe.Alignof(x.t)来获得T的字段对齐保证,其中x为一个结构体值并且t为一个类型为T的结构体字段值。
在运行时刻,对于类型为T的一个值t,我们可以调用reflect.TypeOf(t).Align()来获得类型T的一般对齐保证, 也可以调用reflect.TypeOf(t).FieldAlign()来获得T的字段对齐保证。
对于当前的官方Go编译器(1.14版本),一个类型的一般对齐保证和字段对齐保证总是相等的。
重排优化
T1,T2内字段最大的都是int64, 大小为 8bytes,对齐按机器字确定,64 位下是 8bytes,所以将按 8bytes 对齐
T1.a 大小 2bytes, 填充 6bytes 使对齐(后边字段已对齐,所以直接填充)
T1.b 大小 8bytes, 已对齐
T1.c 大小 2bytes,填充 6bytes 使对齐(后边无字段,所以直接填充)
总大小为 8+8+8=24
T2中将c提前后,a和c总大小 4bytes,在填充 4bytes 使对齐
总大小为 8+8=16
所以,合理重排字段可以减少填充,使 struct 字段排列更紧密。
零大小字段对齐
零大小字段(zero sized field)是指struct{},大小为 0,按理作为字段时不需要对齐,但当在作为结构体最后一个字段(final field)时需要对齐的。即开篇我们讲到的面试题的情况,假设有指针指向这个final zero field, 返回的地址将在结构体之外(即指向了别的内存),如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放),go会对这种final zero field也做填充,使对齐。当然,有一种情况不需要对这个final zero field做额外填充,也就是这个末尾的上一个字段未对齐,需要对这个字段进行填充时,final zero field就不需要再次填充,而是直接利用了上一个字段的填充。
思考: 假如这个 E 不是struct{},而是 [0]int32 或者 [0]int64,那 unsafe.Sizeof(S{}) 会是多少呢?
64 位字安全访问保证
在 32 位系统上想要原子操作 64 位字(如 uint64)的话,需要由调用方保证其数据地址是 64 位对齐的,否则原子访问会有异常。
拿uint64来说,大小为 8bytes,32 位系统上按 4字节 对齐,64 位系统上按 8字节对齐。在 64 位系统上,8bytes 刚好和其字长相同,所以可以一次完成原子的访问,不被其他操作影响或打断。而 32 位系统,4byte 对齐,字长也为 4bytes,可能出现uint64的数据分布在两个数据块中,需要两次操作才能完成访问。如果两次操作中间有可能别其他操作修改,不能保证原子性。这样的访问方式也是不安全的。
来看下 Golang 一个 issue。
Cox大神的解答:
在32位系统上,开发者有义务使64位字长的数据的原子访问是64位(8字节)对齐的。在全局变量,结构体和切片的的第一个字长数据可以被认为是64位对齐的。
如何保证呢?
变量或开辟的结构体、数组和切片值中的第一个 64 位字可以被认为是 8 字节对齐
开辟的意思是通过声明,make,new 方式创建的,就是说这样创建的 64 位字可以保证是 64 位对齐的。
总结
内存对齐是为了让 CPU 更高效访问内存中数据
struct 的对齐是:如果类型 t 的对齐保证是 n,那么类型 t 的每个值的地址在运行时必须是 n 的倍数。
即 uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0
struct 内字段如果填充过多,可以尝试重排,使字段排列更紧密,减少内存浪费
零大小字段要避免作为 struct 最后一个字段,会有内存浪费
32 位系统上对 64 位字的原子访问要保证其是 8bytes 对齐的;当然如果不必要的话,还是用加锁(mutex)的方式更清晰简单
因为s1和s2在内存上是连续的,我们也已知s1的大小是40,那为什么s2的地址相对s1的地址偏移量却是48呢?(0xc0000aa090-0xc0000aa060 = 48)
先给出答案,因为对于s1这个对象,它的大小是40bytes,而go在内存分配时,会从span中拿大于或等于40的最小的span中的一个块给这个对象,而sizeclass中这个块的大小值为48。所以虽然s1的大小是40bytes,但实际分配给这个对象的内存大小是48。
go的内存分配,首先是按照sizeclass划分span,然后每个span中的page又分成一个个小格子(大小相同的对象object):
span是golang内存管理的基本单位,是由一片连续的8KB(golang page的大小)的页组成的大块内存。
如下图,span由一组连续的页组成,按照一定大小划分成object。
每个span管理指定规格(以golang 中的 page为单位)的内存块,内存池分配出不同规格的内存块就是通过span体现出来的,应用程序创建对象就是通过找到对应规格的span来存储的,下面是 mspan结构中的主要部分。
那么要想区分不同规格的span,必须要有一个标识,每个span通过spanclass标识属于哪种规格的span,golang的span规格一共有67种,具体如下:
|
|
每个 mspan 按照它自身的属性 Size Class 的大小分割成若干个 object,每个 object 可存储一个对象。并且会使用一个位图来标记其尚未使用的 object。属性 Size Class 决定 object 大小,而 mspan 只会分配给和 object 尺寸大小接近的对象,当然,对象的大小要小于 object 大小。