操作系统面试题 —— 从进程管理到虚拟内存的深度问答
覆盖进程与线程、CPU 调度、死锁、内存管理、虚拟内存、页面置换、文件系统、IO、中断、系统调用十大核心主题,30 道高频面试题附原理图解
操作系统是”计算机的大管家”——管理 CPU、内存、磁盘、网络,让所有程序和谐共处。面试中,操作系统题目出现频率极高,因为它直接反映你对”计算机到底怎么跑起来的”这个问题的理解深度。
这篇文章的组织思路:每道题先给出”一句话记忆点”,再展开原理,最后附上面试回答要点。不求面面俱到,但求把面试高频考点讲透。
第一部分:进程与线程
Q1:进程和线程的区别?
记忆点:进程是资源分配的基本单位,线程是 CPU 调度的基本单位。进程有独立地址空间,线程共享所属进程的地址空间。
| 维度 | 进程 | 线程 |
|---|---|---|
| 定义 | 程序的一次执行实例 | 进程中的一条执行路径 |
| 地址空间 | 独立 | 共享所属进程的地址空间 |
| 资源 | 拥有独立的文件描述符、信号处理等 | 共享进程资源,有自己的栈和寄存器 |
| 创建开销 | 大(需要分配地址空间、页表等) | 小(只需分配栈和少量管理结构) |
| 切换开销 | 大(需要切换页表、刷新 TLB) | 小(同进程线程切换不需要切换页表) |
| 通信方式 | 需要 IPC(管道、共享内存等) | 直接读写共享变量(需同步) |
| 安全性 | 一个进程崩溃不影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
面试加分: 线程共享的是代码段、数据段、堆、文件描述符表;线程私有的是栈、寄存器、线程 ID、errno、信号掩码。
Q2:进程有哪些状态?状态之间怎么转换?
记忆点:五状态模型——新建、就绪、运行、阻塞、终止。核心转换:就绪 → 运行(调度)、运行 → 就绪(时间片用完)、运行 → 阻塞(等 IO)、阻塞 → 就绪(IO 完成)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
创建
|
v
+------[新建]
| |
| admit(许可)
| |
| v
| +->[就绪] <---+
| | | |
| | dispatch | IO 完成 / 事件到来
| | (调度) |
| | | |
| | v |
| +--[运行]-->[阻塞]
| 时间片 |
| 用完 | exit
| v
+------>[终止]
关键转换触发条件:
- 就绪 → 运行: CPU 调度器选中该进程(dispatch)
- 运行 → 就绪: 时间片用完(抢占式调度)、更高优先级进程到来
- 运行 → 阻塞: 发起 IO 请求、等待锁、等待信号量、sleep
- 阻塞 → 就绪: IO 完成、锁释放、信号到达
- 运行 → 终止: 进程执行完毕、被 kill
注意:阻塞状态 不能直接 转为运行状态,必须先回到就绪队列排队。
Q3:进程间通信(IPC)有哪些方式?
记忆点:管道、命名管道、消息队列、共享内存、信号量、信号、Socket。共享内存最快但需要同步,Socket 最通用可跨网络。
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 管道(Pipe) | 半双工,父子进程间 | 简单的父子进程通信 |
| 命名管道(FIFO) | 半双工,不限亲缘关系 | 无关进程间通信 |
| 消息队列 | 有格式的消息,可按类型读取 | 需要消息分类的场景 |
| 共享内存 | 最快,直接映射同一块物理内存 | 大量数据传输 |
| 信号量 | 计数器,用于同步 | 控制并发访问 |
| 信号(Signal) | 异步通知机制 | 通知进程某事件发生 |
| Socket | 全双工,可跨机器 | 网络通信、跨机器 IPC |
面试高频追问: 共享内存为什么最快?因为它不需要内核态和用户态之间的数据拷贝,两个进程直接读写同一块物理内存。但正因如此,需要配合信号量等同步机制防止竞争。
更详细的 IPC 原理和代码实现,参考 进程间通信实战教程。
Q4:fork() 的工作原理?父子进程的关系?
记忆点:fork 创建子进程,子进程是父进程的”几乎完整拷贝”。fork 返回两次——父进程返回子进程 PID,子进程返回 0。现代系统用 COW(写时复制)优化。
1
2
3
4
5
6
7
8
9
10
pid_t pid = fork();
if (pid < 0) {
// 创建失败
} else if (pid == 0) {
// 子进程:pid == 0
printf("I am child, my PID = %d\n", getpid());
} else {
// 父进程:pid == 子进程的 PID
printf("I am parent, child PID = %d\n", pid);
}
写时复制(Copy-On-Write, COW):
fork 后父子进程共享同一份物理页面,页表标记为只读。当任何一方尝试写入时,触发缺页异常,内核才真正拷贝那一页。
- 好处: 如果子进程 fork 后立刻 exec(执行新程序),旧地址空间直接丢弃,根本不需要拷贝
- 体现:
fork + exec是 Unix 创建新程序的经典模式
Q5:孤儿进程和僵尸进程是什么?怎么处理?
记忆点:孤儿进程——父进程先退出,子进程被 init 收养,无害。僵尸进程——子进程退出但父进程没有 wait 回收,占用 PID 资源,有害。
| 类型 | 产生原因 | 危害 | 解决方法 |
|---|---|---|---|
| 孤儿进程 | 父进程先于子进程退出 | 无害,被 init(PID=1) 收养 | 不需要特殊处理 |
| 僵尸进程 | 子进程退出,父进程未 wait | 占用 PID 和进程表项 | 父进程调用 wait/waitpid |
防止僵尸进程的方法:
- 父进程调用 wait/waitpid: 显式回收子进程
- 信号处理: 注册
SIGCHLD信号处理函数,在处理函数中调用 waitpid - 两次 fork: 父进程 fork 子进程,子进程再 fork 孙进程后立即退出。孙进程变成孤儿进程被 init 收养,不会产生僵尸
1
2
3
4
// 方法 2:信号处理
signal(SIGCHLD, [](int) {
while (waitpid(-1, nullptr, WNOHANG) > 0);
});
Q6:线程的实现方式?用户态线程 vs 内核态线程?
记忆点:用户态线程由线程库管理,内核不感知,切换快但一个阻塞全阻塞。内核态线程由内核管理,一个阻塞不影响其他,但切换要陷入内核。Linux 用 1:1 模型(每个线程对应一个内核线程)。
| 模型 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| 多对一(N:1) | 多个用户线程映射到一个内核线程 | 切换快、跨平台 | 一个阻塞全阻塞 |
| 一对一(1:1) | 每个用户线程对应一个内核线程 | 真正并行、阻塞不影响 | 创建开销大 |
| 多对多(M:N) | M 个用户线程映射到 N 个内核线程 | 灵活、兼顾性能 | 实现复杂 |
Linux 的实现:
- Linux 内核不区分进程和线程,统一用
task_struct表示 pthread_create底层调用clone系统调用,通过标志位控制哪些资源共享clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, ...)→ 创建线程(共享地址空间)clone(0, ...)→ 类似 fork(不共享地址空间)
Q7:协程(Coroutine)是什么?和线程有什么区别?
记忆点:协程是用户态的轻量级”线程”,由程序自己调度(协作式),切换不需要陷入内核。一个线程可以跑成千上万个协程。
| 维度 | 线程 | 协程 |
|---|---|---|
| 调度方 | 内核(抢占式) | 用户程序(协作式) |
| 切换开销 | 微秒级(陷入内核) | 纳秒级(保存/恢复寄存器) |
| 并发/并行 | 多核并行 | 单线程并发(不是并行) |
| 栈大小 | 固定(通常 1-8MB) | 可动态调整(通常几 KB) |
| 数量上限 | 受内核限制(通常几千) | 可达百万级 |
C++20 协程:
1
2
3
4
5
6
// C++20 coroutine 示例(概念性代码)
task<int> compute() {
int a = co_await async_read(); // 挂起,让出执行权
int b = co_await async_read(); // 挂起,让出执行权
co_return a + b; // 返回结果
}
协程的核心价值:用同步的写法实现异步的性能。
第二部分:CPU 调度
Q8:常见的 CPU 调度算法有哪些?
记忆点:FCFS 简单但护航效应、SJF 最优但不可知、RR 公平但上下文切换多、优先级可能饥饿、多级反馈队列是现实中的综合方案。
| 算法 | 全称 | 原理 | 优点 | 缺点 |
|---|---|---|---|---|
| FCFS | 先来先服务 | 按到达顺序执行 | 简单公平 | 护航效应(短进程等长进程) |
| SJF | 最短作业优先 | 选最短的执行 | 平均等待时间最优 | 无法预知运行时间,可能饥饿 |
| SRTF | 最短剩余时间 | SJF 的抢占版 | 比 SJF 更优 | 同 SJF |
| RR | 时间片轮转 | 每个进程执行一个时间片 | 公平、响应快 | 时间片选取关键 |
| 优先级 | 优先级调度 | 按优先级执行 | 重要任务优先 | 低优先级饥饿 |
| MLFQ | 多级反馈队列 | 多个优先级队列+动态调整 | 综合最优 | 实现复杂 |
面试重点——时间片大小的权衡:
- 太大 → 退化为 FCFS,响应慢
- 太小 → 上下文切换频繁,CPU 花在切换上
- 经验值:10-100ms,且时间片要远大于上下文切换时间(通常几微秒)
多级反馈队列(MLFQ)核心规则:
- 新进程进入最高优先级队列
- 如果用完时间片(CPU 密集型),降到下一级队列(时间片更长)
- 如果在时间片内主动让出 CPU(IO 密集型),留在当前队列
- 定期将所有进程提升到最高优先级(防止饥饿)
Q9:什么是上下文切换?开销有多大?
记忆点:上下文切换就是保存当前进程/线程的 CPU 状态(寄存器、PC、栈指针等),恢复下一个进程/线程的状态。直接开销 3-5 微秒,间接开销(缓存失效)更大。
上下文切换保存/恢复的内容:
- 通用寄存器(rax, rbx, rcx, …)
- 程序计数器(PC/RIP)
- 栈指针(RSP)
- 状态寄存器(RFLAGS)
- 进程切换还需要:页表基址寄存器(CR3)、刷新 TLB
间接开销(往往比直接开销更大):
- CPU 缓存失效: 新进程的数据不在 L1/L2 cache 中,需要重新从内存加载
- TLB 失效(进程切换): 页表基址变了,TLB 条目全部失效
- 分支预测器失效: 新进程的分支模式不同
减少上下文切换的方法:
- 使用线程代替进程(同进程线程切换不需要切换页表)
- 使用协程(用户态切换,无需陷入内核)
- 使用异步 IO(减少阻塞导致的切换)
- CPU 亲和性(affinity)绑定,减少跨核迁移
Q10:什么是抢占式调度和非抢占式调度?
记忆点:抢占式——操作系统可以强制”打断”正在运行的进程(时间片用完、高优先级到来)。非抢占式——进程一旦开始运行就一直运行到结束或主动让出 CPU。现代操作系统基本都是抢占式。
- 非抢占式(协作式): 进程必须主动放弃 CPU(通过 yield 或 IO)。如果一个进程死循环,整个系统就卡死。Windows 3.1 就是这样。
- 抢占式: 操作系统通过时钟中断强制收回 CPU 控制权。Linux、Windows NT 以后都是抢占式。
内核态的抢占:
Linux 2.6 以后支持内核抢占(CONFIG_PREEMPT),即进程在内核态执行系统调用时也可以被抢占(在安全点),这提高了系统的实时响应性。
第三部分:死锁
Q11:什么是死锁?产生的四个必要条件?
记忆点:死锁就是一组进程互相等待对方持有的资源,谁也无法继续。四个必要条件——互斥、持有并等待、不可抢占、循环等待,缺一不可。
经典场景:
1
2
线程 A: lock(mutex1) → 尝试 lock(mutex2) → 等待...
线程 B: lock(mutex2) → 尝试 lock(mutex1) → 等待...
四个必要条件(全部满足才会死锁):
- 互斥(Mutual Exclusion): 资源同一时刻只能被一个进程使用
- 持有并等待(Hold and Wait): 进程持有至少一个资源,同时等待其他资源
- 不可抢占(No Preemption): 已分配的资源不能被强制收回
- 循环等待(Circular Wait): 存在一条进程链,每个进程都在等待下一个进程持有的资源
Q12:怎么预防、避免和检测死锁?
记忆点:预防——破坏四条件之一(最实用是破坏循环等待:按顺序加锁)。避免——银行家算法(运行时判断安全状态)。检测——资源分配图找环。
1. 预防死锁(破坏四条件之一):
| 破坏哪个条件 | 方法 | 实用性 |
|---|---|---|
| 互斥 | 使用无锁数据结构 | 困难 |
| 持有并等待 | 一次性申请所有资源 | 资源利用率低 |
| 不可抢占 | 拿不到就释放已有的 | trylock 方式 |
| 循环等待 | 对资源编号,按顺序申请 | 最实用 |
2. 避免死锁(银行家算法):
每次分配资源前,检查分配后系统是否仍处于”安全状态”(存在一个安全序列使所有进程都能完成)。如果不安全,拒绝分配。
- 需要预先知道每个进程的最大资源需求
- 运行时开销大,实际系统中很少使用
3. 检测和恢复:
- 定期运行死锁检测算法(资源分配图中找环)
- 发现死锁后:终止进程 / 抢占资源 / 回滚到检查点
面试中最常问的实际方案:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 方案 1:按顺序加锁(破坏循环等待)
// 规则:永远先锁 id 小的,再锁 id 大的
void transfer(Account& from, Account& to, int amount) {
Account& first = (from.id < to.id) ? from : to;
Account& second = (from.id < to.id) ? to : from;
std::lock_guard<std::mutex> g1(first.mtx);
std::lock_guard<std::mutex> g2(second.mtx);
// 转账...
}
// 方案 2:std::lock 同时加多把锁(C++ 标准库)
void transfer(Account& from, Account& to, int amount) {
std::lock(from.mtx, to.mtx); // 原子地同时加两把锁
std::lock_guard<std::mutex> g1(from.mtx, std::adopt_lock);
std::lock_guard<std::mutex> g2(to.mtx, std::adopt_lock);
// 转账...
}
// 方案 3:C++17 scoped_lock(最简洁)
void transfer(Account& from, Account& to, int amount) {
std::scoped_lock lock(from.mtx, to.mtx);
// 转账...
}
更详细的锁机制和并发编程面试题,参考 锁、并发编程与内存模型面试题。
第四部分:内存管理
Q13:虚拟内存是什么?为什么需要虚拟内存?
记忆点:虚拟内存让每个进程以为自己拥有整个地址空间(32位 4GB / 64位 128TB),实际通过页表映射到物理内存。解决了三个问题:内存不够用、进程隔离、地址空间碎片。
虚拟内存的三大好处:
- 扩大可用内存: 物理内存只有 8GB,但可以运行需要 20GB 内存的程序——把暂时不用的页面换到磁盘(swap)
- 进程隔离: 每个进程有独立的虚拟地址空间,A 进程无法访问 B 进程的内存
- 简化编程: 每个进程都从 0 开始编址,不需要关心物理内存布局
虚拟地址 → 物理地址的转换过程:
1
2
3
4
5
6
7
虚拟地址: [页号(VPN)] + [页内偏移(Offset)]
|
v
查页表(Page Table)
|
v
物理地址: [帧号(PFN)] + [页内偏移(Offset)]
Q14:分页和分段的区别?
记忆点:分页——固定大小(通常 4KB),对程序员透明,有内部碎片。分段——按逻辑划分(代码段、数据段、栈段),对程序员可见,有外部碎片。现代系统以分页为主,段主要用于保护。
| 维度 | 分页 | 分段 |
|---|---|---|
| 划分单位 | 固定大小(4KB) | 可变大小(按逻辑) |
| 对用户 | 透明 | 可见(代码段、数据段) |
| 碎片类型 | 内部碎片(最后一页可能没用满) | 外部碎片(段之间的空隙) |
| 地址结构 | 页号 + 页内偏移 | 段号 + 段内偏移 |
| 共享 | 以页为单位 | 以段为单位(更自然) |
Linux 实际使用的是”段页式”: 用分段实现保护(代码段只读、数据段可读写),用分页实现虚拟内存管理。但实际上 Linux 把所有段的基址都设为 0,段限长设为最大值,相当于”绕过”了分段,核心靠分页。
Q15:多级页表是什么?为什么需要?
记忆点:如果用一级页表,32 位系统需要 4MB 连续内存存页表(2^20 个页表项 × 4 字节),64 位系统更不可能。多级页表把页表也分页,用到哪级才分配,节省空间。
32 位系统的两级页表:
1
2
3
4
5
6
7
8
9
虚拟地址 (32 bit):
[一级页号 10bit] [二级页号 10bit] [页内偏移 12bit]
| | |
v v v
一级页表 ──→ 二级页表 ──→ 物理页帧 + 偏移
(页目录) (页表)
一级页表: 1024 个条目, 4KB
每个二级页表: 1024 个条目, 4KB
多级页表的核心优势:
- 一级页表如果某个条目标记为”无效”,对应的二级页表 根本不需要分配
- 进程通常只用到地址空间的很小一部分(代码+数据+栈+堆),大量二级页表不需要创建
- 以少量的额外查询开销,换来巨大的空间节省
64 位系统(x86-64)用四级页表: PGD → PUD → PMD → PTE → 物理页,Linux 5.x 支持五级页表。
Q16:TLB 是什么?为什么重要?
记忆点:TLB(Translation Lookaside Buffer)是页表的缓存,存在 CPU 里。查页表要访问内存(多级页表要多次),TLB 命中就不用查页表了。TLB 命中率通常 > 99%。
地址翻译的完整流程:
1
2
3
4
5
6
7
8
9
10
11
12
CPU 发出虚拟地址
|
v
查 TLB ──命中──→ 直接得到物理地址(1 个时钟周期)
|
未命中
|
v
查多级页表 ──→ 得到物理地址(多次内存访问,慢)
|
v
更新 TLB(下次就命中了)
关键点:
- TLB 通常只有 64-1024 个条目(全相联或组相联)
- 程序的局部性原理保证了极高的命中率
- 进程切换时 TLB 需要刷新(不同进程的页表不同),这是进程切换开销大的重要原因
- 现代 CPU 用 ASID(Address Space Identifier) 给 TLB 条目标记进程 ID,避免切换时全部刷新
Q17:进程的虚拟地址空间布局是怎样的?
记忆点:从低到高——代码段(只读)、数据段(已初始化全局变量)、BSS段(未初始化全局变量)、堆(向上增长)、共享库映射区、栈(向下增长)、内核空间。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
高地址
+------------------+
| 内核空间 | 用户态不可访问
+------------------+ 0xFFFF FFFF (32位) 或更高
| 栈 ↓ | 局部变量、函数调用帧
| | 向低地址增长
+------------------+
| |
| 共享库映射区 | mmap, 动态链接库
| |
+------------------+
| 堆 ↑ | malloc/new 分配
| | 向高地址增长
+------------------+
| BSS 段 | 未初始化的全局/静态变量 (自动清零)
+------------------+
| 数据段 | 已初始化的全局/静态变量
+------------------+
| 代码段 | 程序指令 (只读、可执行)
+------------------+ 0x0000 0000 (32位)
低地址
面试追问——堆和栈的区别?
| 维度 | 栈 | 堆 |
|---|---|---|
| 管理方式 | 编译器自动分配/释放 | 程序员手动(malloc/free) |
| 增长方向 | 向低地址增长 | 向高地址增长 |
| 分配速度 | 极快(移动栈指针) | 较慢(查找空闲块) |
| 大小限制 | 较小(通常 1-8MB) | 较大(受虚拟内存限制) |
| 碎片 | 无 | 有(外部碎片) |
| 对齐 | 自动对齐 | 需要分配器保证 |
第五部分:页面置换
Q18:常见的页面置换算法有哪些?
记忆点:OPT 最优但不可实现,LRU 最常问但纯实现开销大,Clock(二次机会)是 LRU 的实用近似,Linux 实际用的是改进版 Clock。
| 算法 | 原理 | 优缺点 |
|---|---|---|
| OPT | 置换未来最久不用的页 | 最优,但不可实现(需要预知未来) |
| FIFO | 置换最早进入的页 | 简单,但有 Belady 异常 |
| LRU | 置换最近最久未使用的页 | 效果好,但需要硬件支持或开销大 |
| Clock | LRU 的近似,用访问位 | 实用,是 Linux 的基础 |
| LFU | 置换使用频率最低的页 | 区分热度,但对模式变化不敏感 |
LRU 的实现方式(面试重点):
1
2
3
4
5
6
7
8
方式 1:时间戳法
每次访问更新时间戳,置换时间戳最小的。开销 O(n)。
方式 2:栈法
每次访问把该页移到栈顶,栈底就是 LRU 页。O(1) 但需要链表操作。
方式 3:哈希表 + 双向链表
O(1) get + O(1) put,面试手撕 LRU 就是这个方案。
LRU Cache 的 C++ 实现详解,参考 线程安全 LRU Cache TDD 实战。
Q19:什么是 Belady 异常?
记忆点:FIFO 算法的反直觉现象——增加物理页帧数反而导致缺页率上升。LRU 和 OPT 不会出现这个问题(它们是”栈算法”)。
经典例子:页面引用序列 1,2,3,4,1,2,5,1,2,3,4,5
- 3 个页帧:9 次缺页
- 4 个页帧:10 次缺页(更多帧反而更差!)
这是 FIFO 算法的固有缺陷,也是面试官喜欢问的陷阱题。
Q20:什么是页面抖动(Thrashing)?怎么解决?
记忆点:页面抖动——进程频繁缺页,CPU 大量时间花在处理缺页异常和换页上,实际执行指令的时间很少。根本原因是进程的工作集大于物理内存。
触发条件:
- 系统中运行的进程太多
- 每个进程分到的物理内存不够其”工作集”
- 不断缺页 → 换入换出 → CPU 利用率反而下降
- 操作系统以为 CPU 空闲 → 调度更多进程 → 更严重的抖动
解决方案:
- 工作集模型: 监控每个进程的工作集大小,保证分配给它的帧数 ≥ 工作集大小
- 缺页率策略: 监控缺页率。太高→分配更多帧;太低→减少帧
- 减少多道程序度: 挂起部分进程,释放内存给活跃进程
- 加物理内存 — 最直接的解决方案
第六部分:文件系统
Q21:文件系统的作用?常见的文件系统有哪些?
记忆点:文件系统把磁盘上的裸块组织成文件和目录,提供命名、权限、一致性保护。Linux 用 ext4/XFS/Btrfs,Windows 用 NTFS。
| 文件系统 | 使用场景 | 特点 |
|---|---|---|
| ext4 | Linux 默认 | 成熟稳定,支持到 1EB |
| XFS | 大文件/高吞吐 | 并行 IO 性能强 |
| Btrfs | Linux 新一代 | COW、快照、校验 |
| NTFS | Windows | 权限、加密、压缩 |
| FAT32 | U盘/跨平台 | 简单通用,4GB 文件限制 |
| ZFS | 服务器/NAS | 企业级可靠性 |
Q22:inode 是什么?软链接和硬链接的区别?
记忆点:inode 存储文件的元数据(大小、权限、数据块位置),不存文件名。文件名在目录项中,指向 inode 号。硬链接是同一个 inode 的多个名字,软链接是一个独立文件存放目标路径。
1
2
3
4
5
6
7
8
9
10
11
12
目录项 inode 数据块
+----------+ +-----------+ +------------------+
| "a.txt" |--->| inode 42 |--->| 实际文件内容... |
+----------+ | size:1024 | +------------------+
| "b.txt" |--/ | mode:644 |
+----------+ | nlink:2 | (硬链接:a.txt 和 b.txt 指向同一个 inode)
+-----------+
+----------+ +-----------+ +------------------+
| "c.txt" |--->| inode 99 |--->| "/path/to/a.txt" |
+----------+ | 类型:链接 | +------------------+
+-----------+ (软链接:c.txt 是独立 inode,内容是路径)
| 维度 | 硬链接 | 软链接(符号链接) |
|---|---|---|
| 本质 | 同一个 inode 的多个目录项 | 独立文件,内容是目标路径 |
| 跨文件系统 | 不可以 | 可以 |
| 链接目录 | 不可以(防止循环) | 可以 |
| 删除原文件 | 不影响(inode 引用计数 > 0) | 断链(悬挂链接) |
| inode | 与原文件相同 | 不同 |
Q23:什么是磁盘调度算法?
记忆点:磁盘寻道是机械运动,很慢(毫秒级)。调度算法目标是减少磁头移动距离。SCAN(电梯算法)是实际中最常用的。SSD 随机访问性能接近顺序,调度算法意义不大。
| 算法 | 原理 | 特点 |
|---|---|---|
| FCFS | 按请求顺序 | 公平但磁头移动多 |
| SSTF | 最短寻道优先 | 性能好但可能饥饿 |
| SCAN | 磁头单向扫描到底再反向 | 电梯算法,均衡 |
| C-SCAN | 单向扫描,回到起点不服务 | 比 SCAN 更公平 |
| LOOK/C-LOOK | SCAN/C-SCAN 的改进,只到最远请求 | 实际使用 |
第七部分:中断与系统调用
Q24:什么是中断?中断的分类?
记忆点:中断是 CPU 暂停当前执行、跳转到中断处理程序的机制。分为硬件中断(外部设备触发)和软件中断(指令触发,如 int 0x80)。系统调用就是通过软件中断/专用指令实现的。
中断分类:
1
2
3
4
5
6
7
8
9
10
中断
├── 外部中断(硬件中断)
│ ├── 可屏蔽中断(INTR):键盘、网卡、磁盘
│ └── 不可屏蔽中断(NMI):内存校验错误、电源故障
└── 内部中断
├── 异常(Exception)
│ ├── 故障(Fault):可恢复,如缺页异常
│ ├── 陷阱(Trap):系统调用(int 0x80 / syscall)
│ └── 终止(Abort):不可恢复,如除零错误
└── 软件中断:int 指令主动触发
中断处理流程:
- CPU 收到中断信号,完成当前指令
- 保存现场(压栈:PSW、PC、通用寄存器)
- 根据中断号查中断向量表(IDT)
- 跳转到对应的中断处理程序(ISR)
- 执行中断处理
- 恢复现场(出栈),继续执行被中断的程序
Q25:系统调用的过程是怎样的?用户态和内核态怎么切换?
记忆点:系统调用是用户程序请求内核服务的唯一合法入口。用户态 → 内核态通过 syscall 指令(x86-64)或 int 0x80(x86-32)触发,CPU 自动切换到内核栈并提升特权级。
以 write(fd, buf, len) 为例的完整过程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
用户态代码 内核态
───────── ─────
write(fd, buf, len)
|
v
libc 包装函数
| 设置系统调用号 (rax = 1)
| 设置参数 (rdi=fd, rsi=buf, rdx=len)
|
v
syscall 指令 ─────────────→ CPU 切换到内核态
|
v
保存用户态寄存器
|
v
查系统调用表 sys_call_table[1]
|
v
执行 sys_write()
|
v
恢复用户态寄存器
|
sysret 指令 ←─────────── CPU 切换回用户态
|
v
返回给用户程序
用户态 vs 内核态:
| 维度 | 用户态(Ring 3) | 内核态(Ring 0) |
|---|---|---|
| 权限 | 受限,不能直接访问硬件 | 完全权限 |
| 地址空间 | 只能访问用户空间 | 可以访问全部地址空间 |
| 指令 | 不能执行特权指令 | 可以执行所有指令 |
| 切换场景 | 系统调用、中断、异常 | sysret/iret 返回用户态 |
Q26:中断上半部和下半部是什么?
记忆点:中断处理分两阶段。上半部(Top Half):关中断快速处理,只做最紧急的事(读硬件状态、应答中断)。下半部(Bottom Half):开中断延迟处理,做耗时操作(数据处理、协议解析)。
为什么要分两部分?
中断处理期间 CPU 不能响应新中断(或同级中断被屏蔽)。如果中断处理耗时太长,会导致:
- 其他中断丢失
- 系统响应延迟
Linux 下半部的实现方式:
| 方式 | 特点 | 使用场景 |
|---|---|---|
| 软中断(softirq) | 静态定义,高优先级 | 网络收包(NET_RX) |
| tasklet | 基于软中断,动态创建 | 驱动程序 |
| 工作队列(workqueue) | 在内核线程中执行,可以睡眠 | 耗时操作 |
网卡收包为例:
1
2
3
4
5
6
7
8
9
10
11
上半部(硬中断):
1. 读取网卡状态寄存器
2. 把数据从网卡 DMA 缓冲区标记为待处理
3. 触发 NET_RX 软中断
4. 应答中断,返回
下半部(软中断):
1. 从 DMA 缓冲区取数据
2. 解析以太网帧头、IP 头、TCP 头
3. 将数据放入 socket 接收缓冲区
4. 唤醒等待的用户进程
第八部分:IO 模型
Q27:Linux 的五种 IO 模型是什么?
记忆点:阻塞 IO → 非阻塞 IO → IO 多路复用 → 信号驱动 IO → 异步 IO。前四种本质都是”同步 IO”(数据拷贝阶段进程要参与),只有 AIO 是真正的异步。
IO 操作分两个阶段理解:
- 等待数据准备好(数据从网卡/磁盘到内核缓冲区)
- 将数据从内核缓冲区拷贝到用户缓冲区
| 模型 | 阶段1 | 阶段2 | 特点 |
|---|---|---|---|
| 阻塞 IO | 阻塞等待 | 阻塞拷贝 | 最简单,线程挂起 |
| 非阻塞 IO | 轮询(返回 EAGAIN) | 阻塞拷贝 | CPU 空转浪费 |
| IO 多路复用 | select/poll/epoll 等待 | 阻塞拷贝 | 一个线程监控多个 fd |
| 信号驱动 IO | 注册信号,数据就绪通知 | 阻塞拷贝 | 很少使用 |
| 异步 IO(AIO) | 不阻塞 | 不阻塞(内核完成拷贝后通知) | 真正异步 |
面试追问:为什么 IO 多路复用也算”同步”?
因为第二阶段(从内核缓冲区拷贝数据到用户缓冲区)时,进程仍然需要调用 recvfrom 阻塞等待拷贝完成。只有 AIO 是内核完成全部工作后通知用户进程。
select/poll/epoll 的详细对比和使用,参考 网络编程与进程间通信面试题。
第九部分:经典问题
Q28:用户态和内核态的切换什么时候发生?
记忆点:三种情况——系统调用(主动)、异常(被动,如缺页)、中断(被动,如时钟中断)。每次切换都有保存/恢复现场的开销。
| 触发方式 | 主动/被动 | 例子 |
|---|---|---|
| 系统调用 | 主动 | read, write, fork, exec, mmap |
| 异常 | 被动 | 缺页异常、除零、段错误 |
| 外部中断 | 被动 | 时钟中断、键盘中断、网卡中断 |
切换开销包括:
- 保存用户态寄存器到内核栈
- 切换到内核栈
- 执行内核代码
- 恢复用户态寄存器
- 切换回用户栈
减少切换的优化:
vDSO(Virtual Dynamic Shared Object):把部分只读的内核数据(如gettimeofday)映射到用户空间,不需要真正陷入内核io_uring:提交和完成 IO 请求通过共享内存环,减少系统调用次数
Q29:什么是缺页异常(Page Fault)?处理流程是怎样的?
记忆点:访问的虚拟页面不在物理内存中,触发缺页异常。MMU 产生异常 → 内核处理(合法则从磁盘调入,非法则段错误杀进程)。这是虚拟内存和按需分页的核心机制。
缺页异常的处理流程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
CPU 访问虚拟地址
|
v
查 TLB → 未命中 → 查页表
|
v
页表项有效?
/ \
是 否 → 缺页异常!
| |
v v
正常访问 地址合法?
/ \
是 否 → 段错误(SIGSEGV)
| 杀掉进程
v
有空闲帧?
/ \
是 否 → 页面置换
| 选择一个页面换出
v (如果 dirty 则写回磁盘)
从磁盘读入该页 |
| v
v 腾出空闲帧
更新页表 |
| v
v 从磁盘读入该页
重新执行指令 |
v
更新页表 → 重新执行指令
缺页类型:
| 类型 | 原因 | 处理 |
|---|---|---|
| 硬缺页(Major) | 页面在磁盘上(swap 或文件) | 需要磁盘 IO,慢(毫秒级) |
| 软缺页(Minor) | 页面在内存中但页表未映射 | 只需更新页表,快(微秒级) |
Q30:什么是内存映射(mmap)?有什么用?
记忆点:mmap 把文件或设备直接映射到进程的虚拟地址空间,读写内存就等于读写文件,避免了 read/write 的内核缓冲区拷贝。适用于大文件读写、进程间共享内存、动态库加载。
mmap vs 传统 read/write:
1
2
3
4
5
6
7
8
传统方式:
read(fd, buf, len)
磁盘 → 内核缓冲区(页缓存) → 用户缓冲区 (两次拷贝)
mmap 方式:
ptr = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0)
磁盘 → 页缓存 (直接映射到用户地址空间) (一次拷贝)
直接通过 ptr 访问数据
mmap 的常见用途:
- 读写大文件: 不需要一次加载整个文件,按需缺页加载
- 进程间共享内存:
MAP_SHARED让多个进程映射同一个文件/匿名区域 - 动态链接库加载: .so/.dll 通过 mmap 映射到进程地址空间
- 零拷贝: 配合
sendfile或splice减少数据拷贝
1
2
3
4
5
6
7
8
9
10
11
// mmap 读文件示例
int fd = open("big_file.dat", O_RDONLY);
struct stat sb;
fstat(fd, &sb);
char* ptr = (char*)mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
// 直接通过 ptr 访问文件内容,像访问数组一样
printf("First byte: %c\n", ptr[0]);
munmap(ptr, sb.st_size);
close(fd);
第十部分:综合与高频追问
Q31:进程调度和线程调度有什么区别?Linux 调度的是什么?
记忆点:Linux 内核的调度单位是”任务”(task_struct),不区分进程和线程。内核调度器看到的都是 task_struct,线程就是共享地址空间的 task。
Linux 的 CFS(完全公平调度器):
- 核心思想:每个任务应该获得”公平”的 CPU 时间
- 使用红黑树组织可运行任务,按
vruntime(虚拟运行时间)排序 vruntime小的优先运行 → 保证公平- 优先级高的任务 vruntime 增长更慢 → 自然获得更多 CPU 时间
1
2
3
4
5
6
CFS 红黑树:
vruntime=10
/ \
vruntime=5 vruntime=15
| |
最先调度 最后调度
Q32:什么是 Copy-On-Write?在哪些场景中使用?
记忆点:COW 就是”先共享,写的时候才复制”。fork 时父子进程共享页面,哪个进程先写就触发拷贝那一页。节省了 fork + exec 模式下不必要的复制。
COW 的应用场景:
- fork 系统调用: 子进程与父进程共享所有页面,只在写时复制
- std::string(旧版 GCC): 多个 string 共享同一块内存,修改时才复制(C++11 后已弃用,因为不线程安全)
- 文件系统快照: Btrfs/ZFS 用 COW 实现快照,创建快照只记录指针不复制数据
- 虚拟机内存去重: KSM(Kernel Same-page Merging)合并相同内容的页面
Q33:大页(Huge Pages)是什么?有什么好处?
记忆点:默认页面 4KB,大页 2MB 或 1GB。好处是减少 TLB 条目数(一个 TLB 条目覆盖更大范围),减少页表层级。适用于大内存应用(数据库、JVM)。
| 页面大小 | TLB 覆盖范围(假设 1024 条目) | 页表级数 |
|---|---|---|
| 4KB | 4MB | 4 级 |
| 2MB | 2GB | 3 级(少一级) |
| 1GB | 1TB | 2 级(少两级) |
Linux 配置大页:
1
2
3
4
5
# 预留 128 个 2MB 的大页 (共 256MB)
echo 128 > /proc/sys/vm/nr_hugepages
# 或使用透明大页 (THP),内核自动合并
echo always > /sys/kernel/mm/transparent_hugepage/enabled
Q34:什么是内存泄漏?什么是内存溢出?怎么排查?
记忆点:内存泄漏——分配了没释放,内存越用越少。内存溢出(OOM)——需要内存但已经没有了。泄漏是因,溢出可能是果。
| 概念 | 含义 | 例子 |
|---|---|---|
| 内存泄漏 | 已分配的内存丢失了指针,无法释放 | new 了没 delete |
| 内存溢出 | 需要分配内存但不够了 | 请求 1GB 但系统只剩 100MB |
| 野指针 | 指向已释放内存的指针 | delete 后继续使用 |
| 悬挂指针 | 指向无效内存的指针 | 函数返回局部变量地址 |
排查工具:
- Valgrind(Memcheck): 检测内存泄漏、越界访问、未初始化内存使用
- AddressSanitizer(ASan): 编译时插桩,检测越界、use-after-free,速度比 Valgrind 快很多
- LeakSanitizer(LSan): 专门检测内存泄漏
- perf + /proc/meminfo: 监控整体内存使用趋势
1
2
3
4
5
6
# Valgrind 检测内存泄漏
valgrind --leak-check=full ./my_program
# ASan (编译时加参数)
g++ -fsanitize=address -g my_code.cpp -o my_program
./my_program
Q35:Linux 的 OOM Killer 是什么?
记忆点:当系统内存耗尽时,内核的 OOM Killer 会选择一个”最该杀”的进程杀掉,释放内存。选择标准是 oom_score——占内存多、运行时间短、非 root 的进程更容易被杀。
oom_score 的计算因素:
- 进程及其子进程使用的内存越多,得分越高
- root 进程得分减半
- 可以通过
oom_score_adj手动调整(-1000 到 1000) - 设为 -1000 表示永不 OOM Kill(通常用于数据库等关键进程)
1
2
3
4
5
# 查看某个进程的 OOM 得分
cat /proc/<PID>/oom_score
# 保护重要进程不被 OOM Kill
echo -1000 > /proc/<PID>/oom_score_adj
面试追问:overcommit 机制
Linux 默认允许 overcommit(分配的虚拟内存可以超过物理内存+swap)。因为大多数程序申请了内存但不会全部使用。这就是为什么 malloc 几乎不会失败,但实际使用时可能触发 OOM。
面试答题策略
操作系统题目的答题框架
1
2
3
4
1. 一句话定义(是什么)
2. 核心原理(为什么/怎么做)
3. 对比分析(如果有多个方案,对比优劣)
4. 联系实际(Linux 实际怎么做的、对性能的影响)
高频考点优先级
| 优先级 | 主题 | 核心考点 |
|---|---|---|
| 最高 | 进程/线程 | 区别、状态转换、IPC |
| 最高 | 虚拟内存 | 页表、TLB、缺页处理 |
| 高 | CPU 调度 | FCFS/SJF/RR/MLFQ、上下文切换 |
| 高 | 死锁 | 四条件、预防/避免/检测 |
| 高 | 页面置换 | LRU/Clock/FIFO、Belady 异常 |
| 中 | 文件系统 | inode、软硬链接 |
| 中 | IO 模型 | 五种模型、同步 vs 异步 |
| 中 | 系统调用 | 用户态/内核态切换过程 |
与其他面试主题的关联
| 操作系统知识 | 相关面试文章 |
|---|---|
| 进程间通信 | IPC 进程间通信实战教程 |
| 锁/并发/内存模型 | 锁、并发编程与内存模型面试题 |
| IO 多路复用 | 网络编程与进程间通信面试题 |
| 网络协议 | 计算机网络面试题 |
| 线程安全 LRU Cache | 线程安全 LRU Cache TDD 实战 |
| shared_ptr 多线程 | shared_ptr + Lambda + 多线程 |
操作系统是一门”知其然更要知其所以然”的学科。面试时不要死记硬背结论,而是要能从第一性原理出发推导出答案。比如”为什么需要多级页表”——先想一级页表要多大内存,自然就理解了。理解了原理,换个问法也不怕。