# 第 1 章 存储层次与 CPU 微架构


> 本章解释"代码为什么快或慢"的硬件根因，是后续性能与并发章节的物理地基。

## 学习目标

读懂本章后你应当能够：推导 AMAT 并判断该优化命中率还是缺失惩罚；从地址位段划分推出冲突缺失，说明组相联如何压缩冲突概率；完整推导 MESI 四态转换并解释伪共享的硬件成因；判断代码触发的是结构、数据还是控制冒险，说明三种解法的边界；说清 Spectre/Meltdown 的成因链条与三种缓解手段各堵住哪一环；用 NUMA 的本地/远程延迟差解释绑核的收益来源。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| 虚拟地址到物理地址的翻译、TLB | 见第 8 章 8.2 节；本章只用"地址先经 TLB 翻译"这一结论 |
| 原子指令、锁的语义与 Java 内存模型 | 见第 6 章 6.1 节、第 30 章 30.2 节；本章 1.9 节只讲硬件为什么必须提供屏障 |

## 1.1 存储层次金字塔：数量级从哪里来

性能问题的根源可概括为一句话：**核心的执行速度与内存的供给速度差了两个数量级以上，存储层次的全部设计就是为了填这个洞**。下表是本章必须记住的数量级。

**表 1-1 存储层次的容量、延迟与带宽量级**

| 层级 | 典型容量 | 典型延迟 | 相对寄存器 | 典型带宽口径 | 由谁管理 |
|---|---|---|---|---|---|
| 寄存器 | 数百字节 ~ 数 KB | < 1 ns | 1× | 数百 GB/s | 编译器 |
| L1 数据缓存 | 32 ~ 64 KB / 核 | 约 1 ns（4 ~ 5 周期） | 1× | 约 100 GB/s / 核 | 硬件 |
| L2 缓存 | 256 KB ~ 2 MB / 核 | 约 4 ns（12 ~ 20 周期） | 4× | 约 50 GB/s / 核 | 硬件 |
| L3 缓存 | 8 ~ 64 MB / 路（共享） | 约 15 ns（30 ~ 50 周期） | 15× | 约 20 GB/s / 核 | 硬件 |
| 主存 DRAM | 数十 GB ~ 数 TB | 约 100 ns | 100× | 约 10 GB/s / 通道 | 硬件 + 操作系统（见第 8 章） |
| SSD（NVMe） | 数百 GB ~ 数 TB | 约 100 μs（4 KB 随机读） | 10⁵× | 顺序约 3 ~ 7 GB/s | 操作系统 + 设备 |
| HDD | 数 TB ~ 数十 TB | 约 5 ~ 10 ms（4 KB 随机读） | 10⁷× | 顺序约 150 ~ 250 MB/s | 操作系统 + 设备 |

延迟为什么是**数量级式**跳变？其一，**每一级都是下一级的缓存**：寄存器只装编译器静态分配的活跃变量，L1 要装一个循环的工作集，主存要装整个进程，容量每放大一档就必须容忍更多"来不及"，只能靠更长的查找路径换更大的命中范围。其二，**物理距离**：L1 的路径在核内几毫米量级，主存要出封装走 PCB 到 DIMM 往返，按电信号在铜中约 `0.2 m/ns` 估算，仅"走一趟"就是几十纳秒。其三，**单位容量成本**：SRAM 单元约 6 个晶体管、DRAM 单元 1 管 1 电容、HDD 靠机械寻道，价格差决定了我们只能买很小的高层与很大的低层。

设命中延迟 `T_c`、命中率 `h`、内存延迟 `T_m`，则 `AMAT = T_c + (1 − h) × T_m`。取 `T_c = 1 ns`、`T_m = 100 ns`：`h = 99.0%` 时 `AMAT = 2.00 ns`（命中延迟的 2 倍）；`h = 99.9%` 时 `AMAT = 1.10 ns`；`h = 95.0%` 时 `AMAT = 6.00 ns`。可见缺失率从 1% 降到 0.1% 使 AMAT 改善 45%，而把内存延迟减半（100 → 50 ns）在 `h = 95%` 时只把 6.00 ns 降到 3.50 ns，且工程上几乎不可能。**优化缓存的第一优先级是减少缺失次数，不是降低单次缺失延迟。**

> [!IMPORTANT]
> 面试官问"L1 为什么不做得更大"，标准答案是**访问时间随容量超线性增长**：更大的 SRAM 阵列意味着更长的字线与位线、更复杂的译码与组选择，周期数从 4 周期涨到 6 ~ 8 周期；而 L1 必须在访存指令后的固定周期内交付数据，命中率带来的收益会被流水线停顿频率吃掉。L2/L3 能变大，正是因为它被允许有更长的延迟。

> [!NOTE]
> 面试官会追问的下一层：**L3 是共享还是私有？** 主流服务器是"每核私有 L1/L2 + 片上共享或分片式 LLC"。共享让一个核写入的数据能被另一个核在 L3 命中，这是 MESI 高效工作的前提；而分片式 LLC 使跨切片访问明显慢于本地切片，是 1.10 节 NUMA 效应的片上版本。

## 1.2 局部性原理：64 字节是怎么定下来的

局部性（locality）不是经验规律，而是**程序结构的统计后果**：**时间局部性（temporal locality）**来自循环体、计数器、栈顶局部变量与热对象；**空间局部性（spatial locality）**来自数组顺序遍历、结构体字段连续布局与顺序执行的指令流。硬件正是押注这两条性质，才以**缓存行（cache line）**而非"字"为单位装载数据，x86-64 与主流 ARM 的行大小都是 **64 字节**。

```text
行大小 L 的三方权衡（S = 容量，A = 相联度）：
  行数 = S / (A × L) ⟹ L 越大，行数越少，tag 存储与比较开销越小
  随机访问时单行浪费的期望字节 ≈ L / 2；填充一行需 L / 总线宽度 个突发周期
  L = 32 B：行数多、tag 开销大，一次缺失搬运的数据可能填不满流水线
  L = 64 B：恰等于一次 DDR 突发（8 × 8 B），一次突发正好填满一行
  L = 128 B：空间局部性收益递减，随机访问下无用数据与带宽浪费翻倍
★ 决定 64 的是"一次内存突发的粒度 × 空间局部性收益递减点"，而非"2 的幂"。
```

叠加一条硬约束：**行的填充必须一次完成，中途不可用**。因此行越大，首次访问该行的延迟、以及该行被驱逐时浪费的干净数据都越大。局部性失效的高频场景如下，共同点是访问模式不连续或工作集超容量：

| 场景 | 失效的局部性 | 表现 |
|---|---|---|
| 链表 / 树的指针追逐 | 空间局部性 | 每节点一次缺失，遍历退化为随机访存 |
| 二维数组按列遍历（行主序语言） | 空间局部性 | 每访问一个元素换一行，缺失率接近 100% |
| 工作集 > LLC 的随机访问 | 容量局部性 | 缺失率由工作集与容量之比决定 |
| 多线程写同一行的不同字段 | 空间局部性被"反向利用" | 见 1.5 节伪共享 |

> [!WARNING]
> 常见错误回答："cache line 是 64 字节，因为计算机喜欢 2 的幂。"这是倒果为因。请务必能用"一次 DDR 突发 = 64 B"与"收益递减点"两个理由正面回答。

## 1.3 映射方式与三类缺失

缓存的本质是一张 `(tag → 数据)` 的表，映射方式决定它如何被索引。以 64 B 行、48 位物理地址为例：

```text
组相联缓存（S 组、A 路）的地址位段划分：
 ┌──────────── tag ────────────┬──── set index ────┬──── block offset ────┐
 │       高位（用于比较）        │    log2(S) 位     │   log2(64) = 6 位     │
 └─────────────────────────────┴───────────────────┴──────────────────────┘
 offset：选中行内第几字节，6 位 = 64 B 的由来，且对齐访问只需低 6 位
 index ：选中哪一组；全相联时 index 宽度为 0，直接映射时 A = 1
 tag   ：tag 相等且有效位 V = 1 才算命中

 例：L1d = 32 KB、8 路、64 B 行 ⟹ S = 32768 / (8 × 64) = 64 组，index 6 位，
     tag = 48 − 6 − 6 = 36 位。地址相差 4096 B 的两个块（4096 = 64 × 64）组号
     相同，落到同一 8 路组的第 9 个块就会挤掉前面某个 —— 这就是冲突缺失。
```

**表 1-2 三种映射方式的对比**

| 映射方式 | 查找方式 | 命中判定 | 缺失类型 | 硬件代价 | 典型用途 |
|---|---|---|---|---|---|
| 直接映射（direct-mapped） | index 唯一确定一路 | 比较 1 个 tag | 冲突缺失最严重 | 最小，无需组选择 | 早期 L1、部分 TLB |
| 全相联（fully associative） | 与所有路并行比较 | 比较 A 个 tag | 无冲突缺失 | 最大，A 个比较器 + 编码器 | TLB（32 ~ 64 项）、victim cache |
| 组相联（set-associative） | 先选组，再组内并行比较 | 比较 A 个 tag | 冲突缺失按 A 缩小 | 中等，A 路并行 | 现代 L1/L2/L3（4 ~ 16 路） |

**三类缺失（3C）**——面试官问"缓存为什么没命中"时必须能区分并给出用例：**强制缺失（compulsory miss）**是该地址首次访问，只有预取能减少，冷启动性能差即源于此；**容量缺失（capacity miss）**是工作集大于容量，注意工作集不等于数据集，一个 `O(n log n)` 排序的工作集可能远小于 `n`；**冲突缺失（conflict miss）**是工作集装得下但多个热地址映射到同一组，是唯一"纯由映射策略造成"的浪费，因此被单独挑出来计数。

三者的计数器口径严格互斥，由此得到面试可用的**两次重放对照法**：用全相联缓存重跑一遍，冲突缺失归零；再用无限大缓存重跑，容量缺失归零。

### 1.3.1 写策略与写分配

读缺失只有一个动作（取行），写缺失有两个正交决策：**写直达（write-through）**同时写缓存与下级，**写回（write-back）**只写缓存并置脏位、驱逐时才写下级；**写分配（write-allocate）**缺失时先取行再写，**非写分配**直接写下级。现代 CPU 一律选**写回 + 写分配**，因为写分配把"部分写"变成"整行写"，整行写才可能被合并成一次突发；写回把同一行的多次写压缩成一次下级流量。代价是一致性变复杂：现在存在多个"最新副本"，必须靠 MESI 找出谁是权威。

## 1.4 MESI：多核如何保证缓存一致

这是本章第一号必考题。先把问题定义说准：**缓存一致性问题不是"内存里到底存了什么"，而是"同一地址的所有副本何时对所有核呈现同一个值"**。它只约束**单个地址**的可见性，不约束多个地址之间的顺序——后者是内存一致性模型，由 1.9 节的屏障处理。

**表 1-3 MESI 四态的含义**

| 状态 | 全称 | 与内存是否一致 | 其他核是否有副本 | 读写约束 |
|---|---|---|---|---|
| M | Modified | 否（本核最新，内存陈旧） | 否（独占） | 可读可写 |
| E | Exclusive | 是 | 否（独占） | 可读，写时静默升 M |
| S | Shared | 是 | 可能有 | 可读，写前必须先升级 |
| I | Invalid | 无有效副本 | —— | 读写均缺失 |

一致性靠**总线嗅探（snooping）**实现：所有缓存控制器监听总线/片上互联，看到地址匹配的事务就做出反应。这是分布式仲裁式实现，与用于大规模多路系统的**目录式（directory）**协议相对。

```text
MESI 状态机（事件 → 动作 → 次态）

                     PrRd / 命中（本核独有）
        ┌──────────────────────────────────────────┐
        ▼                                          │
   ┌─────────┐  PrWr → 发 BusRdX，其他核置 I   ┌─────────┐
   │    I    │ ─────────────────────────────▶ │    M    │
   │  无效    │                                │  已修改  │
   └─────────┘                                └─────────┘
       ▲   │ PrRd → 发 BusRd（他核有副本 → S；无副本 → E）
       │   ▼                                        │ PrWr 命中
   ┌─────────┐   PrRd 命中   ┌─────────┐  ── PrWr 命中 ──┐
   │    S    │ ◀─────────── │    E    │  静默升级，不发事务 │
   │  共享    │ ── PrWr ───▶ │  独占    │                  │
   └─────────┘ 发 BusUpgr，   └─────────┘                  │
        ▲       他核置 I           │ 收到他核 BusRd         │
        │                          ▼ 置 S 并直供/写回       │
        └──────────────────────────┘ ◀─────────────────────┘
  收到他核 BusRd 且本核为 M：必须先写回或直供，再置 S
  收到他核 BusRdX / BusUpgr：M、E、S 一律置 I
```

四个必须记住的转换要点：**I → E 只在"本核发起读且无其他核持有副本"时发生**，这个确认过程本身有延迟，是 L3 缺失路径的主要组成部分；**E → M 是静默的**，所以单线程反复写刚读入的变量几乎不产生一致性流量；**S → M 必须先发 BusUpgr**，若目标核为 M 则它必须先写回，这几十纳秒正是"两个线程交替 RMW 同一变量"的性能杀手；**M 态的行被驱逐时才有资格写回内存**，所以内存中的数据可能长期陈旧。

> [!IMPORTANT]
> 面试的标准三段答法：①问题本质是同一地址的多副本可见性；②MESI 用四态加总线嗅探，**写操作前必须先取得独占权（RFO，read-for-ownership）**，从而把并发写串行化；③代价是共享行的写会引发状态迁移与总线流量，因此要避免伪共享。答到"写前必须独占"就算过关，能画出状态机是加分。

> [!NOTE]
> 面试官会追问的下一层：**四态够用吗？** 不够。真实的 **MOESI** 增加 **O（Owned）** 态，表示"本核持有脏数据且其他核可共享副本"，于是 M 态核能在不写回内存的前提下直接直供其他核，把 `M → S（含写回）` 拆成 `M → O` 加共享，显著减少写回流量；**MESIF** 则用 F 态标记"唯一且干净的转发者"。能主动提到这两个变体是强加分。

## 1.5 伪共享：源码看不出来的性能杀手

**伪共享（false sharing）**的定义要精确到硬件事件：**两个或多个核频繁写同一缓存行内的不同变量**。数据上毫无共享，但硬件以行为单位维护一致性，于是每次写都把对方副本置 I，下一次访问变成缺失加 RFO。

```text
  核0: PrWr(a) → 发 BusUpgr → 核1 的 X 置 I → 核0 的 X 变 M
  核1: PrWr(b) → 缺失! 发 BusRdX → 核0 写回并置 I → 核1 的 X 变 M
  核0: PrWr(a) → 缺失! 发 BusRdX → 核1 写回并置 I → 核0 的 X 变 M
  ... 每次写都是缺失 + 总线 RFO + 可能的写回，真实数据流为零

  量级：L1 命中写约 1 ns，跨核 RFO 常在几十纳秒，叠加写回后伪共享可把紧循环
        吞吐降低数倍到十倍以上。★ 读-读共享不触发（S 态可共存），只有写才触发。
```

修复分两个层次，关键是**先按 64 字节对齐、再填充**：

```java
import jdk.internal.vm.annotation.Contended; // JDK 9+ 需 --add-exports

public final class PaddedCounter {
    // 为什么用 @Contended：JVM 会在该字段前后插入 padding，使它和相邻字段不再共享缓存行。
    // 不加注解时两个线程各自 ++ 的两个 long 极可能相邻、落进同一个 64 B 行，
    // 于是循环里每次自增都退化成一次跨核 RFO —— 源码里看不到共享，硬件事件却全是共享。
    @Contended
    volatile long value;
}
```

```c
#include <stddef.h>

// 为什么手动 padding：C 没有语言级的伪共享注解，只能靠对齐把两个热变量推到不同行。
// 必须先 aligned(64) 再填充；只 padding 不对齐，元素仍可能跨在两行之间。
// 代价是每个计数器独占 64 B，只有确认该变量被多核高频写时才值得。
struct padded_counter {
    volatile long value;
    char pad[64 - sizeof(long)];
} __attribute__((aligned(64)));

struct padded_counter counters[NR_CPUS];   // 每核一个，跨核不再共享行
```

面试必须能答的几点：**`@Contended` 默认只对 JDK 内部类生效**，用户类在 JDK 8 需要 `-XX:-RestrictContended`，JDK 9+ 注解移入 `jdk.internal.vm.annotation` 并需要 `--add-exports`；**反向识别**是"线程数增加而吞吐不增反降"，配合 `perf stat` 的 `cache-misses` 或 `perf c2c` 定位争用行；**不要过度 padding**，每计数器 64 B 会放大内存与缓存压力。

> [!CAUTION]
> 把伪共享答成"两个线程访问同一变量导致的数据竞争"会直接把题目答偏。**数据竞争是正确性问题（需要同步原语），伪共享是性能问题（逻辑完全正确，只是慢）**。面试官一定会追问这个区分。

## 1.6 CPU 流水线：五级流水与三类冒险

流水线把指令执行切成若干阶段，让不同指令占据不同阶段并行推进。经典五级划分与对应硬件就是本节骨架：

```text
  IF 取指 / ID 译码取寄存器 / EX 执行算地址 / MEM 访存（仅 load-store）/ WB 写回

  理想流水（稳态 CPI = 1）：
    周期:     1     2     3     4     5     6     7     8
    I1:      IF    ID    EX    MEM   WB
    I2:            IF    ID    EX    MEM   WB
    I3:                  IF    ID    EX    MEM   WB
    I4:                        IF    ID    EX    MEM   WB
    I5:                              IF    ID    EX    MEM   WB
    → 5 条指令 9 个周期完成，相对非流水的 25 周期加速 5 倍 = 流水级数 k

  load-use 冒险（数据冒险的转发边界）：
    I1: LOAD  r1, [r2]      # r1 要到 MEM 末尾才有效
    I2: ADD   r3, r1, r4    # EX 级就需要 r1，但此刻 I1 还在 MEM

    周期:      1     2     3      4      5     6
    I1:       IF    ID    EX    MEM    WB
    I2:             IF    ID    EX←?                 ← 数据还没到
    I2(实际):       IF    ID   STALL  STALL  EX    MEM   WB

  → L1 命中时至少停顿 1 周期；L1 缺失则可达数百周期，交由乱序执行掩盖。
  → 编译调度把第 3 条无关指令插进 I1 与 I2 之间，即可藏起这段停顿。
```

**表 1-4 三类流水线冒险**

| 冒险 | 触发条件 | 硬件解法 | 编译器解法 | 主要残留代价 |
|---|---|---|---|---|
| 结构冒险（structural） | 两条指令同周期争用同一部件（取指与访存抢端口） | 分离 I/D-cache、增加端口、多功能单元 | 调度避免连续访存 | 端口冲突导致的发射停顿 |
| 数据冒险（data） | 后一条指令要用的寄存器尚未写回（RAW 为主） | 转发（forwarding）、停顿、乱序执行 | 插入无关指令、寄存器重命名 | load-use 延迟通常无法完全转发 |
| 控制冒险（control） | 取指时还不知道分支是否跳转 | 分支预测 + 推测执行、延迟槽 | 循环展开、条件移动（cmov）、predication | 误判时清空流水线，约 5 ns 且与深度成正比 |

### 1.6.1 数据冒险的三种解法与取舍

**转发（forwarding/bypass）**把 EX/MEM 级的结果直接旁路回 EX 级输入，不等它写回寄存器堆，成立条件是**生产者的结果已算完**，1.6 节开头的 load-use 时序就是它失效的经典例外。三种解法的适用边界：**转发**要求结果产生级不晚于消费者的需要级，对 load-use 与跨长延迟单元（除法、浮点）失效；**停顿**总能正确工作，但直接抬升 CPI，缺失时可停顿数百周期；**编译调度**要求存在可填充的无关指令且编译器能看到足够指令窗口，受寄存器数量限制填不满，且分支迫使调度保守、运行时缺失概率编译期无法预知。

> [!TIP]
> 面试话术：数据冒险的标准答案不是"加气泡"，而是"**优先转发，转发不了才停顿，停顿尽量由编译器调度和乱序执行掩盖**"。这三句话的层级关系能立刻把回答从背概念拉到机制层。

## 1.7 分支预测：控制冒险的代价与解法

控制冒险的根因是：**取指阶段必须在上一周期分支结果出来之前就知道下一条取哪里**，而在深流水线里分支方向与地址至少要到 EX 级才能确定。

**表 1-5 分支预测器的层次与能力**

| 类型 | 判据 | 能处理的模式 | 失效场景 |
|---|---|---|---|
| 静态预测 | 固定启发式（后向跳转预测跳、前向预测不跳） | 循环的绝大多数迭代 | 所有数据相关的分支 |
| 一位动态预测 | 记录上次方向 | 简单二次循环 | 循环退出时的两次误判 |
| 两位饱和计数器（bimodal） | 需连续两次反向才翻转 | 单层循环 | 规律性强但周期长的模式 |
| 两级自适应（correlating） | 用全局历史寄存器 GHR 索引模式历史表 PHT | 相邻分支方向彼此关联 | 历史长度不足时抓不住长周期模式 |
| 锦标赛（tournament） | 两个预测器并用选择计数器仲裁 | 混合负载 | 选择器本身需要预热 |
| TAGE | 用几何递增的多个历史长度分别索引多张 PHT，取命中历史最长者的预测 | 超长周期模式、复杂相关性 | 首次遇到的模式；表格规模限制历史长度 |

一条核心认识：**预测器实际是在用历史模式识别重复出现的分支序列**；历史越长覆盖的模式越长，但表格越大。TAGE 用几何递增的多个历史长度做多级索引，以近似对数级的存储代价覆盖很长的历史，因此成为当前主流。误判代价 = 分支解析时刻之前已发射的指令数 × CPI_ideal（被推测执行但方向弄错的指令全部冲刷，重新从正确地址取指）：五级流水分支在 EX 级解析，IF/ID 中最多 2 条指令作废，约 2 ~ 3 周期 ≈ 1 ns；深流水（15 ~ 20 级）分支可能很晚解析、取指端已推进十几条，约 15 ~ 20 周期 ≈ 5 ns（按 3 ~ 4 GHz 折算）。

**预测失败的代价与流水线深度成正比，而不是与"分支多少"成正比。** 这既是"深流水 + 高主频"必须配强预测器的原因，也解释了为何分支密集且不可预测的代码（解释器主循环、JSON 解析器）在深流水 CPU 上远差于预期。

> [!WARNING]
> 常见错误回答："预测失败就是多执行了几条指令。"错。失败的指令**不会提交**（乱序核里被 ROB 丢弃），真正的代价是**流水线冲刷与重新取指期间执行单元、取指和访存带宽的空转**，以及已被冲刷指令占用的预测器状态。答"多执行了指令"会被追问"那为什么还要清空流水线"。

## 1.8 乱序执行、推测执行与 Spectre/Meltdown

顺序五级流水的问题是：一次 L1 缺失（约 100 ns ≈ 300 周期）的装载会阻塞后面所有不相关指令。乱序执行（out-of-order execution）的做法是**把程序顺序与执行顺序解耦**：
```text
  取指 ─▶ 译码 ─▶ 重命名 ─▶ 发射队列 ─▶ 各执行单元 ─▶ 提交
                    │         (保留站)                 │
             寄存器重命名表 RAT                    ROB（重排序缓冲）
             (架构寄存器 → 物理寄存器)         按程序顺序排队，顺序提交

  1) 寄存器重命名：WAW/WAR 冒险在重命名阶段即消失。物理寄存器远多于架构寄存器
     （16 个架构整数寄存器对应 200+ 物理寄存器），每次写分配新物理寄存器，旧名
     不再被引用即可释放 ⟹ 只有真正的 RAW 限制并行度，这就是 ILP 的上限来源。
  2) 发射队列/保留站：操作数就绪即可发射，不按程序顺序。
  3) ROB：按程序顺序提交，保证精确异常——异常发生时比它年轻的指令全部丢弃，
     架构状态停在异常点之前。ROB 大小决定乱序窗口与能掩盖的缺失延迟长度。
  推测执行 = 分支预测 + 乱序执行 + ROB：预测后先执行，错了按 ROB 回滚架构状态。
```

### 1.8.1 成因链条

两个侧信道漏洞（公开披露于 2018 年）的本质是**回滚不彻底**：ROB 能让**架构状态**（寄存器、内存）回到正确样子，但**微架构状态**（缓存内容、预测器状态、TLB 状态）不会回滚。把残留状态当侧信道读出来，就绕过了权限检查。

```text
成因链条（四步，缺一不可）

  ① 处理器推测执行了本不该执行的指令
       Meltdown：越权读内核地址。权限检查在提交阶段才生效，而乱序核在数据到达
                 cache 后已把它前递给后续指令。
       Spectre ：通过训练预测器，让受害者（或本进程）推测执行到一段以秘密数据
                 为索引的访存代码。
  ② 被推测执行的指令用秘密值作地址访存：idx = secret_byte * 4096;
  ③ 该访存在缓存中留下痕迹（秘密值决定哪一行被装入）
  ④ 攻击者用 Flush+Reload / Prime+Probe 等缓存计时侧信道测出哪一行在缓存里，
     逐字节还原秘密

  ★ 漏洞不在"分支预测错了"，而在于"缓存状态不可回滚"——
    任何"只回滚架构状态"的硬件都天然存在这个侧信道。
```

**表 1-6 缓解手段各自堵住哪一环**

| 缓解手段 | 原理 | 堵住哪一环 | 代价 |
|---|---|---|---|
| KPTI（内核页表隔离） | 用户态运行时把内核映射从页表移除 | ①：内核地址在用户态根本无法被推测访问 | 系统调用/中断需切换页表并冲刷部分 TLB，有可观性能损失 |
| 微码更新 + IBRS/IBPB | 限制间接分支的推测目标，或在切换时清空预测器状态 | ①：预测器无法被跨权限/跨进程训练 | 间接分支密集负载性能下降明显 |
| `lfence` / `array_index_nospec` | 在越界检查与访存之间插入序列化点 | ②：带秘密值的访存不会在检查确定前发射 | 需逐处修补代码，序列化本身有开销 |
| 缓存隔离（CAT/Intel RDT）、地址随机化、禁用高精度计时器 | 削弱 probe 数组与秘密地址的可分辨性，或抬高计时噪声 | ③④：侧信道分辨率下降、计时不可靠 | 隔离资源降低缓存利用率，计时器精度下降影响正常性能分析 |

> [!IMPORTANT]
> 面试官问"Spectre 和 Meltdown 的区别"，要按**利用路径**答而非漏洞编号：Meltdown 利用"权限检查被推测执行绕过"，受害者是任意用户进程，影响面限于部分实现；Spectre 利用"分支预测被投毒"，通常需要在受害者进程内先运行诱导的 gadget，影响几乎全部推测执行处理器，也更难彻底修。**Spectre 至今没有"关一个开关就修好"的方案，只能靠软件插入屏障**——这是它比 Meltdown 更麻烦的根因。

## 1.9 指令级并行与内存屏障的硬件位置

乱序执行的目标是榨取**指令级并行（instruction-level parallelism, ILP）**，其上限由三件事决定，面试按此顺序展开：**真实数据依赖（RAW）的数量**由程序语义决定，硬件无法消除，是根本上限；**乱序窗口大小**（ROB 条目数、发射队列深度、物理寄存器数量）决定能跨越多少缺失，收益在前几百条指令内累积最快、之后递减；**执行资源宽度**（每周期发射数、ALU/AGU/FP 单元个数）不足时窗口再大也无处发射。典型 x86 核稳态 IPC 可达 3 ~ 4，而存在长依赖链或缺失时会跌到 1 以下——这是微架构与性能优化之间的直接桥梁，见第 3 章 3.2 节的 Amdahl 定律与 3.3 节的吞吐/延迟分析。

### 1.9.1 为什么硬件需要屏障

以上所有机制都为"更快"，但它们会破坏程序员通常默认的假设：**程序顺序即内存可见顺序**。破坏有两个独立来源：**编译器重排序**（受语言内存模型约束）与**硬件重排序**——写缓冲（store buffer）让写延迟可见、失效队列（invalidation queue）让嗅探响应延迟处理、乱序发射让读越过更早的写。x86 的 TSO（Total Store Order）只允许"写→读"重排，ARM/PowerPC 的弱模型允许更多重排。

```text
x86-TSO 下的经典反例（StoreLoad 重排）—— Dekker 算法为什么必须加屏障
  初始：X = 0, Y = 0
  核0                          核1
  ──────────────────────       ──────────────────────
  X = 1;                       Y = 1;
  r1 = Y;   // 可能读到 0       r2 = X;   // 可能读到 0

  原因：核0 的写 X=1 还躺在 store buffer 里，读 Y 先完成；核1 同理。
  ⟹ 屏障（mfence / lock 前缀 / sfence / lfence / dmb）的作用是划定一条分界线：
     "此前的访存对本核与所有其他核的可见顺序"。
     软件可见性原语（volatile、synchronized、Lock）最终都编译成这些指令。
```

> [!NOTE]
> 本章只回答"**硬件为什么必须提供屏障**"：写缓冲、失效队列与乱序发射都会让访存顺序偏离程序顺序。屏障的四种类型（LoadLoad/StoreStore/LoadStore/StoreLoad）、JMM 的 `happens-before`、`volatile` 与 `synchronized` 的语义，见第 30 章 30.2 与 30.3 节；原子指令与锁的取舍见第 6 章 6.1 与 6.2 节。

## 1.10 SMP 与 NUMA：内存不总是等距的

最后一个必须记住的硬件事实：**主存访问延迟不是常数**。**SMP / UMA**（Symmetric Multiprocessing / Uniform Memory Access）下所有核到内存延迟相同，靠共享前端总线时总线很快成为瓶颈；**NUMA**（Non-Uniform Memory Access）下每个路自带内存控制器与本地 DIMM，访问本地节点内存快、访问其他节点要跨片上互联（UPI、Infinity Fabric），延迟明显更高。现代多路服务器全部是 NUMA。

**表 1-7 UMA 与 NUMA 的对比**

| 维度 | UMA/SMP | NUMA |
|---|---|---|
| 内存延迟 | 所有核一致（约 100 ns） | 本地约 100 ns，远程约 150 ~ 250 ns（约为本地 1.5 ~ 2.5 倍，随拓扑跳数变化） |
| 聚合带宽 | 受共享总线/控制器限制 | 随节点数近似线性扩展 |
| 扩展性 | 数十核后遭遇瓶颈 | 可扩展到多路、数百核 |
| 主要风险 | 总线争用 | 远程访问比例高、内存分配不均衡 |
| 观测手段 | —— | `numactl --hardware`、`/sys/devices/system/node/node*/numastat`、`lstopo` |

NUMA 的性能原则只有一句：**让线程尽量访问它所在节点的内存**。这解释了绑核提升性能的完整逻辑：其一，避免线程在节点间迁移——默认调度器为负载均衡可能把热线程迁到远端 CPU，此后它的全部工作集都要跨节点访问（见第 5 章 5.4 节）；其二，迁移后 L1/L2 中的数据留在原核，本地缓存变冷；其三，必须配合**首次触碰（first-touch）分配策略**——Linux 默认在**第一次写**某页时把它分配到触发者的本地节点，因此"主线程预分配大数组、再交给 worker 线程使用"会让所有页落在主线程节点，所有 worker 都在做远程访问。
```bash
# 为什么这样写：先绑核再跑，保证进程 CPU 固定在一个 NUMA 节点内，
# 从而让 first-touch 把页分配到该节点，所有后续访问都是本地内存。
numactl --cpunodebind=0 --membind=0 ./service

# 为什么用 interleave 而不是 membind：当单节点内存带宽喂不饱全部线程时
# （带宽受限而非延迟受限的负载），让页在所有节点交错分布，用带宽换延迟。
# 这是"延迟敏感绑核、带宽敏感交错"的取舍。
numactl --interleave=all ./bandwidth_bound_service
```

> [!CAUTION]
> 把"绑定 CPU"答成"减少上下文切换"是不完整的。绑核的第一收益是**内存亲和性与缓存/TLB 热度**，减少调度迁移只是附带效果（见第 5 章 5.4 节）。反过来，**盲目绑核也可能变慢**：它使负载无法均衡，单节点出现热点或线程等待时没有其他核可用。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 存储层次是数量级台阶 | 每级都是下级的缓存，容量换延迟是唯一杠杆 | 说"缓存越大越快"，忽略访问时间随容量超线性增长 |
| AMAT 由缺失率主导 | `AMAT = T_c + (1 − h) × T_m`，缺失率的小改善收益远大于命中路径优化 | 只优化命中路径，不测缺失率 |
| 64 字节是权衡结果 | 一次内存突发粒度 × 空间局部性收益递减点 | 说"因为 2 的幂" |
| 三类缺失口径互斥 | 全相联消掉冲突缺失、无限容量消掉容量缺失、冷访问必是强制缺失 | 把冲突缺失也算成容量不足 |
| MESI 的核心是写前独占 | 写必须先取得 RFO，从而把并发写串行化 | 只背四态名称，说不出其他核副本何时失效 |
| 伪共享是性能问题 | 逻辑正确、只是慢；只有写才触发 | 答成数据竞争（正确性问题） |
| 数据冒险有优先级层级 | 先转发，转发不了才停顿，停顿尽量被调度与乱序掩盖 | 张口就答"加气泡" |
| 误判代价与流水线深度成正比 | 五级约 2 ~ 3 周期，深流水约 15 ~ 20 周期，量级约 5 ns | 说成"多执行了几条指令" |
| 推测执行漏洞在回滚不彻底 | 缓存等微架构状态不回滚，形成侧信道；屏障的存在源于硬件重排序 | 说成"分支预测器有 bug"，或认为屏障只是为了编译器不重排 |
| NUMA 下绑核的本质是亲和性 | 减少远程内存访问 + 保持缓存/TLB 热度 | 只答"减少上下文切换" |

## 自测题

**Q1**（计算）L1 命中延迟 1 ns、L2 命中 4 ns、主存 100 ns。L1 缺失率 5%，其中 40% 命中 L2。求 AMAT；若改善布局使 L1 缺失率降至 2%、L2 命中率不变，AMAT 变为多少？

<details><summary>答案与解析</summary>

分层展开（L1 缺失后先查 L2，L2 也缺失才到主存）：`AMAT = 0.95 × 1 + 0.02 × 5 + 0.03 × 105 = 4.20 ns`。改善后 `AMAT = 0.98 × 1 + 0.008 × 5 + 0.012 × 105 = 2.28 ns`。

**错在哪**：最常见的错误是把 `(1 − 0.05) × 100` 直接当缺失代价，漏掉 L2 命中的那部分只需多花 4 ns；另一个错误是以为"L2 命中率不变"意味着 L2 绝对命中次数不变——它随 L1 缺失数一起下降了 60%。**面试官会怎么追问**：①"缺失率只降 3 个百分点，为什么 AMAT 几乎减半？"——主存项 3.15 ns 占 AMAT 的 75%，缺失率的绝对变化虽小，乘上 100 ns 的惩罚就是主要收益。②"缺失率降到 0 时 AMAT 下限是多少？"——1.0 ns，即命中延迟本身，这就是优化天花板。

</details>

**Q2**（推导）某缓存 32 KB、8 路、64 B 行，程序按 4096 字节步长遍历大数组。会发生什么缺失？给出两种修复方案。

<details><summary>答案与解析</summary>

组数 `S = 32768 / (8 × 64) = 64`，组号来自地址第 6 ~ 11 位。步长 4096 B 意味着每次访问间隔 `4096 / 64 = 64` 行，恰好等于组数，**每 4096 字节回绕到同一组**；8 路组只能容纳 8 个这样的地址，第 9 个就挤掉第 1 个。工作集远小于 32 KB，但访问全落在同一个 8 路组里，触发严重**冲突缺失**，命中率接近 0。修复：① **破坏步长与组数的整数关系**，把元素间距改为 `4096 + 64` 或加一个 64 B 偏移，使相邻访问落到不同组；② **分块（tiling/blocking）遍历**，让块内访问不冲突、块间依次推进；③ 数据结构层面做 padding，把冲突结构彻底打散。

**错在哪**：答"容量缺失"最典型——32 KB 装几个元素绰绰有余，问题在映射而非容量；答"强制缺失"则把首次访问与重复回绕混为一谈。**面试官会怎么追问**：①"怎么用实验区分冲突缺失与容量缺失？"——在全相联缓存模型上重放同一程序，冲突缺失归零即确认是冲突；或把容量翻倍再测，缺失率几乎不变说明不是容量问题。②"为什么步长是 2 的幂时最严重？"——组索引取自地址中间位，2 的幂步长只改变高位而中间位恒定，索引不变。

</details>

**Q3**（分析）两个线程各自循环自增 `volatile long counter[i]`（`i` 不同），压测显示 8 线程吞吐低于 1 线程。给出根因、验证方法与修复，并说明为什么读操作不会引发同样问题。

<details><summary>答案与解析</summary>

**根因**：两个 `long` 在数组里相邻，共处同一 64 B 缓存行。每次写都要通过 BusUpgr/BusRdX 取得独占权并把对方副本置 I，对方下一次写立刻缺失。源码访问的是不同变量，硬件事件却全在同一行争抢——这就是伪共享。

**验证与修复**：`perf stat -e cache-misses,cache-references` 对比 1 线程与 8 线程（缺失数随线程数陡增），`perf c2c` 可直接定位跨核争用行，最直接的是 A/B 实验——加 64 B padding 后吞吐立刻回升即确认因果。修复用 C 的 `aligned(64)` 结构体填充或 Java 的 `@Contended`，关键是**先对齐再填充**。

**为什么读不会**：共享只读行在各核中都处于 S 态，S 态可共存，嗅探一次即可，后续读全部本地命中，没有一致性流量。只有**写**需要独占权（S → M 需 BusUpgr，I → M 需 BusRdX），才会反复互相失效。

**错在哪**：把根因答成"多线程竞争同一变量需要加锁"，会被立刻追问"我加了 padding 就好了，跟锁有什么关系"；正确说法是"逻辑上无共享，物理上共享了缓存行"。**面试官会怎么追问**：①"`@Contended` 在 JDK 8 和 JDK 17 用法一样吗？"——不一样，JDK 8 需 `-XX:-RestrictContended`，JDK 9+ 注解移入 `jdk.internal.vm.annotation` 且需 `--add-exports`。②"还有什么比 padding 更省内存的办法？"——把每核计数聚合成局部变量（`ThreadLocal` 或每核缓冲），只在最终阶段合并。

</details>

**Q4**（判断）"分支预测失败的主要代价是多执行了几条本不该执行的指令，因此减少分支数量一定能提升性能。"判断并说明理由，另举一个"减少分支反而变慢"的场景。

<details><summary>答案与解析</summary>

**前半句错**。被推测执行的指令**不会提交**：乱序核按程序顺序在 ROB 提交，方向判错后比它年轻的指令全部丢弃，架构状态回到正确位置。真正的代价是**流水线冲刷期间执行单元、取指与访存带宽的空转**，加上重新取指的延迟，量级为"流水线深度 × 周期时间"：五级约 2 ~ 3 周期（约 1 ns），深流水约 15 ~ 20 周期（约 5 ns）。清空流水线才是代价，"多执行了指令"不是。

**后半句也错**，因为减少分支本身有成本，收益取决于该分支是否可预测：① **分支可预测时几乎免费**，100 次迭代只错 1 次，摊到每次迭代接近零；展开成无分支代码反而增大代码体积、挤占 I-cache 与 μop cache。② **`cmov` 消除了控制依赖却引入数据依赖**，两个操作数都必须先算完，当预测很准而计算很贵时更慢。③ **无分支算术技巧需额外指令**，省下的是潜在误判，付出的是确定的多条指令。**错在哪**：把"误判的代价"与"分支的代价"混为一谈，或把"减少分支"当成无条件优化，忽略"预测准确率"这个决定性变量。

**面试官会怎么追问**：①"怎么判断该不该消除一个分支？"——先测 `perf stat` 的 `branch-misses / branch-instructions`，只有误判率高且位于热路径时才考虑，并且必须 A/B 实测；②"为什么不把预测器做成无限大？"——表格规模、索引与训练延迟都随历史长度增长，且预测器必须在 1 个周期内给出答案，面积与功耗同样是约束。

</details>

**Q5**（设计）2 路 NUMA 服务器上的服务，压测吞吐只有预期一半，`numastat` 显示远程访问占比很高。给出诊断步骤与三类修复方案及各自适用边界。

<details><summary>答案与解析</summary>

**诊断**：① `numactl --hardware` 或 `lstopo` 看清节点数、每节点 CPU/内存与节点距离；② `numastat -p <pid>` 看 `numa_hit` 与 `numa_miss`/`other_node` 占比，这是最直接的证据；③ 采样 `/proc/<pid>/task/*/stat` 的 CPU 字段或 `perf sched`，确认热点线程是否在节点间漂移；④ 检查是否存在"主线程预分配 + 线程池使用"的错配模式。

**三类修复与边界**：① **绑核绑内存**（`numactl --cpunodebind=N --membind=N`），适用延迟敏感、工作集能装进单节点内存的场景；边界是单节点内存不足时不可用，节点内出现热点时失去均衡能力。② **交错分配**（`--interleave=all`），适用带宽敏感的顺序扫描与流式处理，用延迟换聚合带宽；边界是延迟敏感负载变慢，因为一半访问变远程。③ **代码层保证 first-touch 亲和**，让实际使用数组的线程各自初始化自己那段（或 `mbind`/`set_mempolicy` 显式指定策略），这是最根本的修法；边界是需改代码，且对象池、共享缓存这类跨线程结构难以完全本地化。

**错在哪**：答"加机器"或"打开透明大页"都错了病因——问题是内存与计算不在同一节点；另一个常见错误是**只绑核不绑内存**：线程不再迁移，但已分配的页仍在远端，远程访问比例不降。**面试官会怎么追问**：①"绑核后性能反而下降，可能是什么原因？"——失去跨核负载均衡出现单节点热点，或该负载是带宽敏感型而被绑核限制了可用带宽。②"为什么 Linux 默认用 first-touch？"——内核对"哪个线程最终会用这个页"没有先知信息，first-touch 是零成本且对"谁分配谁使用"这一最常见模式的最优猜测。

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应小节 |
|---|---|---|---|
| 为什么要有存储层次？延迟为什么是数量级递增而非线性增长？ | 是否理解"每级都是下级的缓存"与容量-延迟-成本三角 | 陷阱：答"因为快的贵"就停住，说不出容量放大导致查找路径变长、延迟超线性增长。加分：推导 `AMAT = T + (1 − h) × M`，指出缺失率的小改善收益远大于命中延迟优化 | 1.1 |
| 缓存行为什么是 64 字节？改成 128 字节会怎样？ | 行大小是空间局部性收益与带宽浪费的权衡 | 陷阱：答"因为 2 的幂"或"越大命中率越高"。加分：指出 64 B 对应一次 DDR 突发长度，行越大则随机访问下无用数据搬运与首次访问延迟越高 | 1.2 |
| 你说缓存没命中，那到底是哪种没命中？怎么证明？ | 三类缺失的区分与实验方法 | 陷阱：只背 3C 名字，说不清口径互斥关系，也拿不出测量方法。加分：说出"全相联重放消掉冲突缺失、无限容量重放消掉容量缺失"的对照法 | 1.3 |
| 多核之间怎么保证缓存一致？说说 MESI。 | 一致性协议的状态机与嗅探 | 陷阱：只背四个状态名，说不清"写之前必须先拿到独占权（RFO）"，也画不出转换。加分：主动提 MOESI 的 O 态用于减少脏行写回、MESIF 的 F 态用于指定唯一转发者 | 1.4 |
| 两个线程写同一结构体的不同字段，会有什么问题？ | 伪共享的识别与量化 | 陷阱：答成"数据竞争，需要加锁"，把性能问题当正确性问题。加分：说出"只有写才触发、读-读共享不触发"，给出 `@Contended`/padding 的用法与对齐要求 | 1.5 |
| 你的代码里 load 之后紧跟一条用它的指令会发生什么？分支预测失败又贵在哪里？ | 数据冒险的转发边界与控制冒险的代价构成 | 陷阱：前者只答"停顿一个周期"却说不清转发为何失效（数据要到 MEM 末尾才有效）；后者答"多执行了几条指令"。加分：说明编译器调度与乱序执行各能掩盖到什么程度，以及误判代价与流水线深度成正比（五级约 1 ns、深流水约 5 ns） | 1.6 / 1.7 |
| 乱序执行是怎么做到既乱序又不违反程序语义的？ | 寄存器重命名与 ROB 的分工 | 陷阱：把"乱序"理解成"完全无序"，说不出 ROB 顺序提交保证精确异常。加分：说清 WAW/WAR 在重命名阶段被消除、只有 RAW 限制并行度 | 1.8 |
| Spectre 和 Meltdown 的成因是什么？为什么修不干净？ | 推测执行与微架构侧信道 | 陷阱：答"分支预测器有 bug"。加分：指出根因是"架构状态可回滚、缓存等微架构状态不可回滚"，并逐一说明 KPTI、IBRS、`lfence` 各堵住链条的哪一环 | 1.8 |
| 为什么把线程绑到固定 CPU 上通常能提升性能？ | NUMA 与缓存亲和的收益来源 | 陷阱：只答"减少上下文切换"，说不出内存亲和性这个主因。加分：说明 first-touch 导致的"分配者与使用者不在同一节点"的经典错配，以及绑核在带宽敏感负载上可能变慢 | 1.10 |

## 延伸阅读

- Hennessy & Patterson, *Computer Architecture: A Quantitative Approach*：第 2 章（存储层次与 AMAT）、附录 B（缓存与一致性协议的状态机推导）；Bryant & O'Hallaron, *CSAPP* 第 6 章"存储器层次结构"（局部性度量与缓存友好的矩阵乘法推导）。
- Intel 64 and IA-32 Architectures Optimization Reference Manual（分支预测器、ROB 与流水线深度）；Intel SDM Vol. 3A 内存类型与内存顺序小节，是 1.9 节的权威依据。
- ARM Architecture Reference Manual 的内存模型与屏障指令（`dmb`/`dsb`/`isb`）章节：对照 x86-TSO 理解弱内存模型。
- Linux 内核文档 `Documentation/admin-guide/numa_memory_policy.rst`、`numactl(8)`、`numastat(8)`、`lstopo(1)`，以及 `Documentation/admin-guide/hw-vuln/`：1.10 与 1.8 节的观测与缓解依据；OpenJDK 关于 `jdk.internal.vm.annotation.Contended` 的说明与 JEP 142 是伪共享注解的原始依据。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch01/  

