掘金 后端 ( ) • 2024-04-12 18:27

结构体与内存对齐

我们先来看一个示例:结构体Example1有四个字段,类型包括 int8int16int32;结构体 Example2 也有相同类型但顺序不同的四个字段。(注:Go 语言在 64 位 Windows 操作系统下,int8 类型的变量大小为 1 byte,int16 为 2 bytes,int32 为 4 bytes)

type Example1 struct {
	f1 int8  // 1 byte
	f2 int8  // 1 byte
	f3 int16 // 2 byte
	f4 int32 // 4 byte
}

type Example2 struct {
	f1 int8  // 1 byte
	f2 int32 // 4 byte
	f3 int16 // 2 byte
	f4 int8  // 1 byte
}

// main:
var (
	t1 Example1
	t2 Example2
)
sizeof := unsafe.Sizeof(t1)
fmt.Println("Example1:"+sizeof)
sizeof = unsafe.Sizeof(t2)
fmt.Println("Example2:"+sizeof)

// output:
// Example1:8
// Example2:12

从上代码中可以看到,尽管结构体 Example1Example2 的字段类型大小总和都是 1 + 1 + 2 + 4 = 8,但我们通过 unsafe.Sizeof 函数得到的结构体各自的大小却不一样。这是因为在实际的内存布局中,编译器会对结构体进行内存对齐以提高访问效率。在这个过程中,编译器可能会在结构体字段之间插入填充字节,以满足对齐要求。

下面我们来看一下结构体 Example1Example2 实例的内存布局:作图风格参考

从上图中我们可以发现,t1、t2 的区别:在 t2 的内存布局中,有两处被填充(padding)的字节。这是因为在 Go 编译的时候,编译器会按照既定的规则进行内存布局,以满足对齐要求。而Example2 结构体实例 t2 比 Example1 结构体实例 t1 多出的 4 字节就是在这个过程中填充的。填充字节的存在可以确保结构体字段能够按照正确的边界对齐,从而提高内存访问效率[1]

这种内存对齐的行为使得结构体在不同的架构或编译器下可能具有不同的大小,但在相同的架构和编译器下,同一结构体的大小应该是固定的。

unsafe.Sizeof

在Go语言中,unsafe.Sizeof 函数接受任意类型的表达式 x,并返回以字节为单位的大小。它测量的是变量本身占用的内存空间,而不包括它所引用的数据结构的大小。例如,如果 x 是一个切片,unsafe.Sizeof 返回的是切片变量本身的大小,而不是切片所引用的底层数据的大小。

s1 := make([]int8, 8, 8)
s2 := make([]int8, 0, 0)

sizeof1 := unsafe.Sizeof(s1)
sizeof2 := unsafe.Sizeof(s2)

fmt.Println(sizeof1)
fmt.Println(sizeof2)

// output: (64 位 Windows 下)
// 24
// 24

而对于没有任何字段的空 struct{} 和没有任何元素的 array 占据的内存空间大小为 0,且不同的大小为 0 的变量可能指向同一块地址[2]

1. 内存对齐

刚才举的例子中,我们或许有这样的疑问:为什么Example2的内存大小一定是12?为什么Example2的内存需要填充字节?这么做的目的是什么?在正式回答这些问题前,我们得先了解什么是内存对齐:

在计算机体系结构中,CPU 访问内存时通常不是逐个字节访问的,而是以字长为单位访问,即内存访问粒度。例如,在一个 32 位的 CPU 中,字长为 4 字节,因此 CPU 访问内存的单位也是 4 字节(在 64 位中则是 8 字节)[3]。现在,让我们把这个 4 字节的地址空间看作一个区块(1 word),其中的第一个地址是“对齐的”,即地址对齐。如果一个 4 字节大小的变量能够完全放置在某个区块中,则称该变量是对齐的,即内存对齐或数据对齐[4]

然而,如果一个 4 字节变量不对齐,它就会占据多个区块。例如,它可能被放置在地址 4n + 1(其中 n 是非负整数),因此它将占据地址 4n+1、4n+2、4n+3 和 4(n+1)。如下图所示:

在这种情况下,为了读取变量,CPU 将需要两次读取内存:一次来自第一个 4 字节块,另一次来自第二个块,然后再进行数据拼接。这样的操作会导致 CPU 效率降低[5]。因此,为了保证效率,我们需要对数据进行对齐,确保它们占据连续的内存块。

综上所述,内存对齐是计算机系统中的一个重要概念,用于指定数据在内存中存放的起始位置。通过对数据进行对齐,可以最大程度地提高内存访问的效率,从而提升程序的性能。

a. 对齐系数

在计算机系统实现内存对齐的过程中,一般会规定数据类型在内存中的起始位置必须是某个特定值的倍数,这个特定值即为对齐系数。对齐系数通常与系统下数据类型的字节大小相等。一般来说,如果对齐系数为 n,则数据的起始地址必须是 n 的倍数。比如,在一个 32 位系统中,int 类型的数据大小通常是 4 字节,而在 64 位系统中,通常是 8 字节。即在该 32/64 位系统中int类型的对齐系数就是 4/8。因此,对于一个 int 类型的变量,在 32 位系统中,其起始位置必须是 4 的倍数,在 64 位系统中,必须是 8 的倍数[6]

在 Go 语言中,我们可以使用unsafe.Alignof来获取变量的对齐系数。其规则如下:[2]

  • 对于任意类型的变量 x ,unsafe.Alignof(x) 至少为 1。
  • 对于 struct 类型的变量 x,计算 x 每一个字段 f 的 unsafe.Alignof(x.f),unsafe.Alignof(x) 等于其中的最大值。
  • 对于 array 类型的变量 x,unsafe.Alignof(x) 等于构成数组的元素类型的对齐倍数。

b. 内存对齐规则

回到介绍内存对齐前我们提到的问题上,让我们来看一看Go 语言的结构体是如何进行内存布局的。(注:以下讨论的环境默认基于 64 位 Windows 操作系统 )

// Size:8 byte		Alignof:4
type Example1 struct {
	f1 int8  // Size:1 byte	Alignof:1
	f2 int8  // Size:1 byte	Alignof:1
	f3 int16 // Size:2 byte	Alignof:2
	f4 int32 // Size:4 byte	Alignof:4
}

// Size:12 byte	Alignof:4
type Example2 struct {
	f1 int8  // Size:1 byte	Alignof:1
	f2 int32 // Size:4 byte	Alignof:4
	f3 int16 // Size:2 byte	Alignof:2
	f4 int8  // Size:1 byte	Alignof:1
}

在上一小节中我们提到,Go 结构体的对齐系数是其成员中的最大对齐要求的值。因为Example1Exmaple2中最大对齐系数都是 4,所以这两个结构体的对齐系数也都为 4。(结构体其余字段对齐系数如上代码注释)

我们先来看Exmaple1的内存布局。Exmaple1的对齐系数为 4,且所有字段大小总和为 8 bytes,所以Exmaple1的大小必须为 4 的倍数,且至少为 8 bytes:

  • f1 字段: 作为 Example1 的第一个字段,对齐系数为 1,因此它可以放置在任意位置的地址上。通常情况下,它会被放置在结构体的起始位置,因此其偏移量为 0。其大小为 1 byte,故占据 1 byte 空间
  • f2 字段: 作为 Example1 的第二个字段,对齐系数也为 1,因此也可以放置在任意位置的地址上。由于 f1 已经在偏移量 0 的位置占据了 1 byte 空间,因此 f2 的偏移量为 1。其大小同样为 1 byte,占据 1 byte 空间。

  • f3 字段: 作为 Example1 的第三个字段,对齐系数为 2。根据对齐要求,它需要放置在 2 的倍数的地址上。由于 f2 已经在偏移量 1 的位置占据了 1 byte 空间,因此现在f3 的偏移量刚好是 2。其大小为 2 bytes,占据 2 bytes 空间。
  • f4 字段: 对齐系数为 4,它需要放置在 4 的倍数的地址上。由于 f3 已经在偏移量 2 的位置占据了 2 bytes 空间,因此现在 f4 的偏移量刚好为 4。其大小为 4 bytes,占据 4 bytes 空间。

type Example1 struct {
	f1 int8  // Offset:0
	f2 int8  // Offset:1
	f3 int16 // Offset:2
	f4 int32 // Offset:4
}

经过以上分析,我们可以得出结论,Example1 的内存布局没有填充任何字段,每个字段的大小总和为 1 + 1 + 2 + 4 = 8,恰好等于结构体对齐系数 4 的 2 倍。因此,该结构体的大小为 8。这意味着结构体中没有任何额外的内存浪费,所有的字段都被紧凑地排列在一起,使得内存的利用率最大化。

Exmaple2的内存布局同理。Exmaple2的对齐系数为 4,且所有字段大小总和为 8,所以Exmaple2的大小也必须为 4 的倍数,且至少为 8 bytes:

  • f1 字段:对齐系数为 1,可以放置在任意位置的地址上。作为结构体的第一个字段,其偏移量为 0。其大小为 1,占据 1 byte。
  • f2 字段:对齐系数为 4,需要放置在 4 的倍数的地址上。但是由于 f1 只占用了 1 byte的地址空间,因此起始地址的偏移量为 1,但这并不满足对齐要求。为保证起始地址是 4 的倍数,就需要填充 3 bytes 的空间,让起始地址的偏移量为 4。大小为 4,占据 4 bytes。

  • f3 字段:对齐系数为 2,需要放置在 2 的倍数的地址上。由于 f2 在偏移量 4 的位置占据了 4 bytes 的地址空间,所以 f3 的偏移量为 8,这个地址刚好是对齐系数 2 的 4 倍,因此无需在 f2 f3 之间填充字节。其大小为 2,占据 2 bytes。

  • f4 字段:对齐系数为 1,可以放置在任意位置的地址上。由于 f3 在偏移量 8 的位置占据了 2 bytes 的地址空间,所以 f4 的偏移量为 10,并且无需在 f3f4 之间填充字节。其大小为 1,占据 1 byte。

type Example2 struct {
	f1 int8  // Offset:0
	f2 int32 // Offset:4
	f3 int16 // Offset:8
	f4 int8  // Offset:10
}

根据以上分析,Example2 结构体各字段的内存占用总和为 1 + 4 + 2 + 1 = 8 bytes。然而,由于在字段 f1f2 之间填充了 3 bytes 空间,结构体的实际空间占用变为 11 bytes。然而,11 并不是该结构体对齐系数 4 的倍数,因此不符合对齐要求。结合对齐要求,我们需要找到对齐字数 4 大于或等于 11 的最小倍数,即 12。只有 12 才能够确保结构体的对齐以及最小化内存占用。但现在实际只有 11 bytes ,所以还需要额外在字段 f4 的地址后面填充 1 byte,以满足对齐要求。因此,Example2 结构体的最终大小为 12 字节。

为什么 Exmaple2 的大小是 12 bytes 而不能是 16、20 或者 4n(其中 n>3)呢?

为什么不是 16 或 20 bytes,是因为在内存布局中会尽量避免不必要的内存浪费。在 Example2 中,尽管添加额外的字节能够使结构体的大小达到 16 或 20 字节的倍数,但这将会造成额外的内存浪费,因为这些额外的字节并没有被结构体的字段所使用。因此,Go 编译器会选择最小化内存浪费,将结构体的大小调整到最接近的对齐系数的倍数,尽可能减少内存占用。

结构体成员变量顺序带来的影响

在 Go 中,结构体字段的排列顺序会影响结构体的大小。通过优化字段顺序,我们可以减少结构体的大小以节省内存空间。但我们是否应该这样做呢?

考虑优化结构体字段顺序时,我们需要权衡各种因素。首先,内存空间的优化是一个重要考虑因素。如果内存占用和性能在程序中至关重要,并且经过测试发现优化结构体字段顺序可以明显地节省内存并提升性能,那么可以考虑实施优化顺序。但如果优化字段顺序会导致代码变得难以理解和维护,或者优化带来的性能提升不明显,那么可能并不值得这么做。因为原本按照一定逻辑顺序排列的字段,一旦被改变,可能会增加阅读者理解代码的难度[7]

2. 空结构体与内存对齐

空结构体 struct{}的大小为 0,因此在结构体中使用空结构体类型的字段时,通常不需要对空结构体字段进行内存对齐。然而,当空结构体类型作为结构体的最后一个字段时,且如果存在指向该字段的指针,可能会返回超出结构体范围的地址。这样的情况可能导致内存泄漏,因此会额外进行一次内存对齐以避免此问题[8]

一般来说,对于可寻址的结构值,所有字段都可以被取地址。然而,如果非零尺寸的结构体值的最后一个字段尺寸为零(如下Example3),且在该零尺寸字段后没有填充额外字节的情况下,取此字段的地址可能会返回一个超出为该结构体值分配的内存块的地址。这个返回的地址可能指向另一个被分配的内存块。

type Example3 struct {
	f1 int8     
    f2 struct{} // 若在 f2 后不额外填充字节, 则可能会导致内存泄漏
}

在目前的官方 Go 标准运行时的实现中,只要存在一个活跃的指针引用一个内存块,该内存块将不会被视为垃圾,也不会被回收。因此,存在一个活跃的指针存储着结构体值的最后一个字段的越界地址,可能会阻止垃圾收集器回收另一个内存块,导致内存泄漏的发生。为了避免这种问题,标准的 Go 编译器确保取一个非零尺寸的结构体值的最后一个字段的地址时,绝对不会返回超出为此结构体值分配的内存块的地址。它通过在结构体最后的零尺寸字段之后填充一些字节来实现这一点[9]。例如,在结构体中使用空结构体类型作为最后一个字段时,编译器会在需要时填充额外的字节以确保内存地址不会越界。

// size:1
type Example3 struct {
	f1 struct{} // size:0
	f2 int8     // size:1
}

// size:2
// 会额外填充字节
type Example3 struct {
	f1 int8     // size:1
    f2 struct{} // size:0
}

然而,如果一个结构体的所有字段类型都是零尺寸的,即整个结构体的大小也是零尺寸,那么就不需要填充字节。因为标准编译器会专门处理零尺寸的内存块,所以不会出现越界的情况。