2. Cache基本原理:时间局部性与空间局部性,Cache命中与缺失

好,咱们今天聊聊Cache最核心的两个概念——局部性原理。说白了,Cache能工作,全靠程序有这个“毛病”。

2.1 时间局部性:刚用过的,很快还会用

时间局部性,字面意思就是“时间上的局部”。一个数据被访问后,很可能在不久的将来再次被访问。

我举个例子。你写个循环:

for (i = 0; i < 1000000; i++) {
    sum += a[i];
}

变量 sum 在每次循环里都被读和写。这就是典型的时间局部性。如果 sum 不在Cache里,每次都要去内存读,那性能就崩了。

为什么会有时间局部性?

因为程序逻辑本身就有“重复”的特性。循环、递归、频繁调用的函数,都是时间局部性的来源。我个人习惯把时间局部性理解为“热点数据”——那些被反复锤的变量。

核心观点:时间局部性越强,Cache命中率越高。设计Cache时,我们利用这一点,把最近访问过的数据留在Cache里,别急着踢出去。

2.2 空间局部性:用了一个,旁边的也会用

空间局部性更有意思。程序访问了一个地址,接下来很可能访问它附近的地址。

还是那个数组例子:

for (i = 0; i < 1000000; i++) {
    sum += a[i];
}

数组 a 在内存里是连续存放的。你访问 a[0],下一个就是 a[1],再下一个是 a[2]……这就是空间局部性。

为什么会有空间局部性?

因为程序处理的数据结构往往是连续的。数组、结构体、栈帧,都是连续的内存块。指令本身也是顺序执行的——你想想看,CPU取指令,下一条大概率就在当前指令的后面。

我的经验:我在项目中遇到过一种情况,一个结构体里字段很多,但每次只访问其中几个。如果结构体太大,Cache行里装的全是“没用”的邻居数据,空间局部性反而帮倒忙。这时候就要考虑结构体拆分或重新排列字段。

2.3 Cache行与预取

Cache不是按字节管理的,而是按“行”(Cache Line)管理。常见的行大小是64字节。

当你访问一个字节,Cache会把整个64字节都搬进来。这就是利用空间局部性——你访问 a[0],顺便把 a[1]a[15](假设是int数组)都带进来了。后续访问这些元素时,直接命中Cache。

预取(Prefetch) 是硬件自动做的,也可以软件手动触发。我记得有一次调优一个图像处理算法,手动预取把性能提升了30%。

注意:预取不是免费的。预取错了,会把有用的数据踢出去,反而降低命中率。我曾经因为过度预取,导致Cache污染,性能反而下降。所以“预取有风险,使用需谨慎”。

2.4 Cache命中与缺失

这两个概念很简单:

  • Cache命中(Hit):CPU要的数据,就在Cache里。直接拿,快得很。
  • Cache缺失(Miss):CPU要的数据,不在Cache里。得去下一级存储(L2、L3或内存)取,慢很多。

命中率 = 命中次数 / 总访问次数。这个值越高越好。现代CPU的L1 Cache命中率通常在95%以上。

缺失的三种类型:

缺失类型 英文 说明
强制缺失 Compulsory Miss 第一次访问某个数据,Cache里肯定没有。也叫“冷缺失”。
容量缺失 Capacity Miss Cache太小,装不下所有需要的数据。有些数据被踢出去了。
冲突缺失 Conflict Miss 多个数据映射到同一个Cache行,互相“打架”。

嗯,这里要注意:强制缺失没法避免,只能通过预取缓解。容量缺失靠增大Cache解决。冲突缺失靠提高关联度(比如从直接映射改成组相联)来缓解。

2.5 一个简单的例子

咱们用代码感受一下局部性的威力:

// 版本1:按行遍历(空间局部性好)
for (int i = 0; i < N; i++) {
    for (int j = 0; j < N; j++) {
        sum += matrix[i][j];
    }
}

// 版本2:按列遍历(空间局部性差)
for (int j = 0; j < N; j++) {
    for (int i = 0; i < N; i++) {
        sum += matrix[i][j];
    }
}

版本1访问的是连续内存,Cache行利用率高。版本2跳着访问,每次都要重新加载Cache行。在我的机器上,N=1024时,版本1比版本2快了一个数量级。

避坑指南:我曾经接手过一个性能优化项目,代码里全是按列遍历的二维数组。改了一行循环顺序,性能直接翻倍。所以写代码时,脑子里要时刻想着“内存布局”和“访问模式”。

2.6 知识体系图

下面这张图总结了Cache基本原理的核心逻辑:

Cache基本原理知识体系 局部性原理 时间局部性:刚用过的还会用 空间局部性:旁边的也会用 → 决定Cache设计策略 Cache行与预取 行大小:常见64字节 预取:硬件/软件提前加载 → 利用空间局部性 命中与缺失 命中:数据在Cache中 缺失:数据不在Cache中 → 命中率决定性能 缺失类型 强制缺失:第一次访问 容量缺失:Cache太小 冲突缺失:映射冲突 局部性 → Cache行 → 命中/缺失 → 性能

这张图把咱们刚才讲的内容串起来了。局部性原理是根基,Cache行和预取是具体实现手段,命中与缺失是最终效果,缺失类型帮我们分析问题根源。

我的建议:刚开始学Cache时,别急着背那些替换算法和写策略。先把局部性原理吃透。你想想看,没有局部性,Cache根本没用。理解了“为什么”,再去学“怎么做”,事半功倍。


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