目录

golang 结构体内存对齐

为何需要内存对齐

CPU和内存数据交互的过程。CPU和内存是通过总线进行数据交互的。其中地址总线用来传递CPU需要的数据地址,内存将数据通过数据总线传递给CPU, 或者CPU将数据通过数据总线回传给内存。

./pic1.png

地址总线

专门用来传送地址的,由于地址只能从CPU传向外部存储器或I/O端口,所以地址总线总是单向的。地址总线的位数决定了CPU可直接寻址的内存空间大小,比如8位微型机的地址总线为16位,则其最大可寻址空间为2^16=64KB,16位微型机的地址总线为20位,其可寻址空间为2^20=1MB。

数据总线

是CPU与内存或其他器件之间的数据传送的通道。每条传输线一次只能传输1位二进制数据, 数据总线每次可以传输的字节总数就称为机器字长或者数据总线的宽度。 它决定了CPU和外界的数据传送速度。我们现在日常使用的基本上是32位(每次可以传输4字节)或者64位(每次可以传输8字节)机器字长的机器。

由于数据是通过总线进行传输,若数据未经一定规则的对齐,CPU的访址操作与总线的传输操作将会异常的复杂,所以编译器在程序编译期间会对各种类型的数据按照一定的规则进行对齐, 对齐过程会按一定规则对内存的数据段进行的字节填充, 这就是字节对齐。

例如: 现在要存储变量A(int32)和B(int64)那么不做任何字节对齐优化的情况下,内存布局是这样的:

./pic2.png

字节对齐优化后是这样子的:

./pic3.png

字节对齐后浪费了内存, 但是当我们去读取内存中的数据给CPU时,64位的机器(一次可以原子读取8字节)在内存对齐和不对齐的情况下A变量都只需要原子读取一次就行, 但是对齐后B变量的读取只需一次, 而不对齐的情况下,B需要读取2次,且需要额外的处理牺牲性能来保证2次读取的原子性。所以本质上,内存填充是一种以空间换时间, 通过额外的内存填充来提高内存读取的效率的手段。

总的来说,内存对齐主要解决以下两个问题:

【1】跨平台问题:如果数据不对齐,那么在64位字长机器存储的数据可能在32位字长的机器可能就无法正常的读取。

【2】性能问题:如果不对齐,那么每个数据要通过多少次总线传输是未知的,如果每次都要处理这些复杂的情况,那么数据的读/写性能将会收到很大的影响。之所以有些CPU支持访问任意地址,是因为处理器在后面多做了很多额外处理。

内存对齐的规则

内存对齐主要是为了保证数据的原子读取, 因此内存对齐的最大边界只可能为当前机器的字长。当然如果每种类型都使用最大的对齐边界,那么对内存将是一种浪费,实际上我们只要保证同一个数据不要分开在多次总线事务中便可。

Go也提供了unsafe.Alignof(x)来返回一个类型的对齐值,并且作出了如下约定:

  • 对于任意类型的变量 x ,unsafe.Alignof(x) 至少为 1。
  • 对于 struct 结构体类型的变量 x,计算 x 每一个字段 f 的 unsafe.Alignof(x.f),unsafe.Alignof(x) 等于其中的最大值。
  • 对于 array 数组类型的变量 x,unsafe.Alignof(x) 等于构成数组的元素类型的对齐倍数。
  • 没有任何字段的空 struct{} 和没有任何元素的 array 占据的内存空间大小为 0,不同的大小为 0 的变量可能指向同一块地址。

总结来说,分为基本类型对齐和结构体类型对齐.

基本类型对齐

go语言的基本类型的内存对齐是按照基本类型的大小和机器字长中最小值进行对齐

./pic4.png

结构体类型对齐

go语言的结构体的对齐是先对结构体的每个字段进行对齐,然后对总体的大小按照最大对齐边界的整数倍进行对齐。有一个特殊的情况就是,如果空结构体嵌套到一个结构体尾部,那么这个结构体也是要额外对齐的,因为如果有指针指向该字段, 返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题。

怎么检查并优化

如果结构体字段比较多,靠人工去分析太慢而且还容易出错。

import 	"golang.org/x/tools/go/analysis/analysistest"
	"golang.org/x/tools/go/analysis/passes/fieldalignment"

testdata := "/Users/xingliuhua/Documents/goproject/hello"
analysistest.RunWithSuggestedFixes(t, testdata, fieldalignment.Analyzer, "a")

analysis工具可以帮我们去检查并给出优化后代码。

案例

案例1

type TestStruct1 struct {
	a int8      //  1 字节====> max align 1 字节
	b int32    //  4 字节====> max align 4 字节
	c []string // 24 字节====> max align 8 字节
}

TestStruct1在编译期就会进行字节对齐的优化。优化后各个变量的相对位置如下图(以64位字长下环境为例):

./pic5.png

TestStruct1 内存占用大小分析:最大对齐边界为8,总体字节数 = 1 + (align 3) + 4 + 24 = 32, 由于32刚好是8的倍数,所以末尾无需额外填充,最后这个结构体的大小为32字节。

a后面为啥不直接补7个字节呢?因为b也不到8个字节,a+b可以合一起加一块8个字节,更省内存。

案例2

type TestStruct2 struct {
	a []string     // 24 字节====> max align 8 字节
	b int64       //   8 字节====> max align 8 字节
	c int32       //   4 字节====> max align 4 字节
}

./pic6.png

TestStruct2 内存占用大小分析:最大对齐边界为8字节,总体字节数 = 24(a) + 8(b) + 4(c) + 4(填充) = 40, 由于40刚好是8的倍数,所以c字段填充完后无需额外填充了。

案例3

type TestStruct3 struct {
	a int8
	b int64
	c struct{}
}

./pic7.png

TestStruct3 内存占用大小分析:最大对齐边界为8字节,总体字节数 = 1(a)+ 7(填充) + 8(b) + 8(c填充)=24, 空结构体理论上不占字节,但是如果在另一个结构体尾部则需要进行额外字节对齐 。

a后面直接就是b,b是需要8个字节的。那a只能补7个了。

案例4

type TestStruct4 struct {
    a struct{}
	 b int8
	 c int32
}

./pic8.png

TestStruct4 内存占用大小分析:最大对齐边界为4字节,总体字节数 = 0(a)+ 1(b)+ 3(填充) + 4(c) = 8。