阿里面试题分析与延展之:为什么要内存对齐

前言

阿里的面试非常的喜欢问体系结构相关的问题。比如我在秋招阿里云polardb团队终面当中被问到的这个问题:

你知道什么是内存对齐以及为什么要内存对齐么?

相信大家都思考或者看到过这个问题,看似离我们平时写代码很远的细节却能考察出我们对计算机体系结构的了解,这也是为什么在阿里的面试当中会出现这个问题的原因。

举一个例子:

下面是一段C代码,在一台32位的机器上。

//32位系统
#include<stdio.h>
struct{
    int x;
    char y;
}s;

int main()
{
    printf("%d\n",sizeof(s);  
    return 0;
}
  • 问题:上述代码的输出是多少?

    如果你的答案是5个字节那么下面的文章或多或少肯定会给你帮助。

    正确的答案是8个字节。原因就是内存对齐。

现在大家可以思考:为什么要额外的3字节去填充这个结构体?一个原本5字节的结构现在变成8 字节,几乎扩大了 2 倍的存储空间,这样的空间开销是否值得?又是什么样的原因导致这样的设计?

什么是内存对齐

内存对齐就是:编译器将程序中的每个“数据单元”安排在适当的位置上。

简单理解就是按照某种规则将我们定义的结构体成员放在合适的地址偏移位置上存储。

为什么要内存对齐

  • 内存是以字节为单位

    处理器并不会按照一个字节为单位去存取内存。CPU把内存当成是一块一块的,块的大小可以是2,4,8,16字节大小,我们将上述这些存取单位称为内存存取粒度。对于现代计算机硬件来说,内存只能通过特定的对齐地址(比如按照机器字)进行访问。举个例子来说,比如在64位的机器上,不管我们是要读取第0个字节还是要读取第1个字节,在硬件上传输的信号都是一样的。因为它都会把地址0到地址7,这8个字节全部读到CPU,只是当我们是需要读取第0个字节时,丢掉后面7个字节,当我们是需要读取第1个字节,丢掉第1个和后面6个字节。

  • 假如没有使用内存对齐:

    比如有一个整型变量(4 字节),现在有一块内存单元: 地址从 0~7。这个整型变量从 地址为 1 的位置开始占据了 1,2,3,4 这 4 个字节。 现在处理器需要读取这个整型变量。假设处理器是 4 字节 4 字节的读取,所以从 0 开始读读取 0,1,2,3发现并没有读完整这个变量,那么需要再读一次,读取 4,5,6,7。然后对两次读取的结果进行处理,提取出 1,2,3,4 地址的内容。需要两次访问内存,同时通过一些逻辑计算才能得到最终的结果。

    如果进行内存对齐,将这个整型变量放在从0开始的地址存放,那么CPU只需要一次内存读取,并且没有额外的逻辑计算。

  • 内存对齐的好处

    性能原因:减少CPU读取内存的次数,提升程序执行的效率

图片说明
上图是CPU和几种存储之间的存取速度在这30多年的发展对比。内存就是上述的DRAM存储,CPU的速度和内存 的速度之间差距接近1000倍,3个数量级的差距。可见如果能够减少对内存的读取次数可以极大的提升程序的执 行效率。

平台原因(移植原因):有的硬件体系不支持非对齐内存地址的电路系统.当遇到非对齐内存地址的存取时,它将抛 出一个异常,可能导致程序崩溃。

对齐规则

内存对齐主要遵循下面三个原则:

  1. 结构体变量的起始地址能够被其最宽的成员大小整除
  2. 结构体每个成员相对于起始地址的偏移能够被其自身大小整除,如果不能则在前一个成员后面补充字节
  3. 结构体总体大小能够被最宽的成员的大小整除,如不能则在后面补充字节

编译器在编译的时候是可以指定对齐大小的,实际使用的有效对齐其实是取指定大小和自身大小的最小值,一般默认的对齐大小是4。可以通过预编译命令#pragma pack(n)

谁去完成内存对齐的工作

编译器:编译器能够为代码做很多优化,比如使生成的目标程序最小等,但是 C 编译器不会自动进行结构体的变量顺序的优化,因为 C 是一门主要面相操作系统等控制硬件的软件开发,如果 C 擅自进行了结构体中变量顺序的优化有可能导致异常行为。因为很多硬件信号都是通过某一特定位来控制的。

内存对齐总结

通过填充字段padding使得结构体大小与机器字倍数对齐是一种常见的做法。

显然内存对齐是会浪费一些空间的。但是这种空间上得浪费却可以减少存取的时间。这是典型的一种以空间换时间的做法。在内存越来越便宜的今天,这一点点的空间上的浪费就不算什么了。因为访问内存的速度对于处理来说是非常非常的慢, 内存访问速率对于现在 CPU 来说越来越跟不上, 额外的内存访问无疑是浪费 CPU的。

补充知识

其他的对齐规则

我们知道计算机体系结构当中缓存是很重要的一环,CPU不是直接读取内存而是读取缓存:高速缓冲存储器,其作用是为了更好的利用局部性原理,减少CPU访问主存的次数。因为存取内存相对存取缓存是慢很多的,***也可以看做是一种空间换时间的做法。实际读取内存的是缓存。所以内存对齐有的时候还需要考虑缓存更新的读取策略,一些规则如下:

  • 对较大结构体进行*** LINE对齐

    ***与内存交换的最小单位为*** LINE。一个*** LINE大小以64字节为例。当我们的结构体大小没有与64字节对齐时,一个结构体可能就要占用比原本需要更多的*** LINE。

    还有叫做错误共享的问题,大家可以自行google。

  • 只读字段和读写字段隔离对齐

    只读字段和读写字段隔离对齐的目的就是为了尽量保证那些只读字段和读写字段分别集中在***的不同*** LINE中。使得读写字段的淘汰尽量少的影响只读字段。

延伸面试题

  1. C++当中一个空的结构体或者类的对象的大小是多少?

    答案: 空的类或者结构体的大小是1个字节,因为C++当中每个实例在内存中都有一个独一无二的地址,为了达到这个目的,编译器往往会给一个空类隐含的加一个字节,这样空类在实例化后在内存得到了独一无二的地址。

  2. 结构体成员的声明顺序会影响结构体的大小么?比如下面两个结构体A,B他们大小是多少?
    struct A  // sizeof (A) == 12
    {
     char b;
     int a;
     char c;
    };
    struct B  // sizeof (B) == 8
    {
     char b;
     char c;
     int a;
    };

    答案:成员声明顺序会影响结构体大小。