文章目录
- 一、硬件结构
- 1. 冯诺依曼模型由哪几部分构成?
- 2. 简要介绍一下内存、中央处理器、总线和输入输出设备
- 3. 64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?
- 4. 软件的 32 位和 64 位之间的区别?32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?
- 5. 简述存储器的层次结构
- 6. 存储器的层次关系
- 7. 机械硬盘、固态硬盘、内存和CPU L1 Cache的访问速度相差多少倍?
- 8. 为什么有了内存,还需要CPU Cache?
- 9. CPU Cache的数据结构和读取过程是怎么样的?
- 10. 如何写出让CPU跑得更快的代码?
- 11. 写入数据的方法有哪些?
- 12. 现在CPU都是多核心的,由于L1/L2 Cache是多个核心独有的,那么如何保证缓存一致性的问题?
- 13. 写传播和事务串行化具体是用什么技术实现的?
- 14. 什么情况下会出现Cache伪共享?Cache伪共享是什么?避免伪共享的方法有哪些?
- 15. CPU是根据什么来选择当前要执行的线程?
- 16. 中断是什么?什么是软中断?系统中有哪些软中断?
- 17. 为什么负数要用补码表示?
- 18. 十进制小数怎么转换成二进制?
- 19. 计算机是如何存储小数的?
- 20. 0.1 + 0.2 = 0.3 吗?
- 二、操作系统内核
- 三、内存管理
- 24. 虚拟内存有什么作用?
- 25. 操作系统是如何管理虚拟地址和物理地址之间的关系?
- 26. 分段机制下,虚拟地址和物理地址是如何映射的?
- 27. 内存分段会出现内存碎片吗?
- 28. 如何解决外部内存碎片的问题?
- 29. 分段为什么会导致内存交换效率低的问题?
- 30. 分页是如何解决分段的外部内存碎片和内存交换效率低的问题?
- 31. 简单的分页有什么缺陷吗?
- 32. 如何解决简单分页产生的页表占据内存过大的问题?
- 33. 64位的操作系统中,多级页表对应的目录是什么?
- 34. TLB是什么?为什么需要有TLB?
- 35. 段页式内存管理
- 36. Linux系统采用了什么方式管理内存?以及Linux的虚拟地址空间是如何分布的?
- 37. malloc() 是如何分配内存的?
- 38. 什么场景下malloc()会通过brk()分配内存?什么场景下通过mmap()分配内存?
- 39. malloc()分配的是物理内存吗?
- 40. malloc(1)会分配多大的虚拟内存?
- 41. free 释放内存,会归还给操作系统吗?
- 42. 为什么不全部使用 mmap 来分配内存?
- 43. 为什么不全部使用 brk 来分配内存?
- 44. free()函数只传入了一个内存地址,为什么能知道要释放多大的内存空间?
- 45. 如果物理内存不足,那么内核就会进行内存回收工作,内存回收的方式有哪些?
- 46. 在进行内存回收时,哪些内存可以被回收?
- 47. 针对频繁的回收内存,磁盘的I/O次数会很多,如何降低内存回收对性能的影响?
- 48. 如何保护一个进程不被OOM杀掉呢?
- 49. 在4GB物理内存的机器上,能够申请8GB内存吗?
- 50. 操作系统会在读磁盘的时候会额外多读一些到内存中,但是最后这些数据也没用到,有什么改善的方法吗?
- 51. 批量读数据的时候,可能会把热点数据挤出去,有什么改善的办法吗?
- 四、进程管理
- 52. 什么是进程?
- 53. 并发和并行有什么区别?
- 54. 进程的状态有哪几种?以及进程状态的变迁有哪些?
- 55. 什么是挂起状态?
- 56. PCB是什么?每个PCB具有哪些信息?每个PCB是如何组织的呢?
- 57. 进程的控制
- 58. CPU的上下文切换
- 59. 进程上下文切换
- 60. 发生进程上下文切换的常见场景有哪些?
- 61. 什么是线程?线程的优缺点有哪些?
- 62. 比较线程和进程
- 63. 线程上下文切换的是什么?
- 64. 线程的实现方式有哪几种?
- 65. 用户线程如何理解?有什么优缺点?
- 66. 内核线程如何理解?有什么优缺点?
- 67. 轻量级进程如何理解?有什么优缺点?
- 68. 进程调度的时机
- 69. 进程的调度原则
- 70. 进程间有哪些通信方式?
- 71. 针对 TCP 协议通信的 socket 编程模型
- 72. 针对 UDP 协议通信的 socket 编程模型
- 73. 针对本地进程间通信的 socket 编程模型
- 74. 什么是互斥和同步?
- 75. 如何实现多进程/线程的互斥与同步?
- 76. 生产者—消费者模型(待解答)
- 77. 哲学家就餐问题(待解答)
- 78. 读者—写者问题(待解答)
- 79. 什么是死锁?产生死锁需要满足哪些条件?如何避免死锁问题的产生?
- 80. 常见的锁有哪些?它们的特点与应用场景是什么?
- 81. 一个进程最多可以创建多少个线程?
- 82. 线程崩溃,进程一定会崩溃吗?
- 五、调度算法
- 六、文件系统
一、硬件结构
1. 冯诺依曼模型由哪几部分构成?
冯诺依曼模型由运算器、控制器、存储器、输入设备、输出设备
5个部分构成。
2. 简要介绍一下内存、中央处理器、总线和输入输出设备
- 内存:我们的程序和数据都是存储于内存,存储数据的基本单位是
字节
,每一个字节对应一个内存地址,内存地址是从0开始编号的,并且内存的读写任何一个数据的速度都是一样的。 - 中央处理器:也就是我们常说的
CPU
32位和64位CPU的区别:32位CPU一次可以计算4个字节,64位CPU一次可以计算8个字节。这里的32位和64位,通常称为CPU的位宽
,代表CPU一次可以运算的数据量。
CPU内部包含寄存器、控制单元和逻辑运算单元等。控制单元负责控制CPU工作,逻辑运算单元负责计算,而寄存器主要用于存储计算时的数据。寄存器又分为通用寄存器、程序寄存器和指令寄存器。 - 总线:总线用于CPU和内存以及其他设备之间的通信,总线分为3种:地址总线、数据总线和控制总线。
当CPU要读写内存数据的时候,首先要通过地址总线来指点内存的地址,然后通过控制总线控制是读还是写的操作,最后通过数据总线来传输数据。 - 输入、输出设备:输入设备向计算机输入数据,计算机经过计算后,把数据输出到输出设备。
3. 64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?
(1)64位相比32位CPU的优势主要体现在两个方面:
- 64位CPU可以一次计算超过32位的数字,而32位CPU一次最多只能32位的数字,如果要计算超过32位的数字,就要分多步骤进行计算,所以,
当运算超过32位的大数字的时候,64位CPU的计算效率更高
。 - 通常来说64位CPU的地址总线是48位,而32位CPU的地址总线是32位,所以
64位CPU可以寻址更大的物理内存空间
。32位CPU的最大寻址空间是4G,即使有8G的物理内存,也还是只能寻址4G大小的地址;64位CPU的最大寻址空间是248bit,寻址范围远远超过32位CPU。
(2)64位CPU的计算性能也不一定比32位CPU高很多,大部分应用程序很少会计算超过32位那么大的数字,所以只有运算大数字的时候,64位CPU的优势才能体现出来,否则和32位CPU的计算性能相差不大。
4. 软件的 32 位和 64 位之间的区别?32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么?
- 软件的32位和64位实际代表指令是32位还是64位的。
- 如果32位的指令在64位机器上运行,只需要一套兼容机制,就可以做到兼容运行了;然而64位的指令不能在32位机器上运行,因为32位的寄存器存不下64位的指令。
操作系统其实也是一种程序,因此32位的操作系统可以运行在64位的电脑上,而64位的操作系统无法运行在32位的电脑上。
总之,硬件的64位和32位指的是CPU的位宽
,软件的64位和32位指的是指令的位宽
。
5. 简述存储器的层次结构
存储器通常分为以下几个层次:寄存器、CPU Cache、内存、SSD/HDD硬盘
。
寄存器:材料最贵、处理速度最快、容量最小。
寄存器的数量通常在几十到几百之间,32位CPU中的大多数寄存器可以存储4个字节,64位CPU中的大多数寄存器可以存储8个字节。
一般要求在半个CPU时钟周期内完成读写,CPU时钟周期为主频的倒数。
CPU Cache:是一种叫SRAM(静态随机存储器)的芯片,断电数据就会丢失。
CPU的高速缓存,通常分为L1、L2、L3三层高速缓存:
- L1高速缓存的访问速度只需要2~4个时钟周期,大小在几十KB到几百KB,L1高速缓存通常分为指令缓存和数据缓存;
- L2高速缓存的访问速度在10~20个时钟周期,大小在几百KB到几MB;
- L3高速缓存的访问速度在20~60个时钟周期,大小在几MB到几十MB。
每个CPU核心都独自拥有自己的L1高速缓存和L2高速缓存,L3高速缓存是多个核心共用的。
内存:是一种叫DRAM(动态随机存储器)的芯片,需要定时刷新电容,才能保证数据不会丢失。
内存的访问速度大概在200~300个时钟周期之间。
SSD/HDD 硬盘:断电后数据不会丢失。
内存的读写速度比SSD大概快10~1000倍,比HDD快10w倍左右。
6. 存储器的层次关系
存储器包括寄存器、CPU高速缓存、内存、SSD/HHD硬盘
。
存储空间越大的存储器设备,其访问速度越慢,所需成本也相对越低。
每个存储器只和相邻的一层存储器设备直接打交道,并且存储设备为了追求更快的速度,所需的成本必然也会更高,也正因为成本太高,所以CPU内部的寄存器、L1/L2/L3 Cache 只好用较小的容量,相反内存和硬盘则可以使用更大的容量,这就是我们今天所说的存储器结构层次。
7. 机械硬盘、固态硬盘、内存和CPU L1 Cache的访问速度相差多少倍?
CPU L1 Cache 比内存快100
倍左右,比SSD快15w
倍左右,比机械硬盘快1000w
倍左右。
8. 为什么有了内存,还需要CPU Cache?
根据摩尔定律,CPU的访问速度每年增长60%左右,而内存访问速度每年只增长7%左右,CPU与内存访问性能上的差距在不断拉大。到现在,CPU和内存访问速度已经相差200~300倍,为了弥补CPU和内存两者之间的性能差距,就在CPU内部引入了CPU Cache。
9. CPU Cache的数据结构和读取过程是怎么样的?
CPU Cache是由很多个Cache Line(缓存块)
组成的,Cache Line 是CPU从内存中读取数据的基本单位,而Cache Line 是由组标记(Tag)、有效位(Valid bit)和数据块
组成。
一个内存的访问地址包括组标记、CPU Cache Line索引、偏移量。CPU Cache的数据结构则是由索引、有效位、组标记和数据块
组成。
从CPU Cache中读取数据的过程:
- 根据内存地址中的索引信息,计算在CPU Cache中的
索引
,找出对应的CPU Cache Line; - 判断CPU Cache Line 中的
有效位
,确定CPU Cache Line 中的数据是否有效,如果是无效的,CPU直接访问内存,并重新加载数据,如果数据有效,则往下执行; - 对比内存地址中的组标记和CPU Cache Line 中的
组标记
,确定缓存块中的数据是否是我们要访问的内存数据,如果不是的话,CPU就会直接访问内存,并重新加载数据,如果是的话,则往下执行; - 根据内存地址中的
偏移量
信息,从CPU Cache Line 的数据块中,读取对应的字。
10. 如何写出让CPU跑得更快的代码?
提高访问数据的缓存命中率,缓存命中率越高的话,代码的性能越好,CPU也就跑的越快。
CPU L1 Cache 分为数据缓存和指令缓存,因而需要分别提高它们的缓存命中率:
- 对于
数据缓存
,在我们遍历数据的时候,应该按照内存布局的顺序操作,这是因为 CPU Cache 是根据 CPU Cache Line 从内存中批量加载数据的,所以顺序地操作连续内存数据时,可减少对内存空间的访问次数,提高数据缓存的命中率; - 对于
指令缓存
,有规律的条件分支语句能够让CPU的分支预测器发挥作用,可以提前把预测会执行的语句放在指令缓存中,这样CPU可以直接冲CPU Cache 中读取数据,代码的执行速度就会很快; - 对于
多核的CPU系统
,线程可能在不同CPU核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要提高线程的缓存命中率,可以考虑将线程绑定在某一个CPU核心上。
11. 写入数据的方法有哪些?
有两种针对写入数据的方法:写直达和写回
(1)写直达
:将数据同时写入内存和Cache中
如果数据已经在Cache中,先将数据更新到Cache里面,再写入到内存中;
如果数据没有在Cache中,就直接把数据更新到内存中。
特点:直观简单,但是每次写操作都会写回到内存中,这样写操作将会花费大量的时间,写数据的性能不高。
(2)写回
:当发生写操作时,新的数据仅仅被写入Cache Block中,只有当修改过的Cache Block被替换时才需要写到内存中,这样可以减少数据写回内存的频率,提高系统的性能。
写回的具体实现:
- 当发生写操作时,如果数据已经存在于CPU Cache中,则把数据更新到CPU Cache里,同时标记CPU Cache 里的这个Cache Block 为
脏(Dirty)
的,这个脏的标记代表这时我们的 CPU Cache 中的这个Cache Block 的数据和内存中的数据不一致,此时不用将数据写回内存里; - 当发生写操作时,如果数据所对应的 Cache Block 里存放的是别的内存地址的数据的话,就需要检查这个Cache Block 里的数据有没有被标记为脏的:
- 如果被标记为脏的,我们需要把这个 Cache Block 中的数据写回到内存,然后再把当前要写入的数据先从内存读入到该Cache Block中,然后再把当前要写入的数据写入到 Cache Block,最后将该Cache Block标记为脏的;
- 如果不是脏的,先把当前要写入的数据从内存读入到Cache Block 中,接着将数据写入到这个Cache Block中,然后再把该Cache Block标记为脏的。
12. 现在CPU都是多核心的,由于L1/L2 Cache是多个核心独有的,那么如何保证缓存一致性的问题?
要保证多个核心的缓存一致性,就需要同步不同核心的缓存数据,要保证做到以下两点:
- 第一点,某个CPU核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为
写传播
; - 第二点,某个CPU核心里对数据的操作顺序,必须在其他核心看上去顺序是一样的,这个称为
事务的串行化
。
要实现事务的串行化,要做到以下两点:- CPU核心对于Cache中数据的操作,需要同步到其他的CPU核心;
- 要引入锁的概念,如果两个CPU核心里有相同数据的Cache,那么对于这个Cache数据的更新,只有拿到了锁,才能进行对应的数据更新。
13. 写传播和事务串行化具体是用什么技术实现的?
(1)写传播的实现方式:总线嗅探
CPU需要每时每刻监听总线上的一切活动,并检查是否有相同的数据在自己的Cache中。不管别的核心的Cache是否缓存相同的数据,都需要发出一个广播事件,这无疑加重了总线的负载。同时,总线嗅探无法保证事务的串行化。
(2)事务串行化的实现方式:MESI协议
M:Modified,已修改;E:Exclusive,独占;S:Shared,共享;I:Invalidated,已失效。
这四个状态标记了 Cache Line 四个不同的状态:
已修改
:代表该 Cache Block 里的数据已经被更新,同时还没有写入到内存中,该状态就是所谓的脏标记;已失效
:代表该 Cache Block 里的数据已经失效了,不可以读取该状态的数据;独占
:代表该 Cache Block 里的数据是干净的,数据只存储在一个CPU核心的Cache里,可以自由地向Cache中写入数据,而不需要通知其他CPU核心,此时只有你有该数据,不存在缓存一致性的问题;共享
:代表该 Cache Block 里的数据是干净的,相同的数据存储在多个CPU核心的Cache里,当对某个核心的共享数据进行修改时,需要先向所以的其他CPU核心广播一个请求,要求其他核心的Cache 中对应的 Cache Block 标记为无效状态,然后再更新当前Cache里面的数据。
基于总线嗅探机制的MESI协议是保证缓存一致性的协议。整个MESI状态的变更,则是根据来自本地CPU核心的请求,或者来自其他CPU核心通过总线传输过来的请求,从而构成一个流动的状态机。另外,对于已修改或者独占状态的 Cache Block,更新其数据不需要发送广播给其他CPU核心。
14. 什么情况下会出现Cache伪共享?Cache伪共享是什么?避免伪共享的方法有哪些?
- CPU从内存读取数据到Cache的单位是CPU Cache Line,比如L1 Cache 一次载入数据的大小是64字节,如果当不同的核心并行运行着不同的线程,当各自的线程读写的不同变量位于同一个Cache Line时,就会出现Cache失效,也就是出现Cache伪共享。
- 多个线程同时读写同一个Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为
伪共享
。 - 避免伪共享的方法:Cache Line大小字节对齐,以及字节填充等。
15. CPU是根据什么来选择当前要执行的线程?
Linux内核里的调度器,调度的对象是task_struct
,根据任务的优先级以及响应要求可将任务分为:
- 实时任务:对系统的响应时间要求很高,优先级在0~99的范围;
- 普通任务:响应时间没有很高的要求,优先级在100~139的范围;
优先级的数值越小,优先级越高。
16. 中断是什么?什么是软中断?系统中有哪些软中断?
- 在计算机中,中断是系统用来响应硬件设备请求的一种机制,操作系统受到硬件的中断请求,会打断当前正在执行进程,然后调用内核中的中断处理程序来响应请求。
- 中断是一种异步的事件处理机制,可以提高系统的并发处理能力。中断处理程序要尽可能快的执行完,这样可以减少对正常进程运行调度地影响。
- Linux系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是
上半部和下半部
。上半部用来快速处理中断
,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情;下半部用来延迟处理上半部未完成的工作
,一般以内核线程
的方式运行。
- 中断处理程序的上半部和下半部可以理解为:
- 上半部直接处理硬件请求,也就是硬中断,主要负责耗时短的工作,特点是快速执行;
- 下半部是由内核触发,也就是软中断,主要负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行。软中断是以内核线程的方式执行,并且每一个CPU都对应一个软中断内核线程,名字通常为
ksoftirqd/CPU编号
。
- 软中断不只是包括硬件设备中断处理程序的下半部,一些内核自定义事件也属于软中断,比如内核调度、RCU锁等。可通过
cat /proc/softirqs
来查看软中断的运行情况。
17. 为什么负数要用补码表示?
如果负数不使用补码表示,则在做基本的加减法运算的时候,还需要进一步判断数字是否是负数,如果是负数,还得把加法反转为减法或者把减法反转为加法;而如果负数使用补码表示,对于负数的加减法操作,实际上是和正数加减法操作相同。
18. 十进制小数怎么转换成二进制?
十进制整数部分转二进制采用的是除2取余法,十进制小数部分转二进制采用的是乘2取整法。
将十进制数0.1转换成二进制的小数部分出现了无限循环0.00011001100110...
,由于计算机的资源是有限的,所以没办法用二进制精确的表示0.1,只能用近似值来表示,于是就会造成精度缺失的情况。
19. 计算机是如何存储小数的?
计算机是以浮点数的形式来存储小数的,大多数计算机的浮点数格式包含三个部分:
- 符号位:表示数字是正数还是负数,符号位为0表示正数,为1表示负数。
- 指数位:指定了小数点在数据中的位置,指数可以是负数,也可以是正数,为了减少不必要的麻烦,在实际存储指数的时候,需要将指数加上偏移量,转换成无符号整数。指数位的长度越长,则数值的表达范围就越大。
- 尾数位:小数点右侧的数字,也就是小数部分,而且尾数的长度决定了这个数的精度,因此如果要表示精度更高的小数,则需要提高尾数位的长度。
用32位表示的浮点数,称为单精度浮点数,也就是编程语言中的 float 变量,而用64位表示的浮点数,称为双精度浮点数,也就是 double 变量。
20. 0.1 + 0.2 = 0.3 吗?
不是的,0.1 和 0.2 这两个数字用二进制表示是一个循环的二进制数,对于计算机而已,0.1 和 0.2 无法精确表示,这是浮点数计算造成精度损失的根源。因此,只能用近似值来表示该二进制数,那么意味着计算机中存放的小数可能不是一个真实值。0.1 + 0.2 并不等于完整的0.3,这主要是因为这两个小数无法用完整的二进制数来表示,只能根据精度舍入,所以计算机里只能采用近似值的方式来保存,那两个近似值相加,得到的必然是一个近似值。
二、操作系统内核
21. 什么是内核呢?内核有哪些功能?内核是怎么工作的?
(1)内核是应用与硬件设备之间连接的桥梁,应用程序只需关心与内核交互,不用关心硬件的细节。
(2)现代操作系统的内核一般具有以下4个功能:
- 管理进程、线程,决定哪个进程、线程使用CPU,也就是
进程调度
的能力; - 管理内存,决定内存的分配和回收,也就是
内存管理
的能力; - 管理硬件设备,为进程与硬件设备之间提供通信能力,也就是
硬件通信
能力; 提供系统调用
,如果应用程序要运行更高权限的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口。
(3)大部分操作系统,把内存分成了两个区域:
- 内核空间,只有内核程序才能访问;
- 用户空间,专门给应用程序使用。
内核程序执行在内核态,用户程序执行在用户态。应用程序如果需要进入内核空间,就需要通过系统调用,当应用程序使用系统调用时,会产生一个中断。发生中断后,CPU会中断当前正在执行的程序,转而跳转到中断处理程序,也就是开始执行内核程序。内核程序执行完成后,主动触发中断,把CPU执行权限交回给应用程序,回到用户态继续执行程序。
22. Linux和Windows系统的设计理念
- Linux:MultiTask 多任务、SMP 对称多处理、 ELF 可执行文件链接格式、Monolithic Kernel 宏内核;
- Windows:MultiTask多任务、SMP对称多处理、PE 可执行文件格式、混合型内核。
23. 内核的架构分为哪几种?
对于内核的架构一般分为以下三种类型:
- 宏内核,包含多个模块,整个内核像一个完整的程序;
- 微内核,有一个最小版本的内核,一些模块和服务则由用户态管理;
- 混合内核,是宏内核和微内核的结合体,内核中抽象出了微内核的概念,也就是内核中会有一个小型的内核,其他模块就在这个基础上搭建,整个内核是个完整的程序。
Linux的内核设计上采用了宏内核,Windows的内核设计上则采用了混合内核。
这两个操作系统的可执行文件格式也不一样,Linux可执行文件格式叫作ELF,Windows 可执行文件格式叫作PE。
三、内存管理
24. 虚拟内存有什么作用?
- 第一,虚拟内存可以使得进程的运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间都是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
25. 操作系统是如何管理虚拟地址和物理地址之间的关系?
主要有两种方式,分别是内存分段和内存分页
。
- 内存分段:程序是由若干个逻辑分段组成的,即可有代码分段、数据分段、栈段、堆段组成。不同的段有不同的属性,所以就用分段的形式把这些段分离出来。虚拟地址是通过
段表
与物理地址进行映射的,每个段在段表中有一个项,在这个项找到段的基地址,再加上偏移量,就能找到物理内存中的地址。 - 内存分页:分页是把整个虚拟内存空间和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为
4KB
。虚拟地址是通过页表
与物理地址进行映射的,页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,通过这个基地址与页内偏移的组合就能找到物理内存中的地址。
26. 分段机制下,虚拟地址和物理地址是如何映射的?
分段机制下的虚拟地址有两部分组成:段选择因子和段内偏移量。
- 段选择因子就保存在段寄存器里面。段选择因子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
- 虚拟地址中的段内偏移量应该位于0和段界限之间,如果段内偏移量是合法的,就将段地址加上段内偏移量得到物理内存地址。
虚拟地址是通过段表和物理地址进行映射的,分段机制会把程序的虚拟地址分成4个段,每个段在段表有一个项,在这一项找到段的基地址,再加上偏移量,于是就找到物理内存中的地址。
27. 内存分段会出现内存碎片吗?
内存碎片主要分为内部内存碎片和外部内存碎片。
内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片。
但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生多个不连续的小物理内存,导致新的程序无法被装载,会出现外部内存碎片的问题。
28. 如何解决外部内存碎片的问题?
解决外部内存碎片的方法就是内存交换。将部分的内存数据写回到硬盘的swap空间,在需要该数据时,再从硬盘加载到内存里。
29. 分段为什么会导致内存交换效率低的问题?
对于多进程的系统来说,使用内存分段管理的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,就需要进行内存交换,这个过程会产生性能瓶颈。因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存空间数据写到硬盘上。所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,会导致整个机器都会显得卡顿。
30. 分页是如何解决分段的外部内存碎片和内存交换效率低的问题?
- 采用内存分页,页与页之间是紧密排列的,所以不会有外部碎片。但是,由于内存分页机制分配内存的最小单位是页,所以当程序不足一页的时候,也是分配一页的内存空间,页内就会出现内存浪费,也就是说内存分页机制会有内部内存碎片的现象。
- 如果内存空间不够,操作系统会把其他正在运行的进程中的最近最少被使用的内存页面写回到硬盘上,称为换出。一旦需要的时候,再加载到内存中来,称为换入。所以,一次性写入磁盘的数据只有少数的一个页或者几个页,不会消耗太多的时间,内存交换的效率就相对比较高。
- 更进一步地,采用内存分页管理,不需要一次性将程序加载到物理内存中,只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
31. 简单的分页有什么缺陷吗?
有空间上的缺陷。因为操作系统可以同时运行多个进程,由于每个进程都有自己的虚拟地址空间,也就是说都有自己的页表。在32位的环境下,整个4GB空间的映射就需要4MB的内存来存储页表,如果运行着100个进程的话,就需要400MB的内存来存储页表,这时非常大的内存开销,更别说64位的环境了。
32. 如何解决简单分页产生的页表占据内存过大的问题?
为了减小页表所占内存,就需要采用多级页表。由于程序局部性原理,对于大部分程序而言,其使用到的空间远未达到4GB,如果某一个一级页表的页表项没有使用到,也就不需要创建这个页表项对应的二级页表,即在需要时才创建二级页表,依此类推,对于多级页表采用同样的原理创建对应的页表,这样,页表所占用的内存空间大大降低。
33. 64位的操作系统中,多级页表对应的目录是什么?
对于64位的操作系统,多级页表中有四级目录:
- 全局页目录项 PGD(Page Global Directory);
- 上层页目录项 PUD(Page Upper Directory);
- 中间页目录项 PMD(Page Middle Directory);
- 页表项 PTE(Page Table Entry);
34. TLB是什么?为什么需要有TLB?
- 在CPU中有一个专门用于存放程序最常访问的页表项的Cache,这个Cache就是TLB(Translation Lookaside Buffer),通常称为页表缓存、转址旁路缓存、快表等。
- 多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换需要通过查询多级页表,就会降低地址转换的速度,带来时间上的开销。由于程序具有局部性原理,相应的,执行程序所访问的存储空间也局限于某个内存区域。利用这一特性,把最常访问的几个页表项存储到TLB中,减少对常规页表的访问。
35. 段页式内存管理
段页式内存管理实现的方式:
- 先将程序划分成多个逻辑意义的段,也就是分段机制;
- 再把每个段划分为多个页,也就是对分段划分出来的连续空间在划分固定大小的页。
这样,地址结构由段号、段内页号和页内偏移三部分组成。
段页式内存管理从虚拟地址转换为物理地址的转换过程:
- 第一次访问段表,得到页表起始地址;
- 第二次访问页表,得到物理页号;
- 第三次将物理页号与页内偏移量相结合,得到物理地址。
36. Linux系统采用了什么方式管理内存?以及Linux的虚拟地址空间是如何分布的?
- Linux 系统主要采用了分页内存管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。
- Linux 系统中虚拟空间分布可分为用户态和内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。
37. malloc() 是如何分配内存的?
malloc() 不是系统调用,而是C库里的函数,用于动态分配内存。
malloc() 通过两种方式向操作系统申请内存:
- 方式一:通过
brk()
系统调用从堆分配内存; - 方式二:通过
mmap()
系统调用在文件映射区域分配内存;
方式一实现的方式很简单,通过brk()函数将堆顶指针向高地址移动,获得新的内存空间。
方式二通过mmap()系统调用中的私有匿名映射的方式,在文件映射区分配一块内存。
38. 什么场景下malloc()会通过brk()分配内存?什么场景下通过mmap()分配内存?
malloc()源码里默认定义了一个阈值:
- 如果用户分配的内存小于
128KB
,则通过brk()
申请内存; - 如果用户分配的内存大于
128KB
,则通过mmap()
申请内存;
39. malloc()分配的是物理内存吗?
不是的,malloc()分配的是虚拟内存。
如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。
40. malloc(1)会分配多大的虚拟内存?
malloc()在分配内存的时候,会预分配更大的空间作为内存池。malloc(1)实际上预分配132KB
的内存。
41. free 释放内存,会归还给操作系统吗?
- 如果 malloc 通过
brk()
方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用; - 如果 malloc 通过
mmap()
方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
42. 为什么不全部使用 mmap 来分配内存?
如果全部使用mmap()来分配内存,相当于每次都要执行系统调用,不仅每次都会发生运行态的切换,而且每次mmap()分配的虚拟地址都是缺页的,然后在第一次访问该虚拟地址时,会触发缺页中断。
而如果使用brk()来分配内存,会预分配更大的内存作为内存池,当内存释放的时候,就缓存在内存池中。等下次再申请内存的时候,就直接从内存池中取出对应的内存块,而且可能该内存块的虚拟地址与物理地址的映射关系还存在,这不仅减少了系统调用的次数,也减少了缺页中断的次数,将大大降低CPU的消耗,所以不能全部使用mmap()来分配内存。
43. 为什么不全部使用 brk 来分配内存?
由于通过brk()从堆空间分配的内存,并不会归还给操作系统,因此,随着系统频繁地malloc和free,尤其对于小块内存,堆中将产生越来越多不可用的碎片,导致“内存泄露”。
所以,malloc实现中,充分考虑了brk和mmap行为上的差异及优缺点,默认分配大块内存(128KB)才使用mmap分配内存空间。
44. free()函数只传入了一个内存地址,为什么能知道要释放多大的内存空间?
malloc返回给用户态的内存起始地址比进程的堆空间起始地址多16字节,这多出来的16字节保存了该内存块的描述信息,比如有该内存块的大小。当执行free()函数时,free会对传入进来的内存地址向左偏移16字节,然后从这16字节中分析出当前内存块的大小,自然也就知道要释放多大的内存空间。
45. 如果物理内存不足,那么内核就会进行内存回收工作,内存回收的方式有哪些?
回收内存的方式主要有以下两种:
- 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒kswapd内核线程来回收内存,这个回收内存的过程是异步的,不会阻塞进程的执行。
- 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会触发OOM(Out of Memory)机制。
46. 在进行内存回收时,哪些内存可以被回收?
主要有两类内存可以被回收,而且它们的回收方式也不同。
- 文件页:内核缓存的磁盘数据和内核缓存的文件数据都叫作文件页。回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存。
- 匿名页:这部分内存没有实际载体,所以不能直接释放内存,它们的回收方式是通过Linux的Swap机制,Swap会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。
文件页和匿名页的回收都是基于LRU算法,也就是优先回收最近最少访问的内存。
47. 针对频繁的回收内存,磁盘的I/O次数会很多,如何降低内存回收对性能的影响?
- 调整文件页和匿名页的回收倾向,一般建议将 /proc/sys/vm/swappiness 设置为 0(默认值是 60),这样在回收内存的时候,会更倾向于文件页的回收;
- 尽早触发后台内存回收,来避免应用程序进行直接内存回收。
内核定义了三个内存阀值:页高阀值、页低阀值、页最小阀值;
当剩余内存页小于页低阀值时,就会触发后台内存回收,然后kswapd会一直回收到剩余内存页大于页高阀值。可通过设置页最小阀值间接设置页低阀值,可以增大页最小阀值来尽早地触发后台回收。 - 调整NUMA(非一致存储访问)架构下的内存回收策略,在回收本地内存之前,会在其他 Node 寻找空闲内存,从而避免在系统还有很多空闲内存的情况下,因本地 Node 的内存不足,发生频繁的直接内存回收,导致性能下降的问题。
48. 如何保护一个进程不被OOM杀掉呢?
OOM killer 会根据每个进程的内存占用情况和oom_score_adj 的值进行打分,得分最高的进程就会首先被杀掉。我们可以通过调整进程的oom_sore_adj的数值,来改变进程的得分结果。如果不想某个进程被杀掉,可以将 oom_score_adj 设置为 -1000。
49. 在4GB物理内存的机器上,能够申请8GB内存吗?
- 在32位操作系统中,因为进程理论上最大能申请3GB的虚拟内存,所以申请8GB内存会失败。
- 在64位操作系统中,因为进程理论上最大能申请128TB的虚拟内存,即使物理内存只有4GB,申请8GB内存也没有问题,因为申请的内存是虚拟内存。如果这块内存被访问了,要看系统有没有Swap分区:
- 如果系统没有Swap分区,因为物理内存不足,进程会被操作系统杀掉,原因是OOM(内存溢出);
- 如果系统有Swap分区,即使物理内存只有4GB,程序也能正常使用8GB的内存,进程可以正常运行。
50. 操作系统会在读磁盘的时候会额外多读一些到内存中,但是最后这些数据也没用到,有什么改善的方法吗?
这是传统的LRU算法存在的预读失效现象,通过改进传统LRU链表来避免预读失效带来的影响,具体的改进如下:
- 进行Linux操作系统实现了两个LRU链表:活跃LRU链表(active_list)和非活跃LRU链表(inactive_list)。
- active_list 活跃内存页链表,用于存放最近被访问过(活跃)的内存页;inactive_list 不活跃内存页链表,用于存放很少被访问(不活跃)的内存页。
- 有了这两个LRU链表后,预读页就只需要加入到 inactive_list 链表的头部,当页被真正访问的时候,才将页插入到 active_list 的头部。如果预读的页一直没有被访问,就会从 inactive_list 中移除,这样就不会影响 active_list 中的热点数据。
51. 批量读数据的时候,可能会把热点数据挤出去,有什么改善的办法吗?
这其实是传统LRU算法存在的缓存污染现象,通过改进传统LRU算法来避免缓存污染的问题,只要我们提高进入到活跃LRU链表(或者 young 区域)的门槛,就能有效地保证活跃LRU链表(或者 young 区域)里的热点数据不会轻易被替换掉。
Linux 操作系统和 MySQL Innodb 存储引擎分别提高了升级为热点数据的门槛:
- Linux操作系统:在内存页被访问第二次的时候,才将页从 inactive_list 升级到 active_list 里;
- MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,还要进一步停留在 old 区域进行时间判断:
- 如果第二次访问的时间与第一次访问的时间间隔在1秒内(默认值),那么该页就不会从 old 区域升级到 young 区域;
- 如果第二次访问的时间与第一次访问的时间间隔超过1秒,那么该页就会从 old 区域升级到 young 区域。
四、进程管理
52. 什么是进程?
我们编写的代码只是存放在硬盘上的静态文件,通过编译生成二进制的可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为进程(Process)
。
53. 并发和并行有什么区别?
在多个进程运行的过程中,在同一时刻只能有一个进程运行时,就是并发;在同一时刻有多个进程同时运行,就是并行。
54. 进程的状态有哪几种?以及进程状态的变迁有哪些?
进程有5种基本状态:
- 运行态:该状态的进程占用CPU;
- 就绪态:该状态的进程可运行,由于其他进程处于运行状态而暂时处于排队等待运行;
- 阻塞态:该状态的进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
- 创建态:进程正在被创建时的状态;
- 终止态:进程正在从系统中消失时的状态。
进程的状态变迁:
- NULL -> 创建状态:一个新进程被创建时的第一个状态;
- 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
- 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
- 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
- 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
- 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
- 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;
55. 什么是挂起状态?
在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。那么,当进程没有占用实际的物理内存空间的情况,这种状态就是挂起状态。
挂起状态可以分为两种:
- 阻塞挂起状态:进程在硬盘并等待某个事件的发生;
- 就绪挂起状态:进程在硬盘上,进入内存即可立即运行。
56. PCB是什么?每个PCB具有哪些信息?每个PCB是如何组织的呢?
- PCB也就是进程控制块(Process Control Block)。它是用来描述进程的数据结构。PCB是进程存在的唯一标识。
- PCB包含进程描述信息(进程标识符、用户标识符)、进程控制与管理信息(进程当前状态、进程优先级)、资源分配清单和CPU相关信息。
- PCB通常是通过链表进行组织的,把具有相同状态的进程链接在一起,组成就绪队列或阻塞队列。除了链表的组织方式,还有索引的方式。
57. 进程的控制
进程的创建、终止、阻塞、唤醒的过程就是所谓的进程的控制。
- 创建进程:
操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所有的所有资源。
创建进程的过程如下:- 申请一个空白的PCB,并向PCB中填写一些控制和管理进程的信息,比如进程的唯一标识等;
- 为该进程分配运行时的资源,比如内存资源;
- 将PCB插入到就绪队列,等待被调度运行;
- 终止进程
进程可以有3种终止方式:正常结束、异常结束以及外界干预(信号kill掉)。
当子进程被终止时,由父进程对其的资源进行回收。而当父进程被终止时,该子进程就变成了孤儿进程,会被1号进程收养,在子进程结束后,由1号进程对它的资源进行回收。
终止进程的过程如下:- 查找需要终止的进程的PCB;
- 如果处于运行态,则立即终止该进程的运行,然后将CPU资源分配给其他进程;
- 如果该进程还有子进程,则将子进程交给1号进程接管;
- 将该进程所拥有的全部资源归还给操作系统;
- 将其从PCB所在队列中删除;
- 阻塞进程
当进程需要等待某一事件的完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由其他进程唤醒。
阻塞进程的过程如下:- 找到将要被阻塞的进程标识号所对于的PCB;
- 如果该进程处于运行态,则保护其现场,将其状态转换为阻塞态,停止运行;
- 将该PCB插入到阻塞队列中;
- 唤醒进程
只有当阻塞态的进程所等待的事件出现时,才由发现者进程用唤醒语句唤醒该进程。
唤醒进程的过程如下:- 在该进程的阻塞队列中找到相应进程的PCB;
- 将其从阻塞队列中移除,并将其状态转换为就绪态;
- 把该PCB插入到就绪队列中,等待调度程序的调度;
如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。
58. CPU的上下文切换
- CPU上下文是指CPU寄存器和程序计数器,它们是CPU运行任何任务前所必须依赖的环境。
- CPU上下文切换就是先把上一个任务的CPU上下文(CPU寄存器和程序计数器)信息保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的位置,运行新任务。
- 这里所谓的任务,主要就是进程、线程和中断。根据任务的不同,CPU上下文切换可分为进程上下文切换、线程上下文切换和中断上下文切换
59. 进程上下文切换
- 进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
- 进程的上下文切换不仅包括虚拟内存、栈、全局变量等用户空间的资源,还包括内核堆栈、寄存器等内核空间的资源。
- 通常,会把交换的信息保存在进程的PCB。
60. 发生进程上下文切换的常见场景有哪些?
- 当处于运行态的进程时间片耗尽时,进程就会从运行态转换为就绪态,系统从就绪队列选择另外一个进程运行;
- 当系统资源(比如内存)不足时,进程要等到资源满足后才可以运行,这时进程会被挂起,并由系统调度其他进程运行;
- 当进程通过sleep函数将自己挂起时,系统也会重新调度其他进程运行;
- 当有优先级更高的进程需要运行时,为了保证高优先级进程的运行,当前进程会被挂起,然后运行高优先级的进程;
- 当发生硬件中断时,CPU上的进程也会被中断挂起,转而执行内核中的中断处理程序;
61. 什么是线程?线程的优缺点有哪些?
- 线程是进程当中的一条执行流程。
- 同一个进程的多个线程之间可以共享代码段、数据段、打开的文件等资源,但是每个线程各自拥有一套独立的寄存器和栈,这样可以确保线程的控制流是相互独立的。
- 线程的优缺点:
- 线程的优点:一个进程中可以同时存在多个线程;各个线程之间可以并发运行;各个线程之间可以共享地址空间和文件等资源。
- 线程的缺点:针对C/C++语言,当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃。
62. 比较线程和进程
- 进程是资源(包括内存、打开的文件等)分配的基本单位,线程是 CPU 调度的基本单位;
- 进程拥有完整的资源平台,而线程只独享必不可少的资源,比如寄存器和栈;
- 线程同样具有就绪、阻塞、运行三种基本状态,同样具有状态之间的转换关系;
- 线程能减少并发执行的时间和空间开销;
- 线程使用有一定难度,需要处理数据一致性的问题。
线程相比进程能减少开销,主要体现在:
- 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
- 线程的终止时间比进程快,因为线程终止时,需要释放的资源比进程少很多;
- 同一个进程内的线程切换比进程切换快,因为线程共享虚拟地址空间,线程切换的过程中不需要切换页表;而进程都独自拥有自己的虚拟内存,进程切换时需要对页表进行切换,而页表的切换过程开销还是比较大的;
- 由于同一个进程的各线程间共享内存和文件资源,那么在线程之间传递数据的时候,不需要经过内核空间,这使得线程之间的数据交互的效率更高。
63. 线程上下文切换的是什么?
- 当两个线程不属于同一个进程时,线程上下文切换过程和进程上下文切换一样;
- 当两个线程属于同一个进程时,因为线程间共享虚拟内存,所以在线程切换时,虚拟内存这些资源就保存不动,只需要切换线程私有的资源、寄存器等不共享的数据。
64. 线程的实现方式有哪几种?
主要有 3 种线程的实现方式:
- 用户线程:在用户空间实现的线程,由用户态的线程库对线程进行管理;
- 内核线程:在内核中实现的线程,由内核对线程进行管理;
- 轻量级进程(LWP):在内核中支持的用户线程。
用户线程和内核线程的对应关系:多对一、一对一、多对多。
65. 用户线程如何理解?有什么优缺点?
用户线程是基于用户态的线程库来实现的,线程控制块(Thread Control Block,TCB)也是在线程库里实现的,对于操作系统而言是看不见这个TCB的,它只能看见整个进程的PCB。由用户级线程库函数完成线程的管理,包括线程的创建、终止、同步和调度等。
用户线程的优点:
- 每个进程都有私有的线程控制块列表,用来跟踪记录它各个线程的状态,TCB由用户级线程库函数来管理,可用于不支持线程技术的操作系统;
- 用户线程的切换由线程库函数来完成,无需用户态与内核态的切换,线程切换的速度特别快。
用户线程的缺点:
- 由于操作系统不参与线程的调度,如果一个线程发起系统调用后发生了阻塞,那么进程中的其他用户线程也都无法执行;
- 当一个线程开始运行后,除非该线程主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行;
- 由于时间片是分配给进程的,故与其他进程相比,多线程的进程执行时,每个线程得到的时间片较少,运行会比较慢。
66. 内核线程如何理解?有什么优缺点?
内核线程是由操作系统管理的,线程对应的TCB存在于操作系统中,这样线程的创建、终止和管理都是由操作系统负责。
内核线程的优点:
- 在一个进程中,如果每个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
- 时间片是分配给线程的,多线程的进程可获得更多的CPU运行时间。
内核线程的缺点:
- 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB;
- 线程的创建、终止和切换都是通过系统调用的方式进行;
因此,内核线程对系统的开销比较大。
67. 轻量级进程如何理解?有什么优缺点?
轻量级进程(LWP)是内核支持的用户线程,一个进程可以有一个或多个LWP,每个LWP是跟内核线程一对一映射的,也就是说每个 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理,并向普通进程一样被调度。在大多数系统中,LWP 与普通进程的区别是它只有一个最小的执行上下文和调度程序所需的统计信息。
在 LWP 之上也是可以使用用户线程的,LWP 与用户线程的对应关系有以下三种:
- 1 : 1,一个 LWP 对应一个用户线程;
- 优点:实现并行,当一个LWP阻塞,不会影响其他用户线程;
- 缺点:每个用户线程对应一个内核线程,创建线程的开销较大。
- N : 1,一个LWP对应多个用户线程;
- 优点:多个用户线程的管理是在用户空间完成的,上下文切换发生在用户空间,线程切换的效率高;
- 缺点:如果一个用户线程阻塞,整个进程就会阻塞,无法充分利用 CPU 资源。
- M : N,多个LWP对应多个用户线程;
- 优点:大部分的线程上下文切换发生在用户空间,且多个线程可以充分利用CPU资源。
68. 进程调度的时机
在进程的生命周期中,当进程从就绪态转换为运行态或者从运行态转换为其他状态时,都会触发操作系统的调度。
- 从就绪态转换为运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
- 从运行态转换为阻塞态:当进程等待 I/O 事件的完成而阻塞时,操作系统必须选择另外一个进程运行;
- 从运行态转换为结束态:当进程结束后,操作系统会从就绪队列选择一个进程运行。
69. 进程的调度原则
- 要提高
CPU利用率
,当进程发生 I/O 事件时,调度程序需要从就绪队列中选择一个进程来运行; - 要提高
系统的吞吐量
,调度程序要权衡长任务和短任务进程的运行完成数量; - 要使得
周转时间
尽可能短,避免进程等待时间很长而运行时间很短的情况发生; - 要使得
等待时间
尽可能短,就绪队列中进行的等待时间也是调度程序所需要考虑的原则; - 对于交互式比较强的应用,
响应时间
越快越好,响应时间也是调度程序需要考虑的原则。
70. 进程间有哪些通信方式?
进程间通信的方式有匿名管道、命名管道、消息队列、共享内存、信号量、信号和Socket通信
。
(1)匿名管道
- 匿名管道没有名字,它是特殊的文件只存在于内存,不存在于文件系统中,shell命令中的
|
竖线就是匿名管道,通信的数据是无格式的流并且大小受限。通信的方式是单向的,数据只能在一个方向上传输,如果要双向通信,需要创建两个管道,并且匿名管道只能用于有关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。 - 匿名管道的创建需要通过系统调用:
int pipe(int fd[2])
,这里表示创建了一个匿名管道,并返回了两个文件描述符,一个是管道的读端文件描述符fd[0]
,另一个是管道的写端描述符fd[1]
。 - 所谓的管道,就是内核中的一块缓存。我们可以使用
fork
创建子进程,创建的子进程会复制父进程的文件描述符,这样父子进程就可以通过各自的fd
读写同一个管道文件实现进程间通信。
(2)命名管道
- 命名管道可以在不相关的进程间通信,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程可通过这个文件进行通信。
- 无论是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时也是从内核中读取,同时通信数据遵循先进先出的原则,不支持 lseek 之类的文件定位操作。
(3)消息队列
- 消息队列克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的消息链表,消息队列的消息体是用户自定义的数据类型。
- 消息队列的生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在。
- 消息队列的通信不及时,每次数据的读写都需要经过用户态与内核态之间的拷贝过程。
- 消息队列不适合传输比较大的数据,因为内核中的消息体的长度和消息队列总长度都有限制。
(4)共享内存
- 共享内存是直接分配一个共享空间,每个进程都可以直接访问,这样就可以解决消息队列通信中用户态和内核态之间数据拷贝带来的开销,不需要陷入内核态或者使用系统调用,大大提高了通信的速度,享有最快的进程间通信方式之名。
- 高效便捷的共享内存通信,却存在一个问题,如果多个进程同时修改同一个共享内存,很有可能会产生冲突,造成数据的错乱。
(5)信号量
- 信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
- 信号量表示资源的数量,控制信号量的方式有两个原子操作:
- 一个是
P
操作,这个操作会把信号量减1
。如果相减后信号量< 0
,则表明资源被占用,进程需阻塞等待;如果相减后信号量>= 0
,则表明还有资源可使用,进程可正常继续执行。 - 另一个是
V
操作,这个操作会把信号量加1
。如果相加后信号量<= 0
,则表明当前有进程被阻塞,于是会将其中一个阻塞的进程唤醒运行;如果相加后信号量> 0
,则表明当前没有进程被阻塞。
- 一个是
- P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作必须成对出现。
- 将信号量初始化为
1
,则代表 互斥信号量,它可以保证在任一时刻只有一个进程访问共享内存。 - 将信号量初始化为
0
,则代表 同步信号量,它可以保证进程有顺序的执行。
(6)信号
- 信号是进程间通信机制中唯一的异步通信机制,信号可以在应用进程与内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件。
- 信号事件的来源主要有硬件来源(如键入Ctrl + C)和软件来源(如 kill 命令)。
- 一旦有信号产生,进程有三种响应信号的方式:1、执行默认操作;2、捕捉信号;3、忽略信号。有两个信号是应用进程无法捕捉和忽略的,即
SIGKILL
和SIGSTOP
,这是为了我们能在任意时刻结束或停止某个进程。
(7)Socket 通信
- Socket 通信不仅可以使网络中不同主机上的进程间通信,还可以在同主机上进程间通信。
- 创建 Socket 的系统调用:
int socket(int domain, int type, int protocal)
三个参数分别代表:- domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;
- type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;
- protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;
- 根据创建 socket 类型的不同,通信的方式也就不同:
- 实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;
- 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;
- 实现本地进程间通信: 「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;
71. 针对 TCP 协议通信的 socket 编程模型
- 服务端和客户端初始化
socket
,得到文件描述符; - 服务端调用
bind
,将绑定在 IP 地址和端口; - 服务端调用
listen
,进行监听; - 服务端调用
accept
,等待客户端连接; - 客户端调用
connect
,向服务器端的地址和端口发起连接请求; - 服务端
accept
返回用于传输的socket
的文件描述符; - 客户端调用
write
写入数据;服务端调用read
读取数据; - 客户端断开连接时,会调用
close
,那么服务端read
读取数据的时候,就会读取到了EOF
,待处理完数据后,服务端调用close
,表示连接关闭。
注意⚠️:服务端调用 accept
时,连接成功了会返回一个已完成连接的 socket
,后续用来传输数据。所以,监听的 socket
和真正用来传送数据的 socket
,是两个不同的 socket
,一个叫作监听socket
,一个叫作已完成连接socket
。
72. 针对 UDP 协议通信的 socket 编程模型
- 通信双方初始化
socket
,得到文件描述符; - 通信双方调用
bind
,将绑定在 IP 地址和端口; - 每次通信时,调用
sendto
和recvfrom
,都要传入目标主机的 IP 地址和端口。
73. 针对本地进程间通信的 socket 编程模型
- 对于本地字节流 socket,其 socket 类型是
AF_LOCAL
和SOCK_STREAM
。 - 对于本地数据报 socket,其 socket 类型是
AF_LOCAL
和SOCK_DGRAM
。 - 本地字节流 socket 和 本地数据报 socket 在
bind
的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。
74. 什么是互斥和同步?
- 临界区:临界区是访问共享资源的代码片段,一定不能让多个线程同时执行该代码片段。
- 互斥:保证一个线程在临界区执行时,其他线程应该被阻止进入临界区。互斥并不只是针对多线程,在多进程竞争共享资源时,同样采用互斥的方式来避免资源竞争造成的资源混乱。
- 同步:同步就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这个相互制约的等待与互通信息称为进程/线程同步。
75. 如何实现多进程/线程的互斥与同步?
实现多进程/线程互斥的方法:(1) 锁
:加锁、解锁操作;(2) 信号量
:P、V操作。
实现多进程/线程同步的方法:信号量
:P、V操作。
(1)锁
原子操作
:代码段要么全部执行,要么都不执行,不能出现执行到一半的中间状态。
根据锁的实现不同,可分为忙等待锁
和无忙等待锁
。
当线程获取不到锁时,线程就会一直处于 while 循环,不做任何事情,所以这种锁被称为忙等待锁
,也被称为自旋锁(spin lock)
。
在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃CPU使用权。
无等待锁
指当线程获取不到锁的时候,不用自旋,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。
(2)信号量
- 信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
- 信号量表示资源的数量,控制信号量的方式有两个原子操作:
- 一个是
P
操作,这个操作会把信号量减1
。如果相减后信号量< 0
,则表明资源被占用,进程需阻塞等待;如果相减后信号量>= 0
,则表明还有资源可使用,进程可正常继续执行。 - 另一个是
V
操作,这个操作会把信号量加1
。如果相加后信号量<= 0
,则表明当前有进程被阻塞,于是会将其中一个阻塞的进程唤醒运行;如果相加后信号量> 0
,则表明当前没有进程被阻塞。
- 一个是
- P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作必须成对出现。
- 将信号量初始化为
1
,则代表 互斥信号量,它可以保证在任一时刻只有一个进程访问共享内存。 - 将信号量初始化为
0
,则代表 同步信号量,它可以保证进程有顺序的执行。
76. 生产者—消费者模型(待解答)
77. 哲学家就餐问题(待解答)
78. 读者—写者问题(待解答)
79. 什么是死锁?产生死锁需要满足哪些条件?如何避免死锁问题的产生?
当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁
,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁
。
简单来说,死锁问题的产生是由于两个或者两个以上的线程并行执行的时候,争夺资源而互相等待造成的。
死锁只有同时满足
以下四个条件才会产生:
互斥条件
:指多个线程不能同时使用同一个资源;持有并等待条件
:线程在持有一部分资源的同时,等待其他资源的到来;不可剥夺条件
:线程持有的资源,在自己使用完之前不能被其他线程获取;环路等待条件
:在发生死锁时,多个线程获取资源多顺序构成了环形链路。
避免死锁问题产生的方法是只需要破坏产生死锁的其中一个条件即可,最常见的并且可行的方法是使用资源有序分配法,来破坏环路等待条件
。
80. 常见的锁有哪些?它们的特点与应用场景是什么?
常见的锁分为互斥锁、自旋锁、读写锁、乐观锁、悲观锁
。
(1)互斥锁与自旋锁
- 互斥锁和自旋锁是最底层的两种锁,很多高级的锁都是基于它们实现的,它们是各种锁的地基。
- 互斥锁加锁失败后,线程会释放CPU给其他线程;自旋锁加锁失败后,线程会忙等待,直到它拿到锁。
- 互斥锁是一种独占锁,对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。
- 互斥锁加锁失败后,会有两次线程上下文切换的成本:
- 当线程加锁失败后,内核会把线程的状态从运行状态设置为睡眠状态,然后把 CPU 切换给其他线程运行;
- 接着,当锁被释放时,之前睡眠状态的线程被其他线程唤醒,转换为就绪状态,然后内核会在合适的时机把 CPU 切换给该线程运行。
- 当两个线程属于同一个进程时,因为虚拟内存是共享的,所以在线程上下文切换时,虚拟内存这些资源保存不动,只需要切换线程的私有数据、寄存器等不共享的数据。
- 自旋锁是通过 CPU 提供的
CAS
函数(Compare And Swap),在用户态完成加上和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也会小一些。 - 一般加锁的过程包含两个步骤:
- 第一步,查看锁的状态,如果锁是空闲的,则执行下一步;
- 第二步,将锁设置为当前线程持有;
- CAS 函数把这两个步骤合并成一条硬件级指令,形成原子指令。设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么
CAS(lock, 0, pid)
表示自旋锁的加锁操作,CAS(lock, pid, 0)
表示解锁操作。 - 自旋锁是最简单的一种锁,一直自旋,利用CPU周期,直到锁可用。在单核 CPU 上,需要 抢占式的调度器 (即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃CPU的控制权。
- 如果能确定被锁住的代码执行时间很短,就应该选用自旋锁,否则使用互斥锁。
(2)读写锁
- 读写锁适用于能明确区分读操作和写操作的场景,读写锁在 读多写少 的场景下,能发挥出优势。
- 写锁是独占锁,任意时刻只能有一个线程持有写锁,类似于互斥锁和自旋锁;而读锁是共享锁,多个线程能够同时持有读锁也不会破坏共享资源的数据,这大大提高了共享资源的访问效率。
- 根据实现不同,读写锁分为 读优先锁 和 写优先锁 。读优先锁期望读锁能够被更多的读线程持有,写线程可能会被饿死;写优先锁是优先服务写线程,读线程可能被饿死。
- 为了避免线程饥饿的问题,采用公平读写锁,用队列把获取锁的线程排队,不管是读线程还是写线程都按照先进先出的原则加锁,这样读线程仍然可以并发,也不会出现饥饿的现象。
(3)乐观锁与悲观锁
- 悲观锁比较悲观,它认为多线程同时修改共享资源的概率比较高,很容易出现冲突,所以访问共享资源之前,先要加锁。互斥锁、自旋锁、读写锁都属于悲观锁。
- 乐观锁比较乐观,它认为多线程同时修改共享资源的概率非常低,不容易出现冲突,所以乐观锁全程并没有加锁。它的工作方式是:先修改完共享资源,再验证这段时间内有没有冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
- 只有在冲突概率非常低,且加锁成本非常高的场景下,才考虑使用乐观锁。一旦冲突概率上升,就不适合使用乐观锁了,因为它解决冲突的重试成本非常高。
- 不管使用哪种锁,加锁的代码范围应尽可能的小,也就是加锁的粒度要小,这样执行速度就会比较快。
81. 一个进程最多可以创建多少个线程?
- 一个进程最多可以创建的线程数量跟两个东西有关:
进程的虚拟内存空间上限
,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间也就越大,那么虚拟内存就会占用的越多。系统参数限制
,有系统级别的参数限制整个系统的最大线程个数。
- 因此
- 对于
32
位的操作系统而言,用户态的虚拟空间只有3G,如果创建每个线程时分配10M大小的栈空间,那么一个进程最多只能创建300
个左右的线程。 - 对于
64
位的操作系统而言,用户态的虚拟空间大到有128T,理论上不会受虚拟内存大小的限制,但是会受到系统的参数或性能限制。
- 对于
82. 线程崩溃,进程一定会崩溃吗?
线程崩溃,进程不一定会崩溃。
一般来说,由于进程中的各个线程的虚拟地址空间时共享的,那么某个线程对地址的非法访问就会导致内存的不确定性,进而影响其他线程,因此线程崩溃会导致所属进程崩溃,但是,Java语言中的线程崩溃却不会造成进程崩溃。
在 Java 中由于非法访问内存而产生的常见错误是 StackoverflowError 和 NPE,JVM却没有崩溃的原因是虚拟机内部定义了信号处理函数,而在信号处理函数中对这两者做了额外的处理以让 JVM 不崩溃。如果 JVM 不对信号做额外的处理,最后会自己退出并产生 crash 文件,这个文件记录了虚拟机崩溃的重要原因。
五、调度算法
83. 进程调度算法
进程调度算法也称为 CPU 调度算法,常见的进程调度算法有:先来先服务调度算法、最短作业优先调度算法、高响应比优先调度算法、时间片轮转调度算法、最高优先级调度算法、多级反馈队列调度算法
。
(1)先来先服务调度算法(First Come First Severd,FCFS)
- 最简单的一种调度算法,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从就绪队列中选择下一个进程接着运行。
- 先来先服务调度算法对长作业的进程有利,适用于 CPU 繁忙型作业的系统,而不适合 I/O 繁忙型作业的系统。
(2)最短作业优先调度算法(Shortest Job First,SJF)
- 最短作业优先调度算法会优先选择运行时间最短的进行来运行,这有利于提高系统的吞吐量。
- 不利于长作业的进程,可能会导致长作业的进程长期不会被运行的极端现象。
(3)高响应比优先调度算法(Highest Response Ratio Next,HRRN)
- 高响应比调度算法主要是权衡了短作业和长作业,每次进行进程调度时,先计算响应比优先级,然后把响应比优先级最高的进程投入运行。
- 响应比优先级的计算公式为:
响应比优先级 = (等待时间 + 要求服务时间) / 要求服务时间
。 - 由于一个进程的要求服务时间是不可预估的,所以高响应比优先算法是理想型的调度算法,现实中是实现不了的。
(4)时间片轮转调度算法(Round Robin,RR)
- 时间片轮转调度算法是最古老、最简单、最公平且使用最广的算法。
- 每个进程被分配一个时间段,称为
时间片(Quantum)
,即允许一个进程在该时间段中运行。如果当前时间片用完时,进程还在运行,那么就把该进程从 CPU 释放出来,并把 CPU 分配给另外一个进程;如果当前进程在时间片结束前阻塞或结束,则 CPU 立即进行进程切换。 - 时间片通常设为
20ms~50ms
。如果时间片设得太短,会导致频繁的进程上下文切换,降低 CPU 效率;如果时间片设得太长,可能会使得短作业进程的响应时间变长。
(5)最高优先级调度算法(Highest Priority First,HPF)
- 调度程序会从就绪队列中选择最高优先级的进程进行运行。
- 进程优先级可分为静态优先级和动态优先级:
- 静态优先级:创建进程时,进程的优先级就确定了,然后整个运行时间优先级都不会变化;
- 动态优先级:根据进程的动态变化调整优先级。进程的运行时间增加时,降低其优先级;进程的等待时间增加时,升高其优先级。
(6)多级反馈队列调度算法(Multilevel Feedback Queue)
- 由时间片轮转算法和最高优先级调度算法综合发展而来。
- 多级表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短;反馈表示如果有新的进程加入优先级高的队列时,立即停止当前正在运行的进程,转而去运行优先级高的队列中的进程。
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没有运行完成,则该进程退出第一级队列,加入到第二级队列的末尾,以此类推,直至完成。
- 如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列的末尾,接着让较高优先级的进程运行。
- 多级反馈队列调度算法很好的兼顾了长短作业,同时有较好的响应时间。
84. 缺页中断和一般中断的主要区别
- 缺页中断在指令执行期间产生和处理中断信号;一般中断在一条指令执行完成后检查和处理中断信号。
- 缺页中断执行完成后,返回到该指令的开始重新执行该命令;一般中断执行完成后,返回后执行该指令的下一条指令。
85. 缺页中断的处理流程
- 当 CPU 执行当前指令需要访问内存时,通过页号去查询对应的页表项。
- 如果页表项的状态位是有效的,那 CPU 就直接去访问物理内存,如果状态位是无效的,则 CPU 会发送缺页中断请求。
- 操作系统收到缺页中断,调用中断处理函数,先会查找该页面在磁盘中的页面的位置。
- 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找到空闲页。
- 页面从磁盘换入到物理内存以后,则把页表项中的状态位修改为有效的,并填充该页表项。
- 最后,CPU 重新执行缺页异常的指令。
86. 内存页面置换算法
页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面
,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
常见的内存页面置换算法有:最佳页面置换算法(OPT)、先进先出置换算法(FIFO)、最近最久未使用的置换算法(LRU)、时钟页面置换算法(Clock)、最不常用置换算法(LFU)。
(1)最佳页面置换算法
- 最佳页面置换算法基本思路是,
置换在未来最长时间不被访问的页面
。 - 该算法在实际系统中无法实现,因为无法预知每个页面下一次的访问时间。
- 最佳页面置换算法作用是为了衡量其他算法的效率。如果其他算法的效率越接近该算法的效率,那么算法也就越高效。
(2)先进先出置换算法
- 先进先出置换算法基本思路是,选择将内存中驻留时间最长的页面进行置换。
(3)最近最久未使用的置换算法
- 最近最久未使用(LRU)的置换算法的基本思路是,当发生缺页时,选择过去一段时间内,最久没有被访问的页面进行置换。也就是说,该算法假设已经很久没有使用的页面很可能在未来较长的一段时间内仍然不会被使用。
- 该算法近似最优页面置换算法,最优页面置换算法是通过未来的使用情况来推测要淘汰的页面,而 LRU 则是通过历史的使用情况来推测要淘汰的页面。
- 要实现LRU算法,开销会比较大。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。因此,在实际应用中很少使用。
(4)时钟页面置换算法
- 时钟页面置换算法是一种性能和开销较均衡的算法,又称
clock算法
或最近未用算法(NRU,NotRecently Used)
,它跟 LRU 类似,又是对 FIFO 的一种改进。 - 时钟页面置换算法的基本思路是,为每个页面设置一个访问位,再将内存中的页面都通过链接指针链接成一个循环链表,一个表针指向最老的页面。
- 当某个页面被访问时,该页面的访问位置为1。
- 当发生缺页中断时,算法首先检查表针指向的页面:
- 如果它的访问位是0就直接将该页面换出,并将新的页面插入到这个位置,然后把表针前移一个位置;
- 如果它的访问位是1就将访问位清0,并将表针前移一个位置,重复这个过程直到找到啦一个访问位为0的页面为止。
- 简单的CLOCK算法选择一个淘汰页面最多会经过两轮扫描。
(5)最不常用算法
- 最不常用(LFU)算法的基本思路是,当发生缺页中断时,选择访问次数最少的那个页面,并将其淘汰。
- 需对每个页面设置一个访问计数器,每当一个页面被访问时,该页面的访问计数器累加1。要增加一个计数器来实现,这个硬件成本比较高,同时,查找访问次数最少的页面也可能非常耗时,效率不高。
- 最不常用算法只考虑了频率问题,没考虑时间的问题,比如某些页面在过去一段时间里频繁访问,但是现在已经没有被访问了,那么可能长期不会被淘汰,反而一些近期频繁访问的页表可能被置换到磁盘。解决方法如下:
- 可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以2,随着时间推移,以前的高访问次数的页面会慢慢减少,被置换出去的概率也是越来越大。
87. 磁盘调度算法
磁盘调度算法的目的就是为了提高磁盘的访问性能,一般是通过优化磁盘的访问请求顺序来做到的。
常见的磁盘调度算法有:先来先服务算法、最短寻道时间优先算法、扫描算法、循环扫描算法、LOOK算法、C-LOOK算法。
(1)先来先服务算法
- 先来先服务(First-Come First-Served, FCFS),先到来的请求先被服务。
- 如果请求访问的磁道很分散,那么先来先服务在性能上可能比较差,寻道时间较长。
(2)最短寻道时间优先算法
- 最短寻道时间优先(Shortest Seek First, SSF)算法的工作方式是优先选择从当前磁头位置所需寻道时间最短的请求。
- 该算法可能会导致某些请求的饥饿,产生饥饿的原因是磁头一直在一小块区域来回移动,离当前磁头较远位置的请求得不到响应。
(3)扫描算法
- 扫描算法(Scan)的基本思路是磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向继续扫描。
- 采用扫描算法,中间部分的磁道相比其他部分的磁道响应的频率会更高,也就是说每个磁道的响应频率存在差异。
(4)循环扫描算法
- 循环扫描(Circular Scan,CSCAN)算法的基本思路是只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且返回中途不处理任何请求。
- 该算法的特点是磁道只响应一个特定方向上的请求,各个位置磁道响应频率相对比较平均。
(5)LOOK算法
- 对扫描算法进行优化,优化的思路是当磁头移动到最远的请求位置后,立即反向移动,不需要移动到磁盘的最始端或最末端,反向移动时会响应请求。
(6)C—LOOK算法
- 对循环扫描算法进行优化,优化的思路当磁头移动到最远的请求位置后,立即反向移动,不需要移动到磁盘的最始端或最末端,反向移动时不会响应请求。
六、文件系统
88. 文件系统的基本组成
文件系统是操作中负责管理持久数据的子系统,说简单点,就是负责把用户的文件存储到磁盘硬件中,因为即使计算机断电,磁盘里的数据也不会丢失,所以可以持久化的保存文件。
文件系统的基本数据单位是文件,它的目的是对磁盘上的文件进行组织管理,组织的方式不同,就会形成不同的文件系统。
Linux最经典的一句话是:一切皆文件。
Linux文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。
- 索引节点,也就是 inode ,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘中的位置等等。索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间。
- 目录项,也就是 dentry ,用来记录文件的名称、索引节点指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放在磁盘中,而是缓存在内存。
目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个别名。
注意⚠️:目录也是文件,也是通过索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里保存的是文件数据,而目录文件在磁盘里保存的是子目录或文件。
89. 目录项和目录是同一个东西吗?
目录项和目录不是同一个东西,目录是文件,持久化存储在磁盘,而目录项是由内核维护的一个数据结构,缓存在内存。
如果查询目录总是频繁从磁盘中读取,效率会很低,所以内核会把已经读过的目录用目录项这个数据结构缓存在内存,下次再次读取到相同的目录时,只需从内存中读取,大大提高了文件系统的效率。
90. 文件数据是如何存储在磁盘的呢?
磁盘读写的最小单位是扇区,扇区的大小只有 512 个字节,文件系统把多个扇区组成一个逻辑块,每次读写的最小单位是逻辑块(数据块),Linux 系统中的逻辑块大小为 4KB,也就是一次性读写 8 个扇区,这大大提高了磁盘的读写效率。
磁盘进行格式化时,会被分成三个存储区域,分别是超级块、索引节点区和数据块区。
- 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等。
- 索引节点区,用来存储索引节点。
- 数据块区,用来存储文件或者目录数据。
超级块和索引节点被加载到内存的时机不同:超级块是当文件系统挂载时进入内存,而索引节点是当文件被访问时进入内存。
91. 为啥需要引入虚拟文件系统?Linux 的文件系统分为哪几类?
文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,因此在用户层与文件系统层之间引入了虚拟文件系统(Virtual File System, VFS)这个中间层。
Linux 根据存储位置的不同,可以把文件系统分为以下三类:
- 磁盘的文件系统,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。
- 内存的文件系统,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的 /proc 和 /sys 文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据。
- 网络的文件系统,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。
92. 什么是文件描述符?打开文件表维护着文件的哪些信息?
当我们打开一个文件后,操作系统会为每个进程维护一个打开文件表,表里的每一项代表文件描述符。文件描述符是内核为了高效管理已被打开的文件所创建的索引,用于指向被打开的文件。文件描述符是打开文件的标识。
操作系统在打开文件表中维护着打开文件的状态和信息:文件指针、文件打开计数器、文件磁盘位置、访问权限。
93. 用户和操作系统对文件的读写操作时有差异的,那么文件系统对于读写文件的过程如何处理的?
用户习惯以字节的方式读写文件,而操作系统以数据块为单位读写文件,需要文件系统来处理这种差异。
- 当用户进程从文件中读取 1 个字节大小的数据时,文件系统则先获取该字节所在的数据块,然后再返回数据块中对应的用户进程所需的数据部分。
- 当用户进程把 1 字节大小的数据写入文件中时,文件系统则找到需要写入数据的数据块,然后修改数据块中对应的数据部分,最后再把数据块写回磁盘。
94. 文件数据在磁盘上的存储方式
文件数据在磁盘上有两种存放方式:连续空间存放方式、非连续空间存放方式。
(1)连续空间存放方式
- 文件存放在磁盘的连续物理空间中,文件头里需要指定起始块的位置和长度。
- 连续空间存放方式的读写效率很高,能够高效的顺序和随机访问文件数据,但是使用该方式存放文件数据有磁盘空间碎片和文件长度不易扩展的缺陷。
(2)非连续空间存放方式
非连续空间存放方式分为链表存储方式和索引存储方式。
对于链表的存放方式 - 文件数据可以离散不连续存放,也就能够消除磁盘碎片,提高磁盘空间的利用率,同时文件的长度可以动态扩展。
- 根据实现方式的不同,可分为隐式链表和显式链表。
- 隐式链接的实现方式是文件头包含要第一块和最后一块数据块的位置,并且每个数据块里有一个指针空间,用来存放下一个数据块的位置。缺点在于无法直接访问数据块,只能通过指针顺序访问文件数据,同时数据块指针消耗了一定的存储空间。隐式链表方式存放文件数据的稳定性较差,链表中的某个指针丢失或损坏,会导致文件数据的丢失。
- 显式链接的实现方式是操作系统把链接文件各数据块的指针显示地存放在内存的的文件分配表中,该表在磁盘中仅设置一张,每个表项中存放链接指针,指向下一个数据块号。由于查找记录的过程是在内存中进行的,因而显著的提高了检索速度,大大减少了磁盘的访问次数。但整个表都存放于内存中,因此不适合与大磁盘。
对于索引的存储方式
- 索引实现是为每个文件创建一个索引数据块,里面存放指向文件数据块的指针列表,文件头中包含指向索引文件块的指针。这样就可以通过文件头知道索引数据块的位置,再通过索引数据块中的索引信息找到对应的数据块。
- 优点是文件的创建、扩展、缩小都很方便,不会产生碎片,支持顺序读写和随机读写文件数据。
组合的存储方式
如果文件很大,可通过链式索引或多级索引实现文件数据的存储。
- 链式索引的实现方式是在索引数据块中留出一块空间用于存放指向下一个索引数据块的指针。
- 多级索引的实现方式是通过一个索引块来存放多个索引数据块,一层套一层索引。
Unix文件的实现方式:顺序分配、链表分配和索引分配。对于小文件使用直接查找的方式可减少索引数据块的开销;对于大文件则以多级索引的方式来支持,所以大文件在访问数据块时需要大量查询,效率比较低。
95. 磁盘的空闲空间管理方法
常见的空闲空间管理方法有:空闲表法、空闲链表法和位图法。
在 Linux 文件系统中采用块位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也存储在磁盘中,也需要对其管理。
96. 软链接和硬链接
(1)硬链接
- 硬链接是多个目录项中的索引节点指针指向同一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接只能用于同一个文件系统。
- 由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。
(2)软链接
软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。
97. 文件I/O
常见的文件I/O分类有:缓冲与非缓冲 I/O、直接与非直接 I/O、阻塞与非阻塞 I/O、同步与异步 I/O。
(1)根据是否利用标准库缓冲,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O:
- 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。
- 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。
(2)根据是否利用操作系统的缓存,可以把文件 I/O 分为直接 I/O 与非直接 I/O:
- 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。
- 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。
(3)I/O 可分为两个过程的:
- 数据准备的过程
- 数据从内核空间拷贝到用户进程缓冲区的过程
阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,而非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。
异步 I/O 则不同,「过程 1 」和「过程 2 」都不会阻塞。