8、内存对齐与填充:自然对齐、结构体对齐规则、填充(Padding)技术、避免伪共享(False Sharing)

聊到内存分配器,有个绕不开的话题——内存对齐

说实话,我早年刚写C的时候,觉得对齐这事儿挺玄乎的。不就是变量放内存里嘛,有啥好对齐的?直到有一次,我在嵌入式项目里写了一个结构体,用来解析网络协议包。结果数据死活对不上,调试了整整两天。最后发现——结构体里编译器偷偷塞了填充字节,把字段位置给挪了。

嗯,从那以后,我再也不敢小看对齐了。

8.1 什么是自然对齐?

自然对齐,说白了就是:变量的地址,必须能被它自身的大小整除

  • char(1字节)——地址可以是任意值
  • short(2字节)——地址必须是2的倍数
  • int(4字节)——地址必须是4的倍数
  • double(8字节)——地址必须是8的倍数

为什么要有这个规则?因为CPU读内存不是按字节读的,而是按“字”读的。x86上,一个内存访问周期能读4字节或8字节。如果int变量放在地址0x1001,那CPU得读两次才能拼出来——性能直接打对折。

核心原则:对齐是为了让CPU一次搞定,不对齐就得“拆包重组”。

8.2 结构体对齐规则

结构体的对齐,比单个变量复杂一点。我总结一下规则,其实就三条:

  1. 每个成员按自己的自然对齐存放——编译器会在前面补填充字节
  2. 结构体整体大小,必须是最大成员对齐值的整数倍
  3. 结构体起始地址,必须按最大成员对齐

举个例子:

struct Example {
    char   a;   // 1字节,偏移0
    int    b;   // 4字节,偏移4(中间填充3字节)
    short  c;   // 2字节,偏移8
};
// 总大小:10字节 → 对齐到4的倍数 → 12字节

你看,a后面编译器偷偷塞了3个字节的填充。为什么?因为b需要4字节对齐,而a只占了1字节,剩下的3个位置就浪费了。

我在项目中遇到过一种情况:结构体里字段顺序没排好,结果一个本来能塞进64字节缓存行的结构体,硬生生膨胀到了80字节。内存池里一分配,碎片率直接飙升。

小技巧:把大的字段往前放,小的往后放。这样填充最少,结构体最紧凑。

8.3 填充(Padding)技术

填充,听起来像是浪费。但有时候,我们故意加填充

为什么?两个原因:

  • 为了对齐——编译器自动加的,我们管不了
  • 为了隔离——手动加填充,避免多核竞争

手动填充的例子:

struct CacheLineAligned {
    int data;
    char padding[60];  // 手动填充到64字节
};

你可能会问:浪费这么多空间,值得吗?

嗯,这就要说到下一个话题了——伪共享

8.4 避免伪共享(False Sharing)

伪共享,是我在写多线程内存池时踩过最大的坑之一。

简单解释一下:

  • CPU缓存是以“缓存行”为单位的,通常是64字节
  • 两个线程各自操作不同的变量,但这两个变量恰好在同一个缓存行里
  • 线程A改了变量1,导致整个缓存行失效
  • 线程B的变量2也跟着遭殃,被迫重新从内存加载

这就是伪共享——明明没共享数据,却因为缓存行的“物理共享”而互相拖累。

我曾经在项目中遇到过:一个多线程日志系统,每个线程有自己的缓冲区。结果性能死活上不去,CPU利用率高但吞吐量低。最后用perf一看,缓存未命中率高达40%。原因就是两个线程的缓冲区紧挨着,互相踢缓存行。

解决方案?加填充,让每个线程的数据独占一个缓存行:

struct ThreadBuffer {
    char buffer[1024];
    char padding[64];  // 确保下一个结构体不在同一缓存行
};

或者用编译器属性:

struct __attribute__((aligned(64))) ThreadBuffer {
    char buffer[1024];
};

这样每个结构体都从64字节对齐的地址开始,天然不会跨缓存行。

8.5 知识体系总览

下面这张图,把本章的核心逻辑串起来了:

内存对齐与填充 自然对齐 地址 % 大小 == 0 CPU一次读取,性能最优 结构体对齐规则 成员按自然对齐存放 整体大小是最大对齐的倍数 填充(Padding) 编译器自动填充 手动填充隔离数据 伪共享(False Sharing) 多核CPU缓存行竞争 两个线程操作不同变量,但同一缓存行 解决方案:缓存行对齐 + 填充

8.6 实际应用中的建议

说了这么多理论,最后给几条实战建议:

  • 写结构体时,手动排好字段顺序——大字段在前,小字段在后,减少填充浪费
  • 多线程共享数据,务必缓存行对齐——用 aligned_alloc 或编译器属性
  • 内存池分配时,按对齐要求返回地址——我习惯在分配器里内置对齐逻辑
  • offsetof 宏检查偏移——调试时打印出来,一目了然

个人习惯:我在每个结构体定义后面加一行静态断言:static_assert(sizeof(MyStruct) % 64 == 0, "must align to cache line");。这样编译期就能发现问题,不用等到运行时崩溃。

好了,关于内存对齐和填充,就聊到这儿。记住一句话:对齐是性能的基石,填充是隔离的武器。写内存池的时候,这两点一定要刻在脑子里。


公众号:蓝海资料掘金营,微信deep3321