一个操作系统的虚拟内存和linux部分知识点的笔记整理,资料大多参考于:小林coding和Javaguide。
虚拟内存的作用
- 第一,虚拟内存可以使得进程运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
分页和分段的区别?
操作系统是如何管理虚拟地址与物理地址之间的关系?主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的。
内存分段
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。**不同的段是有不同的属性的,所以就用分段(*Segmentation*)的形式把这些段分离出来。**段的大小不统一。
分段机制下的虚拟地址由两部分组成,段选择因子和段内偏移量。
段选择因子和段内偏移量:
- 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
- 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:
如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
- 第一个就是内存碎片的问题。
- 第二个就是内存交换的效率低的问题。
但是分段会产生内存碎片:
分段为什么会导致内存交换效率低的问题?
对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 Swap
内存区域,这个过程会产生性能瓶颈。
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页。
内存分页
分段的好处就是能产生连续的内存空间,但是会出现「外部内存碎片和内存交换的效率太低」的问题。
要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页(Paging)。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB
。
虚拟地址与物理地址之间通过页表来映射,如下图:
页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
分页是怎么解决分段的「外部内存碎片和内存交换效率低」的问题?
内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。
但是,因为内存分页机制分配内存的最小单位是一页,程序不足一页大小,也只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
分页机制下,虚拟地址和物理地址是如何映射的?
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
- 把虚拟内存地址,切分成页号和偏移量;
- 根据页号,从页表里面,查询对应的物理页号;
- 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
多级页表
100
个进程,就需要 400MB
的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。要解决上面的问题,就需要采用一种叫作多级页表(Multi-Level Page Table)的解决方案。
在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB
的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024
个页表(二级页表),每个表(二级页表)中包含 1024
个「页表项」,形成二级分页。
如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。局部性原理。
每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB
,这对比单级页表的 4MB
是一个巨大的节约。
为什么不分级的页表就做不到节约内存呢?
我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
- 全局页目录项 PGD(Page Global Directory);
- 上层页目录项 PUD(Page Upper Directory);
- 中间页目录项 PMD(Page Middle Directory);
- 页表项 PTE(Page Table Entry);
段页式内存管理
内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,称为段页式内存管理。
段页式内存管理实现的方式:
- 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
- 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
地址结构就由段号、段内页号和页内位移三部分组成。
用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:
段页式地址变换中要得到物理地址须经过三次内存访问:
- 第一次访问段表,得到页表起始地址;
- 第二次访问页表,得到物理页号;
- 第三次将物理页号与页内位移组合,得到物理地址。
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。
内碎片外碎片?
内存碎片主要分为,内部内存碎片和外部内存碎片。
内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片。
但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部内存碎片的问题。
解决「外部内存碎片」的问题就是内存交换。
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
TLB是什么?
Translation Lookaside Buffer
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
逻辑地址和物理地址的映射过程?
这个过程将程序使用的逻辑地址转换为实际的物理地址,通常通过分段和分页机制来实现。通常由 CPU 内的内存管理单元(MMU)完成,它负责根据页表快速将逻辑地址转换为物理地址。MMU 也会缓存页表条目以加速转换过程,这被称为转换后备缓冲区(TLB)。
- 逻辑地址与物理地址
- 逻辑地址(虚拟地址):由程序生成的地址,程序认为自己拥有一片连续的内存空间,这些地址是虚拟的。逻辑地址由 CPU 生成,对程序员可见。
- 物理地址:内存芯片上实际存在的地址,对硬件可见,由内存管理单元(MMU)通过映射逻辑地址生成。
- 分段和分页机制
映射过程通常结合了分段和分页机制,分段负责逻辑地址的保护和管理,分页则负责内存的高效分配。
分段(Segmentation)
在分段机制下,逻辑地址分为两部分:
- 段选择子(Segment Selector):指定段表中的一个段描述符,段描述符包含段的基址(段的起始地址)和段的大小等信息。
- 段内偏移量(Offset):在该段内的偏移地址。
通过段选择子,可以确定段的基址,将偏移量加上基址,得到线性地址。
分页(Paging)
线性地址经过分页机制进一步映射到物理地址:
- 线性地址分为:页目录项(Page Directory Entry),页表项(Page Table Entry),页内偏移量(Offset)。
- 页目录项:定位页表。
- 页表项:定位具体物理页。
- 页内偏移量:定位物理页的具体地址。
有几种页表?
- 单级页表(Single-Level Page Table)
- 结构:单级页表是一种最简单的页表结构,每个虚拟地址直接通过页表映射到一个物理地址。页表是一个连续的数组,其中每个条目对应一个页面。
- 优点:实现简单,适合地址空间较小的系统。
- 缺点:如果虚拟地址空间很大,页表会非常大,占用大量内存。
- 多级页表(Multi-Level Page Table)
- 结构:多级页表将页表分成多个层次。虚拟地址被分成多个部分,每个部分用于索引不同级别的页表。常见的是二级或三级页表。
- 优点:通过分级减少了页表的内存占用,尤其在虚拟地址空间很大但实际使用内存较少的情况下。
- 缺点:访问内存时需要多次查找页表,增加了访问延迟。
- 反向页表(Inverted Page Table)
- 结构:反向页表与传统页表不同,它使用物理地址作为索引。每个条目记录了哪个虚拟页面映射到该物理页框。这样,页表的大小与物理内存的大小有关,而不是与虚拟地址空间大小有关。
- 优点:大幅减少页表所需的内存,适用于地址空间很大的系统。
- 缺点:查找时间较长,因为可能需要遍历页表来找到对应的虚拟地址。
Linux内存布局
Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
由于操作系统的虚拟内存管理机制,虚拟地址空间和物理地址空间是分离的。虽然不同的段或进程的基地址相同(都是 0),但通过页表映射,虚拟地址可以安全地映射到不同的物理地址,不会发生地址冲突。这种机制保障了内存的安全性和隔离性。
假设两个进程 A 和 B 都有一个虚拟地址 0x00400000。在进程 A 的页表中,0x00400000 可能映射到物理地址 0x10000000,而在进程 B 的页表中,0x00400000 可能映射到物理地址 0x20000000。虽然它们的虚拟地址相同,但物理地址不同,因此不会发生冲突。
Linux 的虚拟地址空间是如何分布
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:
32
位系统的内核空间占用1G
,位于最高处,剩下的3G
是用户空间;64
位系统的内核空间和用户空间都是128T
,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
内核空间与用户空间的区别:
- 进程在用户态时,只能访问用户空间内存;
- 只有进入内核态后,才可以访问内核空间的内存;
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是同一个物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
linux分配内存的过程?(malloc)
malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 方式一:通过 brk() 系统调用从堆分配内存
- 方式二:通过 mmap() 系统调用在文件映射区域分配内存;
方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。如下图:
方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图:
什么场景下 malloc() 会通过 brk() 分配内存?又是什么场景下通过 mmap() 分配内存?
malloc() 源码里默认定义了一个阈值:
- 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
- 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;
注意,不同的 glibc 版本定义的阈值也是不同的。
malloc() 分配的是物理内存吗?
不是的,malloc() 分配的是虚拟内存。
如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。
只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。
malloc(1) 会分配多大的虚拟内存?
malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。
free 释放内存,会归还给操作系统吗?
brk()方式申请的内存不会归还,但是通过 mmap ()方式会归还。
针对 malloc 通过 brk() 方式申请的内存的情况,与其把小于128kb的内存释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 小于128kb的内存时就可以直接复用,这样速度快了很多。
- malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用;
- malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
为什么不全部使用 mmap 来分配内存?
因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。
如果都用 mmap 来分配内存,等于每次都要执行系统调用。
另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。
也就是说,频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。
为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。
等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗。
既然 brk 那么牛逼,为什么不全部使用 brk 来分配?
前面我们提到通过 brk 从堆空间分配的内存,并不会归还给操作系统,那么我们那考虑这样一个场景。
如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。
但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。
因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。
free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?
malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节,这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。
这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。
内存泄漏是什么意思?
内存泄漏是指在计算机程序中,动态分配的内存未能在不再使用时释放,从而导致系统可用内存逐渐减少的一种问题。
虽然程序继续运行,但它使用的内存不断增加,最终可能导致系统性能下降或程序崩溃。
随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
内存中程序分为哪几部分?
- 代码段,包括二进制可执行代码;
- 数据段,包括已初始化的静态常量和全局变量;
- BSS 段,包括未初始化的静态变量和全局变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长;
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是
8 MB
。当然系统也提供了参数,以便我们自定义大小;
### 缺页中断?
缺页中断(Page Fault)是指当程序访问一个不在内存中的虚拟内存页时,CPU 发出的中断信号。
当程序访问某个虚拟地址时,CPU 会通过页表查找该地址对应的物理内存位置。如果该地址对应的页面不在物理内存中(即该页面可能被换出到磁盘或者从未加载过),会发生缺页。这时,CPU 无法找到所需的页面,会触发一个缺页中断。
缺页中断和一般中断的区别?
- 缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。
- 缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。
缺页中断的处理流程?
- 在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。
- 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求。
- 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。
- 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。
- 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。
- 最后,CPU 重新执行导致缺页异常的指令。
如果找不到空闲页面,就说明此时的内存已经满了,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。
页表项字段:
- 状态位:用于表示该页是否有效,也就是说是否在物理内存中,供程序访问时参考。
- 访问字段:用于记录该页在一段时间被访问的次数,供页面置换算法选择出页面时参考。
- 修改位:表示该页在调入内存后是否有被修改过,由于内存中的每一页都在磁盘上保留一份副本,因此,如果没有修改,在置换该页时就不需要将该页写回到磁盘上,以减少系统的开销;如果已经被修改,则将该页重写到磁盘上,以保证磁盘中所保留的始终是最新的副本。
- 硬盘地址:用于指出该页在硬盘上的地址,通常是物理块号,供调入该页时使用。
页面置换算法
当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种:
- 最佳页面置换算法(OPT):置换在「未来」最长时间不访问的页面,实际系统中无法实现,一它用来衡量算法的效率。
- 先进先出置换算法(FIFO)
- 最近最久未使用的置换算法(LRU)
- 时钟页面置换算法(Lock)
- 最不常用置换算法(LFU)
先进先出置换算法(FIFO)
选择在内存驻留时间很长的页面进行中置换,这个就是「先进先出置换」算法的思想。
最近最久未使用的置换算法(LRU)
最近最久未使用(LRU)的置换算法的基本思路是,发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。
困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。
所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。
时钟页面置换算法(Lock)
它跟 LRU 近似,又是对 FIFO 的一种改进。
该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
当发生缺页中断时,算法首先检查表针指向的页面:
- 如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
- 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;
最不常用置换算法(LFU)
最不常用(LFU)算法,这名字听起来很调皮,但是它的意思不是指这个算法不常用,而是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。
要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。
LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。
这个问题的解决的办法还是有的,可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2,也就说,随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率。
页面抖动指的是什么?
页面抖动(Page Thrashing)是指计算机系统频繁发生页面置换,导致大量时间花在交换内存页面和磁盘之间的现象。这种情况通常发生在系统内存不足的情况下,程序频繁访问超出物理内存容量的页面,导致频繁的缺页中断。
页面抖动通常是由于以下原因导致的:
- 内存不足:系统物理内存不足以容纳所有正在使用的页面,导致频繁的页面换入和换出。
- 工作集过大:程序的工作集(即当前活跃的内存页面集合)过大,超过了可用物理内存的容量。
- 低效的页面置换算法:如果页面置换算法不够智能,可能会不断换出刚被换入的页面,从而导致页面抖动。
Linux
用户态和内核态?以及如何切换?
用户态:应用程序运行的模式,权限低,安全性高。
内核态:操作系统内核运行的模式,权限高,控制系统资源。
切换:通过系统调用、中断或异常实现用户态和内核态的切换。
在 Linux 系统中,用户态和内核态是操作系统管理程序执行的两种不同模式。这两种模式是通过 CPU 的特权级别(Privilege Levels)来实现的,用于保护系统的核心资源和数据免受用户程序的误操作或恶意攻击。
用户态(User Mode)
- 定义:用户态是普通应用程序运行的模式,具有较低的特权级别。在用户态下,程序只能访问受限的资源,不能直接操作硬件或访问内核空间的内存。
- 特点:
- 访问权限有限:用户态程序只能访问自己的内存空间,不能直接访问内核空间。
- 安全性高:由于不能直接操作硬件,用户态的程序运行时即使出错,也不会影响整个系统的稳定性。
- 程序执行受到内核的监控:所有敏感操作(如 I/O 操作、进程管理)都必须通过系统调用(System Call)由内核代为执行。
内核态(Kernel Mode)
- 定义:内核态是操作系统内核运行的模式,具有最高的特权级别。在内核态下,操作系统可以访问所有硬件资源和内存空间,并执行所有特权操作。
- 特点:
- 完全访问权限:内核态可以访问系统的所有资源,包括内存、硬件设备、CPU 等。
- 负责系统核心功能:内核态负责管理进程调度、内存管理、文件系统、设备驱动、网络通信等核心功能。
- 危险性高:在内核态下的错误(如空指针访问、非法内存访问)可能导致整个系统崩溃。
内核空间与用户空间的区别:
- 进程在用户态时,只能访问用户空间内存;
- 只有进入内核态后,才可以访问内核空间的内存;
用户态和内核态之间的切换是通过系统调用(System Call)、中断(Interrupts)和异常(Exceptions)来实现的。具体过程如下:
从用户态切换到内核态
- 系统调用:
- 用户程序通过系统调用请求内核提供服务。例如,文件读写、进程创建、内存分配等。
- 系统调用通过
int 0x80
或syscall
指令(在 x86 架构上)触发 CPU 从用户态切换到内核态,执行对应的内核函数。
- 硬件中断:
- 当硬件设备(如键盘、网络卡)需要与 CPU 交互时,会发出中断信号。此时,CPU 会暂停当前的用户态程序,切换到内核态,处理中断请求。
- 处理完中断后,CPU 会恢复到用户态,继续执行原程序。
- 异常:
- 当用户态程序发生错误(如除零错误、非法内存访问)时,CPU 触发异常,切换到内核态进行异常处理。
从内核态切换回用户态
- 当系统调用、中断或异常处理完毕后,内核会使用
iret
(中断返回指令)或sysret
指令切换回用户态,继续执行被暂停的用户程序。
系统调用的过程?
系统调用(System Call)是用户态程序请求内核执行某项操作的主要接口。由于用户态的程序不能直接访问硬件资源或执行特权操作(如文件操作、进程管理、内存分配等),它们需要通过系统调用与操作系统内核交互。系统调用的过程涉及用户态和内核态的切换。
系统调用的过程
以下是系统调用的一般过程:
- 用户态程序发起系统调用请求:
- 用户态的应用程序需要进行某些特权操作(例如读取文件、分配内存)时,会调用标准库函数(如
printf
、malloc
、read
等)。这些库函数最终会调用一个与操作系统相关的系统调用接口,例如 Linux 下的read()
。
- 用户态的应用程序需要进行某些特权操作(例如读取文件、分配内存)时,会调用标准库函数(如
- 设置系统调用号和参数:
- 系统调用的具体操作由系统调用号决定,不同的系统调用对应不同的编号。例如,Linux 中
read()
对应的系统调用号是 0。参数(如文件描述符、缓冲区、字节数等)通过寄存器或栈传递给内核。
- 系统调用的具体操作由系统调用号决定,不同的系统调用对应不同的编号。例如,Linux 中
- 陷入指令触发内核态切换:
- 库函数使用一个特定的指令,如
int 0x80
(x86 架构下的中断指令)或syscall
指令,将 CPU 从用户态切换到内核态。 - 这会导致 CPU 暂停当前的用户程序,并转而执行内核中的系统调用服务例程。
- 库函数使用一个特定的指令,如
- 内核处理系统调用:
- CPU 进入内核态后,操作系统会根据系统调用号查找对应的内核函数,并使用传入的参数执行该函数。
- 内核完成系统调用的操作,如读取文件、写入数据、分配内存等,并将结果保存在寄存器中。
- 内核态切换回用户态:
- 系统调用处理完成后,内核会将结果(如成功或失败的状态码)返回给用户态程序。
- 使用
iret
或类似指令将 CPU 切换回用户态,并恢复用户态程序的上下文。
- 用户态程序继续执行:
- 用户态程序接收到系统调用的返回结果后,继续执行后续代码。如果系统调用失败,程序可以根据错误码采取相应的错误处理措施。
软连接和硬链接的区别?
在 Linux/类 Unix 系统上,文件链接(File Link)是一种特殊的文件类型,可以在文件系统中指向另一个文件。常见的文件链接类型有两种:
硬链接(Hard Link)
硬链接是直接指向文件数据块的多个目录项,所以修改文件内容是同步的。只有当所有的硬链接都被删除时,数据才会被真正删除。硬链接与原文件共享相同的 inode,因此它们是同一文件的多个名字。
- 在 Linux/类 Unix 文件系统中,每个文件和目录都有一个唯一的索引节点(inode)号,用来标识该文件或目录。硬链接通过 inode 节点号建立连接,硬链接和源文件的 inode 节点号相同,两者对文件系统来说是完全平等的(可以看作是互为硬链接,源头是同一份文件),删除其中任何一个对另外一个没有影响,可以通过给文件设置硬链接文件来防止重要文件被误删。
- 只有删除了源文件和所有对应的硬链接文件,该文件才会被真正删除。
- 硬链接具有一些限制,不能对目录以及不存在的文件创建硬链接,并且,硬链接也不能跨越文件系统。(每个文件系统都有自己的独立 inode 表,且每个 inode 表只维护该文件系统内的 inode。如果在不同的文件系统之间创建硬链接,可能会导致 inode 节点号冲突的问题)
ln
命令用于创建硬链接。
软链接(Symbolic Link 或 Symlink)
软连接是一个特殊的文件,它包含指向另一个文件的路径。它类似于 Windows 系统中的快捷方式。
- 软链接是一个独立的文件,它存储的是另一个文件的路径。
- 源文件删除后,软链接依然存在,但是指向的是一个无效的文件路径。
- 不同于硬链接,可以对目录或者不存在的文件创建软链接,并且,软链接可以跨越文件系统。
ln -s
命令用于创建软链接。
僵尸进程和孤儿进程?
僵尸进程(Zombie Process)
- 定义:**僵尸进程是已经终止但其父进程尚未读取其退出状态的进程。**尽管进程已经完成执行,但其进程表项仍然存在,以保存其退出状态信息,直到父进程通过系统调用
wait()
或waitpid()
读取该状态。 - 产生原因:
- 当一个子进程终止时,它会将退出状态信息传递给其父进程,并进入僵尸状态,等待父进程调用
wait()
读取该信息。 - 如果父进程未调用
wait()
,则子进程的状态不会被清除,进而在进程表中残留为僵尸进程。
- 当一个子进程终止时,它会将退出状态信息传递给其父进程,并进入僵尸状态,等待父进程调用
- 影响:
- 僵尸进程本身不占用系统资源(如 CPU、内存),但占用一个进程表项。如果僵尸进程过多,可能导致系统无法创建新进程。
- 解决方法:
- 通过父进程调用
wait()
或waitpid()
来清理僵尸进程。 - 如果父进程本身没有处理僵尸进程,可以使用信号
SIGCHLD
通知父进程处理子进程的终止。 - 终止父进程也会导致僵尸进程被操作系统的
init
进程(PID 1)收养并清理。
- 通过父进程调用
孤儿进程(Orphan Process)
-
定义:孤儿进程是父进程已经终止,但其子进程仍在运行的进程。当父进程终止后,这些子进程会被
init
进程(PID 1)收养,并由init
负责其状态管理。(
init
进程(PID 1)是 Linux 和 Unix 系统中的第一个进程,也是所有其他进程的祖先。系统启动时,内核加载后会启动init
进程,它的进程号为 1(PID 1)。init
进程负责系统的初始化、启动各种服务。) -
产生原因:
- 当父进程意外崩溃或正常终止,而其子进程尚未完成时,这些子进程就会成为孤儿进程。
-
影响:
- 孤儿进程不会对系统造成负面影响,因为
init
进程会负责收养和管理这些进程,确保它们在终止时被正确清理。
- 孤儿进程不会对系统造成负面影响,因为
-
解决方法:
- 无需特别处理,孤儿进程会自动被
init
进程管理。
- 无需特别处理,孤儿进程会自动被
如何查看是否有僵尸进程?
ps命令
ps
命令可以显示系统中的进程信息,并且可以通过 grep
来过滤僵尸进程。
ps aux | grep 'Z'
在输出中,如果看到进程的 STAT
列显示为 Z
(代表 Zombie),则表示该进程是僵尸进程。
top命令
top
命令可以实时监控系统中的进程。
top
查看 Tasks:
部分中的 zombie
项,它会显示当前系统中的僵尸进程数量。
htop命令
htop
是一个增强版的 top
,提供更友好的界面。
htop
如果系统中存在僵尸进程,htop
也会在顶部状态栏中显示 zombie
的数量。