1. 存储层次:为什么不能只靠内存?
计算机存储不是只有一块内存,而是一个层次结构:
text
寄存器 → L1 缓存 → L2 缓存 → L3 缓存 → 内存 → SSD/HDD
越靠近 CPU,速度越快,但容量越小、成本越高。
| 层级 | 特点 | 典型用途 |
|---|---|---|
| 寄存器 | 极快,容量极小 | CPU 正在运算的数据 |
| L1/L2 缓存 | 很快,容量小 | 热点数据、循环变量 |
| L3 缓存 | 较快,多核共享 | 进程常用数据 |
| 内存 | 容量大,速度中等 | 运行中的程序 |
| 磁盘 | 容量最大,速度最慢 | 文件、数据库、日志 |
可以把它理解成一个工作台:
- 寄存器:手里正在用的工具
- 缓存:桌面上随手能拿到的资料
- 内存:书柜里的书
- 磁盘:仓库里的档案
如果 CPU 每次都要去磁盘取数据,程序会慢到无法接受。
2. 缓存行:为什么一次不只取一个字节?
缓存不是按单个字节加载的,而是按缓存行 加载。常见缓存行大小是 64 字节。
也就是说,当你访问数组中的 arr[0] 时,CPU 很可能把 arr[0] 附近的一段数据一起加载到缓存里。
这正好利用了空间局部性:访问一个地址后,附近地址很快也会被访问。
所以顺序遍历数组通常比随机访问快很多:
java
int[] arr = new int[10_000_000];
// 顺序访问,空间局部性好,缓存命中率高
long sum = 0;
for (int v : arr) {
sum += v;
}
如果是随机访问:
java
// 随机访问,缓存命中率低
long sum = 0;
for (int i = 0; i < arr.length; i++) {
sum += arr[randomIndex(i)];
}
后者更容易出现缓存缺失,性能会明显下降。
3. 时间局部性:刚用过的数据,很可能马上还会用
时间局部性指的是:最近访问过的数据,短期内很可能再次被访问。
比如循环变量、计数器、热点配置、频繁调用的方法,都属于时间局部性较好的数据。
后端里的典型例子:
- 线程池中的工作线程反复从队列取任务
- 缓存中热点 Key 被高频访问
- 数据库连接池复用连接
- JVM 中频繁调用的方法被 JIT 编译优化
这些场景都受益于时间局部性。
4. 冯·诺依曼结构:程序和数据为什么能放在一起?
冯·诺依曼结构的核心是:程序指令和数据存储在同一个存储器中。
计算机主要由这几部分组成:
- 运算器:执行算术和逻辑运算
- 控制器:取指令、解释指令、协调各部件
- 存储器:存放程序和数据
- 输入设备:键盘、网卡、磁盘等
- 输出设备:显示器、网卡、磁盘等
这个结构让计算机变得通用:同一台机器只要加载不同程序,就能完成不同任务。
但也带来一个问题:如果指令和数据共用同一套存储访问路径,CPU 在取指令和读写数据时可能产生资源竞争,这就是后面流水线里会提到的结构冒险。
5. 指令周期:一条指令经历了什么?
CPU 执行一条指令,大致会经历:
- 取指:从内存或缓存中取出下一条指令。
- 译码:分析这条指令要做什么,操作数在哪里。
- 执行:进行运算或地址计算。
- 访存:如果需要,读取或写入内存。
- 写回:把结果写回寄存器。
可以用厨师做菜类比:
- 看菜谱 → 取指
- 理解步骤 → 译码
- 切菜炒菜 → 执行
- 拿调料 → 访存
- 装盘 → 写回
当然真实 CPU 更复杂,但这个模型足够理解程序执行过程。
6. 流水线:CPU 如何同时处理多条指令?
如果每条指令都等前一条完全结束再开始,CPU 会非常浪费。
流水线把指令执行拆成多个阶段,让不同指令在不同阶段并行推进:
text
指令1:取指 → 译码 → 执行 → 访存 → 写回
指令2: 取指 → 译码 → 执行 → 访存 → 写回
指令3: 取指 → 译码 → 执行 → 访存 → 写回
这像工厂流水线:每个工位同时处理不同产品,整体吞吐更高。
但流水线会遇到三类冒险问题。
7. 结构冒险:多个指令抢同一硬件资源
结构冒险本质是资源冲突。
比如某个时刻,一条指令正在访存,另一条指令又要取指,如果它们争用同一块存储访问路径,就会冲突。
常见解决办法:
- 把指令缓存和数据缓存分开
- 增加硬件资源
- 让某些指令暂时等待
现代 CPU 通常通过分离指令缓存和数据缓存来缓解这个问题。
8. 数据冒险:后一条指令依赖前一条的结果
数据冒险发生在多条指令之间存在数据依赖时。
比如:
text
a = 1
b = a + 2
c = b + 3
如果第二条指令还没算出 a,第三条指令就开始用 b,就会出错。
常见解决方式:
- 流水线停顿:插入空操作,等前一条指令完成
- 操作数前推:把刚算出的结果直接转发给下一条指令,不必等到写回寄存器
后端开发里,虽然我们不直接写汇编,但高并发下的数据依赖、内存屏障、volatile、原子操作,底层都和这类问题有关。
9. 控制冒险:遇到分支时,CPU 可能"猜错"
控制冒险来自分支跳转,比如 if/else、for、while、方法调用。
CPU 在分支结果确定之前,可能已经预取了后续指令。如果分支方向猜错了,预取的指令就要被丢弃,造成性能损失。
所以现代 CPU 会使用分支预测:根据历史行为猜测分支走向。预测成功时性能很高,预测失败时则需要清空流水线,付出额外代价。
对后端开发来说,这意味着:
- 频繁且不可预测的分支可能影响性能
- 有序数据通常比无序数据更有利于分支预测
- 热点路径应尽量保持简单、稳定
10. 从 Java 代码到 CPU 执行
可以把整个过程串起来:
text
Java 源码
→ javac 编译成字节码
→ JVM 加载类
→ 解释执行 / JIT 编译
→ 热点代码编译成本地机器码
→ CPU 取指、译码、执行
→ 数据在寄存器、缓存、内存、磁盘中流动
例如:
java
int a = 10;
int b = 20;
int c = a + b;
底层大致会发生:
a、b、c存在于栈帧或寄存器中。- CPU 把需要的数据加载到寄存器或缓存。
- ALU 执行加法。
- 结果写回寄存器或内存。
- 如果数据不在缓存中,会触发缓存行加载。
- 如果这段代码足够热,JIT 会把它编译成更高效的机器码。
11. 后端开发如何用得上?
这些概念并不遥远:
-
为什么顺序读比随机读快?
空间局部性更好,缓存命中率更高。
-
为什么热点数据适合放缓存?
时间局部性强,重复访问概率高。
-
为什么数据库索引常用 B+ 树?
节点大小通常和磁盘页、缓存行配合,减少 I/O。
-
为什么高并发下锁竞争很贵?
线程切换、缓存失效、内存屏障都会带来开销。
-
为什么金额不能用
double?IEEE 754 浮点数存在精度误差。
-
为什么整数溢出会发生?
补码表示下,超出范围会回绕。