Skip to content

操作系统高频题

操作系统面试围绕四个主题:进程与线程、内存管理、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 有事件就处理哪个。

三种方案对比:

维度selectpollepoll
数据结构bitmap链表红黑树+就绪链表
最大fd数1024(FD_SETSIZE)无限制无限制
时间复杂度O(n)O(n)O(1)
fd拷贝每次全量拷贝每次全量拷贝只注册一次
触发方式LT(水平触发)LTLT+ET
性能差(fd越多越慢)好(fd多也不慢)

select 工作流程:

  1. 用户空间把 fd_set 拷贝到内核
  2. 内核遍历所有 fd,检查是否有事件
  3. 返回有事件的 fd 数量
  4. 用户空间再遍历所有 fd 找到有事件的

→ 两次遍历 + 两次拷贝,fd 越多越慢

epoll 工作流程:

  1. epoll_create:创建 epoll 实例(红黑树存 fd + 就绪链表)
  2. epoll_ctl:注册 fd(只在注册时拷贝一次)
  3. epoll_wait:直接从就绪链表取有事件的 fd(O(1))
  4. 内核通过回调把有事件的 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),虚拟页 → 物理页
  • 分段:按逻辑分段(代码段/数据段/栈段),大小不固定
  • 段页式:先分段再分页,结合两者

虚拟地址转物理地址(分页):

  1. 虚拟地址 = 页号 + 页内偏移
  2. 查页表:页号 → 物理页框号
  3. 物理地址 = 物理页框号 × 页大小 + 页内偏移

多级页表:

  • 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 拷贝。

零拷贝方案:

  1. mmap + write
    • mmap 把文件映射到用户空间内存
    • write 直接从映射区写到 Socket
    • 减少1次 CPU 拷贝(不需要内核→用户→内核)
    • 3次上下文切换,1次 CPU 拷贝
  2. sendfile(2.1+):
    • 一次 syscall 完成文件→Socket 传输
    • 数据全程在内核空间
    • 2次上下文切换,1次 CPU 拷贝(内核缓冲区→Socket缓冲区)
  3. sendfile + SG-DMA(2.4+):
    • 如果网卡支持 Scatter-Gather DMA
    • 内核只把文件描述符和长度传给网卡
    • DMA 直接从内核缓冲区传到网卡
    • 2次上下文切换,0次 CPU 拷贝(真正零拷贝)

零拷贝的应用:

  • Kafka:用 sendfile 实现高性能消息传输(零拷贝 + 顺序写 + PageCache)
  • Nginx:静态文件传输用 sendfile
  • Java NIOFileChannel.transferTo() 底层调用 sendfile
  • RocketMQ:CommitLog 用 mmap + write

Java 中的零拷贝 API:

  • FileChannel.transferTo(position, count, writableChannel):底层 sendfile
  • FileChannel.map(MapMode.READ_ONLY, position, size):底层 mmap
  • DirectByteBuffer:直接在堆外内存分配,减少一次 JVM 堆→native 拷贝

追问延伸

  • mmap 和 sendfile 的区别?
  • 为什么 Kafka 用零拷贝?

Q11: 进程调度算法有哪些? 「🟢 校招/初级」

考察点:操作系统调度的基础知识。

参考答案

常见调度算法:

  1. FCFS(先来先服务):
    • 按到达顺序调度
    • 简单公平,但短任务可能被长任务阻塞(护航效应)
  2. SJF(短作业优先):
    • 优先调度预计执行时间最短的
    • 平均等待时间最短,但可能饿死长任务
  3. 优先级调度
    • 每个进程有优先级,按优先级调度
    • 高优先级先执行,低优先级可能饿死(解决:老化策略,等待越久优先级越高)
  4. 时间片轮转(Round Robin):
    • 每个进程分配一个时间片(如10ms),轮流执行
    • 公平,响应快,适合交互式系统
    • 时间片太大 → 退化为 FCFS;太小 → 上下文切换开销大
  5. 多级反馈队列(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 在执行程序时,收到硬件或软件信号,暂停当前程序,转去处理中断处理程序,处理完后返回。

中断类型:

  1. 硬件中断(外部中断):
    • 时钟中断:定时器到期,触发调度
    • I/O 中断:磁盘/网卡完成操作
    • 键盘/鼠标中断
    • 特点:异步发生,与 CPU 执行指令无关
  2. 软件中断(内部中断/异常):
    • 系统调用:用户态 → 内核态(int 0x80 / syscall 指令)
    • 缺页中断:访问的页不在内存
    • 除零异常、栈溢出
    • 断点/陷阱(调试用)
    • 特点:由 CPU 执行指令触发

中断处理流程:

  1. 中断检测:CPU 在每条指令执行完后检查中断信号
  2. 保存现场:保存当前 PC、寄存器、状态字到内核栈
  3. 查中断向量表:根据中断号找到中断处理程序入口
  4. 执行中断处理程序:处理中断
  5. 恢复现场:从内核栈恢复寄存器和 PC
  6. 返回:继续执行被中断的程序

中断的上下半部机制(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 微秒(用户态,无系统调用)

线程切换详细过程:

  1. 用户态 → 内核态(系统调用/中断)
  2. 保存当前线程的寄存器到线程控制块(TCB)
  3. 调度器选择下一个线程
  4. 从新线程的 TCB 恢复寄存器
  5. 切换内核栈
  6. 内核态 → 用户态

追问延伸

  • 为什么线程太多反而慢?(上下文切换开销超过计算时间 + 缓存颠簸)
  • 协程切换为什么不开销 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(&not_full, &mutex);
enqueue(item);
pthread_cond_signal(&not_empty); // 通知消费者
pthread_mutex_unlock(&mutex);

// 消费者
pthread_mutex_lock(&mutex);
while (queue_is_empty())
    pthread_cond_wait(&not_empty, &mutex);
item = dequeue();
pthread_cond_signal(&not_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: 乐观锁和悲观锁有什么区别? 「🟡 中级」

考察点:并发控制策略的对比。

参考答案

维度悲观锁乐观锁
假设认为冲突大概率发生认为冲突小概率发生
策略先加锁再操作先操作再验证
性能并写低时好,高时差并写低时好,冲突多时差
实现方式互斥锁、数据库行锁、synchronizedCAS、version 版本号、AtomicInteger
冲突处理阻塞等待重试或放弃

悲观锁:

  • 操作前先加锁 → 独占资源 → 操作完释放
  • 数据库:SELECT ... FOR UPDATE(行锁)
  • Java:synchronizedReentrantLock
  • 场景:写多读少、冲突频繁、数据一致性要求高

乐观锁:

  • 先操作(不加锁)→ 提交时检查是否有人改过 → 没改过则提交,改过则重试
  • 数据库: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):每个进程还需要的各类资源数

安全状态判断流程:

  1. 找一个 Need ≤ Available 的进程 P
  2. 假设 P 完成 → 释放它持有的所有资源 → Available += Allocation[P]
  3. 标记 P 为完成
  4. 重复 1-3,直到所有进程都能完成 → 安全状态
  5. 如果找不到这样的进程 → 不安全状态(可能死锁)

简化示例(单类资源):

进程    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 机制和虚拟内存的极端场景。

参考答案

内存不足的触发链:

  1. 物理内存不足 → 分配新页时找不到空闲页
  2. 先尝试回收
    • 回收缓存页(Page Cache 中干净页直接丢弃,脏页写回磁盘)
    • 交换不活跃页到 swap(如果 swap 开启)
    • 回收 slab 缓存(echo 3 > /proc/sys/vm/drop_caches
  3. 回收后仍不足 → 触发 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不排序,FIFOSSD
mq-deadline多队列版 DeadlineNVMe SSD
bfq按进程公平分配带宽桌面/交互

追问延伸

  • 为什么 SSD 用 noop 调度器?(没有磁头寻道,排序无意义)
  • SSD 的随机读写为什么比顺序读写慢?(闪存特性:写入需要先擦除,擦除以块为单位)

Q23: 有哪些 IO 模型? 「🔴 高级」

考察点:IO 模型的系统理解,网络编程核心。

参考答案

五种 IO 模型(以 recvfrom 为例):

1. 阻塞 IO(Blocking IO)

  • 调用 recvfrom → 如果没有数据 → 线程阻塞等待 → 数据到了才返回
  • 特点:简单,但一个线程只能等一个 fd
  • 默认的 Socket 都是阻塞的

2. 非阻塞 IO(Non-blocking IO)

  • 设置 socket 为 O_NONBLOCKrecvfrom 没有数据立即返回 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 的三种变体:

  1. 单 Reactor 单线程

    • 一个线程跑 epoll + 处理所有事件
    • 简单但无法利用多核
    • 代表:Redis 6.0 之前
  2. 单 Reactor 多线程

    • Reactor 线程负责 accept 和事件分发
    • Worker 线程池处理读写和业务
    • 代表:Nginx、Memcached
  3. 主从 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
  • 应用发起异步读 → 内核完成后通知 → 处理已就绪的数据
  • 应用不需要自己读写数据(内核代劳)

对比:

维度ReactorProactor
IO 模型同步(epoll)异步(IOCP/io_uring)
数据读写应用自己内核完成
通知时机数据就绪(可读了)数据已读完(在用户缓冲区了)
实现复杂度简单复杂
操作系统LinuxWindows(IOCP 原生)/ Linux(io_uring 新增)
典型应用Netty、Nginx、RedisBoost.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 缓存行的四种状态。

状态全称含义
MModified已修改,与内存不一致,只有本核有副本
EExclusive独占,与内存一致,只有本核有副本
SShared共享,与内存一致,多个核有副本
IInvalid无效,缓存行无效,需要重新加载

状态转换示例(两个核 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 的状态 S

MESI 带来的性能问题 → 伪共享(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)

  • 把工作推迟到内核线程执行
  • 允许睡眠(可以在处理函数中 sleepmutex_lockI/O 等)
  • 适用于需要睡眠的场景(如磁盘 IO)

网络收包的软中断示例:

1. 网卡收到数据 → 硬中断 → 上半部:禁用网卡中断,NAPI 轮询
2. 把数据帧放入接收队列 → 触发 NET_RX_SOFTIRQ
3. 开中断 → 下半部(软中断处理):
   a. 从队列取数据帧
   b. 解析以太网头 → IP 头 → TCP 头
   c. 放入 socket 接收队列 → 唤醒等待的进程

观察软中断:cat /proc/softirqs 查看各 CPU 的软中断计数。

追问延伸

  • 软中断和信号有什么区别?(软中断在内核态,信号在用户态)
  • 为什么工作队列允许睡眠?(因为它在内核线程中执行,有进程上下文)

Q28: 为什么进程崩溃不会对其他进程产生很大影响? 「🟢 校招/初级」

考察点:进程隔离性的理解。

参考答案

进程崩溃不影响其他进程的核心原因是进程隔离

  1. 独立虚拟地址空间
    • 每个进程有自己的页表,虚拟地址映射到不同的物理页
    • 进程 A 无法直接读写进程 B 的内存(MMU 硬件保证)
    • 崩溃进程的内存被 OS 回收,不影响其他进程的映射
  2. 独立资源
    • 每个进程有独立的文件描述符表、信号处理函数表
    • 一个进程崩溃不会关闭其他进程打开的文件或连接
  3. 操作系统保护
    • 进程访问非法内存 → 触发段错误(SIGSEGV)→ OS 杀死该进程
    • 不会让非法访问"穿透"到其他进程的地址空间
  4. 进程回收
    • 进程崩溃后 → OS 回收其所有资源(内存、文件描述符等)
    • 父进程通过 wait() 获知子进程退出状态(僵尸进程回收)

对比线程:

  • 同一进程内的线程共享地址空间 → 一个线程崩溃(如越界写)会影响整个进程的所有线程
  • 这是进程和线程在隔离性上的本质区别

追问延伸

  • 进程间有没有办法共享内存?(mmap MAP_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: 多线程比单线程有什么优势和劣势? 「🟡 中级」

考察点:多线程的工程理解。

参考答案

优势

  1. 提高执行效率:多线程并行执行,充分利用多核 CPU
  2. 避免阻塞:一个线程等待 I/O 时,其他线程可以继续执行
  3. 响应性好:GUI 程序中,后台线程处理任务,主线程保持 UI 响应
  4. 资源共享:同进程线程共享内存,通信比进程间高效
  5. 开发模型简洁:有些场景(如服务器处理连接)天然适合多线程模型

劣势

  1. 线程安全问题:共享数据需要加锁 → 竞态条件、数据不一致
  2. 死锁风险:多个线程互相等待对方释放锁 → 死锁
  3. 调试困难:多线程 bug 难以复现,竞态条件时有时无
  4. 资源消耗:每个线程占用栈空间(默认 1MB)+ 内核 TCB
  5. 上下文切换开销:线程数过多时,切换开销超过计算时间
  6. 编程复杂度:需要处理同步、原子性、可见性、有序性等问题

什么时候用多线程:

  • CPU 密集型:线程数 ≈ CPU 核心数(避免过多切换)
  • I/O 密集型:线程数可以远大于核心数(等待 I/O 时不占 CPU)
  • 需要响应性:GUI、服务器(主线程接请求,工作线程处理)

什么时候用单线程:

  • 任务简单、顺序执行
  • 共享数据多、加锁成本 > 并行收益
  • 对延迟极度敏感(无线程切换开销)
  • 代表:Redis 单线程模型(用 IO 多路复用而非多线程)

追问延伸

  • Redis 为什么选择单线程?(避免锁竞争和上下文切换,IO 多路复用足够)
  • CPU 密集型任务线程数设多少合适?(N+1,N 为核心数)

Q32: 多线程是不是越多越好?太多会有什么问题? 「🟡 中级」

考察点:线程数量的权衡理解。

参考答案

线程不是越多越好,过多会导致以下问题:

  1. 上下文切换开销
    • 线程数远超 CPU 核心数 → 频繁切换 → 每次切换约 1-10 微秒
    • 切换导致 CPU 缓存失效 → 缓存命中率下降 → 性能急剧下降
    • 例:8 核 CPU 跑 1000 线程 → 大量时间浪费在切换上
  2. 内存消耗
    • 每个线程默认栈 1MB(Java) → 1000 线程 = 1GB 栈内存
    • TCB、内核栈等额外开销
  3. 锁竞争加剧
    • 线程越多 → 争抢同一把锁的概率越高 → 等待时间变长
    • 极端情况:线程都在等锁 → 系统吞吐量接近 0
  4. 死锁概率增加
    • 线程越多 → 锁的交互越复杂 → 死锁概率上升
  5. 调度开销
    • 调度器维护就绪队列、阻塞队列 → 线程数多时遍历开销增加
  6. 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(丢失了一次更新)

竞态条件的触发条件:

  1. 共享资源:多个线程访问同一份数据
  2. 至少一个写操作:有线程在修改数据
  3. 非原子操作:操作不是不可分割的(读-改-写不是原子的)

为什么要加锁:

  • 加锁 → 保证同一时刻只有一个线程进入临界区 → 操作变成"原子"的
  • 不加锁 → 并发修改共享数据 → 结果不可预测

竞态条件的危害:

  • 数据不一致:库存超卖、余额错误、计数器丢失更新
  • 难以复现:并发 bug 时有时无,调试困难
  • 安全问题:双重检查锁定(DCL)未加 volatile → 可能拿到未初始化的对象

加锁的基本原则:

  1. 只在需要时加锁:锁保护临界区,非共享数据不需要锁
  2. 锁粒度尽可能小:减小临界区 → 减少锁竞争
  3. 避免死锁:固定加锁顺序、超时机制
  4. 优先用无锁方案: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 原子操作
}

追问延伸

  • synchronizedReentrantLock 怎么选?(简单场景用 synchronized,需要超时/公平/条件变量用 ReentrantLock)
  • CAS 相比加锁有什么优势?(无阻塞、无线程切换开销,但竞争激烈时自旋浪费 CPU)

Q34: 什么是段表?分段机制是怎样的? 「🟡 中级」

考察点:内存管理中分段机制的理解。

参考答案

分段机制:将程序的虚拟地址空间按逻辑分为多个段,每个段是一段连续的地址空间,大小不固定。

程序的逻辑段:

  • 代码段(Text):存放指令,只读
  • 数据段(Data):存放已初始化的全局变量
  • BSS 段:存放未初始化的全局变量
  • 堆段(Heap):动态分配内存
  • 栈段(Stack):函数调用、局部变量

段表:每个段在段表中有一个条目,记录段的物理位置和属性。

段号段基址(物理地址)段限长权限
0(代码段)0x100004KBR-X
1(数据段)0x200008KBRW-
2(栈段)0x3000016KBRW-

虚拟地址转物理地址(分段):

虚拟地址 = 段号 + 段内偏移

1. 从虚拟地址中提取段号和偏移
2. 查段表:段号 → 段基址 + 段限长
3. 检查偏移 < 段限长(越界则段错误 SIGSEGV)
4. 物理地址 = 段基址 + 段内偏移

分段 vs 分页:

维度分段分页
划分依据逻辑(代码/数据/栈)固定大小(4KB)
大小不固定固定(页大小)
碎片外部碎片(段之间空隙)内部碎片(最后一页未填满)
对程序员可见(段寄存器)透明
共享方便(按逻辑段共享)需按页映射

段页式(现代 OS 实际使用):

  • 先分段 → 段内再分页
  • 段表指向页表 → 页表指向物理页
  • 兼具分段的逻辑性和分页的灵活性
  • x86 使用段页式(但 Linux 基本绕过分段,直接用分页)

追问延伸

  • 为什么现代 OS 主要用分页而非分段?(分段有外部碎片问题,分页更灵活)
  • Linux 的分段机制是怎样的?(基本绕过分段,用"扁平段"模型,主要靠分页)

Q35: 中断的作用是什么?没有中断会怎样? 「🟡 中级」

考察点:中断在操作系统中的核心意义。

参考答案

中断是操作系统的"神经系统",没有中断就没有现代操作系统。

中断的四大作用:

  1. 实现多任务
    • 时钟中断定期触发 → 调度器介入 → 切换进程/线程
    • 没有中断 → CPU 只能跑一个程序直到它主动让出 → 无法多任务
  2. 实现系统调用
    • 用户程序通过 int 0x80/syscall 主动触发软中断 → 进入内核态
    • 没有中断 → 用户程序无法安全地请求 OS 服务
  3. 响应外部设备
    • 网卡收到数据 → 硬中断通知 CPU → CPU 处理网络包
    • 磁盘完成读写 → 硬中断通知 → CPU 处理完成的数据
    • 没有中断 → CPU 必须轮询每个设备状态 → 大量 CPU 时间浪费在等待上
  4. 处理异常和错误
    • 除零、缺页、栈溢出 → CPU 异常 → OS 介入处理
    • 没有中断 → 程序出错就直接崩溃,OS 无法介入

没有中断的世界(轮询模式):

CPU 循环:
  检查网卡有没有数据? → 没有
  检查磁盘有没有完成? → 没有
  检查键盘有没有输入? → 没有
  检查定时器到没到? → 没有
  ... 重复以上
  → 99% 的 CPU 时间在"等待",无法做其他事

有了中断的世界(事件驱动):

CPU 正在执行程序A →
  网卡中断 → CPU 暂停A → 处理网络数据 → 恢复A
  时钟中断 → CPU 暂停A → 调度器切换到程序B → 执行B
  磁盘中断 → CPU 暂停B → 处理磁盘完成 → 恢复B
  → CPU 几乎不浪费时间在"等待"上

中断的哲学:

  • 中断 = "异步通知" → CPU 不用轮询等待,事件来了再处理
  • 中断 = "操作系统的入口" → 所有 OS 介入都通过中断
  • 中断 = "并发的基础" → 时钟中断驱动调度 → 多任务成为可能

追问延伸

  • 如果时钟中断频率太高或太低分别有什么问题?(太高→切换开销大;太低→响应慢)
  • 中断和轮询各有什么优缺点?(中断适合低频事件,轮询适合高频事件)