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 结构体对齐规则
结构体的对齐,比单个变量复杂一点。我总结一下规则,其实就三条:
- 每个成员按自己的自然对齐存放——编译器会在前面补填充字节
- 结构体整体大小,必须是最大成员对齐值的整数倍
- 结构体起始地址,必须按最大成员对齐
举个例子:
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 知识体系总览
下面这张图,把本章的核心逻辑串起来了:
8.6 实际应用中的建议
说了这么多理论,最后给几条实战建议:
- 写结构体时,手动排好字段顺序——大字段在前,小字段在后,减少填充浪费
- 多线程共享数据,务必缓存行对齐——用
aligned_alloc或编译器属性 - 内存池分配时,按对齐要求返回地址——我习惯在分配器里内置对齐逻辑
- 用
offsetof宏检查偏移——调试时打印出来,一目了然
个人习惯:我在每个结构体定义后面加一行静态断言:static_assert(sizeof(MyStruct) % 64 == 0, "must align to cache line");。这样编译期就能发现问题,不用等到运行时崩溃。
好了,关于内存对齐和填充,就聊到这儿。记住一句话:对齐是性能的基石,填充是隔离的武器。写内存池的时候,这两点一定要刻在脑子里。