CodeRoad Map 程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。
由 程序员柏风 独立维护 · 关于作者
学习 更多
计算机如何工作
这一课你将能做到
画出冯·诺依曼体系结构,说清楚 CPU、内存、总线在程序运行时的分工
描述一条机器指令从「取指」到「执行」的完整过程,并解释 CPU 主频、缓存为什么影响性能
说清楚 Java 代码从 .java 到运行结果经历的编译、类加载、解释执行与 JIT 过程
为什么要学
很多初学者写了一年 Spring Boot,依然回答不出这三个线上问题:为什么加了机器 CPU 核数接口却没更快?为什么循环里访问大数组有时候快、有时候慢几百倍?为什么 JVM 预热之后接口延迟会下降?
这些问题的答案都不在框架里,而在硬件和执行模型里。本课程后面的数据结构、算法、操作系统、网络知识,全部建立在「程序最终是 CPU 一条条执行指令、内存一格格存取数据」这个事实上。本课先把这张全景图搭起来,之后每节课都是往图里填细节。
结合本站的 Spring Boot 判题平台理解:用户在线提交一段 Java 代码,平台编译、运行、比对输出——这条链路恰好把「编译执行」的每个环节都走了一遍,是最具体的学习样本。
先建立图像
冯·诺依曼结构的核心思想只有一句:程序和数据以同样的二进制形式存放在同一个存储器里 。
┌──────────────────────────────────────────────┐
│ 内存 Memory │
│ ┌────────────┬────────────┬───────────────┐ │
│ │ 机器码指令 │ 机器码指令 │ 数据/堆/栈... │ │
│ │ 010110... │ 111000... │ ... │ │
│ └────────────┴────────────┴───────────────┘ │
└───────▲───────────────────┬───────────────────┘
│ 取指令/读数据 │ 写结果
┌───────┴───────────────────▼───────────────────┐
│ CPU │
│ ┌─────────┐ ┌──────────┐ ┌──────────────┐ │
│ │ PC 程序 │ │ 寄存器 │ │ ALU 算术逻辑 │ │
│ │ 计数器 │→ │ Registers │→ │ 单元(加减与) │ │
│ └─────────┘ └──────────┘ └──────────────┘ │
│ │ 控制单元 CU 按节拍驱动上述部件 │
└───────┼───────────────────────────────────────┘
│ 系统总线(地址总线 / 数据总线 / 控制总线)
┌───────▼────────┐ ┌──────────────┐
│ SSD/磁盘 │ │ 网卡/键盘/... │
└────────────────┘ └──────────────┘
关键直觉:CPU 极快(纳秒级),内存慢约百倍,磁盘慢约十万倍。所以「让数据待在离计算近的地方」是所有性能优化的总根源——CPU 缓存是这样,Redis 是这样,判题平台把热题放进本地缓存也是这样。
跟着做
步骤 1:观察一条指令的一生
CPU 执行任何程序,底层都是无限循环下面四步(指令周期):
取指 Fetch :按 PC(程序计数器)里的地址,从内存把指令读到 CPU
译码 Decode :控制单元搞清楚这是加法还是跳转,操作数在哪
执行 Execute :ALU 做运算,或访问内存
写回 Write-back :结果放进寄存器,PC 指向下一条指令
写一段 Java 代码,用命令看到它真实的字节码:
public class InstructionDemo {
public static int add(int a, int b) {
int c = a + b;
return c;
}
public static void main(String[] args) {
System.out.println(add(3, 5));
}
}
保存为 InstructionDemo.java,在终端执行:
javac InstructionDemo.java
javap -c InstructionDemo
javap -c 会反汇编出字节码,你会看到 iload_0(把局部变量 0 压入操作数栈)、iadd(整数相加)、ireturn(返回)。字节码不是机器码,它是 JVM 这套「虚拟计算机」的指令集——这正是 Java「一次编写,到处运行」的机制:同一份 .class 可以在 Windows、Linux、Mac 的不同真实 CPU 上跑,因为每个平台都有各自的 JVM 负责把字节码翻译成本机指令。
步骤 2:理解编译型、解释型与 Java 的混合路线 C/C++: 源码 ──编译器(提前编译 AOT)──▶ 特定平台机器码 ──▶ 直接执行,启动快峰值高
Python: 源码 ───────────────────────▶ 解释器逐行读取执行,灵活但慢
Java: 源码 ──javac──▶ 字节码 ──▶ JVM 解释执行
└─热点代码─▶ JIT 即时编译为本机机器码(带缓存)
JIT 是 Java 性能的关键:方法被调用足够多次(热点),JVM 把它编译成优化过的本机码并缓存,越跑越快。这也解释了为什么做压测时要先「预热」,判题平台给每个判题任务设置超时上限时,要考虑 JVM 启动本身的固定开销——生产中判题服务是常驻进程,避免每份提交都重启 JVM,而是用预热好的工作进程池。
步骤 3:体会 CPU 缓存与局部性 public class LocalityDemo {
public static void main(String[] args) {
final int N = 10_000;
int[][] a = new int[N][N];
long t1 = System.nanoTime();
long sum1 = 0;
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
sum1 += a[i][j]; // 按行访问:与内存布局一致,缓存友好
}
}
long t2 = System.nanoTime();
long sum2 = 0;
for (int j = 0; j < N; j++) {
for (int i = 0; i < N; i++) {
sum2 += a[i][j]; // 按列访问:每次跳到下一行,缓存频繁失效
}
}
long t3 = System.nanoTime();
System.out.println("按行耗时(ms): " + (t2 - t1) / 1_000_000 + ", 和=" + sum1);
System.out.println("按列耗时(ms): " + (t3 - t2) / 1_000_000 + ", 和=" + sum2);
}
}
两段代码逻辑完全等价,按列版本通常慢几倍到十几倍。原因是 Java 多维数组在内存里「一行接一行」排列,CPU 从内存搬数据是按缓存行(通常 64 字节,即 16 个 int)成批搬的。顺序访问时,搬一次用 16 次;跳跃访问时,搬一次只用 1 次。这就是空间局部性 ;而循环里反复用到的变量会一直留在寄存器/缓存里,叫时间局部性 。
步骤 4:串起判题平台的执行链路 用户在浏览器点「提交」,后端发生的事可以这样对应到本课知识:
Spring Boot 接口收到源码字符串,写入工作目录的 .java 文件(内存 → 磁盘)
调用 javac(或使用 Java Compiler API)把源码编译成 .class
用隔离的 JVM 进程(或容器)加载字节码,解释/JIT 执行,喂入测试输入
收集标准输出与标准答案比对,结果写回 MySQL,热门题目的统计信息缓存在 Redis
整条链路就是「编译 → 加载 → 执行 → 存储」的具象版。
讲清楚要点
冯·诺依曼结构 = 运算器 + 控制器 + 存储器 + 输入 + 输出 ,现代 CPU 把运算器和控制器合在一块芯片里;「存储程序」(程序也放在内存里、可以被当作数据修改)是它区别于早期插线板计算机的根本。
寄存器 → L1/L2/L3 缓存 → 内存 → SSD → 网络/磁盘 ,容量递增、速度递减、单价递减。算法的常数项差异、各种缓存设计,本质都在利用这条层次结构。
CPU 只认识机器码 ,机器码由操作码 + 操作数组成;指令是「加载、存储、运算、跳转」这几类的组合,所有复杂程序最终都塌缩成它们。
Java 经历两级翻译 :javac 做与平台无关的源码到字节码翻译(只做少量优化);JVM 在运行时再做解释或 JIT,重型优化(内联、逃逸分析、锁消除)主要发生在 JIT 阶段。
位是物理的,字节是逻辑的 :内存按字节编址,每个地址唯一指向一个字节;多字节数据有大小端问题,Java 在虚拟机层面屏蔽了大小端,但跨语言通信(如自定义网络协议)时会遇到。
你可能会懵的地方
「32 位 / 64 位」到底指什么?
主要指 CPU 通用寄存器一次能处理的数据宽度,以及内存地址的宽度。64 位机器的地址空间理论上可达 2⁶⁴ 字节,所以能使用远超 4GB 的内存;它并不意味着「一次算 64 位所以快一倍」。
主频 3GHz 代表每秒执行 30 亿条指令吗?
约等于「每秒 30 亿个时钟周期」,不等于指令数。现代 CPU 一个周期可以执行多条指令(流水线、超标量),但访问内存卡顿一次可能浪费上百个周期,所以实际 IPC(每周期指令数)波动很大。
字节码和机器码是什么关系?JVM 是用什么写的?
字节码是 JVM 规范定义的中间指令;JVM(如 HotSpot)本身是 C++ 写的原生程序,负责把字节码解释或编译成所在 CPU 的机器码。
既然 JIT 会优化,写代码还需要关心性能吗?
要。JIT 擅长优化「小而热」的方法和可预测的模式,但它救不了 O(n²) 的算法、缺索引的全表扫描、把大数据集在堆里反复拷贝这类结构性浪费。
进程崩溃了内存里的数据会丢吗?
会。进程退出后操作系统回收它的内存页,所以提交记录、判题结果必须落到 MySQL 或磁盘——这正是「持久化」存在的理由,下一课的存储层次和之后的数据库课都会用到。
错法对照 错法 问题 正解 把 CPU 占用高简单等同于「算得太多」 自旋锁、频繁 GC、缺页中断也会占满 CPU 用线程栈、GC 日志区分「有效计算」与空转 压测一上来就测延迟并下结论 JIT 未预热、缓存未填充,数据失真 先预热若干轮再统计稳态指标 循环内按列遍历大二维数组 缓存行利用率低,慢数倍 按内存布局顺序访问;必要时改数据结构 认为 JVM 内存等于物理内存 JVM 有堆/栈/元空间划分,受 -Xmx 等参数限制 理解内存层次与 JVM 内存区域,再谈调优 判题直接在 Web 服务进程里执行用户代码 一次 System.exit 或死循环拖垮整个平台 隔离进程/容器执行,限制内存、CPU、时间
卡点急救
接口延迟忽高忽低 :先区分是 CPU 密集(看 CPU 使用率与热点方法)、I/O 等待(看磁盘/网络/锁)还是 GC 停顿(看 GC 日志),三者的解法完全不同。
怀疑某段代码「慢」 :不要凭感觉,用 JMH 做微基准并先预热;手写 System.nanoTime() 计时时至少跑多次取均值,避免把 JIT 编译期算进去。
判题任务超时或内存溢出 :先确认限制是否真正下发到执行进程(-Xmx、容器 cgroup 限制),再排查用户代码;平台自身的 JVM 与被执行代码的 JVM 必须隔离。
面试常问
请描述从源码到运行,Java 经历了哪些阶段?
源码经 javac 编译为字节码;类加载器加载、验证、准备、解析;执行引擎先解释执行,热点方法由 JIT 编译为本机码并缓存。可以补充 AOT(GraalVM Native Image)是新趋势,用构建期编译换启动速度。
CPU 缓存为什么能提升性能?什么代码对缓存不友好?
利用程序的时间与空间局部性,以缓存行为单位批量取数据。对缓存不友好的典型是:随机访问大数组、按列遍历行优先存储的矩阵、链表式的指针追逐(每个节点散落在内存各处)。
冯·诺依曼结构的核心是什么?瓶颈在哪?
核心是存储程序、指令与数据同存于一个存储器。瓶颈是 CPU 与内存之间的速度差(冯·诺依曼瓶颈),缓存、预取、向量化、让数据靠近计算(GPU/边缘缓存)都是在缓解它。
自测三问
不看资料,画出 CPU、内存、磁盘与总线,并标出指令周期四步发生在哪里。
解释同一份 .class 文件为什么能在不同操作系统上运行,代价是什么。
两段逻辑等价的二重循环,凭什么判断哪个更快?如何用实验验证?
小结一句话 计算机就是「CPU 按地址从内存取指令和数据、算完写回去」的无限循环,而 Java 在这之上加了字节码与 JIT 两层翻译——理解这条链路,性能问题就有了统一的思考起点。
延伸