操作系统常见面试题详解:深度剖析,助你轻松掌握
对于大部分公司来说,掌握这篇文章的内容就足够了,当然如果你是要面试腾讯、字节等大厂,那就需要更加深入的学习了。
这篇文章涵盖了操作系统相关的重点面试题,比如:用户态和内核态、系统调用、进程和线程、死锁、内存管理、虚拟内存和文件系统等等。需要注意的是,这篇文章只是对部分重要的操作系统概念进行了概述,如果你想更加深入地学习操作系统,建议去啃书。
操作系统基础
什么是操作系统?
操作系统(Operating System,简称 OS)可以用以下四点概括:
- 操作系统是管理计算机硬件与软件资源的程序,是计算机的基石。
- 本质上,它是一个运行在计算机上的软件,用于管理计算机硬件和软件资源。比如,应用程序访问你电脑上的内存、硬盘,都需要通过操作系统。
- 操作系统屏蔽了底层硬件的复杂性,用户不需要了解硬件的具体细节,只需要通过操作系统提供的接口进行操作即可。
- 操作系统的内核是操作系统的核心部分,负责管理系统的内存、硬件设备、文件系统和应用程序。内核是连接应用程序和硬件的桥梁,它对系统的性能和稳定性有着至关重要的作用。
很多人会把操作系统的内核和 CPU(Central Processing Unit,中央处理器)搞混,这里说一下它们之间的区别:
- 操作系统的内核是软件,CPU 是硬件。
- CPU 主要负责计算和执行指令,而内核负责管理系统资源,比如内存管理等,抽象了对硬件的操作。
下图可以帮助你理解应用程序、内核和 CPU 之间的关系。
操作系统的核心功能
从资源管理的角度,操作系统主要完成 6 个方面的功能:
- 进程和线程管理:对进程/线程进行创建、终止、阻塞、唤醒,以及进程/线程间的通信。
- 存储管理:管理内存的分配和回收,以及外部存储器(比如硬盘)的分配。
- 文件管理:对文件的读取、写入、创建和删除等操作进行管理。
- 设备管理:对设备(输入输出设备和外部存储器)的请求和释放,以及设备的启动和关闭等进行管理。
- 网络管理:管理计算机网络的使用,包括网络配置、网络连接、网络通信和网络安全等,以保证网络服务的正常运行。
- 安全管理:对用户的身份进行认证,对用户的访问进行控制,以及对文件进行加密等,以保证系统资源的安全。
常见的操作系统
Windows
最常用的个人桌面操作系统,拥有友好的用户界面和丰富的软件生态。玩游戏的话,Windows 必不可少,所以我自己用一台电脑玩游戏,一台 Mac 进行日常开发和学习。
Unix
最早的多用户、多任务操作系统,对 Linux 的发展产生了重大的影响。 这个操作系统已经逐渐淡出大家的视野了。
Linux
Linux 是一个免费的开源 Unix 类操作系统。 Linux 有很多不同的发行版,它们都基于 Linux 内核。
严格来说,“Linux”指的是 Linux 内核。在 GNU/Linux 系统中,Linux 是内核,而其余部分由 GNU 项目开发和提供的程序组成。
很多人更喜欢使用“GNU/Linux”来描述通常所说的“Linux”。
Mac OS
苹果公司开发的操作系统,编程体验和 Linux 比较接近,不过在用户界面、软件生态和用户体验上更加出色。
用户态和内核态
什么是用户态和内核态?
根据进程访问资源的特点,操作系统将进程的运行分为了两个级别:
- 用户态: 运行在用户态的进程可以直接读取用户程序的数据,并且只能访问有限的系统资源。当应用程序需要执行需要特权的操作,例如读取磁盘文件或发送网络数据包时,它必须发出系统调用来切换到内核态。
- 内核态: 运行在内核态的进程可以访问所有的系统资源,包括内存、设备和驱动程序等,并且不受限制,拥有很高的权限。当操作系统接收到进程的系统调用请求时,它会从用户态切换到内核态来执行相应的系统调用,然后再返回到用户态。
和用户态相比,内核态的级别更高,可以执行更底层、更敏感的操作。 不过,从用户态切换到内核态的开销比较大,因为它需要进行上下文切换和权限检查等操作,因此,应该尽量减少用户态和内核态之间的切换,以提高系统的性能和稳定性。
为什么要有用户态和内核态?
- 一些 CPU 指令是比较危险的,比如内存分配、设置时钟、I/O 操作等,如果所有的程序都可以执行这些指令,那么系统的安全性就无法得到保证。因此,操作系统将这些特权指令只能运行在内核态。
- 如果只有内核态,所有的进程都共享一个地址空间,那么进程之间很容易相互干扰,导致系统崩溃。
因此,操作系统引入了用户态和内核态,以保证系统的安全、稳定和高效。
如何从用户态切换到内核态?
从用户态切换到内核态的 3 种方式:
- 系统调用 (Trap):用户态进程主动请求切换到内核态,需要借助操作系统提供的系统调用接口,例如需要读取磁盘文件的时候。 这个机制主要依赖于操作系统为用户态进程提供的 “中断” 机制来实现。
- 外围设备的中断: 外围设备完成用户请求的操作后,会向 CPU 发出中断信号,CPU 会暂停当前正在执行的指令,转而去执行中断处理程序。如果之前执行的指令是用户态下的指令,那么这个过程就是从用户态切换到内核态的过程,比如硬盘读写操作完成以后,硬盘会向 CPU 发出中断信号。
- 异常: 当用户态进程执行的过程中发生了某些不可预知的异常事件时,例如发生了缺页错误,系统也会触发从用户态切换到内核态的机制来处理这些异常事件。
在实际的操作中,中断和异常的处理机制非常相似,它们都会使用中断向量表来处理中断或异常。 它们之间的区别在于,中断是由外部设备触发的,而异常是由当前正在执行的指令触发的。
系统调用
什么是系统调用?
大部分程序运行在用户态,如果需要执行操作系统的某些操作,就需要使用系统调用。 从本质上来讲,任何涉及到系统资源的操作 (例如文件管理、进程控制、内存管理) 都必须通过系统调用来完成,因为它们都需要在内核态下执行。
系统调用可以分为以下几类:
- 设备管理: 请求或者释放设备 (包括输入输出设备和外部存储器)。
- 文件管理: 对文件的读、写、创建和删除等操作。
- 进程管理: 对进程/线程进行创建、终止、阻塞、唤醒,以及进程/线程间的通信。
- 内存管理: 申请和释放内存空间,以及获得作业占用内存区的大小及地址等。
系统调用的使用方法和普通的库函数调用非常相似,只不过系统调用是由操作系统内核提供的,并且运行在内核态,而普通的库函数是由用户自己定义的,运行在用户态。
总的来说,系统调用是应用程序和操作系统之间进行交互的一种机制,它允许应用程序访问操作系统级别的资源,例如文件、设备和网络等。
系统调用过程
系统调用过程可以概括为以下几个步骤:
- 用户态程序发起系统调用,涉及特权指令 (只能在内核态执行) 的时候,会被 CPU 中断,引发 Trap (陷阱,一种中断)。
- Trap 会导致 CPU 从用户态切换到内核态,中断当前程序的执行,跳转到中断处理程序,也就是开始执行内核代码。
- 内核代码执行完毕后,会再次引发 Trap,切换回用户态,回到用户程序之前中断的地方继续执行。
进程和线程
什么是进程和线程?
- 进程: 是指在系统中正在运行的一个应用程序。比如你打开的微信就是一个进程。
- 线程: 是进程中的一个执行单元,负责当前进程中程序的执行。一个进程中至少有一个线程,一个进程可以运行多个线程,多个线程可以并发执行。比如,你的微信里,有个线程负责接收别人给你发的消息。
进程和线程的区别
下图是 Java 内存区域,可以帮助你理解线程和进程之间的关系:
从图中可以看出,一个进程中可以包含多个线程,创建多个线程是为了并发执行。这些线程共享进程的 堆 和 方法区 (JDK 1.8 之后的元空间),每个线程都有自己的 程序计数器 、虚拟机栈 和 本地方法栈 。
总结:
- 线程是进程划分成的更小的运行单位。线程在进程下运行(是进程的一部分),一个进程可以生成多个线程,每个线程又可以执行不同的任务。
- 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(例如打开的文件和信号),某进程内的线程在其它进程不可见。
- 线程的开销比进程小,但不利于资源的管理和保护;而进程正相反。
既然有了进程,为什么还要线程呢?
- 进程间上下文切换的开销比较大,而线程间上下文切换的开销比较小。
- 线程更加轻量级,一个进程可以创建多个线程。
- 线程可以并发执行,充分利用多处理器和多核系统的优势。进程只能串行执行,如果遇到 I/O 阻塞,整个进程都会被阻塞。
- 线程之间共享内存和文件,可以方便地进行通信,而进程之间需要通过内核进行通信。
为什么要使用多线程?
概括性的来说:
- 从计算机底层角度来看: 线程是轻量级的进程,是程序执行的最小单位,线程间的切换和调度成本远小于进程。在多核 CPU 时代,多线程可以充分利用多核 CPU 的优势,减少程序的上下文切换开销。
- 从当代互联网发展趋势角度来看: 现在的系统动不动就要求百万级甚至千万级的并发量,多线程并发编程正是开发高并发系统的基础,可以有效提高系统的吞吐量和性能。
深入计算机底层来说:
- 单核时代: 多线程主要用于提高 CPU 和 I/O 设备的利用率。比如,一个单线程的 Java 程序在执行 I/O 操作的时候,整个进程都会阻塞,导致 CPU 和 I/O 设备的利用率都很低。如果使用多线程,就可以让一个线程专门负责 I/O 操作,其他线程继续执行计算任务,从而提高 CPU 和 I/O 设备的利用率。
- 多核时代: 多线程可以充分利用多核 CPU 的优势,例如,如果一个计算量很大的任务需要执行很长时间,如果使用单线程,那么无论 CPU 有多少个核,都只有一个核在工作。如果使用多线程,就可以让多个线程在不同的核上同时执行,从而提高程序的执行效率,执行时间大约可以缩短到原来的 (单核执行时间 / CPU 核数)。
线程同步的几种方式
线程同步是为了保证多个线程在并发访问共享资源时的安全性,防止出现数据不一致的情况。
线程同步的几种常见方式:
- 互斥量 (Mutex):使用一个互斥量对象,只有持有互斥量的线程才能访问共享资源。互斥量保证了共享资源在同一时刻只能被一个线程访问。Java 中的
synchronized
关键字和各种Lock
都是基于互斥量实现的。 - 读写锁 (Read-Write Lock):允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。
- 信号量 (Semaphore):一种同步工具,允许多个线程同时访问共享资源,但可以控制同时访问共享资源的线程数量。
- 栅栏 (Barrier):一种同步原语,它会等待多个线程都到达某个点之后再继续执行。例如:Java 中的
CyclicBarrier
。 - 事件 (Wait/Notify): 通过通知操作保持线程同步,可以方便的实现线程间优先级的比较。
什么是进程控制块 (PCB) ?
PCB (Process Control Block) 是操作系统用来管理和控制进程的数据结构,每一个进程都有一个 PCB。 你可以把 PCB 理解为进程的大脑。
当操作系统创建一个新的进程时,它会为该进程分配一个唯一的进程 ID,并创建一个对应的 PCB。当进程运行时,PCB 中的信息会不断更新,操作系统会根据 PCB 中的信息来管理和调度进程。
PCB 中通常包含以下信息:
- 进程描述信息,包括进程的名称、进程 ID 等。
- 进程调度信息,包括进程的状态 (就绪、运行、阻塞)、进程的优先级、进程阻塞的原因等。
- 进程资源使用情况,包括进程占用的 CPU 时间、内存空间、I/O 设备等。
- 进程打开的文件信息,包括文件描述符、文件类型、文件访问模式等。
- 进程的 CPU 状态信息 (包括各种寄存器的值),包括通用寄存器、指令计数器、程序状态字 (PSW) 、用户栈指针等。
- ...
进程有哪些状态?
进程的状态可以分为 5 种,和线程的状态基本一致:
- 新建 (New): 进程正在被创建,还没有被调度到就绪队列中。
- 就绪 (Ready): 进程已经准备好运行,只差 CPU 的调度了。一旦被 CPU 调度,就可以立即执行。
- 运行 (Running): 进程正在 CPU 上执行 (单核 CPU 同一时刻只能有一个进程运行)。
- 等待 (Waiting): 也称为阻塞状态,进程正在等待某个事件的发生,例如等待 I/O 操作完成或者等待某个资源可用。即使 CPU 空闲,进程也无法运行。
- 终止 (Terminated): 进程正在从系统中移除,可能是进程正常结束,也可能是进程被异常终止。
进程间通信的方式有哪些?
以下内容参考:《进程间通信 (IPC)》这篇文章,这篇文章写的很棒,建议大家阅读一下。
- 管道 (Pipe): 管道用于具有亲缘关系的父子进程之间或者兄弟进程之间的通信。
- 有名管道 (Named Pipe): 匿名管道应用于有亲缘关系的进程之间的通信,有名管道则突破了这种限制,它可以用于没有亲缘关系的进程之间的通信。有名管道提供了一个路径名与之关联,以使其以文件的方式存在于文件系统中。
- 信号 (Signal): 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
- 消息队列 (Message Queue): 消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能传输无格式字节流以及缓冲区大小受限等缺点。消息队列允许任意进程访问,并且允许消息的随机访问,消息队列可以按照先进先出 (FIFO) 方式访问,或者按照消息的类型访问。
- 共享内存 (Shared Memory): 共享内存就是映射一段能被其他进程所访问的内存区域,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
- 信号量 (Semaphore): 信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
- 套接字 (Socket): 套解字也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。
进程调度算法
这是一个很重要的知识点!为了确定 CPU 服务进程的顺序,计算机科学家们定义了一些算法来解决这个问题。
- 先来先服务 (FCFS): 按照进程进入就绪队列的顺序来调度进程,先进入就绪队列的进程先被调度。
- 短作业优先 (SJF): 从就绪队列中选择估计运行时间最短的进程来调度。
- 时间片轮转 (RR): 这是一种最古老,最简单,最公平,使用最广的算法。每个进程被分配一个时间段,称为时间片,即该进程允许运行的时间。
- 多级反馈队列调度算法 (MFQ): 为了解决以上几种算法的不足,把它们结合起来,得到了多级反馈队列调度算法。
- 优先级调度: 按照进程的优先级来调度进程,优先级高的进程先被调度。
什么是僵尸进程和孤儿进程?
在 Unix/Linux 中,子进程是由 fork() 系统调用创建的,fork() 会创建一个新的进程,这个新进程是父进程的一个独立副本。每个进程都有自己的 PCB,子进程可以独立于父进程继续运行,即使父进程终止,子进程也可以继续运行。
当一个进程调用 exit() 系统调用时,内核会释放该进程所占用的资源,例如内存和打开的文件等,但不会立即释放该进程的 PCB,而是将其状态设置为 僵死 (ZOMBIE),直到父进程调用 wait() 或 waitpid() 系统调用来获取子进程的退出状态,内核才会释放该进程的 PCB。这样做是为了防止子进程在父进程不知情的情况下终止,导致父进程无法获取子进程的退出状态。
- 僵尸进程: 子进程已经终止,但是父进程还没有调用 wait() 或 waitpid() 系统调用来获取子进程的退出状态,导致子进程的 PCB 仍然存在于系统中。
- 孤儿进程: 父进程已经终止或者不存在了,但是子进程仍然在运行。操作系统会将孤儿进程的父进程设置为 init 进程 (进程 ID 为 1),由 init 进程来回收孤儿进程的资源。
如何查看僵尸进程?
在 Linux 中,可以使用 top
命令查看僵尸进程的数量,如果僵尸进程的数量为 0,则说明系统中没有僵尸进程。
可以使用以下命令查看僵尸进程以及僵尸进程的父进程:
ps -A -ostat,ppid,pid,cmd |grep -e '^[Zz]'
死锁
什么是死锁?
死锁是指两个或多个进程因争夺资源而造成的一种僵局,若无外力作用,这些进程都将无法向前推进。
操作系统中死锁的例子?
举个例子,假设有两个进程 A 和 B,它们都需要两个资源 X 和 Y 才能完成任务,如下所示:
进程 | 占有的资源 | 需要的资源 |
---|---|---|
A | X | Y |
B | Y | X |
在这种情况下,进程 A 占有资源 X,请求资源 Y;进程 B 占有资源 Y,请求资源 X。两个进程都在等待对方释放资源,导致死锁。
死锁产生的 4 个必要条件
- 互斥条件: 一个资源一次只能被一个进程使用。
- 请求与保持条件: 一个进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件: 进程已获得的资源,在未使用完之前,不能强行剥夺。
- 循环等待条件: 若干个进程之间形成一种头尾相接的循环等待资源关系。
注意 ⚠️: 发生死锁时,以上四个条件一定是同时满足的。如果其中任何一个条件不成立,死锁就不会发生。
用代码模拟死锁
下面是一个演示线程死锁的代码示例:
public class DeadLockDemo {
private static Object resource1 = new Object(); // 资源 1
private static Object resource2 = new Object(); // 资源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + " 获取了 resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + " 等待 resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + " 获取了 resource2");
}
}
}, "线程 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + " 获取了 resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + " 等待 resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + " 获取了 resource1");
}
}
}, "线程 2").start();
}
}
输出:
Thread[线程 1,5,main] 获取了 resource1
Thread[线程 2,5,main] 获取了 resource2
Thread[线程 1,5,main] 等待 resource2
Thread[线程 2,5,main] 等待 resource1
线程 A 获取 resource1
的锁,然后休眠 1 秒钟,让线程 B 执行并获取 resource2
的锁。 两个线程都将无限期地等待对方释放资源,导致死锁。
如何解决死锁?
解决死锁问题的方法可以从多个角度进行分析,一般包括 预防死锁、避免死锁、检测死锁和 解除死锁:
- 预防死锁: 通过设置某些限制条件,去破坏产生死锁的必要条件,来防止死锁的发生。
- 避免死锁: 在资源的动态分配过程中,用某种方法去防止系统进入不安全状态,从而避免死锁的发生。
- 检测死锁: 允许系统在运行过程中发生死锁,但可通过系统设置的检测机构及时地检测出死锁的发生,并精确地确定与死锁有关的进程和资源。
- 解除死锁: 当检测到死锁后,便采取适当的措施,把进程从死锁状态中解脱出来。
预防死锁
只要破坏产生死锁的 4 个必要条件中的任意一个就可以预防死锁。
为了破坏 互斥条件,可以允许多个进程同时访问某些资源。这种方法比较简单,有时候也是可行的,但是对于很多资源来说,它们本身就不能被多个进程同时访问,比如打印机。
为了破坏 不剥夺条件,可以考虑使用 抢占式调度算法,不过这种方法一般只适用于主存和 CPU 资源的分配。
因此,在实际的操作系统中,预防死锁的方法一般都是针对 请求与保持条件 和 循环等待条件:
1. 静态资源分配策略: 要求进程在运行前一次性申请完它所需要的全部资源。这种方法可以消除 “请求” 条件,但会导致资源利用率很低。
2. 层次分配策略: 将系统中的所有资源按照类型进行分层,并规定每一层中的资源只能按层次顺序申请。这种方法可以消除 “循环等待” 条件。
避免死锁
预防死锁的方法是通过限制资源的请求来预防死锁的发生,而避免死锁的方法是在资源的动态分配过程中,用某种方法去防止系统进入不安全状态,从而避免死锁的发生。
系统安全状态和不安全状态:
如果操作系统能够保证所有的进程在有限的时间内都能得到需要的资源,那么系统就处于安全状态,否则系统就处于不安全状态。处于安全状态的系统一定不会发生死锁,而处于不安全状态的系统可能会发生死锁。
避免死锁最著名的算法是 Dijkstra 提出的 银行家算法,它就是用来判断资源分配是否会导致系统进入不安全状态的。
检测死锁
死锁预防和避免都是以限制资源请求为代价的,它们都限制了资源的利用率。而 死锁的检测和恢复 策略则允许系统动态地分配资源,但是系统会定期地执行 死锁检测程序 来检测系统中是否存在死锁。
资源分配图
任何时刻系统的状态都可以用 资源分配图 来表示,它可以清晰地描述系统资源和进程之间的关系。
资源分配图是一个有向图,图中的节点分为两种:
- 进程节点: 用圆圈表示。
- 资源节点: 用方框表示。
图中的边也分为两种:
- 请求边: 用进程节点指向资源节点的箭头表示,表示进程请求资源。
- 分配边: 用资源节点指向进程节点的箭头表示,表示资源已经分配给进程。
如果资源分配图中存在环路,那么系统就可能发生了死锁。
需要注意的是,资源分配图中存在环路只是死锁的必要不充分条件,也就是说,资源分配图中存在环路并不一定就发生了死锁。 比如,在上面的例子中,虽然进程 P1 和 P3 之间形成了环路,但是如果其他进程能够释放资源,那么 P1 和 P3 还是可以完成任务的,也就是环路会被打破。
死锁检测步骤
死锁检测程序 的实现步骤可以概括为以下几步:
- 如果资源分配图中没有环路,那么系统就没有发生死锁。
- 如果资源分配图中存在环路,并且每个资源类中只有一个资源,那么系统就发生了死锁。
- 如果资源分配图中存在环路,并且每个资源类中有多个资源,那么系统可能发生了死锁。如果一个进程能够释放资源而不被阻塞,那么就可以将该进程从资源分配图中移除,然后重复执行上述步骤,直到所有的边都被移除或者确定发生了死锁。
解除死锁
当检测到死锁后,就需要采取措施来解除死锁,常用的解除死锁的方法有:
- 立即结束所有进程,重启操作系统: 这种方法比较简单粗暴,但是会造成数据丢失。
- 结束所有参与死锁的进程: 这种方法可以消除循环等待条件,但是也会造成数据丢失。
- 逐个结束参与死锁的进程: 这种方法可以减少数据丢失,但是需要花费更多的时间。
- 资源抢占: 从一个或多个进程中抢占资源,直到死锁被打破。
内存管理
内存管理主要做什么?
内存管理是操作系统的重要组成部分,主要负责以下任务:
- 内存的分配与回收: 根据进程的需要,分配内存空间给进程使用,并在进程结束后回收内存空间。例如:使用 malloc 函数分配内存,使用 free 函数释放内存。
- 地址转换: 将程序中的逻辑地址转换为内存中的物理地址。
- 内存的扩充: 当物理内存不足时,使用虚拟内存技术或分页技术来逻辑上扩展内存。
- 内存共享: 允许多个进程共享同一块内存区域,例如动态链接库。
- 内存保护: 防止一个进程访问其他进程的内存空间,保证系统的安全性和稳定性。
什么是内存碎片?
内存碎片是指内存中空闲空间无法被利用的情况,它通常分为两种类型:
- 内部碎片: 内存分配给进程后,进程并没有完全使用,导致内存空间浪费。例如,如果一个进程需要 65 字节的内存,但是操作系统分配了 128 字节 (2^7) 的内存给它,那么剩下的 63 字节就是内部碎片。
- 外部碎片: 内存中存在很多空闲空间,但是这些空闲空间都不够大,无法满足进程的内存需求。分段存储方式容易产生外部