何为内存对齐

现代计算机中内存空间都是按照字节(byte)进行划分的,所以从理论上讲对于任何类型的变量访问都可以从任意地址开始,但是在实际情况中,在访问特定类型变量的时候经常在特定的内存地址访问,所以这就需要把各种类型数据按照一定的规则在空间上排列,而不是按照顺序一个接一个的排放,这种就称为内存对齐,内存对齐是指首地址对齐,而不是说每个变量大小对齐。

为何要有内存对齐

CPU一次可以从内存中读取数据的位数,称为CPU的字长(注意这里和字节的区别,字节是固定的8位,而字长随着CPU的规格变化,32位的字长是4字节,64位的字长是8字节)。比如64位机器中的64位指的就是CPU一次可以从内存中读取64位的数据,即8个字节。

主要原因可以归结为两点:

  • 有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定地址访问数据,因此不同硬件平台具有差异性,这样的代码就不具有移植性,如果在编译时,将分配的内存进行对齐,这就具有平台可以移植性了
  • CPU每次寻址都是要消费时间的,并且CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问,所以数据结构应该尽可能地在自然边界上对齐,如果访问未对齐的内存,处理器需要做两次内存访问,而对齐的内存访问仅需要一次访问,内存对齐后可以提升性能。

举个例子:

假设当前CPU是32位的,并且没有内存对齐机制,数据可以任意存放,现在有一个int32变量占4byte,存放地址在0x00000002 - 0x00000005(纯假设地址,莫当真),这种情况下,每次取4字节的CPU第一次取到[0x00000000 - 0x00000003],只得到变量1/2的数据,所以还需要取第二次,为了得到一个int32类型的变量,需要访问两次内存并做拼接处理,影响性能。如果有内存对齐了,int32类型数据就会按照对齐规则在内存中,上面这个例子就会存在地址0x00000000处开始,那么处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,使用空间换时间,提高了效率。

没有内存对齐机制:

内存对齐后:

相关函数

数据类型的大小和对齐系数

在unsafe包中有三个函数

1
2
3
func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr

unsafe.Sizeof 返回变量x的占用字节数,但不包含它所指向内容的大小,对于一个string类型的变量它的大小是16字节,一个指针类型的变量大小是8字节(基于64位机器,下文中的例子也是,不再说明)

unsafe.Offsetof 返回结构体成员地址相对于结构体首地址相差的字节数,称为偏移量,注意:x必须是结构体成员

unsafe.Alignof 返迴变量x需要的对齐倍数,它可以被x地址整除

对齐系数

每个特定平台上的编译器都有自己的默认"对齐系数",常用平台默认对齐系数如下:

  • 32位系统对齐系数是4
  • 64位系统对齐系数是8

注意:不同硬件平台占用的大小和对齐系数都可能是不一样的。

在C语言中可以通过预编译指令#pragma pack(n)来修改对齐参数

1
2
3
4
5
6
#pragma pack(2)
typedef struct
{
    char e_char;
    long double e_ld;
}S1;

不过在go语言中没有办法修改默认对齐参数

对于一个类型T,我们可以调用unsafe.Alignof(t)来获得它的一般对齐系数,其中t为一个T类型的非字段值, 也可以调用unsafe.Alignof(x.t)来获得T的字段对齐系数,其中x为一个结构体值并且t为一个类型为T的结构体字段值。

Go 官方文档中对对齐系数也有如下保证:

  • 对于任何类型的变量 x,unsafe.Alignof(x) 的结果最小为1 (类型最小是一字节对齐的)。
  • 对于一个结构体类型的变量 x,unsafe.Alignof(x) 的结果为 x 的所有字段的对齐字节数中的最大值。
  • 对于一个数组类型的变量 x , unsafe.Alignof(x) 的结果和此数组的元素类型的一个变量的对齐字节数相等,也就是 unsafe.Alignof(x) == unsafe.Alignof(x[i])

下面这个表格列出了每种数据类型对齐系数

数据大小

在 Go 中,如果两个值的类型为同一种类的类型,并且它们的类型的种类不为接口、数组和结构体,则这两个值的尺寸总是相等的。

目前(Go 1.14),至少对于官方标准编译器来说,任何一个特定类型的所有值的尺寸都是相同的。所以我们也常说一个值的尺寸为此值的类型的尺寸。

下表列出了各种种类的类型的尺寸(对标准编译器 1.14 来说):

一个结构体类型的尺寸取决于它的各个字段的类型尺寸和这些字段的排列顺序。为了程序执行性能,编译器需要保证某些类型的值在内存中存放时必须满足特定的内存地址对齐要求。 地址对齐可能会造成相邻的两个字段之间在内存中被插入填充一些多余的字节。 所以,一个结构体类型的尺寸必定不小于(常常会大于)此结构体类型的各个字段的类型尺寸之和。

一个数组类型的尺寸取决于它的元素类型的尺寸和它的长度。它的尺寸为它的元素类型的尺寸和它的长度的乘积。

struct{}[0]T{} 的大小为 0; 不同的大小为 0 的变量可能指向同一块地址。

结构体的内存对齐规则

考虑到结构体方便举例,这里只介绍结构体的对齐规则,其实其他的所有类型都需要做内存对齐

规则一:结构体第一个成员变量偏移量为0,后面的成员变量的偏移量等于成员变量大小和成员对齐系数两者中较小的那个值的最小整数倍,如果不满足规则,编译器会在前面填充值为0的字节空间

规则二:结构体本身也需要内存对齐,其大小等于各成员变量占用内存最大的和编译器默认对齐系数两者中较小的那个值的最小整数倍

我们用一个实例解释一下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type Demo1 struct {
  a bool
  b string
  c int16
}

func main()  {
   demo1 := Demo1{}
  fmt.Printf("demo1 size:%d\n", unsafe.Sizeof(demo1))
}

先看结果

1
demo1 size:32

demo1占用32个字节大小,分析如下:

首先根据第一条规则:

  • a成员,bool类型,占用1个字节大小,对齐系数为1,因为是第一个成员,偏移量为0,所有不需要填充,直接排在内存空间的第一位
  • b成员,string类型,占用16个字节大小,对齐系数为8,当前偏移量为2,根据规则一,其偏移量为两者中较小的8,所以调整后的偏移量为8,b的前面要填充7个字节,从第9位开始占用16个字节空间
  • c成员,int16类型,占用2个字节大小,对齐系数为2,当前偏移量为24,根据规则一,其偏移量为两者中较小的,为2,24满足条件2的倍数条件,所以不需要填充,从第25位开始占用2个字节空间

第一条规则算下来结构体占用大小=8+16+2=26

接下来看第二条规则

通过上面分析结构体最大成员变量大小为16,编译器默认对齐系数为8,取两者最小值8的最小整数倍,因本身结构体当前大小为26,所以最后结构体大小=4*8=32

成员变量顺序对内存对齐带来的影响

如果我们把成员a和b的位置调换一下情况会怎样?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type Demo2 struct {
  a string
  b bool
  c int16
}

func main()  {
  demo2 := Demo2{}
  fmt.Printf("demo2 size:%d\n", unsafe.Sizeof(demo2))
}

结果如下

1
demo2 size:24

占用24个字节空间,为什么少了8个字节大小?接着分析:

首先根据第一条规则:

  • a成员,string类型,占用16个字节大小,对齐系数为8,当前偏移量为0,不需要填充,从第1位开始占用16个字节空间
  • b成员,bool类型,占用1个字节大小,对齐系数为1,当前偏移量为16,根据规则一,其偏移量为两者中较小的,为1,16满足条件1的倍数,所以不需要填充,从第17位开始占用1个字节空间
  • c成员,int16类型,占用2个字节大小,对齐系数为2,当前偏移量为17,根据规则一,其偏移量为两者中较小的2的最小倍数,所以偏移量调整为2*9=18,c的前面需要填充1个字节,从第19位开始占用2个字节空间

第一条规则算下来结构体占用大小=16+2+2=20

接下来看第二条规则

通过上面分析结构体最大成员变量大小为16,编译器默认对齐系数为8,取两者最小值8的最小整数倍,因本身结构体当前大小为20,所以最后结构体大小=3*8=24

通过demo1和demo2分析对比,结构体成员变量的顺序会影响整个结构体的占用内存大小

空结构体字段对齐

Go语言中空结构体的大小为0,如果一个结构体中包含空结构体类型的字段时,通常是不需要进行内存对齐的,举个例子:

1
2
3
4
5
6
7
8
type demo1 struct {
 a struct{}
 b int32
}

func main()  {
 fmt.Println(unsafe.Sizeof(demo1{}))
}

运行结果:

1
4

从运行结果可知结构体demo1占用的内存与字段b占用内存大小相同,所以字段a是没有占用内存的,但是空结构体有一个特例,那就是当 struct{} 作为结构体最后一个字段时,需要内存对齐。因为如果有指针指向该字段, 返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放),所以当struct{}作为结构体成员中最后一个字段时,要填充额外的内存保证安全。

1
2
3
4
5
6
7
8
type demo2 struct {
 a int32
 b struct{}
}

func main()  {
 fmt.Println(unsafe.Sizeof(demo2{}))
}

运行结果:

1
8

考虑内存对齐的设计

sync.WaitGroup分析sync.waitgroup的源码时,使用state1来存储状态:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// A WaitGroup must not be copied after first use.
type WaitGroup struct {
 noCopy noCopy

 // 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
 // 64-bit atomic operations require 64-bit alignment, but 32-bit
 // compilers do not ensure it. So we allocate 12 bytes and then use
 // the aligned 8 bytes in them as state, and the other 4 as storage
 // for the sema.
 state1 [3]uint32
}

state1这里总共被分配了12个字节,这里被设计了三种状态:

  • 其中对齐的8个字节作为状态,高32位为计数的数量,低32位为等待的goroutine数量
  • 其中的4个字节作为信号量存储

提供了(wg *WaitGroup) state() (statep*uint64, semap *uint32)帮助我们从state1字段中取出他的状态和信号量,为什么要这样设计呢?

因为64位原子操作需要64位对齐,但是32位编译器不能保证这一点,所以为了保证waitGroup在32位平台上使用的话,就必须保证在任何时候,64位操作不会报错。所以也就不能分成两个字段来写,考虑到字段顺序不同、平台不同,内存对齐也就不同。因此这里采用动态识别当前我们操作的64位数到底是不是在8字节对齐的位置上面,我们来分析一下state方法:

1
2
3
4
5
6
7
8
// state returns pointers to the state and sema fields stored within wg.state1.
func (wg *WaitGroup) state() (statep*uint64, semap *uint32) {
 if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
  return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
 } else {
  return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
 }
}

当数组的首地址是处于一个8字节对齐的位置上时,那么就将这个数组的前8个字节作为64位值使用表示状态,后4个字节作为32位值表示信号量(semaphore)。同理如果首地址没有处于8字节对齐的位置上时,那么就将前4个字节作为semaphore,后8个字节作为64位数值。画个图表示一下:

参考

详解内存对齐

Go 内存对齐的那些事儿

go语言中的内存对齐