这部分内容摘自 JavaGuide 下面几篇文章中的重点:

JavaGuide

操作系统常见面试题总结(上)(操作系统基础、进程和线程、死锁)

操作系统常见面试题总结(上)

操作系统常见面试题总结(下)(内存管理、文件系统)

操作系统常见面试题总结(下)

操作系统基础

操作系统基础

操作系统概览

什么是操作系统?

什么是操作系统?

通过以下四点可以概括操作系统到底是什么:

操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序,是计算机的基石。

操作系统本质上是一个运行在计算机上的软件程序 ,主要用于管理计算机硬件和软件资源。 举例:运行在你电脑上的所有应用程序都通过操作系统来调用系统内存以及磁盘等等硬件。

操作系统存在屏蔽了硬件层的复杂性。 操作系统就像是硬件使用的负责人,统筹着各种相关事项。

操作系统的内核(Kernel)是操作系统的核心部分,它负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理。 内核是连接应用程序和硬件的桥梁,决定着系统的性能和稳定性。

很多人容易把操作系统的内核(Kernel)和中央处理器(CPU,Central Processing Unit)弄混。你可以简单从下面两点来区别:

操作系统的内核(Kernel)属于操作系统层面,而 CPU 属于硬件。

CPU 主要提供运算,处理各种指令的能力。内核(Kernel)主要负责系统管理比如内存管理,它屏蔽了对硬件的操作。

下图清晰说明了应用程序、内核、CPU 这三者的关系。

Kernel_Layout

操作系统主要有哪些功能?

操作系统主要有哪些功能?

从资源管理的角度来看,操作系统有 6 大功能:

进程和线程的管理:进程的创建、撤销、阻塞、唤醒,进程间的通信等。

存储管理:内存的分配和管理、外存(磁盘等)的分配和管理等。

文件管理:文件的读、写、创建及删除等。

设备管理:完成设备(输入输出设备和外部存储设备等)的请求或释放,以及设备启动等功能。

网络管理:操作系统负责管理计算机网络的使用。网络是计算机系统中连接不同计算机的方式,操作系统需要管理计算机网络的配置、连接、通信和安全等,以提供高效可靠的网络服务。

安全管理:用户的身份认证、访问控制、文件加密等,以防止非法用户对系统资源的访问和操作。

⭐️用户态和内核态

⭐️用户态和内核态

什么是用户态和内核态?

什么是用户态和内核态?

用户态和内核态描述的是 CPU 执行代码时的特权级别。应用代码通常在用户态运行;需要访问受保护资源时,CPU 会按规定入口进入内核态,由内核代表当前线程完成操作。

用户态(User Mode):权限较低,不能直接执行特权指令,也不能随意访问内核地址空间或操作硬件。应用读文件、收发网络数据时,需要通过系统调用请求内核服务。

内核态(Kernel Mode):权限较高,可以执行管理页表、中断、设备等特权操作。系统调用、中断或同步异常进入内核后,运行的是内核代码,不是把整个用户进程变成了“内核进程”。

用户态和内核态

用户态/内核态切换需要经过架构规定的入口、保存必要状态并执行权限和参数检查,因此比普通函数调用更重。但它不等于线程上下文切换:只有调度器换成另一个执行实体时,才会发生线程切换。

为什么要有用户态和内核态?只有一个内核态不行么?

为什么要有用户态和内核态?只有一个内核态不行么?

在 CPU 的所有指令中,有一些指令是比较危险的比如内存分配、设置时钟、IO 处理等,如果所有的程序都能使用这些指令的话,会对系统的正常运行造成灾难性地影响。因此,我们需要限制这些危险指令只能内核态运行。这些只能由操作系统内核态执行的指令也被叫做 特权指令 。

如果计算机系统中只有一个内核态,那么所有程序或进程都必须共享系统资源,例如内存、CPU、硬盘等,这将导致系统资源的竞争和冲突,从而影响系统性能和效率。并且,这样也会让系统的安全性降低,毕竟所有程序或进程都具有相同的特权级别和访问权限。

因此,同时具有用户态和内核态主要是为了保证计算机系统的安全性、稳定性和性能。

用户态和内核态是如何切换的?

用户态和内核态是如何切换的?

用户态切换到内核态的 3 种方式

让 CPU 从用户态进入内核态的事件主要有三类:

系统调用(Trap):应用主动执行 syscall、ecall 等指令,请求内核完成 read()、send() 等操作。它由当前指令触发,属于同步事件。

syscall
ecall
read()
send()

硬件中断(Interrupt):由定时器、网卡、磁盘等外部硬件触发,与当前正在执行的指令没有直接关系,因此属于异步事件。

同步异常(Exception):由当前指令触发,例如除零、非法指令或 Page Fault。异常不一定代表程序出错,懒分配、COW 和从文件调页也会触发可恢复的 Page Fault。

中断、异常和系统调用描述的是 CPU 为什么进入内核;信号则是内核通知进程或线程的一种软件机制。非法内存访问可能先触发 Page Fault,内核判断无法修复后,再向当前线程投递 SIGSEGV。

SIGSEGV

⭐️系统调用

⭐️系统调用

什么是系统调用?

什么是系统调用?

系统调用是内核向用户程序提供的受控服务接口。应用不能直接操作磁盘、页表和网卡等受保护资源,需要通过系统调用让内核代为完成。

系统调用

这些系统调用按功能大致可分为如下几类:

设备管理:完成设备(输入输出设备和外部存储设备等)的请求或释放,以及设备启动等功能。

文件管理:完成文件的读、写、创建及删除等功能。

进程管理:进程的创建、撤销、阻塞、唤醒,进程间的通信等功能。

内存管理:完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。

系统调用和库函数不是同一层概念。普通函数调用始终在用户态执行;glibc 等运行库提供的 read() 包装函数会按 ABI 准备系统调用号和参数,再执行特殊指令进入内核。也有很多库函数完全不需要系统调用。

read()

系统调用的过程了解吗?

系统调用的过程了解吗?

以 Linux x86-64 下的 read(fd, buf, count) 为例:

read(fd, buf, count)

glibc 包装函数按照调用约定,把系统调用号和参数放入指定寄存器,执行 syscall。

syscall

CPU 切换到内核特权级和对应入口,内核保存必要状态,再根据系统调用号分发到 read 的处理逻辑。

read

内核检查文件描述符、用户缓冲区和访问权限,然后进入 VFS、文件系统、网络协议栈或设备驱动等路径。

数据已就绪时直接完成读取;数据未就绪时,当前线程可能进入等待状态,调度器转而运行其他任务。

调用完成后返回用户态;出错时,glibc 通常把内核错误码转换成 -1 和 errno。

-1
errno

系统调用的过程

⭐️系统调用一定会发生上下文切换吗?

⭐️系统调用一定会发生上下文切换吗?

不一定。系统调用一定会让 CPU 进入内核态,但如果内核很快处理完并返回原线程,整个过程只有用户态/内核态切换,没有切换线程。

只有系统调用需要等待 I/O、锁或其他资源,当前线程阻塞并由调度器换成另一个可运行任务时,才会发生线程上下文切换。反过来,时钟中断进入内核后,如果调度器仍让原线程继续运行,也不会发生线程切换。

⭐️进程和线程

⭐️进程和线程

什么是进程和线程?

什么是进程和线程?

进程(Process) 是指计算机中正在运行的一个程序实例。举例:你打开的微信就是一个进程。

线程(Thread) 也被称为轻量级进程,更加轻量。多个线程可以在同一个进程中同时执行,并且共享进程的资源比如内存空间、文件句柄、网络连接等。举例:你打开的微信里就有一个线程专门用来拉取别人发你的最新的消息。

进程和线程的区别是什么?

进程和线程的区别是什么?

下图是 Java 内存区域,我们从 JVM 的角度来说一下线程和进程之间的关系吧!

Java 运行时数据区域(JDK1.8 之后)

从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的堆和方法区 (JDK1.8 之后的元空间)资源,但是每个线程有自己的程序计数器、虚拟机栈 和 本地方法栈。

总结:

线程是进程划分成的更小的运行单位,一个进程在其执行的过程中可以产生多个线程。

线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。

线程执行开销小,但不利于资源的管理和保护;而进程正相反。

有了进程为什么还需要线程?

有了进程为什么还需要线程?

同一进程内的线程共享地址空间和打开文件,协作通常比进程间 IPC 更直接。

同进程线程切换通常不需要切换整套地址空间,成本往往低于跨进程切换,但仍有调度和缓存开销。

单核 CPU 上,一个线程等待 I/O 时,其他线程可以继续运行;多核 CPU 上,可拆分的任务有机会并行执行。

线程并不是越多越好。过多线程会增加栈内存、调度、锁竞争和缓存失效等成本。

为什么要使用多线程?

为什么要使用多线程?

先从总体上来说:

从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。

从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

再深入到计算机底层来探讨:

单核时代:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。

多核时代: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。

⭐️多线程一定能提高性能吗?

⭐️多线程一定能提高性能吗?

不一定。多线程能否提速取决于任务类型、CPU 核数和共享资源竞争:

I/O 密集型任务:一个线程等待磁盘、网络或锁时,其他可运行线程可以继续使用 CPU,多线程能隐藏一部分等待时间。

CPU 密集型任务:只有任务可拆分且相互独立时,才可能在多个核心并行;加速效果还会受到串行部分、数据依赖和缓存开销限制。

线程过多:可运行线程远多于 CPU 核数后,运行队列、上下文切换、缓存失效和锁竞争都会增加,吞吐量和延迟反而可能变差。

线程数应结合任务特征、CPU quota、阻塞比例和压测结果设置,不能只按物理核心数或并发请求数直接推算。

线程间的同步的方式有哪些?

线程间的同步的方式有哪些?

线程同步是两个或多个共享关键资源的线程的并发执行。应该同步线程以避免关键的资源使用冲突。

下面是几种常见的线程同步的方式:

互斥锁(Mutex) :采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。

synchronized
Lock

读写锁(Read-Write Lock) :允许多个线程同时读取共享资源,但只有一个线程可以对共享资源进行写操作。

信号量(Semaphore) :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量。

屏障(Barrier) :屏障是一种同步原语,用于等待多个线程到达某个点再一起继续执行。当一个线程到达屏障时,它会停止执行并等待其他线程到达屏障,直到所有线程都到达屏障后,它们才会一起继续执行。比如 Java 中的 CyclicBarrier 是这种机制。

CyclicBarrier

事件(Event) :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。

PCB 是什么?包含哪些信息?

PCB 是什么?包含哪些信息?

PCB(Process Control Block) 即进程控制块,是操作系统中用来管理和跟踪进程的数据结构,每个进程都对应着一个独立的 PCB。你可以将 PCB 视为进程的大脑。

当操作系统创建一个新进程时,会为该进程分配一个唯一的进程 ID,并且为该进程创建一个对应的进程控制块。当进程执行时,PCB 中的信息会不断变化,操作系统会根据这些信息来管理和调度进程。

PCB 主要包含下面几部分的内容:

进程的描述信息,包括进程的名称、标识符等等;

进程的调度信息,包括进程阻塞原因、进程状态(就绪、运行、阻塞等)、进程优先级(标识进程的重要程度)等等;

进程对资源的需求情况,包括 CPU 时间、内存空间、I/O 设备等等。

进程打开的文件信息,包括文件描述符、文件类型、打开模式等等。

处理机的状态信息(由处理机的各种寄存器中的内容组成的),包括通用寄存器、指令计数器、程序状态字 PSW、用户栈指针。

……

TCB 是什么?和 PCB 有什么关系?

TCB 是什么?和 PCB 有什么关系?

TCB(Thread Control Block) 即线程控制块,用来保存线程级别的控制信息,例如线程 ID、线程状态、寄存器现场、栈信息、调度优先级和线程本地存储等。

在一些教材或系统实现里,PCB 和 TCB 是分开的:PCB 更偏进程级资源,TCB 更偏线程级执行现场。Linux 的实现比较特殊,它把进程和线程都看成 task,用 task_struct 描述调度实体,再通过资源结构是否共享来区分进程和线程。

task_struct

面试时不用纠结具体名称,关键是分清:地址空间、文件表等属于进程的资源边界;栈、寄存器、程序计数器等属于线程的执行现场。

进程有哪几种状态?

进程有哪几种状态?

我们一般把进程大致分为 5 种状态,这一点和线程很像!

创建状态(new):进程正在被创建,尚未到就绪状态。

就绪状态(ready):进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。

运行状态(running):进程正在处理器上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。

阻塞状态(waiting):又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。

结束状态(terminated):进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。

进程状态图转换图

进程间的通信方式有哪些?

进程间的通信方式有哪些?

下面这部分总结参考了:《进程间通信 IPC (InterProcess Communication)》 这篇文章,推荐阅读,总结的非常不错。

下面这部分总结参考了:《进程间通信 IPC (InterProcess Communication)》 这篇文章,推荐阅读,总结的非常不错。

《进程间通信 IPC (InterProcess Communication)》

管道/匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。

有名管道(Named Pipes) : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循 先进先出(First In First Out) 。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。

信号(Signal) :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;

消息队列(Message Queuing) :消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺点。

信号量(Semaphores) :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。

共享内存(Shared memory) :使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。

套接字(Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。

⭐️fork、exec、wait 分别做什么?

⭐️fork、exec、wait 分别做什么?

fork、exec、wait 的调用链路

fork():创建子进程。父子进程从同一个位置继续执行,但返回值不同。

fork()

exec():在当前进程中装入另一个程序。它不会新建进程,而是替换当前进程的用户态代码和数据。

exec()

wait()/waitpid():等待子进程状态变化,并回收子进程退出后留在内核里的状态信息。

wait()
waitpid()

Shell 启动外部命令时,常见链路是:Shell 先 fork() 出子进程,子进程再 exec() 成目标程序,父进程通过 wait() 或 waitpid() 等待并回收退出状态。如果父进程一直不回收已经退出的子进程,就可能留下僵尸进程。

fork()
exec()
wait()
waitpid()

⭐️什么是上下文切换?

⭐️什么是上下文切换?

上下文切换指 CPU 从一个执行实体切到另一个执行实体。操作系统要保存当前任务的寄存器、程序计数器、栈指针等现场,再恢复下一个任务的现场。

线程上下文切换和进程上下文切换的成本对比

线程切换和进程切换都有开销,跨进程切换通常更重,因为还可能涉及地址空间和页表切换、TLB 失效以及缓存局部性下降;同一进程内的线程共享地址空间,通常只需切换线程自己的执行现场。

还要区分上下文切换和用户态/内核态切换:系统调用、Page Fault、硬件中断都会进入内核,但只要处理后仍返回原线程,就没有发生线程上下文切换。

进程的调度算法有哪些?

进程的调度算法有哪些?

常见进程调度算法

这是一个很重要的知识点!为了确定首先执行哪个进程以及最后执行哪个进程以实现最大 CPU 利用率,计算机科学家已经定义了一些算法,它们是:

先到先服务调度算法(FCFS,First Come, First Served) : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。

短作业优先的调度算法(SJF,Shortest Job First) : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。

时间片轮转调度算法(RR,Round-Robin) : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。

多级反馈队列调度算法(MFQ,Multi-level Feedback Queue):前面介绍的几种进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成,因而它是目前被公认的一种较好的进程调度算法,UNIX 操作系统采取的便是这种调度算法。

优先级调度算法(Priority):为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。

⭐️load average 和 CPU 使用率有什么区别?

⭐️load average 和 CPU 使用率有什么区别?

Linux 的 load average 反映一段时间内可运行任务和不可中断睡眠任务的数量,主要对应 R 状态和 D 状态;CPU 使用率描述 CPU 时间具体花在用户态、内核态、I/O wait、中断、空闲或虚拟化 steal 等位置。

load 高可能是任务在争抢 CPU,也可能是大量任务等待块设备、网络存储、文件系统或 Swap,后一种情况下 CPU 仍可能有空闲。判断 load 还要结合逻辑 CPU 数:同样是 load 8,对 1 个逻辑 CPU 和 64 个逻辑 CPU 的含义完全不同。

排查时可以先用 uptime 看 1、5、15 分钟趋势,再结合 top、vmstat 1、pidstat 和 mpstat 判断瓶颈。

uptime
top
vmstat 1
pidstat
mpstat

什么是僵尸进程和孤儿进程?

什么是僵尸进程和孤儿进程?

在 Unix/Linux 系统中,子进程通常是通过 fork()系统调用创建的,该调用会创建一个新的进程,该进程是原有进程的一个副本。子进程和父进程的运行是相互独立的,它们各自拥有自己的 PCB,即使父进程结束了,子进程仍然可以继续运行。

当一个进程调用 exit()系统调用结束自己的生命时,内核会释放该进程的所有资源,包括打开的文件、占用的内存等,但是该进程对应的 PCB 依然存在于系统中。这些信息只有在父进程调用 wait()或 waitpid()系统调用时才会被释放,以便让父进程得到子进程的状态信息。

这样的设计可以让父进程在子进程结束时得到子进程的状态信息,并且可以防止出现“僵尸进程”(即子进程结束后 PCB 仍然存在但父进程无法得到状态信息的情况)。

僵尸进程:子进程已经终止,但是其父进程仍在运行,且父进程没有调用 wait()或 waitpid()等系统调用来获取子进程的状态信息,释放子进程占用的资源,导致子进程的 PCB 依然存在于系统中,但无法被进一步使用。这种情况下,子进程被称为“僵尸进程”。避免僵尸进程的产生,父进程需要及时调用 wait()或 waitpid()系统调用来回收子进程。

孤儿进程:一个进程的父进程已经终止或者不存在,但是该进程仍在运行。这种情况下,该进程就是孤儿进程。孤儿进程通常是由于父进程意外终止或未及时调用 wait()或 waitpid()等系统调用来回收子进程导致的。为了避免孤儿进程占用系统资源,操作系统会将孤儿进程的父进程设置为 init 进程(进程号为 1),由 init 进程来回收孤儿进程的资源。

如何查看是否有僵尸进程?

如何查看是否有僵尸进程?

Linux 下可以使用 Top 命令查找,zombie 值表示僵尸进程的数量,为 0 则代表没有僵尸进程。

zombie

僵尸进程查看

下面这个命令可以定位僵尸进程以及该僵尸进程的父进程:

ps -A -ostat,ppid,pid,cmd |grep -e '^[Zz]'
ps -A -ostat,ppid,pid,cmd |grep -e '^[Zz]'

⭐️死锁

⭐️死锁

什么是死锁?

什么是死锁?

死锁(Deadlock)描述的是这样一种情况:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。

能列举一个操作系统发生死锁的例子吗?

能列举一个操作系统发生死锁的例子吗?

假设有两个进程 A 和 B,以及两个资源 X 和 Y,它们的分配情况如下:

进程占用资源需求资源AXYBYX

进程占用资源需求资源

进程

占用资源

需求资源

AXY

A

X

Y

BYX

B

Y

X

此时,进程 A 占用资源 X 并且请求资源 Y,而进程 B 已经占用了资源 Y 并请求资源 X。两个进程都在等待对方释放资源,无法继续执行,陷入了死锁状态。

产生死锁的四个必要条件是什么?

产生死锁的四个必要条件是什么?

互斥:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。

占有并等待:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。

非抢占:资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。

循环等待:有一组等待进程 {P0, P1,..., Pn}, P0 等待的资源被 P1 占有,P1 等待的资源被 P2 占有,……,Pn-1 等待的资源被 Pn 占有,Pn 等待的资源被 P0 占有。

{P0, P1,..., Pn}
P0
P1
P1
P2
Pn-1
Pn
Pn
P0

死锁四个必要条件

注意 ⚠️:这四个条件是产生死锁的 必要条件 ,也就是说只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

下面是百度百科对必要条件的解释:

如果没有事物情况 A,则必然没有事物情况 B,也就是说如果有事物情况 B 则一定有事物情况 A,那么 A 就是 B 的必要条件。从逻辑学上看,B 能推导出 A,A 就是 B 的必要条件,等价于 B 是 A 的充分条件。

如果没有事物情况 A,则必然没有事物情况 B,也就是说如果有事物情况 B 则一定有事物情况 A,那么 A 就是 B 的必要条件。从逻辑学上看,B 能推导出 A,A 就是 B 的必要条件,等价于 B 是 A 的充分条件。

能写一个模拟产生死锁的代码吗?

能写一个模拟产生死锁的代码吗?

下面通过一个实际的例子来模拟下图展示的线程死锁:

线程交叉持锁形成死锁

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() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}
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() + "get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "get resource2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "get resource1");
                }
            }
        }, "线程 2").start();
    }
}

Output

Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]get resource2
Thread[线程 1,5,main]waiting get resource2
Thread[线程 2,5,main]waiting get resource1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。

synchronized (resource1)
resource1
Thread.sleep(1000);
resource2

解决死锁的方法

解决死锁的方法

解决死锁的方法可以从多个角度去分析,一般的情况下,有预防,避免,检测和解除四种。

预防 是采用某种策略,限制并发进程对资源的请求,从而使得死锁的必要条件在系统执行的任何时间上都不满足。

避免则是系统在分配资源时,根据资源的使用情况提前做出预测,从而避免死锁的发生

检测是指系统设有专门的机构,当死锁发生时,该机构能够检测死锁的发生,并精确地确定与死锁有关的进程和资源。

解除 是与检测相配套的一种措施,用于将进程从死锁状态下解脱出来。

死锁处理策略

死锁的预防

死锁的预防

死锁四大必要条件上面都已经列出来了,很显然,只要破坏四个必要条件中的任何一个就能够预防死锁的发生。

破坏第一个条件 互斥条件:使得资源是可以同时访问的,这是种简单的方法,磁盘就可以用这种方法管理,但是我们要知道,有很多资源 往往是不能同时访问的 ,所以这种做法在大多数的场合是行不通的。

破坏第三个条件 非抢占:也就是说可以采用 剥夺式调度算法,但剥夺式调度方法目前一般仅适用于 主存资源 和 处理器资源 的分配,并不适用于所有的资源,会导致 资源利用率下降。

所以一般比较实用的 预防死锁的方法,是通过考虑破坏第二个条件和第四个条件。

1、静态分配策略

静态分配策略可以破坏死锁产生的第二个条件(占有并等待)。所谓静态分配策略,就是指一个进程必须在执行前就申请到它所需要的全部资源,并且知道它所要的资源都得到满足之后才开始执行。进程要么占有所有的资源然后开始执行,要么不占有资源,不会出现占有一些资源等待一些资源的情况。

静态分配策略逻辑简单,实现也很容易,但这种策略 严重地降低了资源利用率,因为在每个进程所占有的资源中,有些资源是在比较靠后的执行时间里采用的,甚至有些资源是在额外的情况下才使用的,这样就可能造成一个进程占有了一些 几乎不用的资源而使其他需要该资源的进程产生等待 的情况。

2、层次分配策略

层次分配策略破坏了产生死锁的第四个条件(循环等待)。在层次分配策略下,所有的资源被分成了多个层次,一个进程得到某一次的一个资源后,它只能再申请较高一层的资源;当一个进程要释放某层的一个资源时,必须先释放所占用的较高层的资源,按这种策略,是不可能出现循环等待链的,因为那样的话,就出现了已经申请了较高层的资源,反而去申请了较低层的资源,不符合层次分配策略,证明略。

死锁的避免

死锁的避免

上面提到的 破坏 死锁产生的四个必要条件之一就可以成功 预防系统发生死锁 ,但是会导致 低效的进程运行 和 资源使用率 。而死锁的避免相反,它的角度是允许系统中同时存在四个必要条件 ,只要掌握并发进程中与每个进程有关的资源动态申请情况,做出 明智和合理的选择 ,仍然可以避免死锁,因为四大条件仅仅是产生死锁的必要条件。

我们将系统的状态分为 安全状态 和 不安全状态 ,每当在为申请者分配资源前先测试系统状态,若把系统资源分配给申请者会产生死锁,则拒绝分配,否则接受申请,并为它分配资源。

如果操作系统能够保证所有的进程在有限的时间内得到需要的全部资源,则称系统处于安全状态,否则说系统是不安全的。很显然,系统处于安全状态则不会发生死锁,系统若处于不安全状态则可能发生死锁。

如果操作系统能够保证所有的进程在有限的时间内得到需要的全部资源,则称系统处于安全状态,否则说系统是不安全的。很显然,系统处于安全状态则不会发生死锁,系统若处于不安全状态则可能发生死锁。

那么如何保证系统保持在安全状态呢?通过算法,其中最具有代表性的 避免死锁算法 就是 Dijkstra 的银行家算法,银行家算法用一句话表达就是:当一个进程申请使用资源的时候,银行家算法 通过先 试探 分配给该进程资源,然后通过 安全性算法 判断分配后系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待,若能够进入到安全的状态,则就 真的分配资源给该进程。

银行家算法详情可见:《一句话+一张图说清楚——银行家算法》 。

《一句话+一张图说清楚——银行家算法》

操作系统教程书中讲述的银行家算法也比较清晰,可以一看.

死锁的避免(银行家算法)改善了 资源使用率低的问题 ,但是它要不断地检测每个进程对各类资源的占用和申请情况,以及做 安全性检查 ,需要花费较多的时间。

死锁的检测

死锁的检测

对资源的分配加以限制可以 预防和避免 死锁的发生,但是都不利于各进程对系统资源的充分共享。解决死锁问题的另一条途径是 死锁检测和解除 (这里突然联想到了乐观锁和悲观锁,感觉死锁的检测和解除就像是 乐观锁 ,分配资源时不去提前管会不会发生死锁了,等到真的死锁出现了再来解决嘛,而 死锁的预防和避免 更像是悲观锁,总是觉得死锁会出现,所以在分配资源的时候就很谨慎)。

这种方法对资源的分配不加以任何限制,也不采取死锁避免措施,但系统 定时地运行一个 “死锁检测” 的程序,判断系统内是否出现死锁,如果检测到系统发生了死锁,再采取措施去解除它。

进程-资源分配图

进程-资源分配图

操作系统中的每一刻时刻的系统状态都可以用进程-资源分配图来表示,进程-资源分配图是描述进程和资源申请及分配关系的一种有向图,可用于检测系统是否处于死锁状态。

用一个方框表示每一个资源类,方框中的黑点表示该资源类中的各个资源,用一个圆圈表示每一个进程,用 有向边 来表示进程申请资源和资源被分配的情况。

图中 2-21 是进程-资源分配图的一个例子,其中共有三个资源类,每个进程的资源占有和申请情况已清楚地表示在图中。在这个例子中,由于存在 占有和等待资源的环路 ,导致一组进程永远处于等待资源的状态,发生了 死锁。

进程-资源分配图

进程-资源分配图中存在环路并不一定是发生了死锁。因为循环等待资源仅仅是死锁发生的必要条件,而不是充分条件。图 2-22 便是一个有环路而无死锁的例子。虽然进程 P1 和进程 P3 分别占用了一个资源 R1 和一个资源 R2,并且因为等待另一个资源 R2 和另一个资源 R1 形成了环路,但进程 P2 和进程 P4 分别占有了一个资源 R1 和一个资源 R2,它们申请的资源得到了满足,在有限的时间里会归还资源,于是进程 P1 或 P3 都能获得另一个所需的资源,环路自动解除,系统也就不存在死锁状态了。

死锁检测步骤

死锁检测步骤

知道了死锁检测的原理,我们可以利用下列步骤编写一个 死锁检测 程序,检测系统是否产生了死锁。

如果进程-资源分配图中无环路,则此时系统没有发生死锁

如果进程-资源分配图中有环路,且每个资源类仅有一个资源,则系统中已经发生了死锁。

如果进程-资源分配图中有环路,且涉及到的资源类有多个资源,此时系统未必会发生死锁。如果能在进程-资源分配图中找出一个 既不阻塞又非独立的进程 ,该进程能够在有限的时间内归还占有的资源,也就是把边给消除掉了,重复此过程,直到能在有限的时间内 消除所有的边 ,则不会发生死锁,否则会发生死锁。(消除边的过程类似于 拓扑排序)

死锁的解除

死锁的解除

当死锁检测程序检测到存在死锁发生时,应设法让其解除,让系统从死锁状态中恢复过来,常用的解除死锁的方法有以下四种:

立即结束所有进程的执行,重新启动操作系统:这种方法简单,但以前所在的工作全部作废,损失很大。

撤销涉及死锁的所有进程,解除死锁后继续运行:这种方法能彻底打破死锁的循环等待条件,但将付出很大代价,例如有些进程可能已经计算了很长时间,由于被撤销而使产生的部分结果也被消除了,再重新执行时还要再次进行计算。

逐个撤销涉及死锁的进程,回收其资源直至死锁解除。

抢占资源:从涉及死锁的一个或几个进程中抢占资源,把夺得的资源再分配给涉及死锁的进程直至死锁解除。

内存管理

内存管理

内存管理主要做了什么?

内存管理主要做了什么?

内存管理职责概览

操作系统的内存管理非常重要,主要负责下面这些事情:

内存的分配与回收:对进程所需的内存进行分配和释放,malloc 函数:申请内存,free 函数:释放内存。

地址转换:将程序中的虚拟地址转换成内存中的物理地址。

内存扩充:当系统没有足够的内存时,利用虚拟内存技术或自动覆盖技术,从逻辑上扩充内存。

内存映射:将一个文件直接映射到进程的进程空间中,这样可以通过内存指针用读写内存的办法直接存取文件内容,速度更快。

内存优化:通过调整内存分配策略和回收算法来优化内存使用效率。

内存安全:保证进程之间使用内存互不干扰,避免一些恶意程序通过修改内存来破坏系统的安全性。

……

⭐️VSZ、RSS 和 PSS 有什么区别?

⭐️VSZ、RSS 和 PSS 有什么区别?

Linux 中查看进程内存时,这三个指标很容易混淆:

VSZ:进程已经映射的虚拟地址空间大小。它包含尚未真正驻留的匿名映射、文件映射、共享库和预留地址,不能直接当作物理内存占用。

RSS:当前已经驻留在 RAM 中,并映射给该进程的页面总量。共享库、共享内存等共享页也会计入相关进程的 RSS。

PSS:按比例分摊共享页后的内存占用。一个物理页如果被 4 个进程共享,每个进程的 PSS 只计算四分之一。

因此,看单个进程的常驻内存可以参考 RSS;估算多个进程合计占用时,PSS 更合适。直接把多个进程的 RSS 相加,通常会重复计算共享页。

grep -E 'VmSize|VmRSS|RssAnon|RssFile|RssShmem|VmSwap' /proc/<pid>/status
cat /proc/<pid>/smaps_rollup
grep -E 'VmSize|VmRSS|RssAnon|RssFile|RssShmem|VmSwap' /proc/<pid>/status
cat /proc/<pid>/smaps_rollup

什么是内存碎片?

什么是内存碎片?

内存碎片是由内存的申请和释放产生的,通常分为下面两种:

内部内存碎片(Internal Memory Fragmentation,简称为内存碎片):已经分配给进程使用但未被使用的内存。导致内部内存碎片的主要原因是,当采用固定比例比如 2 的幂次方进行内存分配时,进程所分配的内存可能会比其实际所需要的大。举个例子,一个进程只需要 65 字节的内存,但为其分配了 128(2^7) 大小的内存,那 63 字节的内存就成为了内部内存碎片。

外部内存碎片(External Memory Fragmentation,简称为外部碎片):由于未分配的连续内存区域太小,以至于不能满足任意进程所需要的内存分配请求,这些小片段且不连续的内存空间被称为外部碎片。也就是说,外部内存碎片指的是那些并未分配给进程但又不能使用的内存。我们后面介绍的分段机制就会导致外部内存碎片。

连续内存分配与碎片

内存碎片会导致内存利用率下降,如何减少内存碎片是内存管理要非常重视的一件事情。

⭐️常见的内存管理方式有哪些?

⭐️常见的内存管理方式有哪些?

内存管理方式可以简单分为下面两种:

连续内存管理:为一个用户程序分配一个连续的内存空间,内存利用率一般不高。

非连续内存管理:允许一个程序使用的内存分布在离散或者说不相邻的内存中,相对更加灵活一些。

连续内存管理

连续内存管理

块式管理 是早期计算机操作系统的一种连续内存管理方式,存在严重的内存碎片问题。块式管理会将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为内部内存碎片。除了内部内存碎片之外,由于两个内存块之间可能还会有外部内存碎片,这些不连续的外部内存碎片由于太小了无法再进行分配。

在 Linux 系统中,连续内存管理采用了 伙伴系统(Buddy System)算法 来实现,这是一种经典的连续内存分配算法,可以有效解决外部内存碎片的问题。伙伴系统的主要思想是将内存按 2 的幂次划分(每一块内存大小都是 2 的幂次比如 2^6=64 KB),并将相邻的内存块组合成一对伙伴(注意:必须是相邻的才是伙伴)。

当进行内存分配时,伙伴系统会尝试找到大小最合适的内存块。如果找到的内存块过大,就将其一分为二,分成两个大小相等的伙伴块。如果还是大的话,就继续切分,直到到达合适的大小为止。

假设两块相邻的内存块都被释放,系统会将这两个内存块合并,进而形成一个更大的内存块,以便后续的内存分配。这样就可以减少内存碎片的问题,提高内存利用率。

伙伴系统(Buddy System)内存管理

虽然解决了外部内存碎片的问题,但伙伴系统仍然存在内存利用率不高的问题(内部内存碎片)。这主要是因为伙伴系统只能分配大小为 2^n 的内存块,因此当需要分配的内存大小不是 2^n 的整数倍时,会浪费一定的内存空间。举个例子:如果要分配 65 大小的内存快,依然需要分配 2^7=128 大小的内存块。

伙伴系统内存浪费问题

对于内部内存碎片的问题,Linux 采用 SLAB 进行解决。由于这部分内容不是本篇文章的重点,这里就不详细介绍了。

非连续内存管理

非连续内存管理

非连续内存管理存在下面 3 种方式:

段式管理:以段(一段连续的物理内存)的形式管理/分配物理内存。应用程序的虚拟地址空间被分为大小不等的段,段是有实际意义的,每个段定义了一组逻辑信息,例如有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。

页式管理:把物理内存划分为固定大小的页框,虚拟地址空间也划分为固定大小的虚拟页,虚拟页可以映射到离散的物理页框,是现代操作系统广泛使用的内存管理方式。

段页式管理机制:程序的地址空间先按逻辑分段,每个段再划分为固定大小的页,物理内存仍按页框分配。

⭐️虚拟内存

⭐️虚拟内存

什么是虚拟内存?有什么用?

什么是虚拟内存?有什么用?

虚拟内存(Virtual Memory) 是计算机系统内存管理非常重要的一个技术,本质上来说它只是逻辑存在的,是一个假想出来的内存空间,主要作用是作为进程访问主存(物理内存)的桥梁并简化内存管理。

虚拟内存作为进程访问主存的桥梁

总结来说,虚拟内存主要提供了下面这些能力:

隔离进程:物理内存通过虚拟地址空间访问,虚拟地址空间与进程一一对应。每个进程都认为自己拥有了整个物理内存,进程之间彼此隔离,一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。

提升物理内存利用率:有了虚拟地址空间后,操作系统只需要将进程当前正在使用的部分数据或指令加载入物理内存。

简化内存管理:进程都有一个一致且私有的虚拟地址空间,程序员不用和真正的物理内存打交道,而是借助虚拟地址空间访问物理内存,从而简化了内存管理。

多个进程共享物理内存:进程在运行过程中,会加载许多操作系统的动态库。这些库对于每个进程而言都是公用的,它们在内存中实际只会加载一份,这部分称为共享内存。

提高内存使用安全性:控制进程对物理内存的访问,隔离不同进程的访问权限,提高系统的安全性。

提供更大的可使用内存空间:可以让程序拥有超过系统物理内存大小的可用内存空间。这是因为当物理内存不够用时,可以利用磁盘充当,将物理内存页(通常大小为 4 KB)保存到磁盘文件(会影响读写速度),数据或代码页会根据需要在物理内存与磁盘之间移动。

没有虚拟内存有什么问题?

没有虚拟内存有什么问题?

如果没有虚拟内存的话,程序直接访问和操作的都是物理内存,看似少了一层中介,但多了很多问题。

具体有什么问题呢? 这里举几个例子说明(参考虚拟内存提供的能力回答这个问题):

用户程序可以访问任意物理内存,可能会不小心操作到系统运行必需的内存,进而造成操作系统崩溃,严重影响系统的安全。

同时运行多个程序容易崩溃。比如你想同时运行一个微信和一个 QQ 音乐,微信在运行的时候给内存地址 1xxx 赋值后,QQ 音乐也同样给内存地址 1xxx 赋值,那么 QQ 音乐对内存的赋值就会覆盖微信之前所赋的值,这就可能会造成微信这个程序会崩溃。

程序运行过程中使用的所有数据或指令都要载入物理内存,根据局部性原理,其中很大一部分可能都不会用到,白白占用了宝贵的物理内存资源。

……

什么是虚拟地址和物理地址?

什么是虚拟地址和物理地址?

物理地址(Physical Address) 是真正的物理内存中地址,更具体点来说是内存地址寄存器中的地址。程序中访问的内存地址不是物理地址,而是 虚拟地址(Virtual Address) 。

也就是说,我们编程开发的时候实际就是在和虚拟地址打交道。比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的虚拟地址。

操作系统一般通过 CPU 芯片中的一个重要组件 MMU(Memory Management Unit,内存管理单元) 将虚拟地址转换为物理地址,这个过程被称为 地址翻译/地址转换(Address Translation) 。

地址翻译过程

通过 MMU 将虚拟地址转换为物理地址后,再通过总线传到物理内存设备,进而完成相应的物理内存读写请求。

MMU 将虚拟地址翻译为物理地址的主要机制有两种: 分段机制 和 分页机制 。

什么是虚拟地址空间和物理地址空间?

什么是虚拟地址空间和物理地址空间?

虚拟地址空间是虚拟地址的集合,是虚拟内存的范围。每一个进程都有一个一致且私有的虚拟地址空间。

物理地址空间是物理地址的集合,是物理内存的范围。

虚拟地址与物理内存地址是如何映射的?

虚拟地址与物理内存地址是如何映射的?

MMU 将虚拟地址翻译为物理地址的主要机制有 3 种:

分段机制

分页机制

段页机制

其中,现代操作系统广泛采用分页机制,需要重点关注!

⭐️分段机制

⭐️分段机制

分段机制(Segmentation) 按程序的逻辑结构划分地址空间,例如代码段、数据段、堆和栈。每个段的长度可以不同,段表项记录基址、段界限和访问权限;传统纯分段管理通常要求每段对应一块连续物理内存。

段表有什么用?地址翻译过程是怎样的?

段表有什么用?地址翻译过程是怎样的?

分段管理通过 段表(Segment Table) 映射虚拟地址和物理地址。

分段机制下的虚拟地址由两部分组成:

段号:标识着该虚拟地址属于整个虚拟地址空间中的哪一个段。

段内偏移量:相对于该段起始地址的偏移量。

具体的地址翻译过程如下:

MMU 首先解析得到虚拟地址中的段号;

通过段号去该应用程序的段表中取出对应的段信息(找到对应的段表项);

检查段内偏移量是否超过段界限、访问权限是否合法;合法时用段基址加偏移量得到物理地址。

分段地址转换

段表中还存有诸如段长(可用于检查虚拟地址是否超出合法范围)、段类型(该段的类型,例如代码段、数据段等)等信息。

如果段选择子无效、段内偏移越界或访问权限不符合要求,CPU 会触发体系结构规定的异常。页面是否已经驻留物理内存属于分页和 Page Fault 处理的问题,不应和纯分段模型混在一起。

分段机制为什么会导致内存外部碎片?

分段机制为什么会导致内存外部碎片?

分段机制容易出现外部内存碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段)。从而造成物理内存资源利用率的降低。

举个例子:假设可用物理内存为 5G 的系统使用分段机制分配内存。现在有 4 个进程,每个进程的内存占用情况如下:

进程 1:0~1G(第 1 段)

进程 2:1~3G(第 2 段)

进程 3:3~4.5G(第 3 段)

进程 4:4.5~5G(第 4 段)

此时,我们关闭了进程 1 和进程 4,则第 1 段和第 4 段的内存会被释放,空闲物理内存还有 1.5G。由于这 1.5G 物理内存并不是连续的,导致没办法将空闲的物理内存分配给一个需要 1.5G 物理内存的进程。

分段机制导致外部内存碎片

⭐️分页机制

⭐️分页机制

分页机制(Paging) 把物理内存划分为固定大小的页框,虚拟地址空间也划分为固定大小的虚拟页。虚拟页可以映射到任意可用的物理页框,因此物理内存可以离散分配。现代操作系统广泛采用分页机制。

注意:这里的页是连续等长的,不同于分段机制下不同长度的段。

在分页机制下,应用程序虚拟地址空间中的任意虚拟页可以被映射到物理内存中的任意物理页上,因此可以实现物理内存资源的离散分配。分页机制按照固定页大小分配物理内存,使得物理内存资源易于管理,可有效避免分段机制中外部内存碎片的问题。

页表有什么用?地址翻译过程是怎样的?

页表有什么用?地址翻译过程是怎样的?

分页管理通过 页表(Page Table) 映射虚拟地址和物理地址。我这里画了一张基于单级页表进行地址翻译的示意图。

单级页表

在分页机制下,每个进程都会有一个对应的页表。

分页机制下的虚拟地址由两部分组成:

页号:通过虚拟页号可以从页表中取出对应的物理页号;

页内偏移量:物理页起始地址+页内偏移量=物理内存地址。

具体的地址翻译过程如下:

MMU 首先解析得到虚拟地址中的虚拟页号;

通过虚拟页号去该应用程序的页表中取出对应的物理页号(找到对应的页表项);

用该物理页号对应的物理页起始地址(物理地址)加上虚拟地址中的页内偏移量得到最终的物理地址。

分页地址转换

页表中还存有访问位、脏位、权限位、存在位等信息。

如果页表项不存在、页面当前不在物理内存中,或者访问权限不匹配,CPU 会触发 Page Fault。内核随后判断能否修复:可以修复就建立映射或调入页面,无法修复则通常向当前线程投递 SIGSEGV。

SIGSEGV

通过虚拟页号一定要找到对应的物理页号吗?找到了物理页号得到最终的物理地址后对应的物理页一定存在吗?

不一定。页表项可能不存在,页面可能尚未驻留物理内存,访问方式也可能不符合页表项记录的权限。这些情况都会触发 Page Fault。

单级页表有什么问题?为什么需要多级页表?

单级页表有什么问题?为什么需要多级页表?

以 32 位的环境为例,虚拟地址空间范围共有 2^32(4G)。假设 一个页的大小是 2^12(4KB),那页表项共有 4G / 4K = 2^20 个。每个页表项为一个地址,占用 4 字节,2^20 2^2 / 1024 1024= 4MB。也就是说一个程序啥都不干,页表大小就得占用 4M。

2^20 * 2^2 / 1024 * 1024= 4MB

系统运行的应用程序多起来的话,页表的开销还是非常大的。而且,绝大部分应用程序可能只能用到页表中的几项,其他的白白浪费了。

为了解决这个问题,操作系统引入了 多级页表 ,多级页表对应多个页表,每个页表与前一个页表相关联。32 位系统一般为二级页表,64 位系统一般为四级页表。

这里以二级页表为例进行介绍:二级列表分为一级页表和二级页表。一级页表共有 1024 个页表项,一级页表又关联二级页表,二级页表同样共有 1024 个页表项。二级页表中的一级页表项是一对多的关系,二级页表按需加载(只会用到很少一部分二级页表),进而节省空间占用。

假设只需要 2 个二级页表,那两级页表的内存占用情况为: 4KB(一级页表占用) + 4KB * 2(二级页表占用) = 12 KB。

多级页表

多级页表属于时间换空间的典型场景,利用增加页表查询的次数减少页表占用的空间。

TLB 有什么用?使用 TLB 之后的地址翻译流程是怎样的?

TLB 有什么用?使用 TLB 之后的地址翻译流程是怎样的?

为了提高虚拟地址到物理地址的转换速度,CPU/MMU 会使用 转址旁路缓存(Translation Lookaside Buffer,TLB,也被称为快表) 缓存近期的地址转换结果。

加入 TLB 之后的地址翻译

在主流的 AArch64 和 x86-64 体系结构下,TLB 是 MMU 使用的硬件缓存。可以把它理解为按虚拟页匹配物理页框的高速缓存,但不能把具体硬件结构等同于软件哈希表。操作系统负责维护页表;映射变化后还要按体系结构要求使相关 TLB 项失效,避免继续使用旧映射。

使用 TLB 之后的地址翻译流程是这样的:

CPU 使用虚拟页号以及地址空间标识等信息匹配 TLB 项;

如果能查到对应的物理页的话,就不用再查询页表了,这种情况称为 TLB 命中(TLB hit)。

如果不能查到对应的物理页的话,还是需要去查询主存中的页表,同时将页表中的该映射表项添加到 TLB 中,这种情况称为 TLB 未命中(TLB miss)。

当 TLB 填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。

TLB 缓存地址转换结果的流程

由于页表也在主存中,没有 TLB 时,CPU 访问一个虚拟地址往往要先访问内存查页表,再访问真正的数据;多级页表下查表次数还会更多。TLB 命中时可以跳过页表查询,直接得到物理页帧号。

TLB 的设计思想非常简单,但命中率往往非常高,效果很好。这就是因为被频繁访问的页就是其中的很小一部分。

看完了之后你会发现快表和我们平时经常在开发系统中使用的缓存(比如 Redis)很像,的确是这样的,操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。

换页机制有什么用?

换页机制有什么用?

换页机制的思想是当物理内存不够用的时候,操作系统选择将一些物理页的内容放到磁盘上去,等要用到的时候再将它们读取到物理内存中。也就是说,换页机制利用磁盘这种较低廉的存储设备扩展的物理内存。

这也就解释了一个日常使用电脑常见的问题:为什么操作系统中所有进程运行所需的物理内存即使比真实的物理内存要大一些,这些进程也是可以正常运行的,只是运行速度会变慢。

这同样是一种时间换空间的策略,你用 CPU 的计算时间,页的调入调出花费的时间,换来了一个虚拟的更大的物理内存空间来支持程序的运行。

什么是缺页异常(Page Fault)?

什么是缺页异常(Page Fault)?

Page Fault 是 CPU 在地址翻译或页级权限检查无法直接完成时触发的同步异常。它由当前访存指令引起,不是外设触发的硬件中断,也不代表程序一定出错。

内核接管后,会检查地址是否落在合法虚拟内存区域中,以及访问类型是否符合权限:

可修复的 Page Fault:例如匿名页首次访问、文件页或 Swap 页尚未驻留、写入 COW 页。内核完成分配、调页或复制并更新页表后,CPU 重新执行触发异常的指令。

不可修复的 Page Fault:例如地址不属于合法映射、写只读页或执行不可执行页。Linux 通常向当前线程投递 SIGSEGV。

SIGSEGV

从性能统计角度,可修复的 Page Fault 常分为主缺页和次缺页:主缺页需要从文件或 Swap 读取页面,涉及实际 I/O;次缺页不需要读取存储设备,例如建立已有物理页的映射、分配零页或处理部分 COW 场景。

常见的页面置换算法有哪些?

常见的页面置换算法有哪些?

当发生主缺页且没有空闲物理页可用时,操作系统可能需要淘汰一个物理页,为新页面腾出空间。

用来选择淘汰哪一个物理页的规则叫做 页面置换算法 ,我们可以把页面置换算法看成是淘汰物物理页的规则。

页缺失太频繁的发生会非常影响性能,一个好的页面置换算法应该是可以减少页缺失出现的次数。

常见的页面置换算法有下面这 5 种(其他还有很多页面置换算法都是基于这些算法改进得来的):

页面置换算法对比

最佳页面置换算法(OPT,Optimal):优先选择淘汰的页面是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。但由于人们目前无法预知进程在内存下的若干页面中哪个是未来最长时间内不再被访问的,因而该算法无法实现,只是理论最优的页面置换算法,可以作为衡量其他置换算法优劣的标准。

先进先出页面置换算法(FIFO,First In First Out) : 最简单的一种页面置换算法,总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。该算法易于实现和理解,一般只需要通过一个 FIFO 队列即可满足需求。不过,它的性能并不是很好。

最近最久未使用页面置换算法(LRU ,Least Recently Used):LRU 算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的,即最近最久未使用的页面予以淘汰。LRU 算法是根据各页之前的访问情况来实现,因此是易于实现的。OPT 算法是根据各页未来的访问情况来实现,因此是不可实现的。

最少使用页面置换算法(LFU,Least Frequently Used) : 和 LRU 算法比较像,不过该置换算法选择的是之前一段时间内使用最少的页面作为淘汰页。

时钟页面置换算法(Clock):可以认为是一种最近未使用算法,即逐出的页面都是最近没有使用的那个。

FIFO 页面置换算法性能为何不好?

主要原因主要有二:

经常访问或者需要长期存在的页面会被频繁调入调出:较早调入的页往往是经常被访问或者需要长期存在的页,这些页会被反复调入和调出。

存在 Belady 现象:被置换的页面并不是进程不会访问的,有时就会出现分配的页面数增多但缺页率反而提高的异常现象。出现该异常的原因是因为 FIFO 算法只考虑了页面进入内存的顺序,而没有考虑页面访问的频率和紧迫性。

哪一种页面置换算法实际用的比较多?

LRU 算法是实际使用中应用的比较多,也被认为是最接近 OPT 的页面置换算法。

不过,需要注意的是,实际应用中这些算法会被做一些改进,就比如 InnoDB Buffer Pool( InnoDB 缓冲池,MySQL 数据库中用于管理缓存页面的机制)就改进了传统的 LRU 算法,使用了一种称为"Adaptive LRU"的算法(同时结合了 LRU 和 LFU 算法的思想)。

⭐️Swap、工作集和抖动分别是什么?

⭐️Swap、工作集和抖动分别是什么?

Swap 是磁盘上的后备空间。匿名页没有对应的文件来源,物理内存紧张时,如果内核要回收这类页面,就可能先把它们写入 Swap;进程再次访问时,再从 Swap 读回内存。Swap 不是免费的内存扩容,频繁换入换出会明显拉高业务延迟。

工作集 是进程在一段时间内真正频繁访问的页面集合。如果物理内存无法容纳主要进程的工作集,页面就会被频繁换出又换入,这种状态叫做 抖动(Thrashing)。此时 CPU 不一定忙于业务计算,大量时间可能耗在缺页处理、页面回收和磁盘 I/O 上。

Linux 页面回收思路

排查时可以结合 free -h、vmstat 1、/proc/vmstat 和 /proc/pressure/memory,重点观察缺页、Swap 换入换出以及内存压力。

free -h
vmstat 1
/proc/vmstat
/proc/pressure/memory

⭐️Overcommit 和 OOM 是什么关系?

⭐️Overcommit 和 OOM 是什么关系?

Linux 允许进程申请的虚拟内存超过当前 RAM 和 Swap,这叫 Overcommit。因此,malloc() 或 mmap() 成功通常只表示虚拟地址空间申请成功,并不代表所有物理页已经准备好;很多物理页要等到首次访问时才会真正分配。

malloc()
mmap()

当进程实际访问页面时,如果内核无法通过页面回收、写回或 Swap 获得足够内存,就可能触发 OOM Killer,选择一个或多个进程杀掉以释放内存。

在容器环境里还要检查 cgroup 限制。宿主机有空闲内存,不代表容器还能继续使用;容器达到 memory.max 后,也可能先触发 cgroup 范围内的 OOM。

memory.max

mmap、COW 和共享内存有什么关系?

mmap、COW 和共享内存有什么关系?

mmap() 会在进程的虚拟地址空间里创建一段映射,既可以映射文件,也可以创建匿名映射。映射建立时不一定马上读入数据,真正访问某个页面时才可能触发缺页异常。

mmap()

多个进程映射同一个文件时,内核可以让它们共享 Page Cache 中的物理页。共享内存 IPC 也是类似思路:不同进程的虚拟地址映射到同一批物理页,进程之间交换数据时无需每次经过内核拷贝。

COW(Copy-On-Write,写时复制) 常见于 fork()。父子进程创建之初共享同一批物理页,页表先标记为只读;任何一方写入时都会触发缺页异常,内核再为写入方复制页面。

fork()

分页机制和分段机制有哪些共同点和区别?

分页机制和分段机制有哪些共同点和区别?

共同点:

都是非连续内存管理的方式。

都采用了地址映射的方法,将虚拟地址映射到物理地址,以实现对内存的管理和保护。

区别:

分页机制以页面为单位进行内存管理,而分段机制以段为单位进行内存管理。页的大小是固定的,由操作系统决定,通常为 2 的幂次方。而段的大小不固定,取决于我们当前运行的程序。

页是物理单位,即操作系统将物理内存划分成固定大小的页面,每个页面的大小通常是 2 的幂次方,例如 4KB、8KB 等等。而段则是逻辑单位,是为了满足程序对内存空间的逻辑需求而设计的,通常根据程序中数据和代码的逻辑结构来划分。

分段机制容易出现外部内存碎片,即在段与段之间留下碎片空间(不足以映射给虚拟地址空间中的段)。分页机制解决了外部内存碎片的问题,但仍然可能会出现内部内存碎片。

分页机制采用页表完成虚拟地址到物理地址的映射,现代系统通常使用多级页表;分段机制则采用段表,每个段表项记录段基址、界限和权限等信息。

分页机制对程序没有任何要求,程序只需要按照虚拟地址进行访问即可;而分段机制需要程序员将程序分为多个段,并且显式地使用段寄存器来访问不同的段。

段页机制

段页机制

结合了段式管理和页式管理的一种内存管理机制。程序视角中,内存被划分为多个逻辑段,每个逻辑段进一步被划分为固定大小的页。

在段页式机制下,地址翻译的过程分为两个步骤:

段式地址映射(虚拟地址 → 线性地址):虚拟地址 = 段选择符(段号)+ 段内偏移。根据段号查段表,找到段基址,加上段内偏移得到线性地址。

虚拟地址 = 段选择符(段号)+ 段内偏移。

根据段号查段表,找到段基址,加上段内偏移得到线性地址。

页式地址映射(线性地址 → 物理地址):线性地址 = 页号 + 页内偏移。根据页号查页表,找到物理页框号,加上页内偏移得到物理地址。

线性地址 = 页号 + 页内偏移。

根据页号查页表,找到物理页框号,加上页内偏移得到物理地址。

⭐️局部性原理

⭐️局部性原理

要想更好地理解虚拟内存技术,必须要知道计算机中著名的 局部性原理(Locality Principle)。另外,局部性原理既适用于程序结构,也适用于数据结构,是非常重要的一个概念。

局部性原理是指在程序执行过程中,数据和指令的访问存在一定的空间和时间上的局部性特点。其中,时间局部性是指一个数据项或指令在一段时间内被反复使用的特点,空间局部性是指一个数据项或指令在一段时间内与其相邻的数据项或指令被反复使用的特点。

在分页机制中,页表的作用是将虚拟地址转换为物理地址,从而完成内存访问。在这个过程中,局部性原理的作用体现在两个方面:

时间局部性:由于程序中存在一定的循环或者重复操作,因此会反复访问同一个页或一些特定的页,这就体现了时间局部性的特点。为了利用时间局部性,分页机制中通常采用缓存机制来提高页面的命中率,即将最近访问过的一些页放入缓存中,如果下一次访问的页已经在缓存中,就不需要再次访问内存,而是直接从缓存中读取。

空间局部性:由于程序中数据和指令的访问通常是具有一定的空间连续性的,因此当访问某个页时,往往会顺带访问其相邻的一些页。为了利用空间局部性,分页机制中通常采用预取技术来预先将相邻的一些页读入内存缓存中,以便在未来访问时能够直接使用,从而提高访问速度。

总之,局部性原理是计算机体系结构设计的重要原则之一,也是许多优化算法的基础。在分页机制中,利用时间局部性和空间局部性,采用缓存和预取技术,可以提高页面的命中率,从而提高内存访问效率。

I/O

I/O

一次阻塞 read() 经历了什么?

一次阻塞 read() 经历了什么?

read()

以 read(fd, buf, count) 为例,用户程序通常先调用 glibc 包装函数,再通过 syscall 等指令进入内核。内核检查文件描述符、用户缓冲区和访问权限,然后进入 VFS、文件系统、Socket 或设备驱动对应的读取路径。

read(fd, buf, count)
syscall

如果数据已经就绪,内核把数据复制到用户缓冲区并返回;如果数据尚未就绪,阻塞 fd 会让当前线程进入等待状态,调度器可以运行其他任务。磁盘 I/O 完成或网卡收到数据后,设备通过硬件中断通知内核,内核唤醒等待线程;该线程以后重新获得 CPU,才会继续完成 read() 并返回用户态。

read()

这条路径可能同时出现系统调用、硬件中断和线程上下文切换,也可能只经历其中一部分。Page Cache 已经有数据时,read() 可以直接返回,不需要等待设备中断,也不一定切换线程。

read()

⭐️什么是 I/O 多路复用?

⭐️什么是 I/O 多路复用?

I/O 多路复用解决的不是“单次读写更快”,而是一个线程如何同时等待多个文件描述符的就绪事件。应用把一批 fd 交给 select、poll 或 epoll,任意 fd 就绪时调用返回,再处理对应连接,适合大量连接空闲、少量连接活跃的场景。

select
poll
epoll

网络读取中的两个阶段

I/O 多路复用仍属于同步 I/O。内核只是通知“可以读/写了”,应用仍要自己调用 read/recv,内核缓冲区到用户缓冲区的数据复制并没有省掉。

read
recv

详细介绍:I/O 多路复用详解

I/O 多路复用详解

⭐️select、poll 和 epoll 有什么区别?

⭐️select、poll 和 epoll 有什么区别?

select、poll 和 epoll 对比

select 使用固定大小的 fd_set 位图,通常受 FD_SETSIZE 限制;每次都要传完整集合,返回后还要线性扫描。

select
fd_set
FD_SETSIZE

poll 改用 pollfd 数组,绕开固定 1024 左右的位图限制,但仍要反复传递并扫描完整集合。

poll
pollfd

epoll 用 epoll_ctl 在内核长期维护关注集合,epoll_wait 主要返回已就绪事件,更适合连接多但活跃比例低的场景。ET 模式还必须配合非阻塞 fd,并循环读到 EAGAIN。

epoll
epoll_ctl
epoll_wait
EAGAIN

epoll 并非所有场景都更快。连接很少或几乎全部活跃时,它维护监听集合、回调和就绪链表的成本也要计算。

epoll

⭐️什么是零拷贝?

⭐️什么是零拷贝?

零拷贝不是完全没有拷贝,而是尽量避免 CPU 在内核缓冲区和用户缓冲区之间搬运数据,从而减少 CPU 拷贝和用户态/内核态切换。传统 read + write 发送文件时,数据通常经历磁盘到 Page Cache、Page Cache 到用户缓冲区、用户缓冲区到 Socket 缓冲区、Socket 缓冲区到网卡四段路径;零拷贝重点减少中间由 CPU 完成的拷贝。

read + write

传统 read/write 的数据拷贝路径

它适合静态文件原样转发、大文件传输和消息队列日志发送。若数据需要 TLS 加密、压缩、格式转换或内容过滤,应用必须处理载荷,就很难一直走纯内核路径。

详细介绍:零拷贝详解

零拷贝详解

mmap、sendfile 和 splice 有什么区别?

mmap、sendfile 和 splice 有什么区别?

传统 read/write、mmap、sendfile 和 splice 对比

mmap + write:把文件映射到进程地址空间,省掉传统 read 的一次 CPU 拷贝,但写入 Socket 通常仍需拷贝。适合发送前还要读取、解析或修改数据。

mmap + write
read

sendfile:更适合“文件到 Socket”的原样发送,数据不进入用户态;支持 SG-DMA 时还可进一步减少 CPU 拷贝。

sendfile

splice:借助 pipe 在内核中移动页引用,适合更一般的 fd 之间转发,但路径通常必须包含 pipe,调用和代码也更复杂。

splice

简单记忆:要处理数据用 mmap,文件原样发 Socket 用 sendfile,更一般的 fd 转发再考虑 splice。

mmap
sendfile
splice

文件系统

文件系统

文件系统主要做了什么?

文件系统主要做了什么?

文件系统职责概览

文件系统主要负责管理和组织计算机存储设备上的文件和目录,其功能包括以下几个方面:

存储管理:将文件数据存储到物理存储介质中,并且管理空间分配,以确保每个文件都有足够的空间存储,并避免文件之间发生冲突。

文件管理:文件的创建、删除、移动、重命名、压缩、加密、共享等等。

目录管理:目录的创建、删除、移动、重命名等等。

文件访问控制:管理不同用户或进程对文件的访问权限,以确保用户只能访问其被授权访问的文件,以保证文件的安全性和保密性。

⭐️文件、目录、inode、dentry 有什么关系?

⭐️文件、目录、inode、dentry 有什么关系?

在 Linux/Unix 文件系统中,文件名通常不存放在 inode 里。

文件名、dentry 和 inode 的关系

目录项:保存文件名到 inode 号的映射。

inode:记录文件类型、权限、所有者、大小、时间戳、链接计数,以及数据块或 extent 的映射信息。

dentry:VFS 在内存中维护的目录项缓存,用来加速路径查找。它通常指向 inode,也可以缓存“不存在”的查找结果。

数据块或 extent:保存普通文件的实际内容。

这也解释了为什么同一个文件可以有多个名字:多个目录项可以指向同一个 inode。同一文件系统内执行 mv a.txt b.txt 时,很多情况下只需修改目录项,不用移动文件内容。

mv a.txt b.txt

open 一个文件时发生了什么?

open 一个文件时发生了什么?

open() 不会把整个文件读入内存,它主要完成路径解析和打开对象创建。

open()

路径到文件描述符

从根目录或当前目录开始解析路径,逐级查找目录项和 dentry 缓存。

找到目标 inode 后,检查权限、打开标志和文件类型。

创建内核中的打开文件对象,记录文件偏移量、打开状态和读写标志等信息。

在当前进程的文件描述符表里分配一个最小可用的非负整数,也就是 fd。

fd 是进程文件描述符表里的索引,不是 inode。dup()、fork() 之后,多个 fd 可能引用同一个打开文件对象,因此共享文件偏移量;两个进程分别 open() 同一个文件,通常会得到不同的打开文件对象,各自维护偏移量。

dup()
fork()
open()

⭐️硬链接和软链接有什么区别?

⭐️硬链接和软链接有什么区别?

硬链接和软链接对比

在 Linux/类 Unix 系统上,文件链接(File Link)是一种特殊的文件类型,可以在文件系统中指向另一个文件。常见的文件链接类型有两种:

1、硬链接(Hard Link)

在 Linux/类 Unix 文件系统中,每个文件和目录都有一个唯一的索引节点(inode)号,用来标识该文件或目录。硬链接通过 inode 节点号建立连接,硬链接和源文件的 inode 节点号相同,两者对文件系统来说是完全平等的(可以看作是互为硬链接,源头是同一份文件),删除其中任何一个对另外一个没有影响,可以通过给文件设置硬链接文件来防止重要文件被误删。

只有删除了源文件和所有对应的硬链接文件,该文件才会被真正删除。

硬链接具有一些限制,不能对目录以及不存在的文件创建硬链接,并且,硬链接也不能跨越文件系统。

ln 命令用于创建硬链接。

ln

2、软链接(Symbolic Link 或 Symlink)

软链接和源文件的 inode 节点号不同,而是指向一个文件路径。

源文件删除后,软链接依然存在,但是指向的是一个无效的文件路径。

软连接类似于 Windows 系统中的快捷方式。

不同于硬链接,可以对目录或者不存在的文件创建软链接,并且,软链接可以跨越文件系统。

ln -s 命令用于创建软链接。

ln -s

硬链接为什么不能跨文件系统?

硬链接为什么不能跨文件系统?

我们之前提到过,硬链接是通过 inode 节点号建立连接的,而硬链接和源文件共享相同的 inode 节点号。

硬链接指向的是 inode,而 inode 号只在当前文件系统内有意义。每个文件系统都有自己的 inode 表,另一个文件系统里的同一个 inode 号不代表同一个文件,因此硬链接不能跨文件系统。软链接保存的是路径字符串,解析时会重新查找目标,所以可以跨文件系统。

⭐️write() 成功后数据一定落盘了吗?

⭐️write() 成功后数据一定落盘了吗?

write()

不一定。对于普通 buffered I/O,write() 成功通常只表示数据已被内核接收,常见情况是进入 Page Cache 并被标记为脏页,不代表已经持久化到底层设备。

write()

文件写入到持久化路径

需要更强持久性时,可以使用:

fsync():同步文件数据和关联元数据。

fsync()

fdatasync():同步文件数据,以及后续读取所必需的元数据。

fdatasync()

O_SYNC、O_DSYNC 等带同步语义的打开标志。

O_SYNC
O_DSYNC

还要注意 write() 可能只写入部分字节。创建文件、rename() 或 unlink() 后,如果要求目录项在掉电后也可靠持久化,通常还要对父目录文件描述符调用 fsync()。

write()
rename()
unlink()
fsync()

日志文件系统解决了什么问题?

日志文件系统解决了什么问题?

创建文件等操作往往要同时修改 inode、目录项、位图等多处元数据。如果机器在写入中途断电,文件系统就可能处于不一致状态。

日志文件系统会先把即将进行的元数据变更写入日志区域,再更新正式位置。系统恢复时会扫描日志:完整提交但尚未写回正式位置的事务可以重放,没有完整提交的事务则会被丢弃。

需要注意,日志机制主要保证文件系统结构的一致性,不等于替应用保证所有业务数据都不丢。事务级持久性仍然依赖应用正确使用 fsync()、控制写入顺序并提供恢复逻辑。

fsync()

提高文件系统性能的方式有哪些?

提高文件系统性能的方式有哪些?

优化硬件:使用高速硬件设备(如 SSD、NVMe)替代传统的机械硬盘,使用 RAID(Redundant Array of Inexpensive Disks)等技术提高磁盘性能。

选择合适的文件系统选型:不同的文件系统具有不同的特性,对于不同的应用场景选择合适的文件系统可以提高系统性能。

运用缓存:访问磁盘的效率比较低,可以运用缓存来减少磁盘的访问次数。不过,需要注意缓存命中率,缓存命中率过低的话,效果太差。

避免磁盘过度使用:注意磁盘的使用率,避免将磁盘用满,尽量留一些剩余空间,以免对文件系统的性能产生负面影响。

对磁盘进行合理的分区:合理的磁盘分区方案,能够使文件系统在不同的区域存储文件,从而减少文件碎片,提高文件读写性能。

⭐️常见的磁盘调度算法有哪些?

⭐️常见的磁盘调度算法有哪些?

下面介绍的 SCAN、SSTF、LOOK 等算法主要面向机械硬盘。机械硬盘的一次读写由寻道时间、旋转延迟和传输时间组成,调度器可以调整请求顺序,减少磁头移动和等待时间。

常见的磁盘调度算法有下面这 6 种(其他还有很多磁盘调度算法都是基于这些算法改进得来的):

常见的磁盘调度算法

先来先服务算法(First-Come First-Served,FCFS):按照请求到达磁盘调度器的顺序处理,实现简单,但没有考虑磁头移动的路径和方向,平均寻道时间可能较长。它不会无限期跳过某个请求,因此通常没有算法意义上的饥饿问题,不过排在长请求后面的请求可能等待较久。

最短寻道时间优先算法(Shortest Seek Time First,SSTF):也被称为最佳服务优先(Shortest Service Time First,SSTF)算法,优先选择距离当前磁头位置最近的请求进行服务。SSTF 算法能够最小化磁头的寻道时间,但容易出现饥饿问题,即磁头附近的请求不断被服务,远离磁头的请求长时间得不到响应。实际应用中,需要优化一下该算法的实现,避免出现饥饿问题。

扫描算法(SCAN):也被称为电梯(Elevator)算法,基本思想和电梯非常类似。磁头沿着一个方向扫描磁盘,如果经过的磁道有请求就处理,直到到达磁盘的边界,然后改变移动方向,依此往复。SCAN 算法能够保证所有的请求得到服务,解决了饥饿问题。但是,如果磁头从一个方向刚扫描完,请求才到的话。这个请求就需要等到磁头从相反方向过来之后才能得到处理。

循环扫描算法(Circular Scan,C-SCAN):SCAN 算法的变体,只在磁盘的一侧进行扫描,并且只按照一个方向扫描,直到到达磁盘边界,然后回到磁盘起点,重新开始循环。

边扫描边观察算法(LOOK):SCAN 算法中磁头到了磁盘的边界才改变移动方向,这样可能会做很多无用功,因为磁头移动方向上可能已经没有请求需要处理了。LOOK 算法对 SCAN 算法进行了改进,如果磁头移动方向上已经没有别的请求,就可以立即改变磁头移动方向,依此往复。也就是边扫描边观察指定方向上还有无请求,因此叫 LOOK。

均衡循环扫描算法(C-LOOK):C-SCAN 只有到达磁盘边界时才能改变磁头移动方向,并且磁头返回时也需要返回到磁盘起点,这样可能会做很多无用功。C-LOOK 算法对 C-SCAN 算法进行了改进,如果磁头移动的方向上已经没有磁道访问请求了,就可以立即让磁头返回,并且磁头只需要返回到有磁道访问请求的位置即可。

SSD 和 NVMe 没有机械寻道,不能直接套用上述磁头调度模型。不过,现代 Linux 块层仍可能合并、排序请求,并通过 blk-mq 和 I/O 调度器权衡吞吐、公平性和延迟;具体策略取决于设备、内核版本和调度器。

写在最后

写在最后

感谢你能看到这里,也希望这篇文章对你有点用。

JavaGuide 坚持更新 6 年多,近 6000 次提交、600+ 位贡献者一起打磨。如果这些内容对你有帮助,非常欢迎点个免费的 Star 支持下(完全自愿,觉得有收获再点就好):GitHub | Gitee。

GitHub

Gitee

如果你想要付费支持/面试辅导(比如简历优化、一对一提问、高频考点突击资料等)的话,欢迎了解我的知识星球。已经坚持维护六年,内容持续更新,虽白菜价(0.4元/天)但质量很高,主打一个良心!

知识星球

JavaGuide 公众号