内存对齐详解 (C++代码)

我每天都有读一下面经的习惯,从里面抽一些我不太懂的内容使用 dfs 的思想去探索,感谢有了GPT4,探索之路变得有趣和高效许多。

今天这个 Topic 如下,是腾讯日常实习面经中的内容

编译内存相关:

  1. C++内存管理,分了多少段,在堆上和在栈上的区别,为什么要区分堆和栈,new和malloc的区别,
  2. 内存对齐,为什么会出现内存对齐问题,程序员为什么要关注内存对齐问题

内存对齐是指数据在内存中的存储方式,要求数据存储在特定边界的倍数上。例如,4 字节的数据类型应该存储在 4 的倍数的地址上。内存对齐主要出于以下原因:

  1. 性能优化:对齐内存可以减少访问内存所需的 CPU 周期数。现代计算机硬件对于对齐的内存访问通常更加高效。不对齐的内存访问可能需要多次访问来读取或写入一个变量,而对齐的内存只需要一次访问。这里涉及缓存行的知识,后面相对提了提

  2. 硬件要求:某些硬件平台要求数据访问必须对齐。否则,这可能导致硬件异常、程序崩溃或其他未定义行为。

程序员需要关注内存对齐问题,因为它可能影响程序的性能和稳定性。以下是关于内存对齐的详细讨论:

  • 对齐边界:对齐边界通常是数据类型大小的倍数。例如,2 字节的数据类型(如 short)应该对齐到 2 字节边界,4 字节的数据类型(如 int)应该对齐到 4 字节边界,依此类推。

  • 结构体对齐:结构体中的成员变量可能会导致内存对齐问题。编译器通常会自动对齐结构体成员,但这可能导致结构体大小增加,因为编译器会在成员之间插入填充字节。了解如何手动调整结构体成员顺序和对齐,以减少填充和提高性能,对程序员来说很有帮助。后面我们通过代码展示结构体中的内存对齐

  • 编译器对齐:大多数编译器都提供了对齐设置的选项,可以在编译时指定对齐要求。例如,在 GCC 编译器中,可以使用 __attribute__((aligned(x)))/std::alignas(x) 指定对齐边界。后续我们会讲解这些关键词

  • 内存分配对齐:动态分配内存时,需要确保分配的内存满足对齐要求。许多内存分配函数,如 C++ 中的 new 或 C 中的 malloc,通常会自动对齐内存。但在某些情况下,需要手动处理对齐问题,例如使用特殊的内存分配函数。

总之,内存对齐对于程序性能和稳定性很重要。了解内存对齐原理和如何处理内存对齐问题,有助于编写高效、稳定的程序。

Test1: 结构体对齐

当我们说 "4 字节的数据类型应该存储在 4 的倍数的地址上" 时,我们是指数据在内存中的存储位置应该是 4 的整数倍。这是为了确保更高效的内存访问和满足某些硬件平台的要求。

假设我们有一个 4 字节的整数类型(如 int),它的内存地址应该是 4 的倍数,比如 0x1000、0x1004、0x1008 等。

#include <bits/stdc++.h>
struct MyStruct{
    char a;
    int b;
    char c;
};

int main(){
    MyStruct my_struct;
    uintptr_t a_address = reinterpret_cast<uintptr_t> (&my_struct.a);
    uintptr_t b_address = reinterpret_cast<uintptr_t> (&my_struct.b);
    uintptr_t c_address = reinterpret_cast<uintptr_t> (&my_struct.c);
    std::cout << "Address of a: " << std::hex << std::showbase << a_address << std::endl;
    std::cout << "Address of b: " << std::hex << std::showbase << b_address << std::endl;
    std::cout << "Address of c: " << std::hex << std::showbase << c_address << std::endl;


    return 0;
}
Address of a: 0x7ff7b762b530
Address of b: 0x7ff7b762b534
Address of c: 0x7ff7b762b538

在这个例子中,我们定义了一个名为 MyStruct 的结构体,包含一个 char 类型的成员 a(1 字节),一个 int 类型的成员 b(4 字节),和另一个 char 类型的成员 c(1 字节)。

当我们创建一个 MyStruct 类型的实例并输出每个成员的地址时,你可能会注意到,尽管 ac 的地址可能不是 4 的倍数,但 b 的地址通常是 4 的倍数(按理来说,char 之后的 b 地址应该不在 4 的倍数上)。这是因为编译器会自动处理内存对齐,以确保 4 字节的 int 类型成员 b 存储在一个 4 的倍数的地址上,从而提高内存访问效率。这个过程可能涉及到在成员之间插入填充字节。

Q: 那存储在4的倍数的地址上有什么好处呢?

存储在 4 的倍数的地址上对于 4 字节的数据类型(如 int)具有好处,主要是因为对齐内存访问可以提高处理器性能。以下是更详细的说明:

  1. 处理器访问内存时,通常会一次性加载多个字节(称为一个“字”或“字宽”),例如,一次加载 4 个字节。处理器的内存总线宽度通常与其字宽相匹配。如果数据已经对齐,处理器只需要一次内存访问就可以将整个数据值加载到寄存器中。

    举例说明:假设处理器的字宽为 4 字节(32 位),并且有一个 4 字节的整数存储在地址 0x1004(4 的倍数)。处理器可以一次访问将整个整数加载到寄存器中。因为内存对齐,处理器不需要执行额外的内存访问。

  2. 如果数据没有对齐,处理器可能需要执行多次内存访问才能获取完整的数据值。这会导致额外的性能开销。

    举例说明:假设处理器的字宽为 4 字节(32 位),但一个 4 字节的整数存储在地址 0x1005(不是 4 的倍数)。这种情况下,处理器需要两次内存访问才能获取整个整数值。首先,处理器需要从地址 0x1004 加载 4 个字节,然后从地址 0x1008 加载另外 4 个字节。接下来,处理器需要将这两部分组合成一个完整的整数值。这样,处理器需要执行额外的内存访问和数据组合操作,导致性能降低。

因此,将数据存储在其大小的倍数的地址上(如将 4 字节的数据类型存储在 4 的倍数的地址上)有助于提高处理器性能,因为处理器可以更有效地访问内存。对于现代处理器和硬件平台来说,这种性能优化是非常重要的,因为内存访问延迟通常是处理器性能的关键瓶颈。

Q: 那为什么我们不能从 0x1005 开始读取内存呢,这样的话我们同样可以 一次性读完呢?

首先,我们有一个前置知识,缓存行(可以参考“伪共享问题”的理解)。处理器通常一次访问一个固定大小的内存块,称为“缓存行”或“缓存块”。处理器从内存中读取数据时,会将整个缓存行加载到缓存中。然后,处理器可以在缓存中访问所需的数据。

首先,要理解缓存行的概念与数据对齐是两个独立的概念。缓存行是处理器缓存的组织方式,通常包含连续的多个字节。缓存行大小(例如 64 字节)是处理器设计的一个固定参数,它主要是为了优化内存访问性能。

而数据对齐是编程时需要考虑的概念,指的是将数据放置在与其大小相匹配的地址。数据对齐有助于提高处理器访问内存的性能。在某些处理器上,对齐访问要比非对齐访问快得多。

现在回到你的问题。缓存行本身并不关心数据的对齐情况。实际上,缓存行可以从任何地址开始,包括非对齐的地址。然而,处理器在设计时,通常会使缓存行的起始地址与缓存行大小对齐,以优化内存访问性能。因此,缓存行通常从对齐的地址开始,如 0x1000、0x1040、0x1080 等。

如果缓存行从非对齐的地址开始,如 0x1005,当处理器访问对齐的数据时,可能会跨越两个缓存行。这会导致额外的内存访问和数据组合操作,从而降低性能。通过将缓存行与其大小对齐,可以减少这种情况的发生,从而提高处理器访问内存的性能。

总之,虽然缓存行可以从任何地址开始,包括非对齐的地址,但处理器通常会使缓存行与其大小对齐,以优化内存访问性能。数据对齐是一个独立的概念,它有助于提高处理器访问内存的性能,尤其是在某些对齐访问性能更好的处理器上。

所以,最后答案是,实际上这是由处理器决定的,是依据工程经验制定的

Test2: 时间开销对比

我们可以使用字符(char)数组作为基础,并将其转换为 int 指针。这是一个修示例,这个例子使用 C++11 的 chrono 库进行计时,并使用 alignas 关键字(后面有这部分的语法补充解释)来强制数据对齐。请注意,这个例子的性能差异可能因处理器、编译器和系统配置的不同而有所不同。

这段代码使用一个 char 类型的缓冲区来模拟非对齐访问。aligned_data 指针指向缓冲区的开头,而 unaligned_data 指针指向缓冲区的第二个字节(即非对齐的 int 地址)。这样,你应该能看到对齐和非对齐访问之间的性能

#include <ios>
#include <iostream>
#include <chrono>

int main() {
    const int kSize = 100000;
    const int kRepeat = 10000;
	// 对齐
    alignas(4) char buffer[sizeof(int) * (kSize + 1)];

    int* aligned_data = reinterpret_cast<int*>(buffer);
    int* unaligned_data = reinterpret_cast<int*>(buffer + 1);

    // Initialize data
    for (int i = 0; i < kSize; ++i) {
        aligned_data[i] = i;
        unaligned_data[i] = i;
    }
    // 用来展示不同地址对齐
    std::cout << std::hex << std::showbase << \
        "aligned_data address: " << &aligned_data[0] << "\n" \
        "unaligned_data address: " << &unaligned_data[0] << "\n";
	// 回到标准的 std::dec
    std::cout.unsetf(std::ios_base::basefield);
    // Measure time for aligned access
    auto start_aligned = std::chrono::high_resolution_clock::now();
    for (int r = 0; r < kRepeat; ++r) {
        int sum = 0;
        for (int i = 0; i < kSize; ++i) {
            sum += aligned_data[i];
        }
    }
    auto end_aligned = std::chrono::high_resolution_clock::now();

    // Measure time for unaligned access
    auto start_unaligned = std::chrono::high_resolution_clock::now();
    for (int r = 0; r < kRepeat; ++r) {
        int sum = 0;
        for (int i = 0; i < kSize; ++i) {
            sum += unaligned_data[i];
        }
    }
    auto end_unaligned = std::chrono::high_resolution_clock::now();

    auto aligned_duration = std::chrono::duration_cast<std::chrono::microseconds>(end_aligned - start_aligned).count();
    auto unaligned_duration = std::chrono::duration_cast<std::chrono::microseconds>(end_unaligned - start_unaligned).count();

    std::cout << "Time for aligned access: " << aligned_duration << " microseconds" << std::endl;
    std::cout << "Time for unaligned access: " << unaligned_duration << " microseconds" << std::endl;

    return 0;
}
aligned_data address: 0x7ff7bacc8ab4
unaligned_data address: 0x7ff7bacc8ab5
Time for aligned access: 1018942 microseconds
Time for unaligned access: 1032926 microseconds

可以发现,首先我们的地址确实设计成了对齐非对齐两种情况。

其次,我们发现确实有运行效率的差异。但是现代 CPU 设计在处理非对齐访问时进行了很多优化,因此在某些情况下,非对齐访问的性能损失可能不太明显

扩展1: __attribute__((aligned(x)))alignas(x) 的区别和使用?

struct alignas(16) AlignedData {
    int a;
    float b;
    double c;
};
struct __attribute__((aligned(16))) AlignedData {
    int a;
    float b;
    double c;
};

两种代码都是可行的。__attribute__((aligned(x)))alignas(x) 都用于指定数据的对齐要求,但它们之间有一些细微的差别:

  1. __attribute__((aligned(x))) 是 GCC 编译器的特定语法,这意味着它在 GCC 编译器以及部分兼容 GCC 的编译器中可用,例如 Clang。然而,它在其他编译器,如 Microsoft Visual Studio 中可能不受支持。
  2. alignas(x) 是 C++11 标准中引入的语法,它在遵循 C++11 或更高标准的编译器中可用。与 __attribute__((aligned(x))) 不同,alignas(x) 是跨编译器可用的标准语法。

所以,一般情况下我们还是使用 alignas(x) 更好。

扩展2: 具体C++语法解析

对齐要求(Alignment)--- CPP reference

每种对象类型都有一种名为对齐要求(alignment requirement)的属性,它是一个非负整数值(类型为std::size_t,并且始终为2的幂),表示在这种类型的对象可以被分配的连续地址之间的字节数。

  • 类型的对齐要求可以使用alignofstd::alignment_of查询。
  • 指针对齐函数std::align可用于在某个缓冲区中获取适当对齐的指针,std::aligned_storage可用于获取适当对齐的存储空间。

(自C++11起)每种对象类型都对其所有对象施加其对齐要求;可以使用alignas(自C++11起)请求更严格的对齐(即具有更大的对齐要求)。

为了满足类的所有非静态成员的对齐要求,可能会在某些成员之后插入填充位。

具体还有 alignof 使用方法和 sizeof 类似,参考

alignof 是 C++11 引入的一个操作符,用于查询类型或对象的对齐要求。对齐要求表示该类型或对象所需的内存地址的倍数。例如,如果某个类型的对齐要求是 4,则该类型的对象应该存储在 4 的倍数的内存地址上。

alignof 操作符的语法如下:

alignof(Type)

其中 Type 是要查询对齐要求的类型。

alignof 返回一个 std::size_t 类型的值,表示给定类型的对齐要求(以字节为单位)。