面试追问地图
| 主问题 | 必讲关键点 | 下一层追问 |
|---|---|---|
| 进程/线程/协程 | 资源、调度、切换成本 | 用户线程与内核线程模型 |
| 上下文切换 | 寄存器、栈、页表、缓存 | 为什么线程切换通常更轻 |
| IPC | 管道、消息队列、共享内存、Socket | 性能、同步和跨机器能力 |
| 调度算法 | 吞吐、响应、公平 | 时间片过大/过小会怎样 |
| 死锁 | 互斥、占有等待、不可剥夺、循环等待 | 检测、预防、银行家算法 |
| 虚拟内存 | 页表、TLB、缺页 | 多级页表为什么省内存、TLB miss 代价、大页与 THP 的坑 |
| malloc | brk/mmap、分配器 | 内存碎片、为何 RSS 不下降 |
| page cache | 读写缓存、脏页回写、可回收 | dirty 参数、direct IO 场景、容器 OOMKilled |
| 页面置换 | LRU、Clock 等 | 抖动、工作集 |
| IO 模型 | 阻塞、非阻塞、复用、异步 | 同步/异步与阻塞/非阻塞区别 |
| epoll | 红黑树、就绪队列、回调 | ET/LT、惊群、必须非阻塞 |
| 零拷贝 | 减少复制和上下文切换 | mmap/sendfile、适用限制 |
| 内存管理 | 堆栈区别、写时复制、内存不足 | brk/mmap 选择、OOM 场景 |
| 并行 vs 并发 | 逻辑并发 vs 物理并行 | 多核利用率、并发模型 |
| 孤儿/僵尸进程 | 父进程退出、未回收 PCB | SIGCHLD 信号、双 fork 技巧 |
| 锁类型 | 互斥锁、自旋锁、读写锁、RCU | 临界区长短与锁选择、写者饥饿 |
| 管程 | Monitor、条件变量、wait/signal | Java synchronized 的管程模型 |
| PV 操作 | 信号量、P/V 原子语义 | 生产者-消费者、读者-写者 |
| 文件系统 | inode、物理结构、目录结构 | 硬链接 vs 软链接、按名存取流程 |
| 磁盘调度 | SSTF、SCAN、C-SCAN | SSD 时代的意义 |
| 缓冲区溢出 | 栈溢出、返回地址覆盖 | 栈金丝雀、DEP、ASLR |
操作系统题要从“资源归谁管理”和“谁负责等待/唤醒”两个角度回答。
一、操作系统
用户态和内核态
- 内核态 :可执行所有指令,访问所有硬件资源
- 用户态 :只能执行部分指令,不能直接访问硬件
- 划分原因:安全性、稳定性、隔离性
进程、线程、协程区别
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 资源分配 | 资源分配基本单位 | 共享进程资源 | 共享进程资源 |
| 调度 | 非调度单位 | CPU 调度基本单位 | 用户态调度 |
| 内存 | 独立地址空间 | 共享堆/全局变量 | 共享堆 |
| 切换开销 | 大(切换页表等) | 小(只切换栈/寄存器) | 极小(用户态) |
| 稳定性 | 崩溃不影响其他进程 | 崩溃可能导致进程崩溃 | 崩溃影响所在进程 |
进程五状态
创建 -> 就绪 -> 运行 -> (阻塞/结束)
- 运行 -> 就绪:时间片用完
- 运行 -> 阻塞:等待 I/O
- 阻塞 -> 就绪:I/O 完成
进程间通信方式
- 管道 :匿名管道(父子进程,单向)、命名管道(无关进程)
- 消息队列 :内核中的消息链表,支持自定义数据类型
- 共享内存 :最快的 IPC,直接映射同一块物理内存
- 信号 :异步通信(如 SIGKILL、SIGSTOP 不可忽略)
- 信号量 :P/V 操作,保护共享资源实现互斥/同步
- Socket :不同主机间通信
线程间同步方式
- 互斥锁 :加锁后其他线程阻塞等待
- 读写锁 :读共享、写独占,适合读多写少
- 自旋锁 :加锁失败忙等待(CAS 原子指令),适合临界区短
- 条件变量 :等待/唤醒机制,与互斥锁配合使用
- 信号量 :计数器控制资源访问次数
进程调度算法
- FCFS :先来先服务,非抢占
- SJF :短作业优先
- HRRN :高响应比优先,兼顾长短作业
- RR :时间片轮转(20ms~50ms)
- HPF :最高优先级(静态/动态)
- 多级反馈队列 :多队列 + 优先级 + 时间片,最实用
死锁四个必要条件
- 互斥 :不能同时使用同一资源
- 持有并等待 :持有资源的同时等待其他资源
- 不可剥夺 :资源不能被强制抢占
- 环路等待 :获取资源顺序构成环形链
避免方法 :资源有序分配法,破坏环路等待条件。
Java 层的死锁代码示例与 jstack 排查见并发编程。
银行家算法
分配资源前预执行,检查是否存在安全序列。核心:不断检查剩余资源能否满足某进程的最大需求,能则加入安全序列并回收其资源,直到所有进程都能完成。
安全状态一定不会死锁,不安全状态不一定死锁 。
虚拟内存与物理内存
- 虚拟地址通过 页表 映射到物理地址(MMU 硬件完成)
- 虚拟地址 = 页号 + 页内偏移
- Linux 页大小 4KB
- 好处:进程内存可超过物理内存、进程间地址隔离、内存访问更安全
通用概念:虚拟地址→页→物理帧,是典型的间接层——同一模式见一致性哈希的虚拟节点、Redis 的哈希槽、Kafka 的分区。代价是每次访问多一次查表,所以必须有 TLB。
多级页表为什么省内存?TLB miss 有多贵?
频次 ★★★ · 难度 🟡
是什么:页表存”虚拟页号 → 物理页帧号”的映射。如果用单级大数组,32 位地址空间就要 2^20 项 × 4B = 4MB/进程,且必须一次性连续分配(数组按下标寻址,不能有洞),64 位下更是天文数字。多级页表把它拆成树——x86-64 用四级(PGD → PUD → PMD → PTE),48 位虚拟地址按 9+9+9+9+12 拆分,前四段逐级做目录索引,最后 12 位是 4KB 页内偏移:
47 39 38 30 29 21 20 12 11 0
+-----------+------------+------------+------------+-------------+
| PGD 索引 9 | PUD 索引 9 | PMD 索引 9 | PTE 索引 9 | 页内偏移 12 |
+-----------+------------+------------+------------+-------------+为什么省内存:省的关键不是”表拆小了”,而是没用到的子树整棵不分配。进程通常只用到地址空间的极小部分,上级目录项为空时,其下辖的整段地址范围(一个 PGD 项管 512GB)就一张表都不用建,只有实际 mmap/brk 出来的区域才逐级建表。代价是一次地址翻译要串行访问 4 次内存(逐级 walk),比单级的 1 次更慢——这就是 TLB 存在的理由。
TLB miss 的代价:TLB(Translation Lookaside Buffer)是 MMU 内缓存”虚拟页 → 物理页”翻译结果的硬件缓存,命中约 1 个时钟周期;miss 则由硬件走四级 page walk,最坏 4 次额外内存访问(每次约百纳秒),一次访存变五次。TLB 只有几百到上千条目,按 4KB 页只能覆盖几 MB 热点——大内存随机访问的程序(数据库缓冲池、JVM 大堆)TLB miss 会成为隐形瓶颈。进程切换还会让 TLB 失效(现代 CPU 用 PCID/ASID 给条目打进程标签缓解),这是”进程切换比线程切换贵”的微观原因之一(见上文进程/线程对比表)。
大页(HugePage)怎么救:把页从 4KB 提到 2MB(或 1GB),一条 TLB 条目覆盖的内存扩大 512 倍,page walk 也少一级(2MB 页在 PMD 级直接命中):
| 维度 | 4KB 普通页 | 静态 HugePage(2MB) | 透明大页 THP |
|---|---|---|---|
| TLB 覆盖 | 条目数 × 4KB | ×512 | ×512 |
| 分配方式 | 缺页时按需分配 | 启动时预留,应用显式申请 | 内核后台线程(khugepaged)自动合并 |
| 典型用户 | 默认 | MySQL/Oracle 缓冲池、JVM -XX:+UseLargePages | 多数发行版默认开启,但 Redis/MongoDB 建议关闭 |
| 坑 | 大内存下 TLB miss 多 | 预留后普通进程用不了,需容量规划 | 后台合并/拆分引入延迟毛刺;fork 后 COW 按 2MB 整页复制 |
数据库和 JVM 的堆是”大而稳定”的内存,最吃大页红利;而 Redis 官方要求关闭 THP:RDB/AOF 重写靠 fork + 写时复制共享内存(见Redis持久化一节与下文”写时复制”),开 THP 后子进程存续期间父进程改 1 字节就要复制整个 2MB 页,内存暴涨且写延迟抖动。
常见追问:
- 为什么不全用 1GB 大页?→ 内存管理粒度变粗:碎片浪费、COW/缺页的复制成本按页放大、大页不能 swap;只适合缓冲池/堆这类大而稳定的区域
- 缺页中断和 TLB miss 是一回事吗?→ 不是。TLB miss 由硬件 walk 页表解决,不进内核;walk 到无效页表项才触发缺页中断进内核(分配物理页/从 swap 读回)
- 怎么观察 TLB 和大页的效果?→
perf stat -e dTLB-load-misses看 miss 率;/proc/meminfo的 HugePages_* 看静态大页;/sys/kernel/mm/transparent_hugepage/enabled看 THP 开关
通用概念:多级页表是稀疏数据的树形按需分配——不为”可能存在但没用到”的空间付费,同类有 radix tree、稀疏文件;TLB 是给慢路径加结果缓存,与 CPU cache、MySQL Buffer Pool 同一层套路,共同点是靠局部性把平均成本压到接近命中成本。
程序内存布局(低到高)
代码段 -> 数据段(已初始化) -> BSS 段(未初始化) -> 堆(向上增长) -> 文件映射段 -> 栈(8MB,向下增长)
堆和栈区别
| 维度 | 堆 | 栈 |
|---|---|---|
| 分配方式 | 动态分配(malloc/mmap) | 编译器自动分配 |
| 管理 | 手动释放,可能泄漏 | 自动释放(函数返回) |
| 大小 | 较大 | 固定(一般 8MB) |
| 速度 | 较慢 | 快 |
写时复制(Copy On Write)
fork 时子进程只复制页表不复制物理内存,父子进程共享物理页(标记只读)。当任一方写操作时,触发写保护中断,内核复制物理页并设置可读写权限。
节省物理内存 ,避免 fork 时大量物理内存复制导致的长时间阻塞。
通用概念:写时复制把读路径的锁全部省掉,代价转嫁给写路径——同一模式见 CopyOnWriteArrayList、Redis 的 RDB fork。
malloc brk vs mmap
- < 128KB :brk(),堆顶指针向上移动
- >= 128KB :mmap(),文件映射区分配
内存不足时会发生什么
- 后台回收(kswapd,异步)-> 2. 直接回收(同步,阻塞进程)-> 3. OOM Killer(杀死占用内存最高的进程)
可回收的内存 :文件页(干净页直接释放,脏页写回磁盘)、匿名页(通过 Swap 机制)
page cache 与 OOM killer:容器里的 JVM 为什么会无声被杀?
频次 ★★★ · 难度 🟡
是什么:page cache 是内核拿空闲内存缓存文件页的机制——读文件先查缓存命中,写文件默认先写缓存并标”脏”、再异步回写磁盘。free 里 buff/cache 很高不代表内存紧张,available 才是真实可用量(cache 大部分可随时回收让位)。
脏页回写参数(vm.*,写延迟毛刺的来源):
| 参数 | 默认 | 含义 |
|---|---|---|
dirty_background_ratio | 10% | 脏页占比超过此值,后台 flusher 线程开始异步回写 |
dirty_ratio | 20% | 超过此值,正在写的进程被同步阻塞去回写——写延迟突刺就是它 |
dirty_expire_centisecs | 30s | 脏页最长驻留时间,到期必回写 |
大量写文件的服务(日志聚合、数据导出)要调低 background 值让回写更早更平滑,或应用层定期 fsync 控节奏——别攒到 dirty_ratio 触发同步阻塞。
direct IO 什么时候用:O_DIRECT 绕过 page cache 直达磁盘。适用于自己管缓存的系统:MySQL 的 Buffer Pool 配 innodb_flush_method=O_DIRECT,避免”数据在 Buffer Pool 一份、page cache 又一份”的双重缓存浪费(见MySQL Buffer Pool 一节)。反例是 Kafka/RocketMQ——不自建缓存,重度依赖 page cache 做读写加速(见消息队列存储两节),顺序访问模式与内核预读天然契合。
cgroup 内存限制与容器里 JVM 被杀:容器的内存上限是 cgroup limit,statistics 计的是 RSS + cgroup 记账的 page cache;超限触发 cgroup OOM,直接 SIGKILL 组内评分最高的进程(通常就是 JVM)。表现为容器退出码 137、K8s 事件 OOMKilled,而 JVM 日志里没有任何异常——这不是 Java 的 OutOfMemoryError,是内核层面的击杀。排查链条:
- JVM 总内存 = 堆 + 元空间 + 线程栈 + 直接内存 + CodeCache,
-Xmx只限住第一项(各块构成见Linux与工程化”容器内存与 JVM 堆”) - 老 JVM(8u191 前)看不见 cgroup 限制,按宿主机内存算默认堆大小,几乎必被杀;现代 JVM 用
UseContainerSupport(默认开)+MaxRAMPercentage按容器限额算 - 两种 “OOM” 要分清:Java OOM(堆满,抛异常、可留 dump)vs cgroup OOM kill(进程总量超限,无声消失,去
dmesg/ K8s Events 找)
常见追问:
- 容器里
free为什么显示的是宿主机内存?→/proc/meminfo没有 namespace 化;要看 cgroup 接口文件(memory.limit_in_bytes、memory.stat),JVM 的容器感知读的也是它们 - 退出码 137 怎么排查?→ 137 = 128 + 9(SIGKILL);先
dmesg | grep -i oom或 K8s describe 确认 OOMKilled,再核算 JVM 各内存块之和与 limit 的差距,常见元凶是直接内存(Netty)和线程数 - page cache 会把容器撑到 OOM 吗?→ cgroup 会先回收本组内可回收的 cache 再杀进程,正常不会;但 mmap 脏页回写不及时、tmpfs/shmem(不可回收)会成为例外
通用概念:page cache 体现”空闲资源不用白不用,但必须可随时归还”——同类有 JVM 堆的软引用缓存(见JVM四种引用);OOM killer 则是资源耗尽时的最后防线:宁可牺牲单个进程也不让整机挂死,与 Redis 内存淘汰(见Redis)、线程池拒绝策略(见并发编程)同属一层兜底设计。
页面置换算法
- OPT :最优置换(理论,无法实现)
- FIFO :先进先出
- LRU :最近最久未使用(近似最优,开销大)
- Clock :时钟算法(LRU 近似 + FIFO 改进,实用)
- LFU :最不常用(考虑访问频率)
中断
CPU 暂停当前任务,处理事件后恢复执行。
- 外部中断 :硬件产生。可屏蔽(INTR),不可屏蔽(NMI)
- 内部中断 :软中断(系统调用)、异常(陷阱/故障/终止)
- 系统调用:32 位 Linux 用
int 0x80,64 位用syscall
I/O 模型
- 阻塞 I/O :等待 I/O 完成才返回
- 非阻塞 I/O :立即返回,轮询检查
- I/O 多路复用 :select/poll/epoll,一个线程监听多个 I/O
- 信号驱动 I/O :I/O 完成时发信号通知
- 异步 I/O :内核完成 I/O 后通知应用
select/poll/epoll 区别
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | 固定位图 | 动态数组 | 红黑树 + 就绪链表 |
| 文件描述符限制 | 1024 | 无 | 系统最大 fd |
| 时间复杂度 | O(n) 遍历 | O(n) 遍历 | O(1) 事件驱动 |
| 用户/内核拷贝 | 2 次 | 2 次 | 只需传入关注的 fd |
epoll ET vs LT
- LT(水平触发) :满足条件就通知,直到数据读完
- ET(边缘触发) :只在状态变化时通知一次,需一次读完数据,一般搭配非阻塞 I/O
零拷贝
频次 ★★★★ · 难度 🟡 · 高频:阿里/字节/美团
零拷贝的目标:减少数据传输过程中的 CPU 拷贝次数和上下文切换次数。
传统 I/O(read + write):
HDD/SSD → DMA → 内核缓冲区 → CPU → 用户缓冲区 → CPU → Socket 缓冲区 → DMA → 网卡
4 次上下文切换(read/write 各 2 次),2 次 DMA 拷贝 + 2 次 CPU 拷贝,共 4 次拷贝。
零拷贝各方案对比:
| 方案 | 系统调用 | 上下文切换 | CPU 拷贝 | DMA 拷贝 | 说明 |
|---|---|---|---|---|---|
| 传统 read + write | read() + write() | 4 | 2 | 2 | 4 次拷贝,无优化 |
| mmap + write | mmap() + write() | 4 | 1 | 2 | 用户态与内核态共享页缓存,省 1 次 CPU 拷贝 |
| sendfile | sendfile() | 2 | 0 (DMA sg 支持) | 2 | 完全零拷贝,文件→Socket 最优方案 |
| splice | splice() | 2 | 0 | 2 | 两个 fd 间移动,不限于文件→Socket |
关键细节:
- mmap + write:将文件映射到内核地址空间,用户态直接读写页缓存,减少用户态↔内核态的数据拷贝;但存在页缓存与映射文件一致性问题、多线程并发写入可能触发 SIGBUS
- sendfile(Linux 2.6.33+ 配合 DMA scatter-gather 实现真正零拷贝):数据从磁盘→页缓存→网卡,全程不需要 CPU 搬移
- splice(Linux 2.6.17+):在两个文件描述符之间建立管道传输,不经用户态,适合代理/网关层转发数据
典型应用:
- Kafka:生产者写入用 mmap(CommitLog 索引),消费者读取用 sendfile(日志传输),见消息队列
- Nginx:静态文件用 sendfile(
sendfile on;) - Netty:
FileRegion封装 sendfile 零拷贝传输,见Netty与RPC
零拷贝是 Kafka 高吞吐、Netty 文件传输的底层支撑,应用侧见消息队列、Netty与RPC。
Linux 文件系统(VFS 与 inode)
频次 ★★★ · 难度 🟡 · 高频:阿里/字节
VFS(虚拟文件系统):
- Linux 通过 VFS 层统一抽象所有文件系统(ext4、XFS、tmpfs、procfs 等)
- 系统调用(open/read/write)→ VFS → 具体文件系统 → 物理存储
- VFS 定义通用接口:超级块(super_block)、inode、dentry、file
inode(索引节点):
- 存储文件元数据(权限、大小、时间戳、数据块指针),不包含文件名
- 每个文件(目录也是一种文件)有唯一 inode 号
- inode 数量有限(
df -i查看),小文件过多会耗尽 inode 导致”磁盘有空间但写不进”
硬链接 vs 软链接:
| 维度 | 硬链接 | 软链接(符号链接) |
|---|---|---|
| inode | 与原文件共享同一个 inode,本质是增加目录项 | 新建文件,存目标路径字符串(自己的 inode) |
| 依赖 | 不依赖原文件名,原文件删除仍可访问 | 原文件删除后断链 |
| 范围 | 不能跨文件系统、不能链目录 | 可跨文件系统、可链目录 |
| 命令 | ln target link_name | ln -s target link_name |
常见追问:
- 为什么硬链接不能跨文件系统?→ inode 号只在同一个文件系统内唯一,不同文件系统的 inode 1 代表不同文件
- 删除文件时底层做了什么?→ 减少 inode 的链接计数(i_link),计数为 0 且没有进程打开时才释放数据块——所以大文件被程序开着时,
rm后磁盘空间不立即释放,要等进程关闭 - 文件描述符(fd)和 inode 关系?→ 进程的 fd 指向内核的 file 结构体,file 结构体指向 dentry,dentry 指向 inode;fd 是用户态看到的整数索引,file 是内核态维护的文件打开状态(偏移量、flag),inode 是持久化的文件元数据
二、进程与线程补充
并行和并发有什么区别?
是什么:并发是逻辑上同时发生(一个 CPU 快速切换,交替执行多个任务),并行是物理上同时发生(多个 CPU 核心真正同时执行)。并发是结构,并行是执行。
| 维度 | 并发 | 并行 |
|---|---|---|
| 本质 | 任务交替执行,宏观”同时” | 任务真正同时执行 |
| 硬件 | 单核也能实现 | 需要多核 |
| 关注点 | 任务调度和协作 | 执行效率提升 |
| 示例 | 单核 CPU 处理多个网络连接 | 多核 CPU 各跑一个线程 |
常见追问:Go 的 goroutine 是并发还是并行?→ 并发模型(goroutine 是逻辑上的并发体),但可以在多核上并行执行(GOMAXPROCS 控制并行度)。
什么是孤儿进程和僵尸进程?如何处理?
频次 ★★★ · 难度 🟡
是什么(先区分两种”异常”进程):
- 孤儿进程:父进程先于子进程退出,子进程变”孤儿”,被 init(PID=1)进程收养。孤儿进程不是问题——init 会在子进程退出时调用 wait 回收它。
- 僵尸进程:子进程已退出,但父进程没有调用 wait/waitpid 回收其退出状态,子进程的 PCB 仍残留在进程表中(状态为 Z)。僵尸进程是问题——占用 PID 和少量内核资源,大量僵尸会耗尽 PID 号段。
处理方法:
- 父进程尽早调用
wait/waitpid(同步阻塞等待)或注册SIGCHLD信号处理函数异步回收 - 杀死父进程,僵尸进程被 init 收养并回收
- 编程中推荐用双 fork 技巧:父进程 fork 出子进程后立即退出,让子进程被 init 收养,子进程退出时 init 自动回收
常见追问:SIGCHLD 信号是什么?→ 子进程状态改变时(退出、暂停、继续),内核向父进程发送 SIGCHLD 信号。父进程可注册信号处理函数,在其中调用 waitpid 非阻塞回收。
进程的控制块 PCB 包含哪些信息?
是什么:PCB(Process Control Block)是内核为每个进程维护的数据结构,是进程的”身份证”和”档案”。
包含信息:
- 进程标识:PID、PPID(父进程 ID)
- 处理器状态:通用寄存器、PC、PSW、栈指针
- 进程调度信息:优先级、状态、时间片
- 内存管理信息:页表基址寄存器、内存界限
- 文件管理信息:打开的文件描述符表
- 记账信息:CPU 时间、资源使用量
Linux 实现:task_struct(定义在 include/linux/sched.h),包含上述所有字段,通过链表/红黑树组合组织。
操作系统的信号机制是什么?有什么作用?
是什么:信号是 OS 提供的一种异步通知机制——一个进程可以给另一个进程发送一个信号编号,接收方在合适时机响应。信号本质是软中断。
常见信号:SIGKILL(9,强制杀死,不可忽略/捕获)、SIGTERM(15,优雅终止,可捕获做清理)、SIGINT(2,Ctrl+C)、SIGCHLD(17,子进程状态改变)、SIGSEGV(11,段错误)、SIGUSR1/2(用户自定义)。
信号处理方式:忽略(大多数信号);捕获(注册信号处理函数);默认(内核预定义动作,如终止进程)。信号处理函数中应只做异步信号安全的操作(如设标志位、写管道),不能在信号处理函数中调用 malloc/printf(不可重入)。
三、同步机制深入
介绍一下几种典型的锁?
频次 ★★★ · 难度 🟡
是什么:锁是并发编程中最基本的同步原语,不同锁在”拿不到锁时怎么办”上有不同的选择:
| 锁类型 | 拿不到锁时的行为 | 特点 | 适用场景 |
|---|---|---|---|
| 互斥锁(mutex) | 阻塞睡眠,等锁释放后被唤醒 | 睡眠/唤醒有上下文切换开销 | 临界区较长(> 几百 ns) |
| 自旋锁(spinlock) | 忙等待(while 循环 CAS),不释放 CPU | 无上下文切换,但空转耗 CPU | 临界区极短(几 ns~几十 ns) |
| 读写锁(rwlock) | 读共享、写独占,写锁等待时,新读锁也需等待 | 适合读多写少 | 配置文件、缓存更新 |
| RCU | 读完全无锁,写时复制新版本再原子替换指针 | 读端零开销 | 内核中路由表、网络数据包处理 |
互斥锁 vs 自旋锁的选择:临界区在几个指令周期内 → 自旋锁(避免睡眠/唤醒开销);临界区可能需要等待较久 → 互斥锁(不空转浪费 CPU)。Java 的 synchronized 升级链路(偏向锁→轻量锁→重量锁)本质就是”自旋→互斥”的自动切换,见并发编程。
常见追问:读写锁的”写者饥饿”问题?→ 如果读锁一直不断,写锁就永远拿不到。改进方案:公平读写锁(写者排队时新读者也排队等待),或 RCU(读完全无锁,适合读多写极少的场景)。
PV 操作是什么?在进程同步中如何应用?
频次 ★★ · 难度 🟡
是什么:PV 操作是信号量(Semaphore)的两种原子操作。信号量是一个计数器,初值表示可用资源数。
- P 操作(Proberen,荷兰语”尝试”):
semaphore--,若减后 < 0,进程阻塞等待 - V 操作(Verhogen,荷兰语”增加”):
semaphore++,若加后 ≤ 0,唤醒一个等待进程
经典应用:
- 互斥:Semaphore mutex = 1;P(mutex) → 进入临界区 → V(mutex),实现互斥锁
- 同步(生产者-消费者):empty = N(缓冲区空位),full = 0(已填充数)。生产者 P(empty) → 放入 → V(full);消费者 P(full) → 取出 → V(empty)
- 读者-写者问题:用信号量控制读写互斥、写写互斥、读读允许
常见追问:P 和 V 操作为什么要原子?→ 非原子时,两个进程可能同时读到相同的 semaphore 值,都认为”还有资源”而同时进入临界区。硬件上通过关中断、TSL 指令或 CAS 原子指令实现。
管程是什么?在操作系统中有什么作用?
频次 ★★ · 难度 🟡
是什么:管程(Monitor)是一种高级同步机制,把共享数据和对共享数据的操作封装在一起,同一时刻只允许一个进程/线程在管程内执行。管程由编译器保证互斥,程序员不需要手动 P/V 操作。
组成:共享变量 + 条件变量 + 对共享变量操作的方法。
条件变量:当管程内的进程需要等待某个条件成立时,调用 wait() 释放管程并阻塞;当条件满足时,另一个进程调用 signal() 唤醒等待者。被唤醒的进程在重新获取管程锁后才能继续执行。
Java 的实现:synchronized 关键字 + wait/notify/notifyAll 就是管程模型。每个 Java 对象关联一个 Monitor,synchronized 方法/块获取的就是 Monitor 锁,wait/notify 操作的是 Monitor 的条件变量。详见并发编程。
四、调度算法补充
多级反馈队列调度算法是如何工作的?
频次 ★★ · 难度 🟡
是什么:多级反馈队列(Multilevel Feedback Queue)是最实用的通用调度算法,Windows 和 Linux 2.6 之前都采用它。核心思想:刚创建的进程放最高优先级,随着执行时间增加逐级降级:
队列 1(优先级最高,时间片 8ms)→ 用完降到队列 2
队列 2(优先级中,时间片 16ms)→ 用完降到队列 3
...
队列 N(优先级最低,时间片最长,FCFS)三条规则:高优先级队列非空时不调度低优先级;进入的进程进最高优先级;时间片用完降级,IO 阻塞返回后保持或提升优先级。
为什么好用:自动区分”IO 密集型”(短时间片就阻塞,一直留在高优先级)和”CPU 密集型”(用完时间片,逐渐降到低优先级),无需事先知道进程类型。
作业调度和进程调度有什么区别?
| 维度 | 作业调度(高级调度) | 进程调度(低级调度) |
|---|---|---|
| 频率 | 低频(几秒~几分钟一次) | 高频(几十 ms 一次) |
| 对象 | 作业(从外存加载到内存) | 就绪进程(分配 CPU) |
| 作用 | 控制内存中并发度 | 决定哪个进程运行 |
| 现代系统 | 分时/实时系统基本无此概念 | 核心调度 |
五、文件系统
文件的逻辑结构和物理结构是什么?
是什么:逻辑结构是用户看到的文件组织形式(字节流 or 记录流),物理结构是文件在磁盘上的实际存储方式。
物理结构(文件在磁盘上怎么放):
- 连续分配:文件占一连续磁盘块。顺序读写快,但外部碎片多、扩展难
- 链接分配:每个块末尾存下一块地址。无外部碎片,但随机访问慢、一个块损坏导致后续丢失
- 索引分配(Unix/Linux inode):用索引块集中存所有数据块指针,兼具随机访问和无碎片。多级索引:inode 有 12 个直接指针 + 1 个一级间接 + 1 个二级间接 + 1 个三级间接,小文件直接指针够用,大文件动态扩展级数
文件按名存取是如何实现的?
是什么:用户通过文件名访问文件,内核通过目录项将文件名映射到 inode。
实现流程:
- 目录文件存(文件名 → inode 号)的映射
- 根据路径逐级查找目录项,找到目标文件的 inode 号
- inode 里存文件属性(所有者、权限、大小)和数据块指针
- 结合进程的打开文件表(fd → 内核文件表 → inode),完成读写
常见追问:硬链接和软链接(符号链接)的区别?→ 硬链接是多个目录项指向同一个 inode(不能跨文件系统、不能链目录);软链接是存目标路径的独立文件(可跨文件系统、可链目录,但目标删了链接就变”悬空链接”)。
文件的目录结构有哪些常见类型?如何实现文件共享?
目录结构:单级目录(全系统一个表,不支持重名)→ 两级目录(主目录 + 用户目录,可重名但无分组)→ 树形目录(多级,Linux/Windows 现行方式,可分组、可层层嵌套)→ 无环图目录(允许文件被多个目录共享,需引用计数避免删除正在被共享的文件)。
文件共享方式:硬链接(同一文件系统,共享 inode)、软链接(独立文件存路径,灵活但脆弱)、不同文件系统间需网络文件系统(NFS/SMB)。
六、设备管理与磁盘
磁盘调度算法有哪些?各有什么特点?
频次 ★★ · 难度 🟡
是什么:磁盘读写最耗时的是寻道(磁头移动到目标柱面),磁盘调度算法通过重新排序请求队列来减少平均寻道时间。
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| FCFS | 按请求顺序处理 | 公平 | 寻道距离长 |
| SSTF(最短寻道) | 每次选离当前磁头最近的请求 | 吞吐高 | 可能饿死远端请求 |
| SCAN(电梯算法) | 磁头从一端走到另一端,沿途处理请求 | 避免饥饿 | 两端的请求等得久 |
| C-SCAN | 单向扫描,到头后快速回起点(不处理回程请求) | 各位置等待时间均匀 | 回程空转 |
| LOOK/C-LOOK | SCAN 的改进版,不到头就反向(走到最远请求处就回头) | 实际使用 |
现代系统:机械硬盘(HDD)时代磁盘调度算法很重要;SSD 无机械寻道,算法收益大幅下降,主要靠 NCQ(Native Command Queueing)做批量优化。
什么是 SPOOLING 技术?在操作系统中有什么作用?
是什么:SPOOLING(Simultaneous Peripheral Operations On-Line)是假脱机技术——把低速 I/O 设备(如打印机)的独占访问变成共享访问。数据先写入磁盘缓冲区(输入井/输出井),由独立的后台进程(SPOOLING 守护进程)按队列顺序把数据从缓冲区送到物理设备。
为什么需要:打印机是独占设备,若一个进程直接占用打印机,其他进程必须等待——SPOOLING 让每个进程”以为”自己有独占打印机,实际是写到磁盘的输出井,后台逐份打印。
常见追问:打印缓冲区和 SPOOLING 是一回事吗?→ 缓冲区是内存中的临时存储,SPOOLING 是系统级的设备虚拟化,用磁盘做中间存储,缓冲只是其一部分。
什么是缓冲区溢出攻击?如何防范?
频率 ★★ · 难度 🟡
是什么:程序向栈/堆上的缓冲区写入超过其容量的数据,覆盖相邻内存(如函数的返回地址),攻击者精心构造数据使程序跳转到恶意代码。
典型场景:C 语言 gets() 不检查长度,输入超过缓冲区大小,覆盖栈上的返回地址,程序 ret 时跳转到攻击者注入的 shellcode。
防范措施:
- Stack Canary(栈金丝雀):函数入口在栈上返回地址和局部变量之间放一个随机值,函数返回前检查该值是否被改动。GCC 默认开启
-fstack-protector - DEP(数据执行保护)/NX bit:标记栈和堆所在内存页为不可执行,即使注入 shellcode 也无法执行
- ASLR(地址空间布局随机化):每次加载程序时随机化栈、堆、库的基地址,攻击者猜不到 shellcode 的跳转目标地址
- 使用安全函数:
fgets代替gets、strncpy代替strcpy、snprintf代替sprintf