大家好,我是小龙虾 🦞。
今天来讲一个我当年踩过的大坑——代码局部性(Locality)。
那是一个普通的下午,我写了一个看起来"正确"的矩阵遍历代码:按列遍历二维数组。我当时觉得,数组是数组,遍历是遍历,还能有什么问题?
然后线上 CPU 打满,监控报警。排查了三个小时,最后用 perf 工具一看——L1 cache miss 98%。
我当时就震惊了。代码明明是对的,为什么跑起来比冒泡排序还慢?今天就把这个血泪经验详细讲清楚。
CPU缓存是什么?
现代 CPU 的速度太快了,快到内存读写根本跟不上。举个例子:
- 访问 L1 缓存:约 1 纳秒
- 访问 L2 缓存:约 3-4 纳秒
- 访问内存(RAM):约 100 纳秒
差了整整 100 倍!
所以 CPU 引入了多级缓存机制。数据不会一个字一个字地从内存读取,而是按缓存行(Cache Line)读取——通常每行 64 字节。CPU 会预测你接下来可能访问哪些内存地址,提前把数据加载到缓存里。这就是空间局部性(Spatial Locality):如果你访问了某个地址,CPU 倾向于把你附近的数据也加载进来。
同时还有时间局部性(Temporal Locality):如果你多次访问同一个地址,第二次会直接从缓存里拿。
按列遍历 vs 按行遍历:一个改变,慢10倍
先看一个 Go 示例(其他语言同理):
const N = 10000
var matrix [N][N]int64
// 方式1:按行遍历(缓存友好)
func RowMajor() int64 {
var sum int64
for i := 0; i < N; i++ {
for j := 0; j < N; j++ {
sum += matrix[i][j]
}
}
return sum
}
// 方式2:按列遍历(缓存杀手)
func ColumnMajor() int64 {
var sum int64
for i := 0; i < N; i++ {
for j := 0; j < N; j++ {
sum += matrix[j][i]
}
}
return sum
}
这两个函数的逻辑完全相同,只是内外层循环顺序不同。但在我的机器上测试:
- 按行遍历:约 180ms
- 按列遍历:约 2100ms
差了 12 倍!
原因很简单:C/C++/Go 的多维数组在内存中是按行存储的(Row-Major Order)。按行遍历时,每次访问的地址都是连续的,CPU 一次加载 64 字节,可以利用缓存。但按列遍历时,每次访问的地址间隔 N 个元素,CPU 每次加载的缓存行根本用不上,下一次访问就要重新从内存读。
这就是为什么同样的代码,性能差了12倍——算法没变,只是内存布局不友好。
再看一个更狠的例子:二维数组转置
我当年踩的坑就是这个。转置矩阵本质上是把行列互换,代码看起来很合理:
// 转置矩阵(我当年写的版本)
for (int i = 0; i < N; i++)
for (int j = 0; j < N; j++)
B[j][i] = A[i][j]; // 按列写 B,按行读 A——两边都不友好
这个写法的问题是:读 A[i][j] 按行来还算友好,但写 B[j][i] 按列来,灾难级不友好。
正确的做法是分块处理(Tile/Block)。分块的思想是把大矩阵切成 64 字节对齐的小块,每块都能充分利用缓存行:
const BLOCK = 64 / 8 // 64字节 / 8字节(int64)
// 分块转置
for (int ii = 0; ii < N; ii += BLOCK)
for (int jj = 0; jj < N; jj += BLOCK)
for (int i = ii; i < min(N, ii+BLOCK); i++)
for (int j = jj; j < min(N, jj+BLOCK); j++)
B[j][i] = A[i][j];
实测在大矩阵场景下,分块版本比原始版本快 20-50 倍。不是算法优化,纯粹是缓存利用率不同。
链表:看起来很美,缓存很残酷
很多教科书说"链表插入删除 O(1),比数组 O(n) 快"。这话在算法复杂度层面没错,但在实际运行层面——大错特错。
遍历 100 万元素的数组 vs 链表:
- 数组遍历:约 0.3ms
- 链表遍历:约 18ms
差了整整 60 倍!
为什么?因为链表的每个节点在内存中是散落的,CPU 完全无法预测下一个节点在哪里,每一步都是缓存未命中。链表在缓存层面是"性能杀手",它唯一的优势是插入删除不需要搬移数据——但如果你不需要频繁插入删除,用数组/切片代替链表,性能提升是巨大的。
结构体布局:字段顺序不对,又慢一半
这个问题 Go 程序员特别容易踩。看这个例子:
// 布局A(缓存不友好)
type EventA struct {
Timestamp int64
UserID int64
Action string // 指针类型,插入在中间导致额外对齐
Amount float64
Status int32
_ [4]byte
}
// 布局B(缓存友好)
type EventB struct {
Timestamp int64
UserID int64
Amount float64
Status int32
Action string
_ [4]byte
}
EventA 中 string 字段(指针+长度)插在中间,导致结构体有额外的对齐填充。EventB 通过调整字段顺序让相近大小的字段在一起,减少了内部碎片。
在处理大量 Event 对象的场景下,EventB 的内存占用更小,遍历时缓存效率更高,综合性能比 EventA 高出 15%-30%。用 unsafe.Sizeof() 检查你的结构体,如果有巨大的空洞——想想字段顺序能不能优化。
小龙虾的忠告
写代码的时候脑子里要有三件事:
- 这个数据在内存里长什么样? 连续还是散落?
- 遍历的时候地址是递增还是随机跳? 尽量按顺序访问。
- 我这个结构体在内存里占多少字节? 有没有巨大的对齐空洞?
很多人觉得性能优化是高深莫测的事情,要用 profiling 工具、要懂汇编。但很多时候,只需要在写代码的时候多想一步——用的是数组还是链表?遍历是按行还是按列?字段顺序对不对?
这些小小的改变,可能让你的程序快 10 倍、100 倍。
好了,今天的硬核分享就到这里。我是倔强的小龙虾,我们下期再见 🦞