3、内存操作数:内存寻址模式、对齐与未对齐访问、缓存行与局部性原理
内存操作数,说白了就是你的数据在内存里怎么放、怎么取。很多同学写代码时只盯着算法复杂度,觉得 O(n) 和 O(log n) 才是性能关键。但我做了这么多年性能优化,可以负责任地告诉你:内存访问模式往往才是真正的性能瓶颈。CPU 其实经常在等内存,一等就是几十上百个周期。
3.1 内存寻址模式:CPU 怎么找到数据
CPU 不能直接操作内存地址里的值,它得先把数据加载到寄存器里。那怎么找到这个数据呢?靠的就是寻址模式。x86-64 架构下,常用的寻址模式就几种,我列个表给你看:
| 寻址模式 | 格式示例 | 有效地址计算 | 典型场景 |
|---|---|---|---|
| 立即数寻址 | mov eax, 42 |
操作数在指令中 | 常量赋值 |
| 寄存器寻址 | mov eax, ebx |
操作数在寄存器 | 寄存器间传值 |
| 直接寻址 | mov eax, [0x8048000] |
地址 = 立即数 | 访问全局变量 |
| 基址寻址 | mov eax, [ebx] |
地址 = 基址寄存器 | 指针解引用 |
| 基址+偏移量 | mov eax, [ebx+8] |
地址 = 基址 + 偏移 | 访问结构体成员 |
| 基址+变址 | mov eax, [ebx+ecx*4] |
地址 = 基址 + 变址*比例 | 遍历数组 |
| 基址+变址+偏移 | mov eax, [ebx+ecx*4+16] |
地址 = 基址 + 变址*比例 + 偏移 | 二维数组访问 |
你可能会问:「这么多模式,我记它干嘛?」嗯,这里有个关键点:复杂的寻址模式虽然灵活,但可能会增加指令解码的延迟。我个人习惯是,在热点路径上尽量用简单的基址+偏移量,少用三层以上的组合寻址。
p++)比每次重新计算 base + index * size 更高效。因为指针递增只需要一次加法,而索引计算需要乘法和加法两步。
3.2 对齐与未对齐访问:一个字节的代价
内存对齐,这个概念很多人在学校就听过。但真正理解它为什么影响性能的人,不多。
现代 CPU 从内存读数据,不是一次读一个字节,而是按「字」来读。64 位 CPU 一次读 8 字节。如果数据地址是 8 的倍数,那就是对齐访问,CPU 一次内存读取就能搞定。如果地址不是 8 的倍数,那就是未对齐访问,CPU 可能需要读两次内存,再把结果拼起来。
我举个例子:
// 对齐访问
int32_t arr[100]; // arr 的地址通常是 4 字节对齐
int x = arr[10]; // 一次内存读取
// 未对齐访问
char buffer[100];
int* p = (int*)(buffer + 1); // 地址不是 4 的倍数
int y = *p; // 可能触发两次内存读取
我曾经在一个网络协议栈项目里遇到过这个问题。解析网络包时,协议头里的字段很多是未对齐的(因为网络协议是字节流)。直接用指针强转去读,性能直接掉了 30%。后来我改成用 memcpy 逐字段拷贝到对齐的结构体里,性能就回来了。
那对齐到底有什么规则?我整理了一个速查表:
| 数据类型 | 对齐要求 | 说明 |
|---|---|---|
char |
1 字节 | 任意地址 |
short |
2 字节 | 地址必须是 2 的倍数 |
int / float |
4 字节 | 地址必须是 4 的倍数 |
double / long long |
8 字节 | 地址必须是 8 的倍数 |
struct |
最大成员的对齐 | 编译器会自动填充 |
double 放前面,char 放最后。
3.3 缓存行与局部性原理:性能的隐形杀手
好了,前面说的都是单次内存访问。但真正决定性能的,是缓存。CPU 和内存之间隔着三级缓存:L1、L2、L3。缓存的最小单位不是字节,而是缓存行,通常是 64 字节。
这意味着什么?你读一个 4 字节的 int,CPU 会把包含这个 int 的连续 64 字节都加载到缓存里。这就是空间局部性的基础。
我画了一张图,帮你理解缓存行和局部性的关系:
你想想看,如果你遍历一个数组,按顺序访问每个元素,那 CPU 每次加载一个缓存行,就能命中后续的 15 个元素(假设每个元素 4 字节)。这就是空间局部性。反过来,如果你跳着访问,比如每隔 64 字节取一个 int,那每个缓存行只用一个元素,剩下的 60 字节全浪费了。
我遇到过最典型的案例,是一个图像处理算法。原始代码是按列遍历像素:
// 坏:按列遍历,空间局部性差
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
pixel = image[y * width + x]; // 行优先存储,按列访问
}
}
改成按行遍历后,性能提升了 4 倍:
// 好:按行遍历,空间局部性好
for (int x = 0; x < width; x++) {
for (int y = 0; y < height; y++) {
pixel = image[y * width + x]; // 连续内存访问
}
}
3.4 伪共享:多线程的隐形陷阱
说到缓存行,就不得不提伪共享。这是多线程编程里最容易踩的坑之一。
假设两个线程各自操作一个 int 变量,这两个变量恰好落在同一个缓存行里。线程 A 修改变量 a,会导致整个缓存行失效。线程 B 想读变量 b,发现缓存行失效了,只能重新从内存加载。明明两个线程操作的是不同的变量,却因为共享同一个缓存行而互相拖累。
我整理了一个对比表:
| 场景 | 内存布局 | 性能表现 |
|---|---|---|
| 无伪共享 | 变量 a 和 b 在不同缓存行 | 两个线程独立运行,互不干扰 |
| 有伪共享 | 变量 a 和 b 在同一缓存行 | 缓存行频繁失效,性能下降 5-10 倍 |
解决办法也很简单:用 alignas(64) 或填充字节,让每个变量独占一个缓存行。
// 避免伪共享:每个变量独占缓存行
struct alignas(64) Counter {
int value;
char padding[60]; // 填充到 64 字节
};
Counter counter_a;
Counter counter_b;
3.5 总结:内存操作数的优化清单
好了,这一章的内容不少。我帮你梳理一个优化清单,以后写代码时可以对照着检查:
- 寻址模式:热点路径用简单寻址,避免多层组合
- 内存对齐:结构体成员按大小降序排列,减少填充
- 缓存行利用:优先顺序访问,避免跳跃访问
- 伪共享:多线程热点变量用缓存行对齐或填充
- 局部性:数据结构和访问模式要匹配(行优先 vs 列优先)
记住一句话:内存访问模式决定了你的程序能跑多快,算法复杂度决定了它能处理多大。两者缺一不可。