C++ 中的内存对齐——实践篇

简介: > 本文为《C++ 中的内存对齐》系列之下篇,[上篇](https://ata.alibaba-inc.com/articles/243681)介绍内存对齐的理论基础,建议优先食用~### TL;DR- 编译器可能会在结构体中填充字节,以满足所有成员的对齐要求;- 可以通过预处理指令 `#pragma pack` 及 `alignas` 标识符自定义内存对齐;- 对于栈上及静态变量,编

TL;DR

  • 编译器可能会在结构体中填充字节,以满足所有成员的对齐要求;
  • 可以通过预处理指令 #pragma packalignas 标识符自定义内存对齐;
  • 对于栈上及静态变量,编译器保证遵循其类型的对齐要求;
  • 对于堆上构造的对象,只有在 C++17 后才能保证任何情况下动态申请的内存都满足对齐要求。

通过上一篇文章我们已经了解到,访问未对齐的内存轻则导致性能损失,重则引发 CPU 异常,甚至静默地访问错误的地址,导致数据错误。虽然近几年来有些 CPU 已经支持访问未对齐内存且不会带来性能影响,但考虑到程序的可移植性,在实践中还是应当尽量避免访问未对齐内存的行为。

所幸,C++ 的内存对齐机制已经自动为我们屏蔽了这些底层细节,在绝大部分情况下不必担心访问到未对齐的内存。然而在开发过程中,有时依然需要手动控制内存对齐的细节,或是为了追求更高的性能,抑或是源于某些外部硬件或嵌入式系统的特殊要求。因此作为程序员,有必要了解 C++ 究竟默默在背后为我们做了什么,以及在特定情况下我们能做什么来改变其默认的行为。

栈上及静态变量对齐

对齐要求

每一个完整的对象类型都有一个叫做对齐要求(alignment requirement)的属性,也称为对齐值。它是一个 size_t 类型的整数值,表示可以分配此类对象的连续地址之间的字节数。换句话说,对象的起始地址必须是其类型的对齐值的整数倍。有效的对齐值是 2 的非负整数幂。我们可以通过 _Alignofalignof 获得某个类型的对齐值。

然而编译器是如何计算对齐值的呢?这里先介绍几个概念:

  • 基本类型的自身对齐值:基本类型自身所占的空间大小,即 sizeof()
  • 结构体或类的自身对齐值:其所有成员中的最大对齐值
  • 指定对齐值:使用 #pragma pack(n) 时指定的对齐值 n (1,2,4,8,16...)
  • 有效对齐值自身对其值指定对其值中的较小者,实际对齐时取该值

结构体的对齐规则

为了让结构体(或类,下同)满足所有成员的对齐要求,编译器需要在某些成员之后插入填充(Padding)。

基于前述对齐值的概念,结构体的对齐规则如下:

  1. 结构体变量的起始地址能够被其有效对齐值整除;
  2. 每个成员相对于结构体首地址的偏移都能被有效对齐值整除,如不能则在前一个成员后填充字节;
  3. 结构体的总大小为有效对齐值的整数倍,如不能则在最后面填充字节。

例如,对于如下结构体 struct x_

struct x_
{
   
    char a;    // 1 byte
    int b;     // 4 bytes
    short c;   // 2 bytes
    char d;    // 1 byte
} bar[3];

编译器通过 padding 使得该结构体的实际内存布局如下:

struct x_
{
   
    char a;           // 1 byte
    char _pad0[3];    // padding to put 'b' on 4-byte boundary
    int b;            // 4 bytes
    short c;          // 2 bytes
    char d;           // 1 byte
    char _pad1[1];    // padding to make sizeof(x_) multiple of 4
} bar[3];

两种声明得到的 sizeof(x_) 值均为 12 bytes,同时 alignof(x_) 值均为 4 bytes。

自定义内存对齐

前文提到,我们可以使用 #pragma pack (n) 设定一个全局的对齐值上限,该功能经常被用于告诉编译器不要对结构体成员进行对齐。例如当 n = 1 时,编译器不做任何 Padding,结构体中的成员紧密地排列在一起,此时整个结构体的大小等于所有成员大小之和。当你在使用硬件内存映射接口、需要准确控制各个成员所在位置时,很可能会用到它,但这往往以牺牲访存速度作为代价。

然而,一枚硬币有两面。从另一个角度来看,自定义内存对齐有时也能起到提升性能的作用。

alignas 类型说明符是一种可移植的 C++ 标准方法,用于指定变量和自定义类型的对齐方式,可以在定义 class、struct、union 或声明变量时使用。如果遇到多个 alignas 说明符,编译器会选择最严格的那个(最大对齐值)。

struct alignas(16) Bar
{
   
    int i;      // 4 bytes
    int n;      // 4 bytes
    alignas(4) char arr[3];
    short s;    // 2 bytes
};

int main()
{
   
    std::cout << alignof(Bar) << std::endl; // output: 16
}

内存对齐可以使处理器更好地利用 cache,包括减少 cache line 访问,以及避免多核一致性问题引发的 cache miss。具体来说,在多线程程序中,一种常用的优化手段是将需要高频并发访问的数据按 cache line 大小(通常为 64 字节)对齐。一方面,对于小于 64 字节的数据可以做到只触及一个 cache line,减少访存次数;另一方面,相当于独占了整个 cache line,避免其他数据可能修改同一 cache line 导致其他核 cache miss 的开销。

当然,在更多情况下,不需要追求那样极致的性能。你可能只是想让结构的内存布局尽量紧凑一些,但又不至于干涉编译器的默认对齐行为。那么你可以在声明结构时,按照大小的递增/递减顺序排列成员,这样可以最小化需要填充的字节,提高内存利用率。

alignas 的局限

alignas 并不是万能的,我们要清楚它不能做什么。

首先,对数组使用 alignas,对齐的是数组的首地址,而不是每个数组元素。也就是说,下面这个数组并不是每个 int 都占 64 字节。

alignas(64) int array[128];

如果一定要让每个元素都对齐,可以这样实现:

struct alignas(64) S {
    int a; };
S array[10];

其次,编译器不保证拷贝后的数据依然保留原来的对齐属性。例如,memcpy 可以拷贝一个带有 alignas 的结构到任何地址。

同样,你也不能对函数参数指定对齐。当你在堆栈上按值传递具有对齐属性的数据时,其对齐方式由调用过程控制。如果数据对齐在被调用函数中很重要,可以在使用前将参数复制到正确对齐的内存中。

最后,对一个类型指定对齐属性,仅意味着其所有的静态和栈上对象按指定值进行对齐,对于堆上构造的对象则未必。因此,一般的分配器(如 mallocoperator new 等)返回的内存地址可能不满足 alignas 的要求。

堆内存的对齐

申请对齐的内存

malloc 返回的指针与原始数据类型的最大大小对齐,该值由编译器定义,通常在 32 位机器上为 8 字节,在 64 机器上为 16 字节。如果要求更大的对齐值,则需要用到特殊的内存分配函数。

C++11 提供了一个标准函数,但笔者使用 g++ 和 clang 实测均在 C++17 及以后才支持:

void *aligned_alloc(size_t alignment, size_t size);

如果你的编译器暂时还不支持,也可以考虑平台特定的方案。

在 Window 上,可以使用 MSVC 提供的:

void *_aligned_malloc(size_t size, size_t alignment);

在 Linux 上,可以使用 glibc 的:

void *memalign(size_t alignment, size_t size);

或者

int posix_memalign(void **memptr, size_t alignment, size_t size);

使用 new 构造对齐的对象

我们来看这样一段代码:

class alignas(32) Vec3d {
    
    double x, y, z;
};

int main() {
   
    std::cout << "sizeof(Vec3d) is " << sizeof(Vec3d) << '\n';
    std::cout << "alignof(Vec3d) is " << alignof(Vec3d) << '\n';

    auto Vec = Vec3d{
   };
    auto pVec = new Vec3d[10];

    if (reinterpret_cast<uintptr_t>(&Vec) % alignof(Vec3d) == 0)
        std::cout << "Vec is aligned to alignof(Vec3d)!\n";
    else
        std::cout << "Vec is not aligned to alignof(Vec3d)!\n";

    if (reinterpret_cast<uintptr_t>(pVec) % alignof(Vec3d) == 0)
        std::cout << "pVec is aligned to alignof(Vec3d)!\n";
    else
        std::cout << "pVec is not aligned to alignof(Vec3d)!\n";

    delete[] pVec;
}

在 C++11/14 下,结果为:

sizeof(Vec3d) is 32
alignof(Vec3d) is 32
Vec is aligned to alignof(Vec3d)!
pVec is not aligned to alignof(Vec3d)!

在 C++17 下,结果为:

sizeof(Vec3d) is 32
alignof(Vec3d) is 32
Vec is aligned to alignof(Vec3d)!
pVec is aligned to alignof(Vec3d)!

在两个结果中,栈上对象的地址均为 32 字节对齐,符合预期。然而,对于堆上对象,C++11/14 不保证其内存地址一定遵循类型的对齐要求。

但在 C++17 中,新标准保证了 new 出来的那个对象也是对齐的。这是如何做到的呢?

原来,C++17 增加了 operator new 的重载函数,带有一个新的 std::align_val_t 类型的入参:

void* operator new(std::size_t count, std::align_val_t al);

那么假设现在有如下两条语句,编译器如何决定选择哪个重载函数呢?

auto p = new int{
   };
auto pVec = new Vec3{
   };

如前所述,编译器默认采用 16 字节对齐(64 位机器)。在 C++17 中,我们可以通过新增的预定义宏来查看默认值:

__STDCPP_DEFAULT_NEW_ALIGNMENT__

当你申请内存的对齐要求大于此默认值时,编译器就会使用带有对齐参数的 operator new 函数,其内部实际调用的正是前文介绍的 aligned_alloc 之类的内存分配函数。

上面举的例子都是按类型的 alignas 标识符所指定的值进行对齐。事实上,你甚至还可以在调用 new 的时候动态指定对齐,只不过在释放内存时需要手动析构,然后显式调用对应的 operator delete 函数。

auto pAlignedType = new (std::align_val_t{
   32}) MyType;
pAlignedType->~MyType();
::operator delete(pAlignedType, std::align_val_t{
   32});

将一切交给编译器吧

动态内存分配并不仅限于上述手动调用 mallocnew 的情况,很多时候还存在于我们看不到的地方,例如 STL、库函数等。能否确保在所有这些情况下,内存分配都能按照我们指定的要求自动对齐呢?

答案是,只要在 C++17 下使用足够高版本的编译器(如 GCC>=7, clang>=5, MSVC>=19.12),那么所有内存对齐相关的细节都可以放心地交给编译器。

举个例子,考虑这段使用了前文定义的 Vec3d(32 字节对齐)的代码, vector 内部申请的内存也能确保是正确对齐的。

std::vector<Vec3d> vec;
vec.push_back({
   });
vec.push_back({
   });
vec.push_back({
   });
assert(reinterpret_cast<uintptr_t>(vec.data()) % alignof(Vec3d) == 0);

此外,现在你也可以直接使用 vector 来存放 SIMD 类型而不必手动分配对齐的内存:

std::vector<__m256> vec(10);
vec.push_back(_mm256_set_ps(0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f));
assert(reinterpret_cast<uintptr_t>(vec.data()) % alignof(__m256) == 0);

是时候拥抱 C++17 了!

目录
相关文章
|
4月前
|
传感器 数据采集 监控
Python生成器与迭代器:从内存优化到协程调度的深度实践
简介:本文深入解析Python迭代器与生成器的原理及应用,涵盖内存优化技巧、底层协议实现、生成器通信机制及异步编程场景。通过实例讲解如何高效处理大文件、构建数据流水线,并对比不同迭代方式的性能特点,助你编写低内存、高效率的Python代码。
241 0
|
5月前
|
安全 C语言 C++
比较C++的内存分配与管理方式new/delete与C语言中的malloc/realloc/calloc/free。
在实用性方面,C++的内存管理方式提供了面向对象的特性,它是处理构造和析构、需要类型安全和异常处理的首选方案。而C语言的内存管理函数适用于简单的内存分配,例如分配原始内存块或复杂性较低的数据结构,没有构造和析构的要求。当从C迁移到C++,或在C++中使用C代码时,了解两种内存管理方式的差异非常重要。
227 26
|
5月前
|
SQL 缓存 安全
深度理解 Java 内存模型:从并发基石到实践应用
本文深入解析 Java 内存模型(JMM),涵盖其在并发编程中的核心作用与实践应用。内容包括 JMM 解决的可见性、原子性和有序性问题,线程与内存的交互机制,volatile、synchronized 和 happens-before 等关键机制的使用,以及在单例模式、线程通信等场景中的实战案例。同时,还介绍了常见并发 Bug 的排查与解决方案,帮助开发者写出高效、线程安全的 Java 程序。
294 0
|
10月前
|
存储 程序员 编译器
玩转C++内存管理:从新手到高手的必备指南
C++中的内存管理是编写高效、可靠程序的关键所在。C++不仅继承了C语言的内存管理方式,还增加了面向对象的内存分配机制,使得内存管理既有灵活性,也更加复杂。学习内存管理不仅有助于提升程序效率,还有助于理解计算机的工作原理和资源分配策略。
|
4月前
|
边缘计算 算法 Java
Java 绿色计算与性能优化:从内存管理到能耗降低的全方位优化策略与实践技巧
本文探讨了Java绿色计算与性能优化的技术方案和应用实例。文章从JVM调优(包括垃圾回收器选择、内存管理和并发优化)、代码优化(数据结构选择、对象创建和I/O操作优化)等方面提出优化策略,并结合电商平台、社交平台和智能工厂的实际案例,展示了通过Java新特性提升性能、降低能耗的显著效果。最终指出,综合运用这些优化方法不仅能提高系统性能,还能实现绿色计算目标,为企业节省成本并符合环保要求。
196 0
|
7月前
|
监控 算法 数据处理
基于 C++ 的 KD 树算法在监控局域网屏幕中的理论剖析与工程实践研究
本文探讨了KD树在局域网屏幕监控中的应用,通过C++实现其构建与查询功能,显著提升多维数据处理效率。KD树作为一种二叉空间划分结构,适用于屏幕图像特征匹配、异常画面检测及数据压缩传输优化等场景。相比传统方法,基于KD树的方案检索效率提升2-3个数量级,但高维数据退化和动态更新等问题仍需进一步研究。未来可通过融合其他数据结构、引入深度学习及开发增量式更新算法等方式优化性能。
207 17
|
6月前
|
C语言 C++
c与c++的内存管理
再比如还有这样的分组: 这种分组是最正确的给出内存四个分区名字:栈区、堆区、全局区(俗话也叫静态变量区)、代码区(也叫代码段)(代码段又分很多种,比如常量区)当然也会看到别的定义如:两者都正确,记那个都选,我选择的是第一个。再比如还有这样的分组: 这种分组是最正确的答案分别是 C C C A A A A A D A B。
130 1
|
11月前
|
存储 C语言 C++
【C++数据结构——栈与队列】顺序栈的基本运算(头歌实践教学平台习题)【合集】
本关任务:编写一个程序实现顺序栈的基本运算。开始你的任务吧,祝你成功!​ 相关知识 初始化栈 销毁栈 判断栈是否为空 进栈 出栈 取栈顶元素 1.初始化栈 概念:初始化栈是为栈的使用做准备,包括分配内存空间(如果是动态分配)和设置栈的初始状态。栈有顺序栈和链式栈两种常见形式。对于顺序栈,通常需要定义一个数组来存储栈元素,并设置一个变量来记录栈顶位置;对于链式栈,需要定义节点结构,包含数据域和指针域,同时初始化栈顶指针。 示例(顺序栈): 以下是一个简单的顺序栈初始化示例,假设用C语言实现,栈中存储
562 77
|
9月前
|
存储 Linux C语言
C++/C的内存管理
本文主要讲解C++/C中的程序区域划分与内存管理方式。首先介绍程序区域,包括栈(存储局部变量等,向下增长)、堆(动态内存分配,向上分配)、数据段(存储静态和全局变量)及代码段(存放可执行代码)。接着探讨C++内存管理,new/delete操作符相比C语言的malloc/free更强大,支持对象构造与析构。还深入解析了new/delete的实现原理、定位new表达式以及二者与malloc/free的区别。最后附上一句鸡汤激励大家行动缓解焦虑。
|
11月前
|
存储 C++
【C++数据结构——树】哈夫曼树(头歌实践教学平台习题) 【合集】
【数据结构——树】哈夫曼树(头歌实践教学平台习题)【合集】目录 任务描述 相关知识 测试说明 我的通关代码: 测试结果:任务描述 本关任务:编写一个程序构建哈夫曼树和生成哈夫曼编码。 相关知识 为了完成本关任务,你需要掌握: 1.如何构建哈夫曼树, 2.如何生成哈夫曼编码。 测试说明 平台会对你编写的代码进行测试: 测试输入: 1192677541518462450242195190181174157138124123 (用户分别输入所列单词的频度) 预
452 14
【C++数据结构——树】哈夫曼树(头歌实践教学平台习题) 【合集】