Appearance
操作系统高频题
操作系统面试围绕四个主题:进程与线程、内存管理、IO、中断与系统调用。答题时尽量结合实际(如 JVM、Go 运行时)展开。
Q1: 进程和线程的区别?什么是协程? 「🟢 校招/初级」
考察点:执行单元的基本概念。
参考答案:
- 进程:资源分配的基本单位,有独立地址空间;进程间隔离性好,切换成本高。
- 线程:CPU 调度的基本单位,共享所属进程的地址空间;切换比进程轻量,但需要处理共享数据的同步问题。
- 协程:用户态的轻量执行单元,由程序自己调度(非抢占),切换不陷入内核,开销极小;Go 的 goroutine、Kotlin 的 coroutine 都是典型代表。
- 关系:一个进程含多个线程,一个线程可承载成千上万个协程(M:N 调度)。
追问延伸:
- 为什么 Go 要用 GMP 模型而不是 1:1 线程模型?(见 Go 篇)
- 进程间通信有哪些方式?
Q2: 进程间通信(IPC)有哪些方式? 「🟡 中级」
考察点:IPC 手段的广度和选型。
参考答案:
- 管道:匿名管道(父子进程)、命名管道(FIFO)。
- 消息队列:内核维护的链表,按消息类型读写。
- 共享内存:最快的方式,但需要配合同步机制(信号量)。
- 信号量:主要用于同步而非传输数据。
- 信号:异步通知,如
SIGTERM。 - Socket:可跨机器,最通用;网络服务都基于它。
追问延伸:
- 共享内存为什么最快?(省去内核中转拷贝)
- 为什么现代微服务偏好 Socket/HTTP 而不是共享内存?
Q3: 什么是虚拟内存?页面置换算法有哪些? 「🟡 中级」
考察点:内存管理的核心机制。
参考答案:
- 虚拟内存让每个进程拥有独立连续的地址空间,通过页表映射到物理内存;按需分页(缺页中断时才加载),并利用磁盘扩展可用内存。
- 好处:进程隔离保护、内存超卖、简化程序链接加载。
- 页面置换算法:FIFO(有 Belady 异常)、LRU(最优近似,实现成本高)、LFU、Clock 算法(LRU 的工程近似)。
追问延伸:
- 为什么 LRU 在操作系统里很难用精确实现?(硬件开销,所以用 Clock)
- 什么是写时复制(COW)?fork 怎么利用它?
Q4: 用户态和内核态的区别?什么是系统调用? 「🟡 中级」
考察点:操作系统安全边界与性能开销意识。
参考答案:
- CPU 分特权级:内核态能访问所有资源,用户态只能访问受限资源;用户程序要读写文件、网络等必须通过系统调用陷入内核。
- 切换开销:保存寄存器、切换页表、刷 TLB 等;所以高性能编程要减少系统调用(如批量 IO、io_uring、sendfile 零拷贝)。
追问延伸:
- 什么是零拷贝?sendfile、mmap 的原理?
- Go 的 netpoller 怎么减少系统调用阻塞?
Q5: 什么是死锁?产生条件和避免方法? 「🟡 中级」
考察点:并发安全的经典问题(详见"并发与锁"篇)。
参考答案:
- 四个必要条件:互斥、持有并等待、不可剥夺、循环等待。
- 破坏任一条件即可避免:一次性申请所有资源(破坏持有并等待)、按固定顺序加锁(破坏循环等待)、加锁超时(破坏不可剥夺)。
- 检测与恢复:资源分配图判环;银行家算法属于事前避免,工程中很少用。
追问延伸:
- 活锁和饥饿的区别?
- 数据库里的死锁是怎么检测和处理的?(见数据库原理篇)
Q6: 什么是上下文切换?开销来自哪里? 「🟡 中级」
考察点:性能分析的底层认知。
参考答案:
- 从一个任务切换到另一个任务时,需要保存当前任务的状态(寄存器、程序计数器等)并恢复下一个任务的状态。
- 开销:直接开销(保存/恢复寄存器、切换页表、刷 TLB/缓存)+ 间接开销(缓存失效导致的性能下降)。
- 线程切换比进程切换轻(共享地址空间);协程切换在用户态完成,最轻。
追问延伸:
- 怎么观察系统的上下文切换次数?(
vmstat的 cs 列) - 为什么线程数不是越多越好?
Q7: select、poll、epoll 的区别是什么? 「🔴 高级」
考察点:IO 多路复用三种方案的深入理解,高频题。
参考答案:
IO 多路复用:一个线程同时监听多个 fd(文件描述符),哪个 fd 有事件就处理哪个。
三种方案对比:
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | bitmap | 链表 | 红黑树+就绪链表 |
| 最大fd数 | 1024(FD_SETSIZE) | 无限制 | 无限制 |
| 时间复杂度 | O(n) | O(n) | O(1) |
| fd拷贝 | 每次全量拷贝 | 每次全量拷贝 | 只注册一次 |
| 触发方式 | LT(水平触发) | LT | LT+ET |
| 性能 | 差(fd越多越慢) | 差 | 好(fd多也不慢) |
select 工作流程:
- 用户空间把 fd_set 拷贝到内核
- 内核遍历所有 fd,检查是否有事件
- 返回有事件的 fd 数量
- 用户空间再遍历所有 fd 找到有事件的
→ 两次遍历 + 两次拷贝,fd 越多越慢
epoll 工作流程:
epoll_create:创建 epoll 实例(红黑树存 fd + 就绪链表)epoll_ctl:注册 fd(只在注册时拷贝一次)epoll_wait:直接从就绪链表取有事件的 fd(O(1))- 内核通过回调把有事件的 fd 加入就绪链表
→ 只有有事件的 fd 才会被处理,不用遍历所有 fd
epoll 的两种触发模式:
- LT(水平触发,默认):只要 fd 有数据可读,每次 epoll_wait 都会返回 → 编程简单
- ET(边缘触发):只在状态变化时通知一次 → 必须一次性读完数据(配合非阻塞IO)
Redis、Nginx、Netty 的高性能都依赖 epoll(Linux)/ kqueue(Mac)/ IOCP(Windows)。
追问延伸:
- ET 模式为什么要配合非阻塞 IO?
- epoll 为什么用红黑树存 fd?
Q8: 操作系统的内存管理是怎样的?虚拟地址怎么转物理地址? 「🟡 中级」
考察点:内存管理的核心机制理解。
参考答案:
虚拟内存:
- 每个进程有独立的虚拟地址空间(32位4GB,64位256TB)
- 虚拟地址 → 物理地址通过页表转换
- 好处:进程隔离、内存共享、内存超分配、按需加载
内存管理方式:
- 分页(最常用):内存分为固定大小的页(通常4KB),虚拟页 → 物理页
- 分段:按逻辑分段(代码段/数据段/栈段),大小不固定
- 段页式:先分段再分页,结合两者
虚拟地址转物理地址(分页):
- 虚拟地址 = 页号 + 页内偏移
- 查页表:页号 → 物理页框号
- 物理地址 = 物理页框号 × 页大小 + 页内偏移
多级页表:
- 64位地址空间太大,单级页表浪费内存
- 多级页表:只映射用到的虚拟地址,节省内存
- x86-64 通常4级页表(PGD→PUD→PMD→PTE)
TLB(Translation Lookaside Buffer):
- 页表缓存在 CPU 的 TLB 中
- TLB 命中 → 不用查内存中的页表 → 极快
- TLB miss → 查多级页表 → 慢(多次内存访问)
- 进程切换时 TLB 需要刷新(ASID 技术可以避免)
缺页中断(Page Fault):
- 访问的虚拟页不在物理内存中 → 触发缺页中断
- OS 从磁盘(swap/文件)加载数据到物理内存
- 更新页表 → 重新执行指令
程序的内存布局:
高地址 ┌──────────┐
│ 内核空间 │
├──────────┤
│ 栈 ↓ │ ← 局部变量、函数调用
│ │
│ ↑ 堆 │ ← malloc/new 分配
├──────────┤
│ BSS段 │ ← 未初始化全局变量
├──────────┤
│ 数据段 │ ← 已初始化全局变量
├──────────┤
│ 代码段 │ ← 程序指令
低地址 └──────────┘追问延伸:
- 分页和分段的区别?
- TLB 和 CPU Cache 的区别?
Q9: 什么是写时复制(Copy-On-Write)?fork() 会复制什么? 「🟡 中级」
考察点:COW 机制的深入理解。
参考答案:
Copy-On-Write(写时复制):
- fork() 创建子进程时,不立即复制父进程的整个地址空间
- 父子进程共享同一份物理内存页(页表标记为只读)
- 只有某一方写入时,才触发缺页中断,复制该页(变可写)
- 优点:fork 极快(不用复制大量内存)、节省内存
fork() 复制的内容:
- 复制:页表(指向相同的物理页,标记只读)、文件描述符表、信号处理函数表、环境
- 不复制(共享):物理内存页(COW)、代码段(只读不写)
- 独立:PID、PPID、资源统计(CPU时间等)
COW 的应用场景:
- Redis BGSAVE:fork 子进程做 RDB 持久化,主进程继续服务
- Redis BGREWRITEAOF:fork 子进程做 AOF 重写
- Linux fork + exec:fork 后立即 exec,COW 避免了无意义的内存复制
- Java CopyOnWriteArrayList:写时复制,读不加锁
- Docker 镜像层:容器层和镜像层共享只读层,写时复制
COW 的风险:
- fork 本身快,但如果 fork 后大量写入,COW 会触发大量页复制,内存翻倍
- Redis 大实例 fork 可能阻塞(页表大、复制慢)
- COW 期间内存占用 = 父+子写入部分(不是全量翻倍)
追问延伸:
- Redis BGSAVE 时 fork 为什么可能阻塞?
- COW 和 CAS 有什么区别?
Q10: 什么是零拷贝(Zero-Copy)? 「🔴 高级」
考察点:高性能 IO 的核心技术理解。
参考答案:
零拷贝:减少数据在内核空间和用户空间之间的拷贝次数(不是零次拷贝,是减少)。
传统文件传输(read + write):
磁盘 → 内核缓冲区 → 用户缓冲区 → Socket缓冲区 → 网卡
(DMA) (CPU拷贝) (CPU拷贝) (DMA)4次上下文切换(read syscall + write syscall),2次 CPU 拷贝。
零拷贝方案:
- mmap + write:
- mmap 把文件映射到用户空间内存
- write 直接从映射区写到 Socket
- 减少1次 CPU 拷贝(不需要内核→用户→内核)
- 3次上下文切换,1次 CPU 拷贝
- sendfile(2.1+):
- 一次 syscall 完成文件→Socket 传输
- 数据全程在内核空间
- 2次上下文切换,1次 CPU 拷贝(内核缓冲区→Socket缓冲区)
- sendfile + SG-DMA(2.4+):
- 如果网卡支持 Scatter-Gather DMA
- 内核只把文件描述符和长度传给网卡
- DMA 直接从内核缓冲区传到网卡
- 2次上下文切换,0次 CPU 拷贝(真正零拷贝)
零拷贝的应用:
- Kafka:用 sendfile 实现高性能消息传输(零拷贝 + 顺序写 + PageCache)
- Nginx:静态文件传输用 sendfile
- Java NIO:
FileChannel.transferTo()底层调用 sendfile - RocketMQ:CommitLog 用 mmap + write
Java 中的零拷贝 API:
FileChannel.transferTo(position, count, writableChannel):底层 sendfileFileChannel.map(MapMode.READ_ONLY, position, size):底层 mmapDirectByteBuffer:直接在堆外内存分配,减少一次 JVM 堆→native 拷贝
追问延伸:
- mmap 和 sendfile 的区别?
- 为什么 Kafka 用零拷贝?
Q11: 进程调度算法有哪些? 「🟢 校招/初级」
考察点:操作系统调度的基础知识。
参考答案:
常见调度算法:
- FCFS(先来先服务):
- 按到达顺序调度
- 简单公平,但短任务可能被长任务阻塞(护航效应)
- SJF(短作业优先):
- 优先调度预计执行时间最短的
- 平均等待时间最短,但可能饿死长任务
- 优先级调度:
- 每个进程有优先级,按优先级调度
- 高优先级先执行,低优先级可能饿死(解决:老化策略,等待越久优先级越高)
- 时间片轮转(Round Robin):
- 每个进程分配一个时间片(如10ms),轮流执行
- 公平,响应快,适合交互式系统
- 时间片太大 → 退化为 FCFS;太小 → 上下文切换开销大
- 多级反馈队列(MLFQ):
- 多个就绪队列,优先级从高到低,时间片从小到大
- 新进程进最高优先级队列
- 用完时间片 → 降级到下一队列
- I/O 密集型进程会留在高优先级(时间片没用完就阻塞)
- 兼顾响应时间和吞吐量,Linux/Windows 实际使用
Linux 实际调度器:
- O(1) 调度器(2.4-2.6):优先级队列数组
- CFS(完全公平调度器,2.6.23+):基于红黑树,按虚拟运行时间排序,O(logN)
- EEVDF(6.6+):CFS 的改进版本,更精确的延迟控制
追问延伸:
- 时间片轮转的时间片设多大合适?
- CFS 怎么保证"完全公平"?
Q12: 什么是中断?中断的流程和类型有哪些? 「🟡 中级」
考察点:操作系统中断机制的全面理解。
参考答案:
中断:CPU 在执行程序时,收到硬件或软件信号,暂停当前程序,转去处理中断处理程序,处理完后返回。
中断类型:
- 硬件中断(外部中断):
- 时钟中断:定时器到期,触发调度
- I/O 中断:磁盘/网卡完成操作
- 键盘/鼠标中断
- 特点:异步发生,与 CPU 执行指令无关
- 软件中断(内部中断/异常):
- 系统调用:用户态 → 内核态(int 0x80 / syscall 指令)
- 缺页中断:访问的页不在内存
- 除零异常、栈溢出
- 断点/陷阱(调试用)
- 特点:由 CPU 执行指令触发
中断处理流程:
- 中断检测:CPU 在每条指令执行完后检查中断信号
- 保存现场:保存当前 PC、寄存器、状态字到内核栈
- 查中断向量表:根据中断号找到中断处理程序入口
- 执行中断处理程序:处理中断
- 恢复现场:从内核栈恢复寄存器和 PC
- 返回:继续执行被中断的程序
中断的上下半部机制(Linux):
- 上半部(Top Half):中断处理程序,关中断,快速处理(如接收网卡数据到队列)
- 下半部(Bottom Half):开中断,延迟处理耗时操作(如解析数据、处理协议)
- 软中断(SoftIRQ)、Tasklet、工作队列(Work Queue)
- 目的:缩短关中断时间,提高系统响应能力
中断的作用:
- 实现多任务(时钟中断触发调度)
- 实现系统调用(用户态进入内核态)
- 实现 I/O 异步(CPU 不需轮询设备状态)
- 处理异常和错误
追问延伸:
- 中断和信号的区别?
- 中断上下半部为什么要分开?
Q13: 进程有哪些状态?状态之间如何切换? 「🟢 校招/初级」
考察点:进程生命周期的基础知识。
参考答案:
进程的五种状态:
┌──────────┐
┌────────→│ 就绪态 Ready │←────────┐
│ └──────────┘ │
│ │ 调度 │ 时间片到/被抢占
│ ↓ │
牵迫/│ ┌──────────┐ │
事件 │ │ 运行态 Running │────────┘
完成 │ └──────────┘
│ │ I/O请求/等待事件
│ ↓
│ ┌──────────┐
└─────────│ 阻塞态 Blocked │
事件完成 └──────────┘
创建态 New ──→ 就绪态 终止态 Terminated ←── 运行态(正常退出/异常终止)状态说明:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| 创建态 | 进程正在被创建 | fork() 后、PCB 初始化中 |
| 就绪态 | 已准备好,等待 CPU | 时间片用完、I/O 完成、被唤醒 |
| 运行态 | 正在 CPU 上执行 | 调度器选中 |
| 阻塞态 | 等待 I/O 或事件 | 主动等待(read、wait、sleep) |
| 终止态 | 执行完毕或被杀死 | exit()、kill -9 |
关键转换:
- 就绪→运行:调度器分配 CPU
- 运行→就绪:时间片用完、被更高优先级抢占
- 运行→阻塞:主动等待 I/O/事件(不是被调度走的,是自愿的)
- 阻塞→就绪:等待的事件完成(I/O 完成)
- 运行→终止:进程结束
注意:阻塞态不能直接到运行态,必须先到就绪态排队。
Linux 实际状态(ps 的 STAT):
R:运行/就绪(Linux 不区分运行和就绪)S:可中断睡眠(可被信号唤醒)D:不可中断睡眠(等待 I/O,不响应信号)Z:僵尸进程(已终止但父进程未回收)T:暂停(被信号停止)
追问延伸:
- 僵尸进程是怎么产生的?怎么处理?
- 进程从运行态到阻塞态是主动的还是被动的?(主动的,是进程自愿等待)
Q14: 进程切换和线程切换的区别是什么?线程切换为什么更快? 「🟡 中级」
考察点:上下文切换的底层理解。
参考答案:
进程切换:
- 保存当前进程的上下文(寄存器、PC、页表基址等)
- 切换页表(更新 CR3 寄存器)→ TLB 全部失效 → 虚拟地址翻译变慢
- 刷新 CPU 缓存(L1/L2/L3 Cache)→ 缓存命中率下降
- 切换到新进程的页表和上下文
线程切换(同一进程内):
- 保存当前线程的上下文(寄存器、PC、栈指针)
- 不切换页表(同一进程的线程共享地址空间)→ TLB 不失效
- 不刷新 CPU 缓存(共享虚拟地址空间,缓存依然有效)
- 切换到新线程的栈和上下文
线程切换更快的原因:
- 省去了页表切换和 TLB 刷新的开销
- 省去了缓存失效导致的性能下降(最大开销来源)
- 只需保存/恢复寄存器和栈指针
切换开销量化(大致参考):
- 进程切换:约 10-50 微秒(含 TLB/Cache 失效的间接开销)
- 线程切换:约 1-10 微秒
- 协程切换:约 0.1-1 微秒(用户态,无系统调用)
线程切换详细过程:
- 用户态 → 内核态(系统调用/中断)
- 保存当前线程的寄存器到线程控制块(TCB)
- 调度器选择下一个线程
- 从新线程的 TCB 恢复寄存器
- 切换内核栈
- 内核态 → 用户态
追问延伸:
- 为什么线程太多反而慢?(上下文切换开销超过计算时间 + 缓存颠簸)
- 协程切换为什么不开销 TLB?(不进内核态,不涉及调度器)
Q15: 线程间通信有哪些方式? 「🟡 中级」
考察点:线程同步与通信机制。
参考答案:
线程间通信(同一进程内,共享地址空间):
| 方式 | 原理 | 适用场景 |
|---|---|---|
| 共享变量 | 直接读写共享内存 | 简单数据共享,需配合同步机制 |
| 互斥锁 | 保护临界区,保证独占访问 | 多线程修改共享数据 |
| 信号量 | 计数器控制访问数量 | 限制并发访问数(如连接池) |
| 条件变量 | 等待/通知模式 | 生产者-消费者 |
| 读写锁 | 读共享、写独占 | 读多写少场景 |
| 自旋锁 | 忙等待(不睡眠) | 临界区极短、不能睡眠的场景 |
| 屏障(Barrier) | 所有线程到齐后一起继续 | 多线程分阶段计算 |
| 管程(Monitor) | 语言级封装(synchronized/wait/notify) | Java 的并发同步 |
生产者-消费者示例(条件变量 + 互斥锁):
c
// C 语言 pthread
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t not_full = PTHREAD_COND_INITIALIZER;
pthread_cond_t not_empty = PTHREAD_COND_INITIALIZER;
// 生产者
pthread_mutex_lock(&mutex);
while (queue_is_full()) // while 防止虚假唤醒
pthread_cond_wait(¬_full, &mutex);
enqueue(item);
pthread_cond_signal(¬_empty); // 通知消费者
pthread_mutex_unlock(&mutex);
// 消费者
pthread_mutex_lock(&mutex);
while (queue_is_empty())
pthread_cond_wait(¬_empty, &mutex);
item = dequeue();
pthread_cond_signal(¬_full); // 通知生产者
pthread_mutex_unlock(&mutex);Java 中的对应:
synchronized+wait()/notify()→ 管程模型ReentrantLock+Condition.await()/signal()→ 显式条件变量BlockingQueue→ 封装好的生产者-消费者CountDownLatch/CyclicBarrier→ 屏障/倒计时
追问延伸:
wait()为什么要放在while循环里?(防止虚假唤醒 Spurious Wakeup)- 互斥锁和信号量的区别?(互斥锁是 0/1 的二值信号量,但语义不同:互斥锁谁加锁谁解锁)
Q16: 互斥锁、自旋锁、读写锁的区别和应用场景? 「🔴 高级」
考察点:锁机制的理解和选型。
参考答案:
| 维度 | 互斥锁 | 自旋锁 | 读写锁 |
|---|---|---|---|
| 获取失败行为 | 线程睡眠(让出 CPU) | 忙等待(不放弃 CPU) | 读锁共享、写锁独占 |
| 开销 | 上下文切换开销 | CPU 空转开销 | 比互斥锁复杂 |
| 适用场景 | 临界区较长 | 临界区极短、多核 | 读多写少 |
| 不能用于 | 中断上下文 | 可用于中断上下文 | 不能睡眠场景 |
| 性能 | 高并发下上下文切换多 | 高并发下 CPU 浪费多 | 读并发高时优于互斥锁 |
互斥锁(Mutex):
- 获取失败 → 线程进入睡眠 → 加入等待队列
- 释放锁 → 唤醒等待线程 → 上下文切换
- 适合:临界区执行时间 > 上下文切换时间(约 1-10 微秒)
- 注意:互斥锁不能在中断处理程序中使用(中断中不能睡眠)
自旋锁(Spinlock):
- 获取失败 → while 循环不断尝试(不睡眠、不让 CPU)
- 适合:临界区极短(< 上下文切换开销),或不能睡眠的场景(中断处理)
- 注意:单核下自旋锁无意义(持有锁的线程没法运行,自旋者死等);只在多核下有意义
- 风险:长时间自旋浪费 CPU → 带最大自旋次数的自旋锁(到期后退化为互斥锁)
读写锁(RWLock):
- 读锁:多个读者可以同时持有(共享)
- 写锁:独占,与所有读锁/写锁互斥
- 读多写少场景性能好
- 风险:写饥饿(读者太多时写者一直拿不到锁)
- 改进:写优先的读写锁(有写者等待时新读者阻塞)
Linux 中的锁:
c
// 互斥锁
pthread_mutex_t mutex;
pthread_mutex_lock(&mutex); // 获取
pthread_mutex_unlock(&mutex); // 释放
// 自旋锁
pthread_spinlock_t spin;
pthread_spin_lock(&spin);
pthread_spin_unlock(&spin);
// 读写锁
pthread_rwlock_t rwlock;
pthread_rwlock_rdlock(&rwlock); // 读锁
pthread_rwlock_wrlock(&rwlock); // 写锁
pthread_rwlock_unlock(&rwlock);Java 中对应的锁:
synchronized/ReentrantLock→ 互斥锁ReentrantReadWriteLock→ 读写锁StampedLock→ 乐观读锁(Java 8+,性能更好)- Java 没有直接的自旋锁,但
synchronized的轻量级锁阶段用了自旋
追问延伸:
- 为什么中断处理中只能用自旋锁不能用互斥锁?(中断中不能睡眠/调度)
- Java 的
synchronized锁升级过程是什么?(无锁→偏向锁→轻量级锁(自旋)→重量级锁)
Q17: 乐观锁和悲观锁有什么区别? 「🟡 中级」
考察点:并发控制策略的对比。
参考答案:
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 假设 | 认为冲突大概率发生 | 认为冲突小概率发生 |
| 策略 | 先加锁再操作 | 先操作再验证 |
| 性能 | 并写低时好,高时差 | 并写低时好,冲突多时差 |
| 实现方式 | 互斥锁、数据库行锁、synchronized | CAS、version 版本号、AtomicInteger |
| 冲突处理 | 阻塞等待 | 重试或放弃 |
悲观锁:
- 操作前先加锁 → 独占资源 → 操作完释放
- 数据库:
SELECT ... FOR UPDATE(行锁) - Java:
synchronized、ReentrantLock - 场景:写多读少、冲突频繁、数据一致性要求高
乐观锁:
- 先操作(不加锁)→ 提交时检查是否有人改过 → 没改过则提交,改过则重试
- 数据库:version 字段
UPDATE t SET ... WHERE version = old_version - Java:
AtomicInteger(CAS)、StampedLock乐观读 - 场景:读多写少、冲突少、追求高并发
CAS(Compare-And-Swap):
java
// AtomicInteger 的 incrementAndGet 底层就是 CAS
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
// 底层:
// do {
// old = count.get();
// newVal = old + 1;
// } while (!count.compareAndSet(old, newVal)); // CAS 重试ABA 问题:
- 线程1读到值A → 线程2把A改成B再改回A → 线程1的CAS成功
- CAS 以为没变过,实际上被改过
- 解决:加版本号(
AtomicStampedReference)
追问延伸:
- CAS 有什么问题?(ABA、自旋开销、只能保证一个变量原子)
- MySQL 的乐观锁怎么实现?(version 字段 +
UPDATE WHERE version=?)
Q18: 什么是银行家算法? 「🔴 高级」
考察点:死锁避免的经典算法。
参考答案:
银行家算法(Dijkstra 提出):一种死锁避免(不是预防也不是检测)算法,在资源分配前检查是否安全。
核心思想:
- 银行家贷款前要评估:借出去后所有客户是否还能顺利还贷
- 操作系统分配资源前要评估:分配后系统是否处于安全状态
关键概念:
- 可用资源(Available):系统当前可用的各类资源数
- 最大需求(Max):每个进程最多需要的各类资源数
- 已分配(Allocation):已经分配给每个进程的各类资源数
- 还需要(Need = Max - Allocation):每个进程还需要的各类资源数
安全状态判断流程:
- 找一个 Need ≤ Available 的进程 P
- 假设 P 完成 → 释放它持有的所有资源 → Available += Allocation[P]
- 标记 P 为完成
- 重复 1-3,直到所有进程都能完成 → 安全状态
- 如果找不到这样的进程 → 不安全状态(可能死锁)
简化示例(单类资源):
进程 Max Allocation Need Available
P0 10 5 5 3
P1 4 2 2
P2 9 2 7
安全序列:P1(需2≤3) → 释放 → Available=5 → P0(需5≤5) → 释放 → Available=10 → P2(需7≤10)
→ 安全!工程现状:
- 银行家算法理论上完备,但实际很少用
- 原因:需要预先知道最大需求(Max),实际难以预测
- 实际更常用:超时机制、死锁检测+恢复、鸵鸟策略(忽略死锁,概率低)
追问延伸:
- 银行家算法属于死锁的哪种处理策略?(避免 Avoidance,不是预防 Prevention)
- 为什么实际系统很少用银行家算法?(需要预知最大资源需求,不现实)
Q19: 堆和栈的区别是什么? 「🟢 校招/初级」
考察点:程序内存布局的基础理解。
参考答案:
| 维度 | 栈 | 堆 |
|---|---|---|
| 管理 | 编译器自动分配/释放 | 程序员手动分配/释放(malloc/new) |
| 生长方向 | 高地址→低地址(向下) | 低地址→高地址(向上) |
| 空间大小 | 有限(Linux 默认 8MB,可调) | 大(受物理内存/虚拟内存限制) |
| 速度 | 快(移动栈指针) | 慢(链表管理 + 碎片整理) |
| 存储内容 | 局部变量、函数参数、返回地址 | 动态分配的对象、大数组 |
| 碎片 | 无(连续入栈出栈) | 有(频繁 malloc/free 产生内存碎片) |
| 生命周期 | 函数返回自动释放 | 手动释放(忘释放 → 内存泄漏) |
栈的分配过程(函数调用):
高地址 ┌──────────────┐
│ main() 栈帧 │ ← 局部变量、返回地址
│ │
├──────────────┤
│ funcA() 栈帧 │ ← 压栈
│ │
├──────────────┤
│ funcB() 栈帧 │ ← 再压栈
│ │
低地址 └──────────────┘ ← 栈顶(SP 指针)堆的分配过程:
malloc(100)→ 在堆空间找一块 >= 100 字节的空闲块- 内存分配器(ptmalloc/jemalloc/tcmalloc)维护空闲链表或伙伴系统
- 分配 → 标记为已用;释放 → 标记为空闲,可能合并相邻空闲块
为什么函数的局部变量不能返回指针:
c
int* badFunc() {
int local = 42;
return &local; // 错误!函数返回后栈帧被回收,指针悬空
}
// 正确做法:用堆
int* goodFunc() {
int* p = malloc(sizeof(int));
*p = 42;
return p; // 堆内存不会被自动回收
}追问延伸:
- 栈溢出是什么原因?(递归太深、局部变量太大)
- 为什么 Java 中对象在堆上而基本类型局部变量在栈上?(JVM 的设计,逃逸分析可能把对象分配到栈上)
Q20: 操作系统的内存分配算法有哪些? 「🔴 高级」
考察点:内存管理的底层实现。
参考答案:
物理内存分配算法:
1. 伙伴系统(Buddy System):
- 将内存分为 2 的幂次方大小的块(1, 2, 4, 8, 16... 页)
- 分配:找到大小最匹配的块 → 如果太大则对半拆分 → 直到合适
- 释放:检查相邻的"伙伴"块是否也空闲 → 合并 → 递归合并更大的块
- 优点:快速合并(O(logN))、减少外部碎片
- 缺点:内部碎片(分配 33 页需要 64 页的块)
- Linux 物理页框分配就用伙伴系统
申请 3 页内存:
空闲链表:[8页块] → 拆成两个4页块 → 取一个4页块 → 拆成两个2页块
分配结果:2页 + 2页(合并为4页块)→ 实际分配4页,内部碎片1页
释放时:检查伙伴是否空闲 → 合并 → 递归合并2. Slab 分配器:
- 在伙伴系统之上,针对小对象的二次分配
- 预先分配一组相同大小的对象(Slab),用完后从池中取,不用每次分配/释放
- 优点:极快(池化)、无内部碎片、适合频繁创建/销毁的对象
- Linux 用 Slab/Slub/Slob 分配内核小对象(如 inode、task_struct)
- 类比:Java 的对象池、Netty 的 ByteBuf 池
3. 空闲链表法:
- 维护一个空闲块的链表
- 分配策略:
- 首次适应(First Fit):从头找第一个够大的块 → 简单,但低地址碎片多
- 最佳适应(Best Fit):找最小的够大的块 → 减少大块被拆,但产生大量小碎片
- 最差适应(Worst Fit):找最大的块切 → 减少小碎片,但大块被拆完
4. 分段分配:
- 按逻辑段分配(代码段、数据段、栈段),大小不固定
- 优点:符合程序逻辑、便于共享
- 缺点:外部碎片(段之间有空隙)
用户态分配器(malloc 底层):
- ptmalloc(glibc 默认):基于空闲链表 + 合并
- jemalloc:分区域(thread/arena/heap),减少锁竞争
- tcmalloc:线程缓存 + 中心堆,Google 开发
- Go 的内存分配器:类似 tcmalloc,多级缓存(mcache→mcentral→mheap)
追问延伸:
- 内部碎片和外部碎片有什么区别?
- Linux 为什么用 Slub 替代 Slab?(Slub 实现更简单、性能更好、碎片更少)
Q21: 操作系统内存不足时会发生什么? 「🔴 高级」
考察点:OOM 机制和虚拟内存的极端场景。
参考答案:
内存不足的触发链:
- 物理内存不足 → 分配新页时找不到空闲页
- 先尝试回收:
- 回收缓存页(Page Cache 中干净页直接丢弃,脏页写回磁盘)
- 交换不活跃页到 swap(如果 swap 开启)
- 回收 slab 缓存(
echo 3 > /proc/sys/vm/drop_caches)
- 回收后仍不足 → 触发 OOM Killer(Out of Memory Killer)
OOM Killer 机制:
- Linux 的最后防线,防止系统崩溃
- 遍历所有进程,给每个进程打分(
/proc/<pid>/oom_score) - 评分因素:占用内存大小、运行时间、
oom_score_adj(用户配置的优先级) - 选出得分最高的进程 → 发送
SIGKILL→ 杀死释放内存
# 查看 OOM 评分
cat /proc/$(pidof mysqld)/oom_score
# 设置 OOM 优先级(-1000 到 1000,越大越容易被杀)
echo -1000 > /proc/$(pidof mysqld)/oom_score_adj # 不被 OOM 杀OOM 在实际中的表现:
- Java 应用:
java.lang.OutOfMemoryError: Java heap space(JVM 先自己 OOM,不一定会被 OS 的 OOM Killer 杀) - MySQL:被 OOM Killer 杀死 → 数据库异常宕机
- Redis:
server.out_of_memory→ 拒绝写入,返回 OOM 错误
预防 OOM:
- 合理配置 JVM/Redis/MySQL 的内存上限
- 关闭 swap(数据库/Redis 建议关闭,避免 swap 导致性能抖动)
- 设置
oom_score_adj保护关键进程 - 监控内存使用率,提前告警
swap 的作用与风险:
- 作用:物理内存不足时把不活跃页换到磁盘,扩展可用内存
- 风险:磁盘 IO 远慢于内存 → 大量 swap 导致系统卡顿("swap 抖动")
- Java 的 GC 如果遇到 swap → Full GC 暂停时间暴增
追问延伸:
- 为什么 Redis 建议关闭 swap?(swap 导致 fork 阻塞和响应延迟)
- OOM Killer 为什么不杀占用 CPU 最高的进程?(它的目标是释放最多内存,不是 CPU)
Q22: 磁盘调度算法有哪些? 「🟡 中级」
考察点:磁盘 IO 优化的基础知识。
参考答案:
磁盘调度算法(优化磁盘寻道时间,减少磁头移动距离):
1. FCFS(先来先服务):
- 按请求顺序处理
- 简单公平,但磁头移动距离可能很长
- 示例:当前在 100,请求队列 [50, 200, 80, 180] → 依次处理
2. SSTF(最短寻道时间优先):
- 每次选离当前磁头最近的请求
- 平均寻道时间短,但可能饿死远端请求
- 示例:当前在 100,请求队列 [50, 200, 80, 180] → 80→50→180→200
3. SCAN(电梯算法):
- 磁头在一个方向移动到尽头 → 反方向移动 → 像电梯
- 请求被服务有方向性,两端请求需要等待
- 公平性比 SSTF 好
4. C-SCAN(循环扫描):
- 只在一个方向服务请求 → 到尽头后快速返回起点 → 重新开始
- 各位置等待时间更均匀
5. LOOK / C-LOOK:
- SCAN/C-SCAN 的优化版
- 磁头只移动到最远的请求位置(不是物理尽头)→ 掉头
SCAN 示意(当前位置 100,向高地址移动):
请求队列:[50, 80, 180, 200]
50 80 100 180 200
│ │ │ │ │
│ │ ●→→→→→→●→→→→→● ← 先服务 180, 200
│ │ ←←←←←← ← 掉头
│ ←←←←← ← 服务 80
←←←← ← 服务 50
处理顺序:180 → 200 → 80 → 50现代 SSD 不需要磁盘调度:
- SSD 没有机械磁头,随机访问时间几乎相同
- SSD 内部有 FTL(Flash Translation Layer)映射,逻辑地址和物理位置无关
- 传统调度算法(SCAN 等)对 SSD 无意义
- Linux 的
noop调度器适合 SSD(不做排序,直接发送)
Linux 实际 I/O 调度器:
| 调度器 | 原理 | 适用 |
|---|---|---|
| CFQ | 公平分配 I/O 时间片 | 传统机械盘 |
| Deadline | 每个请求有截止时间,防止饥饿 | 数据库 |
| noop | 不排序,FIFO | SSD |
| mq-deadline | 多队列版 Deadline | NVMe SSD |
| bfq | 按进程公平分配带宽 | 桌面/交互 |
追问延伸:
- 为什么 SSD 用 noop 调度器?(没有磁头寻道,排序无意义)
- SSD 的随机读写为什么比顺序读写慢?(闪存特性:写入需要先擦除,擦除以块为单位)
Q23: 有哪些 IO 模型? 「🔴 高级」
考察点:IO 模型的系统理解,网络编程核心。
参考答案:
五种 IO 模型(以 recvfrom 为例):
1. 阻塞 IO(Blocking IO):
- 调用
recvfrom→ 如果没有数据 → 线程阻塞等待 → 数据到了才返回 - 特点:简单,但一个线程只能等一个 fd
- 默认的 Socket 都是阻塞的
2. 非阻塞 IO(Non-blocking IO):
- 设置 socket 为
O_NONBLOCK→recvfrom没有数据立即返回EWOULDBLOCK - 特点:不阻塞,但需要轮询(busy loop)→ CPU 浪费严重
- 实际很少单独使用,通常配合 IO 多路复用
3. IO 多路复用(I/O Multiplexing):
- 用
select/poll/epoll同时监听多个 fd - 哪个 fd 有事件就处理哪个 → 一个线程管理多个连接
- 特点:高效管理大量连接,是目前主流方案
- 阻塞在
epoll_wait上(阻塞等待事件,但可以管理多个 fd)
4. 信号驱动 IO(Signal-driven IO):
- 设置
O_ASYNC→ 数据准备好时内核发SIGIO信号 - 在信号处理函数中调用
recvfrom - 特点:不阻塞,但信号处理复杂、边缘情况多
- 实际用得少
5. 异步 IO(Asynchronous IO):
- 调用
aio_read→ 内核完成整个操作(包括把数据拷贝到用户空间)→ 通知应用 - 特点:真正的异步,内核完成所有工作后通知
- Linux 的
io_uring是新一代异步 IO 方案(替代旧版aio) - Windows 的 IOCP 是成熟的异步 IO
阻塞IO: 调用→等待数据(阻塞)→拷贝数据(阻塞)→返回
非阻塞IO: 调用→无数据立即返回(EAGAIN)→轮询→有数据→拷贝(阻塞)→返回
IO多路复用: 注册fd→epoll_wait(阻塞)→有事件→recvfrom(阻塞)→返回
信号驱动IO: 注册信号→做其他事→收到SIGIO→recvfrom(阻塞)→返回
异步IO: 调用aio_read→立即返回→做其他事→内核完成(等待+拷贝)→回调通知关键区分:
- 同步 vs 异步:数据拷贝是否需要应用自己完成
- 前 4 种都是同步(拷贝阶段阻塞)
- 只有异步 IO 是真正的异步
- 阻塞 vs 非阻塞:等待数据阶段是否阻塞线程
- 阻塞 IO、多路复用 → 等待阶段阻塞
- 非阻塞 IO → 等待阶段不阻塞(轮询)
- 异步 IO → 等待阶段不阻塞
追问延伸:
- IO 多路复用是同步还是异步?(同步,数据拷贝阶段仍阻塞)
io_uring和传统epoll有什么区别?(io_uring 真正异步,零拷贝,减少系统调用)
Q24: 什么是 Reactor 和 Proactor 模式? 「🔴 高级」
考察点:高性能网络编程模型的理解。
参考答案:
Reactor 模式(同步 IO):
- 基于 IO 多路复用(
epoll) - 事件驱动:注册 fd →
epoll_wait返回事件 → 分发给 Handler 处理 - 应用负责读写数据(同步)
Reactor 的三种变体:
单 Reactor 单线程:
- 一个线程跑 epoll + 处理所有事件
- 简单但无法利用多核
- 代表:Redis 6.0 之前
单 Reactor 多线程:
- Reactor 线程负责 accept 和事件分发
- Worker 线程池处理读写和业务
- 代表:Nginx、Memcached
主从 Reactor 多线程(Main-Sub Reactor):
- Main Reactor 只负责 accept
- Sub Reactor(多个)负责已连接 fd 的读写
- Worker 线程处理业务
- 代表:Netty、Nginx
主从 Reactor 模式:
Main Reactor Sub Reactor 1 Sub Reactor 2
┌──────────┐ ┌──────────┐ ┌──────────┐
│ epoll │ │ epoll │ │ epoll │
│ accept │ → 分配fd → │ 读写事件 │ │ 读写事件 │
└──────────┘ └──────────┘ └──────────┘
│ │
┌────┴────┐ ┌────┴────┐
│ Worker │ │ Worker │
│ Thread │ │ Thread │
└─────────┘ └─────────┘Proactor 模式(异步 IO):
- 基于异步 IO(Windows IOCP / Linux
io_uring) - 应用发起异步读 → 内核完成后通知 → 处理已就绪的数据
- 应用不需要自己读写数据(内核代劳)
对比:
| 维度 | Reactor | Proactor |
|---|---|---|
| IO 模型 | 同步(epoll) | 异步(IOCP/io_uring) |
| 数据读写 | 应用自己 | 内核完成 |
| 通知时机 | 数据就绪(可读了) | 数据已读完(在用户缓冲区了) |
| 实现复杂度 | 简单 | 复杂 |
| 操作系统 | Linux | Windows(IOCP 原生)/ Linux(io_uring 新增) |
| 典型应用 | Netty、Nginx、Redis | Boost.Asio Proactor、Windows IOCP |
为什么 Linux 下多用 Reactor:
- Linux 的 AIO 早期不完善(
aio_read有限制) epoll+ Reactor 已足够高效io_uring(5.1+)让真正的 Proactor 在 Linux 成为可能
追问延伸:
- Netty 是 Reactor 还是 Proactor?(Reactor,基于 NIO/epoll)
- Redis 6.0 引入多线程 IO 后是哪种 Reactor?(单 Reactor 多线程,IO 线程只负责读写)
Q25: 服务器处理并发请求有哪些方式? 「🟡 中级」
考察点:并发模型的演进理解。
参考答案:
服务器并发模型的演进:
| 模型 | 原理 | 并发数 | 代表 |
|---|---|---|---|
| 多进程 | 每连接一个进程 | 数百 | Apache prefork |
| 多线程 | 每连接一个线程 | 数千 | Tomcat bio |
| 线程池 | 固定线程处理请求 | 数千-数万 | Tomcat NIO |
| IO 多路复用 | 一个线程管理多个 fd | 数万-数十万 | Nginx、Redis |
| 协程 | 用户态轻量级线程 | 数十万-百万 | Go、Kotlin |
1. 多进程模型:
fork()子进程处理每个连接- 优点:隔离好,一个崩溃不影响其他
- 缺点:进程创建开销大、内存占用高
- 适用:连接数少、要求强隔离(如 SSHD)
2. 多线程模型:
- 每个连接一个线程
- 优点:比进程轻量
- 缺点:线程数有限(默认栈 1MB,1000 线程 = 1GB)、线程切换开销
- 改进:线程池(复用线程)
3. IO 多路复用模型(Reactor):
- 一个线程通过
epoll管理所有连接 - 有事件(读/写就绪)才处理
- 优点:高并发、低开销
- 缺点:业务逻辑不能阻塞(阻塞会卡住整个线程)
- 解决:业务用线程池异步处理(Netty 的 Handler 中用线程池)
4. 协程模型:
- 用户态调度,一个线程跑成千上万个协程
- 协程在 IO 时主动让出(
yield),不阻塞线程 - 优点:极高并发、同步编程模型(像写同步代码一样)
- 代表:Go goroutine、Kotlin coroutine
Go 的并发模型(GMP):
- G:Goroutine(用户态协程)
- M:Machine(操作系统线程)
- P:Processor(逻辑处理器,持有可运行 G 的队列)
- M 个线程跑 N 个协程(M:N 映射)
- goroutine 阻塞时,P 会转移到另一个 M 继续调度
传统多线程: 1 请求 → 1 线程(占 1MB 栈)
Go 协程: 1 请求 → 1 goroutine(占 2-8KB 栈)C10K 问题:
- 早期一台服务器要处理 1 万个并发连接,多进程/多线程扛不住
- 解决:epoll(IO 多路复用)+ Reactor → Nginx、Redis 诞生
- C100K/C1M:协程 + io_uring → Go、Rust async
追问延伸:
- 为什么 Java 没有协程?(Java 的 Virtual Thread / Project Loom 正在引入)
- Go 的 GMP 和 Java 的线程池有什么本质区别?(GMP 是 M:N 调度,线程池是 1:1)
Q26: 什么是 CPU 缓存一致性协议(MESI)? 「🔴 高级」
考察点:多核 CPU 缓存一致性的底层理解。
参考答案:
背景:现代 CPU 多核,每个核有 L1/L2 缓存,多核共享 L3。当多个核修改同一变量的不同副本时,会产生数据不一致。
MESI 协议:CPU 缓存行的四种状态。
| 状态 | 全称 | 含义 |
|---|---|---|
| M | Modified | 已修改,与内存不一致,只有本核有副本 |
| E | Exclusive | 独占,与内存一致,只有本核有副本 |
| S | Shared | 共享,与内存一致,多个核有副本 |
| I | Invalid | 无效,缓存行无效,需要重新加载 |
状态转换示例(两个核 A、B):
初始:变量 x=1 在内存中,A、B 缓存都没有
1. A 读 x → 从内存加载 → 缓存行状态为 E(独占)
2. B 读 x → 从内存加载 → A 的状态 E→S,B 的状态 S(共享)
3. A 写 x=2 → A 的状态 S→M(修改),B 的状态 S→I(失效)
4. B 读 x → 发现状态 I → 从 A 的缓存读(或从内存读)→ A 的状态 M→S,B 的状态 SMESI 带来的性能问题 → 伪共享(False Sharing):
- 两个变量在同一个缓存行(通常 64 字节)中
- 线程1修改变量 a → 整个缓存行失效 → 线程2的变量 b 也被失效
- 线程2读 b → cache miss → 从内存重新加载 → 性能下降
解决伪共享:
java
// Java 8+: 用 @Contended 注解让变量独占缓存行
@sun.misc.Contended
class Counter {
volatile long value; // 独占一个缓存行
}
// 或手动填充(Padding)
class Counter {
volatile long value;
long p1, p2, p3, p4, p5, p6, p7; // 填充到 64 字节
}MESI 和内存屏障:
- CPU 乱序执行 + 缓存一致性问题 → 需要内存屏障
- 写屏障(Store Barrier):之前的写操作对其他核可见
- 读屏障(Load Barrier):之后的读操作能看到其他核的最新写
- Java 的
volatile底层用了内存屏障 → 保证可见性和有序性
追问延伸:
- 伪共享是什么?怎么解决?(缓存行填充 /
@Contended) volatile和 MESI 有什么关系?(volatile 底层靠 MESI + 内存屏障保证可见性)
Q27: 软中断和硬中断有什么区别?什么是下半部机制? 「🟡 中级」
考察点:中断处理的深入理解(在 Q12 基础上补充)。
参考答案:
硬中断(上半部)vs 软中断(下半部):
| 维度 | 硬中断 | 软中断 |
|---|---|---|
| 触发 | 硬件设备(网卡、磁盘、时钟) | 软件触发(系统调用、异常) |
| 响应速度 | 要求极快 | 可稍延迟 |
| 关中断 | 处理时关中断 | 处理时开中断 |
| 能否睡眠 | 不能 | 不能(但可以延迟到进程上下文) |
| 优先级 | 最高 | 低于硬中断 |
为什么分上下半部:
- 硬件中断要求快速响应 → 上半部只做最紧急的事(如把网卡数据拷贝到队列)
- 耗时操作放到下半部 → 开中断执行,不影响响应新中断
Linux 下半部的三种实现:
1. 软中断(SoftIRQ):
- 内核预定义的类型(网络收发、定时器、Tasklet 等)
- 同一类型软中断可多核并行执行
- 性能最高,但只有内核能用(静态编译)
2. Tasklet:
- 基于软中断(HI_SOFTIRQ 和 TASKLET_SOFTIRQ)
- 同一 Tasklet 不会多核并行(串行执行)
- 驱动程序常用
- 注:Linux 正在逐步废弃 Tasklet,推荐改为 SoftIRQ 或 Work Queue
3. 工作队列(Work Queue):
- 把工作推迟到内核线程执行
- 允许睡眠(可以在处理函数中
sleep、mutex_lock、I/O等) - 适用于需要睡眠的场景(如磁盘 IO)
网络收包的软中断示例:
1. 网卡收到数据 → 硬中断 → 上半部:禁用网卡中断,NAPI 轮询
2. 把数据帧放入接收队列 → 触发 NET_RX_SOFTIRQ
3. 开中断 → 下半部(软中断处理):
a. 从队列取数据帧
b. 解析以太网头 → IP 头 → TCP 头
c. 放入 socket 接收队列 → 唤醒等待的进程观察软中断:cat /proc/softirqs 查看各 CPU 的软中断计数。
追问延伸:
- 软中断和信号有什么区别?(软中断在内核态,信号在用户态)
- 为什么工作队列允许睡眠?(因为它在内核线程中执行,有进程上下文)
Q28: 为什么进程崩溃不会对其他进程产生很大影响? 「🟢 校招/初级」
考察点:进程隔离性的理解。
参考答案:
进程崩溃不影响其他进程的核心原因是进程隔离:
- 独立虚拟地址空间:
- 每个进程有自己的页表,虚拟地址映射到不同的物理页
- 进程 A 无法直接读写进程 B 的内存(MMU 硬件保证)
- 崩溃进程的内存被 OS 回收,不影响其他进程的映射
- 独立资源:
- 每个进程有独立的文件描述符表、信号处理函数表
- 一个进程崩溃不会关闭其他进程打开的文件或连接
- 操作系统保护:
- 进程访问非法内存 → 触发段错误(SIGSEGV)→ OS 杀死该进程
- 不会让非法访问"穿透"到其他进程的地址空间
- 进程回收:
- 进程崩溃后 → OS 回收其所有资源(内存、文件描述符等)
- 父进程通过
wait()获知子进程退出状态(僵尸进程回收)
对比线程:
- 同一进程内的线程共享地址空间 → 一个线程崩溃(如越界写)会影响整个进程的所有线程
- 这是进程和线程在隔离性上的本质区别
追问延伸:
- 进程间有没有办法共享内存?(
mmapMAP_SHARED、shmget系统调用,显式共享) - 线程崩溃会怎样影响同进程的其他线程?(一个线程触发段错误 → 整个进程被 OS 杀死)
Q29: 进程是"资源分配的基本单位",这个资源指什么? 「🟢 校招/初级」
考察点:进程概念的具体理解。
参考答案:
进程拥有的"资源"包括:
| 资源类型 | 说明 |
|---|---|
| 虚拟地址空间 | 代码段、数据段、堆、栈等独立内存空间 |
| 文件描述符表 | 打开的文件、Socket、管道等 |
| 用户/组信息 | UID、GID、权限 |
| 信号处理表 | 每个信号对应的处理函数 |
| 当前工作目录 | cwd |
| 环境变量 | environ |
| CPU 时间统计 | 已用 CPU 时间、子进程时间等 |
| 内存映射区 | mmap 映射的共享内存、文件映射 |
| PID 和 PPID | 进程 ID 和父进程 ID |
| 资源限制 | ulimit 设置的最大文件数、内存等 |
线程共享进程的资源,但有自己的私有资源:
- 线程栈:每个线程有自己的栈(默认 1MB),局部变量隔离
- 寄存器/PC:每个线程有自己的程序计数器和寄存器
- 线程 ID(TID)
- 信号掩码(可独立设置)
- errno:每个线程有自己的 errno
c
// 验证:同一进程的线程共享文件描述符
int fd = open("file.txt", O_RDONLY); // 主线程打开
// 子线程可以直接用 fd 读写(共享文件描述符表)追问延伸:
- 线程的栈是共享的还是独立的?(独立的,每个线程有自己的栈)
- 为什么线程切换比进程快?(共享地址空间,不用切换页表)
Q30: 为什么进程之下还要设计线程? 「🟡 中级」
考察点:线程设计动机的理解。
参考答案:
线程出现是为了解决进程在并发场景下的两个核心问题:
问题1:进程创建和切换开销大
- 创建进程 →
fork()需要复制页表(COW 虽优化了内存复制,但页表本身还是要复制) - 切换进程 → 切换页表 → TLB 失效 → 性能下降
- 线程创建 → 只需分配栈和 TCB,不复制地址空间
- 线程切换 → 不切页表 → TLB 不失效
问题2:进程间通信复杂
- 进程间通信需要 IPC(管道、消息队列、共享内存等),有内核中转开销
- 线程间共享地址空间 → 直接读写共享变量,天然高效
以视频播放器为例:
单进程串行: 读文件 → 解码 → 渲染 → 读文件 → 解码 → 渲染... ← 串行,CPU 空闲时浪费
多进程并行: 进程1(读) 进程2(解码) 进程3(渲染) ← 并行,但 IPC 开销大
多线程并行: 线程1(读) 线程2(解码) 线程3(渲染) ← 并行,共享内存直接传递数据线程的定位:在进程的"隔离性"和"并发性"之间取折中
- 保留进程的隔离性(不同进程间隔离)
- 在进程内部提供轻量级并发(线程间共享内存)
- 线程 = "进程内的执行流",共享进程的资源,但有独立栈和寄存器
追问延伸:
- 协程和线程的区别是什么?(协程是用户态调度,不进内核,更轻量)
- 为什么有了多线程还需要协程?(协程切换更轻,适合极高并发场景)
Q31: 多线程比单线程有什么优势和劣势? 「🟡 中级」
考察点:多线程的工程理解。
参考答案:
优势:
- 提高执行效率:多线程并行执行,充分利用多核 CPU
- 避免阻塞:一个线程等待 I/O 时,其他线程可以继续执行
- 响应性好:GUI 程序中,后台线程处理任务,主线程保持 UI 响应
- 资源共享:同进程线程共享内存,通信比进程间高效
- 开发模型简洁:有些场景(如服务器处理连接)天然适合多线程模型
劣势:
- 线程安全问题:共享数据需要加锁 → 竞态条件、数据不一致
- 死锁风险:多个线程互相等待对方释放锁 → 死锁
- 调试困难:多线程 bug 难以复现,竞态条件时有时无
- 资源消耗:每个线程占用栈空间(默认 1MB)+ 内核 TCB
- 上下文切换开销:线程数过多时,切换开销超过计算时间
- 编程复杂度:需要处理同步、原子性、可见性、有序性等问题
什么时候用多线程:
- CPU 密集型:线程数 ≈ CPU 核心数(避免过多切换)
- I/O 密集型:线程数可以远大于核心数(等待 I/O 时不占 CPU)
- 需要响应性:GUI、服务器(主线程接请求,工作线程处理)
什么时候用单线程:
- 任务简单、顺序执行
- 共享数据多、加锁成本 > 并行收益
- 对延迟极度敏感(无线程切换开销)
- 代表:Redis 单线程模型(用 IO 多路复用而非多线程)
追问延伸:
- Redis 为什么选择单线程?(避免锁竞争和上下文切换,IO 多路复用足够)
- CPU 密集型任务线程数设多少合适?(N+1,N 为核心数)
Q32: 多线程是不是越多越好?太多会有什么问题? 「🟡 中级」
考察点:线程数量的权衡理解。
参考答案:
线程不是越多越好,过多会导致以下问题:
- 上下文切换开销:
- 线程数远超 CPU 核心数 → 频繁切换 → 每次切换约 1-10 微秒
- 切换导致 CPU 缓存失效 → 缓存命中率下降 → 性能急剧下降
- 例:8 核 CPU 跑 1000 线程 → 大量时间浪费在切换上
- 内存消耗:
- 每个线程默认栈 1MB(Java) → 1000 线程 = 1GB 栈内存
- TCB、内核栈等额外开销
- 锁竞争加剧:
- 线程越多 → 争抢同一把锁的概率越高 → 等待时间变长
- 极端情况:线程都在等锁 → 系统吞吐量接近 0
- 死锁概率增加:
- 线程越多 → 锁的交互越复杂 → 死锁概率上升
- 调度开销:
- 调度器维护就绪队列、阻塞队列 → 线程数多时遍历开销增加
- GC 压力(Java):
- 线程多 → 对象创建快 → GC 更频繁 → STW 时间变长
最佳线程数参考:
- CPU 密集型:
线程数 = CPU 核心数 + 1(+1 是为了在某线程偶尔阻塞时有替代) - I/O 密集型:
线程数 = CPU 核心数 × (1 + 等待时间/计算时间)→ 通常 2-10 倍核心数 - 混合型:按 I/O 占比折算
实际工程中的处理:
- 用线程池控制线程数量(
ThreadPoolExecutor) - 用协程替代线程(Go goroutine:2-8KB/个,可以轻松创建数十万)
- 用IO 多路复用减少线程需求(Nginx:少量 worker 线程管理大量连接)
java
// 线程池配置建议
int cpuCores = Runtime.getRuntime().availableProcessors();
// CPU 密集型
ThreadPoolExecutor cpuPool = new ThreadPoolExecutor(
cpuCores + 1, cpuCores + 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
// I/O 密集型
ThreadPoolExecutor ioPool = new ThreadPoolExecutor(
cpuCores * 2, cpuCores * 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());追问延伸:
- 为什么 I/O 密集型可以开更多线程?(I/O 等待时不占 CPU,线程可以交替执行)
- 线程池的队列大小怎么设?(有界队列防止 OOM,大小根据任务处理速度和吞吐量估算)
Q33: 为什么并发执行线程要加锁?什么是竞态条件? 「🟡 中级」
考察点:并发编程的基本问题。
参考答案:
竞态条件(Race Condition):多个线程同时访问和修改共享数据,最终结果依赖于线程执行的顺序。
经典示例——i++ 不是原子操作:
java
// i++ 实际上分三步:
// 1. 读取 i 的值
// 2. 计算 i + 1
// 3. 写回 i
// 如果两个线程同时执行:
// 线程A读 i=0 → 线程B读 i=0 → 线程A写 i=1 → 线程B写 i=1
// 结果:i 应该是 2,实际是 1(丢失了一次更新)竞态条件的触发条件:
- 共享资源:多个线程访问同一份数据
- 至少一个写操作:有线程在修改数据
- 非原子操作:操作不是不可分割的(读-改-写不是原子的)
为什么要加锁:
- 加锁 → 保证同一时刻只有一个线程进入临界区 → 操作变成"原子"的
- 不加锁 → 并发修改共享数据 → 结果不可预测
竞态条件的危害:
- 数据不一致:库存超卖、余额错误、计数器丢失更新
- 难以复现:并发 bug 时有时无,调试困难
- 安全问题:双重检查锁定(DCL)未加
volatile→ 可能拿到未初始化的对象
加锁的基本原则:
- 只在需要时加锁:锁保护临界区,非共享数据不需要锁
- 锁粒度尽可能小:减小临界区 → 减少锁竞争
- 避免死锁:固定加锁顺序、超时机制
- 优先用无锁方案:CAS(
AtomicInteger)、ConcurrentHashMap、不可变对象
java
// 不安全
class Counter {
private int count = 0;
public void increment() { count++; } // 竞态条件!
}
// 安全:加锁
class Counter {
private int count = 0;
public synchronized void increment() { count++; } // 互斥保证
}
// 更好:无锁(CAS)
class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() { count.incrementAndGet(); } // CAS 原子操作
}追问延伸:
synchronized和ReentrantLock怎么选?(简单场景用 synchronized,需要超时/公平/条件变量用 ReentrantLock)- CAS 相比加锁有什么优势?(无阻塞、无线程切换开销,但竞争激烈时自旋浪费 CPU)
Q34: 什么是段表?分段机制是怎样的? 「🟡 中级」
考察点:内存管理中分段机制的理解。
参考答案:
分段机制:将程序的虚拟地址空间按逻辑分为多个段,每个段是一段连续的地址空间,大小不固定。
程序的逻辑段:
- 代码段(Text):存放指令,只读
- 数据段(Data):存放已初始化的全局变量
- BSS 段:存放未初始化的全局变量
- 堆段(Heap):动态分配内存
- 栈段(Stack):函数调用、局部变量
段表:每个段在段表中有一个条目,记录段的物理位置和属性。
| 段号 | 段基址(物理地址) | 段限长 | 权限 |
|---|---|---|---|
| 0(代码段) | 0x10000 | 4KB | R-X |
| 1(数据段) | 0x20000 | 8KB | RW- |
| 2(栈段) | 0x30000 | 16KB | RW- |
虚拟地址转物理地址(分段):
虚拟地址 = 段号 + 段内偏移
1. 从虚拟地址中提取段号和偏移
2. 查段表:段号 → 段基址 + 段限长
3. 检查偏移 < 段限长(越界则段错误 SIGSEGV)
4. 物理地址 = 段基址 + 段内偏移分段 vs 分页:
| 维度 | 分段 | 分页 |
|---|---|---|
| 划分依据 | 逻辑(代码/数据/栈) | 固定大小(4KB) |
| 大小 | 不固定 | 固定(页大小) |
| 碎片 | 外部碎片(段之间空隙) | 内部碎片(最后一页未填满) |
| 对程序员 | 可见(段寄存器) | 透明 |
| 共享 | 方便(按逻辑段共享) | 需按页映射 |
段页式(现代 OS 实际使用):
- 先分段 → 段内再分页
- 段表指向页表 → 页表指向物理页
- 兼具分段的逻辑性和分页的灵活性
- x86 使用段页式(但 Linux 基本绕过分段,直接用分页)
追问延伸:
- 为什么现代 OS 主要用分页而非分段?(分段有外部碎片问题,分页更灵活)
- Linux 的分段机制是怎样的?(基本绕过分段,用"扁平段"模型,主要靠分页)
Q35: 中断的作用是什么?没有中断会怎样? 「🟡 中级」
考察点:中断在操作系统中的核心意义。
参考答案:
中断是操作系统的"神经系统",没有中断就没有现代操作系统。
中断的四大作用:
- 实现多任务:
- 时钟中断定期触发 → 调度器介入 → 切换进程/线程
- 没有中断 → CPU 只能跑一个程序直到它主动让出 → 无法多任务
- 实现系统调用:
- 用户程序通过
int 0x80/syscall主动触发软中断 → 进入内核态 - 没有中断 → 用户程序无法安全地请求 OS 服务
- 用户程序通过
- 响应外部设备:
- 网卡收到数据 → 硬中断通知 CPU → CPU 处理网络包
- 磁盘完成读写 → 硬中断通知 → CPU 处理完成的数据
- 没有中断 → CPU 必须轮询每个设备状态 → 大量 CPU 时间浪费在等待上
- 处理异常和错误:
- 除零、缺页、栈溢出 → CPU 异常 → OS 介入处理
- 没有中断 → 程序出错就直接崩溃,OS 无法介入
没有中断的世界(轮询模式):
CPU 循环:
检查网卡有没有数据? → 没有
检查磁盘有没有完成? → 没有
检查键盘有没有输入? → 没有
检查定时器到没到? → 没有
... 重复以上
→ 99% 的 CPU 时间在"等待",无法做其他事有了中断的世界(事件驱动):
CPU 正在执行程序A →
网卡中断 → CPU 暂停A → 处理网络数据 → 恢复A
时钟中断 → CPU 暂停A → 调度器切换到程序B → 执行B
磁盘中断 → CPU 暂停B → 处理磁盘完成 → 恢复B
→ CPU 几乎不浪费时间在"等待"上中断的哲学:
- 中断 = "异步通知" → CPU 不用轮询等待,事件来了再处理
- 中断 = "操作系统的入口" → 所有 OS 介入都通过中断
- 中断 = "并发的基础" → 时钟中断驱动调度 → 多任务成为可能
追问延伸:
- 如果时钟中断频率太高或太低分别有什么问题?(太高→切换开销大;太低→响应慢)
- 中断和轮询各有什么优缺点?(中断适合低频事件,轮询适合高频事件)