(二)存储、CPU 与指令执行——程序为什么快,又为什么慢

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 执行一条指令,大致会经历:

  1. 取指:从内存或缓存中取出下一条指令。
  2. 译码:分析这条指令要做什么,操作数在哪里。
  3. 执行:进行运算或地址计算。
  4. 访存:如果需要,读取或写入内存。
  5. 写回:把结果写回寄存器。

可以用厨师做菜类比:

  • 看菜谱 → 取指
  • 理解步骤 → 译码
  • 切菜炒菜 → 执行
  • 拿调料 → 访存
  • 装盘 → 写回

当然真实 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;

底层大致会发生:

  1. a、b、c 存在于栈帧或寄存器中。
  2. CPU 把需要的数据加载到寄存器或缓存。
  3. ALU 执行加法。
  4. 结果写回寄存器或内存。
  5. 如果数据不在缓存中,会触发缓存行加载。
  6. 如果这段代码足够热,JIT 会把它编译成更高效的机器码。

11. 后端开发如何用得上?

这些概念并不遥远:

  • 为什么顺序读比随机读快?

    空间局部性更好,缓存命中率更高。

  • 为什么热点数据适合放缓存?

    时间局部性强,重复访问概率高。

  • 为什么数据库索引常用 B+ 树?

    节点大小通常和磁盘页、缓存行配合,减少 I/O。

  • 为什么高并发下锁竞争很贵?

    线程切换、缓存失效、内存屏障都会带来开销。

  • 为什么金额不能用 double?

    IEEE 754 浮点数存在精度误差。

  • 为什么整数溢出会发生?

    补码表示下,超出范围会回绕。

相关推荐
Highcharts.js1 小时前
Highcharts甘特图开发带导航功能的项目管理图示例
开发语言·前端·javascript·甘特图·highcharts·项目管理图
kyle~1 小时前
C++ 原子操作、内存模型与无锁并发编程
linux·开发语言·c++·操作系统
BeiGuTing_code1 小时前
Java泛型与Object
java·开发语言
惊讶的猫1 小时前
Tika文档解析
java
2401_872418781 小时前
互联网公开信息检索完全指南:Google、Yandex、Wayback Machine、DNS、WHOIS、Shodan 等 OSINT 工具大全
开发语言·php
HEJOO91 小时前
Java 类加载过程详解:从加载到初始化的完整链路
java·开发语言·firefox
hahaha60161 小时前
数字图像锐化
开发语言·python
科学熊1 小时前
Java 分布式理论与高并发编程
java·开发语言·分布式
普贤莲花1 小时前
先学Workbuddy还是“人工智能训练师”,或者python基础课?
开发语言·人工智能·python