指针与内存对齐到底是什么鬼?( 四 )


Test size is:8a is:1b is:2
其实是8个字节 。为啥呢?就是因为int a要符合基本数据类型的首地址必须是类型字节数的整数倍这条规则,所以编译器会在b与a之间插入2个字节的0,使得a的首字节地址是int的整数倍;变成:
typedef struct test {short b;short invisible_padding; //实际看不见int a;} Test;
反汇编验证下:
t_ptr->a = 1;119c: 48 8b 45 f8 mov -0x8(%rbp),%rax /*拿到符号t_ptr的地址*/11a0: c7 40 04 01 00 00 00 movl $0x1,0x4(%rax) /*执行 = 操作符,给符号a的内存赋值*/t_ptr->b = 2;11a7: 48 8b 45 f8 mov -0x8(%rbp),%rax /*拿到符号t_ptr的地址*/11ab: 66 c7 00 02 00 movw $0x2,(%rax) /*执行 = 操作符,给符号b的内存赋值*/
可以从反汇编看到a的内存地址从偏移地址0x4开始,而b从偏移地址0x2开始,而padding是放在t_ptr的开始位置的,这跟我的猜想有点出入,但是并不破坏规则,因为int a的首字节地址依然变成了4的整数倍 。如下图:

指针与内存对齐到底是什么鬼?

文章插图
反汇编1
那么问题就来了,为什么要填充呢?本质的原因是什么?
从CPU角度看看为什么要对齐
一图胜千言:
指针与内存对齐到底是什么鬼?

文章插图
CPU角度看内存加载问题
解释:
 
  • 内存的访问真的没有程序员想的那么简单,而是分组读取的,也就是总线32位宽,其实不是连续的,而是分成了4组,每组读取1个字节,然后拼成一个双字的数据块;x64就分成8个组;
  • 可以把组看成一个通道,CPU可以一次激活最多4个(32位)或者8个(64位)通道,一次读取可以看成一个transaction;
  • 每个通道一次读取一个字节的数据;
  • 每个通道读取的地址是有规律的,比如1号通道(0,4,8,12,16…)二号通道(1,5,9,13,…)依次类推;
  • 数据读取性能跟所需的transaction数量相关,越少性能越高;
  • 根据以上的事实,内存对齐的定义其实就是——让数据结构的首字节地址始终在通道1上就是对齐的数据,否则,就不是;
  • 符号首地址是n字节对齐的含义是:**符号首字节地址是n字节的倍数 。**比如,下图,int a就是4字节对齐的,第二个int a’是6字节对齐的 。
  • 数据不对齐会比对齐的数据,在访问时,多1次内存的开销 。
 
一图胜千言,上图:
指针与内存对齐到底是什么鬼?

文章插图
对齐的与没有对齐的内存读取差别
所以,内存必须对齐,不然同样的数据结构,没对齐比对齐后的内存要多一次内存的开销 。
不要小看这一次内存访问的开销,因为:
 
  • CPU可以说每时每刻都在以超高并发量访问内存,假如1秒1千次的内存访问,如果都多一次,一秒就是2千次,性能会下降50% 。
  • 根据性能金字塔,内存的访问可是在底层,延迟是很大的,所以在CPU这种高并发的场景下,特别是多核的SMP系统,性能问题就会更加严重 。
关于结构体的三条规则
  1. 结构体(首字节地址)必须是最大成员变量数据类型的整数倍(编译器维护);
  2. 结构体中每个成员变量的首字节地址,必须是成员类型的整数倍,如果不是,则编译器填充实现;
  3. 结构体的总体长度必须是最大成员变量类型长度的整数倍,如果不是,编译器在结构体最后一个字节末尾填充0实现 。
 
其中2.就是基本数据类型的首地址必须是类型字节数的整数倍的推论,或者说是等价的,不需要证明 。
关于1.与3.的证明,需要引入一个推论:如果符号的首地址是n字节对齐的,那么一定是n/2对齐的,也一定是n/4对齐的,依次类推下去 。
【指针与内存对齐到底是什么鬼?】举个例子来说就是:符号a的首地址如果是8字节对齐,那么一定也是4字节对齐,一定也是2字节对齐的 。其实很容易证明:如果a的地址是x,x%8 = 0;那么x = b×8;x%4 = b×4×2 %4=0;所以也是4字节对齐的 。


推荐阅读