1.内存布局
struct 通过在一个连续的内存块中将其元素(即结构体字段)一个接着一个地以"平铺"的方式存放,实现了高效的内存布局。下面展示了 struct T 的内存布局:
在处理 struct T 的内存布局时,Go 采用了高效紧凑的方式,完全将分配给结构体的内存用于存储字段,并没有额外的被编译器插入的字段。如果我们想要获取结构体类型变量的内存大小以及每个字段相对于结构体变量起始地址的偏移量,我们可以借助标准库中的 unsafe
包提供的函数:
var t T
unsafe.Sizeof(t)
unsafe.Offsetof(t.F3)
然而,在实际使用过程中,struct 在内存中的占用往往不像上图那么“完美”,我们来看下图:
在实际情况下,尽管 Go 编译器没有在结构体变量的内存空间中插入额外字段,但实际的结构体字段可能并不是紧密相连的,它们之间可能存在一些"缝隙"。这些"缝隙"也是结构体变量占用的内存空间的一部分,我们暂且将它们成为"填充物(Padding)"。
为什么 Go 编译器要在结构体字段之间插入填充物呢?这是因为需要「内存对齐」。
什么是内存对齐?
元素按照定义顺序一个一个地放到内存中去,但并不是紧密排列的。从结构体存储的首地址开始,每个元素放置到内存中时,它都会认为内存是按照自己的大小来划分的,因此元素放置的内存地址一定会在自身大小的整数倍上开始,这就是内存对齐。
以基本数据类型为例,变量的内存地址值必须是其类型大小的整数倍。例如,一个 int64 类型的变量的内存地址应该是 int64 类型自身大小(即 8 字节)的整数倍;一个 uint16 类型的变量的内存地址应该是 uint16 类型自身大小(即 2 字节)的整数倍。
而对于像结构体这样的复合数据类型,内存对齐的要求有所不同。首先是结构体变量的内存地址,只需要是其最长字段长度与系统对齐系数两者中较小值的整数倍即可。对于结构体类型来说,我们需要确保每个字段的内存地址严格满足内存对齐的要求。
为什么需要内存对齐?
1.平台原因,比如 Sun 公司的 Sparc 处理器仅支持内存对齐的地址;
2.性能原因:如果数据的地址不是按照特定的规则对齐的,那么 CPU 将花费额外的时间来读取和处理这些未对齐的数据。假如没有内存对齐,在 64 位 操作系统上,将一个 int 变量(8 字节)放在内存地址位 1 的位置。当处理器去取数据时,要先从 0 地址开始读取第一个 8 字节块,剔除不想要的字节(0 地址)。然后从地址 8 开始读取下一个 8 字节块,同样剔除不要的数据(9-15 地址),最后将两块数据合并放入寄存器。这就需要做两次内存访问。
我们来看一个例子:
type t struct {
f1 byte
f2 int64
f3 uint16
}
整个内存计算过程可以分为 2 步。
- 对齐结构体的各个字段
- 对齐整个结构体
我们先来看第一步,对齐结构体的各个字段。
首先是第一个字段 f1
。由于它是 1 个字节长度的 byte 类型变量,所以把它放在任意地址都可以被 1 整除,我们用一个 size
表示当前已经对其的内存空间大小,这个时候 size = 1
。
再来看第二个字段 f2
。它是 8 个字节长度的 int64 类型,按照内存对齐要求,它应该被放在可以被 8 整除的地址上。如果把 f1
和 f2
紧密排列,显然 f2
的地址无法被 8 整除,这时候我们需要在 f1
和 f2
之间做一些填充,使得 f1
的地址和 f2
的地址都能同时被 8 整除。于是我们填充 7 个字节,此时 size = 1 + 7 + 8
。
最后分析第三个字段 f3
。它是 2 个字节长度的 uint16 类型,按照内存对齐要求,它应该被放在可以被 2 整除的地址上,而 f2
之后的那个地址肯定可以被 8 整除,也就可以被 2 整除,所以 f3
可以直接放在 f2
后面,不需要额外的填充。此时 size = 1 + 7 + 8 + 2
。
现在结构体 t
的所有字段都已经对齐了,我们开始第二步,也就是对齐整个结构体。
根据内存对齐要求,t
的地址是其最长字段长度与系统对齐系数两者中较小值的整数倍,这里 t
的最长字段为 f2
,长度是 8 字节,而 64 位操作系统的系统对齐系数一般为 8,两者相同,我们取 8。那么整个结构体的对齐系数就是 8。
那么我们只要保证每个结构体 t
的变量的内存地址能被 8 整除就可以了吗?其实不行。如果只分配一个 t
类型变量&#