赞
踩
一个操作系统的虚拟内存和linux部分知识点的笔记整理,资料大多参考于:小林coding和Javaguide。
操作系统是如何管理虚拟地址与物理地址之间的关系?主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的。
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。**不同的段是有不同的属性的,所以就用分段(*Segmentation*)的形式把这些段分离出来。**段的大小不统一。
分段机制下的虚拟地址由两部分组成,段选择因子和段内偏移量。
段选择因子和段内偏移量:
虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:
如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
但是分段会产生内存碎片:
内存碎片主要分为,内部内存碎片和外部内存碎片。
内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片。
但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部内存碎片的问题。
解决「外部内存碎片」的问题就是内存交换。
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 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 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,称为段页式内存管理。
段页式内存管理实现的方式:
地址结构就由段号、段内页号和页内位移三部分组成。
用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:
段页式地址变换中要得到物理地址须经过三次内存访问:
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。
Translation Lookaside Buffer
多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。
程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
这个过程将程序使用的逻辑地址转换为实际的物理地址,通常通过分段和分页机制来实现。通常由 CPU 内的内存管理单元(MMU)完成,它负责根据页表快速将逻辑地址转换为物理地址。MMU 也会缓存页表条目以加速转换过程,这被称为转换后备缓冲区(TLB)。
映射过程通常结合了分段和分页机制,分段负责逻辑地址的保护和管理,分页则负责内存的高效分配。
分段(Segmentation)
在分段机制下,逻辑地址分为两部分:
通过段选择子,可以确定段的基址,将偏移量加上基址,得到线性地址。
分页(Paging)
线性地址经过分页机制进一步映射到物理地址:
Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
由于操作系统的虚拟内存管理机制,虚拟地址空间和物理地址空间是分离的。虽然不同的段或进程的基地址相同(都是 0),但通过页表映射,虚拟地址可以安全地映射到不同的物理地址,不会发生地址冲突。这种机制保障了内存的安全性和隔离性。
假设两个进程 A 和 B 都有一个虚拟地址 0x00400000。在进程 A 的页表中,0x00400000 可能映射到物理地址 0x10000000,而在进程 B 的页表中,0x00400000 可能映射到物理地址 0x20000000。虽然它们的虚拟地址相同,但物理地址不同,因此不会发生冲突。
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:
32
位系统的内核空间占用 1G
,位于最高处,剩下的 3G
是用户空间;64
位系统的内核空间和用户空间都是 128T
,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。内核空间与用户空间的区别:
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是同一个物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。
malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。如下图:
方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图:
什么场景下 malloc() 会通过 brk() 分配内存?又是什么场景下通过 mmap() 分配内存?
malloc() 源码里默认定义了一个阈值:
注意,不同的 glibc 版本定义的阈值也是不同的。
不是的,malloc() 分配的是虚拟内存。
如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。
只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。
malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池。
brk()方式申请的内存不会归还,但是通过 mmap ()方式会归还。
针对 malloc 通过 brk() 方式申请的内存的情况,与其把小于128kb的内存释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 小于128kb的内存时就可以直接复用,这样速度快了很多。
因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。
如果都用 mmap 来分配内存,等于每次都要执行系统调用。
另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。
也就是说,频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。
为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。
等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗。
前面我们提到通过 brk 从堆空间分配的内存,并不会归还给操作系统,那么我们那考虑这样一个场景。
如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。
但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。
因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。
malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节,这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。
这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。
内存泄漏是指在计算机程序中,动态分配的内存未能在不再使用时释放,从而导致系统可用内存逐渐减少的一种问题。
虽然程序继续运行,但它使用的内存不断增加,最终可能导致系统性能下降或程序崩溃。
随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。
8 MB
。当然系统也提供了参数,以便我们自定义大小;### 缺页中断?
缺页中断(Page Fault)是指当程序访问一个不在内存中的虚拟内存页时,CPU 发出的中断信号。
当程序访问某个虚拟地址时,CPU 会通过页表查找该地址对应的物理内存位置。如果该地址对应的页面不在物理内存中(即该页面可能被换出到磁盘或者从未加载过),会发生缺页。这时,CPU 无法找到所需的页面,会触发一个缺页中断。
如果找不到空闲页面,就说明此时的内存已经满了,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。
当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种:
选择在内存驻留时间很长的页面进行中置换,这个就是「先进先出置换」算法的思想。
最近最久未使用(LRU)的置换算法的基本思路是,发生缺页时,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。
虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。
困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。
所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。
它跟 LRU 近似,又是对 FIFO 的一种改进。
该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。
当发生缺页中断时,算法首先检查表针指向的页面:
最不常用(LFU)算法,这名字听起来很调皮,但是它的意思不是指这个算法不常用,而是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。
它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。
要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。
LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。
这个问题的解决的办法还是有的,可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2,也就说,随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率。
页面抖动(Page Thrashing)是指计算机系统频繁发生页面置换,导致大量时间花在交换内存页面和磁盘之间的现象。这种情况通常发生在系统内存不足的情况下,程序频繁访问超出物理内存容量的页面,导致频繁的缺页中断。
页面抖动通常是由于以下原因导致的:
用户态:应用程序运行的模式,权限低,安全性高。
内核态:操作系统内核运行的模式,权限高,控制系统资源。
切换:通过系统调用、中断或异常实现用户态和内核态的切换。
在 Linux 系统中,用户态和内核态是操作系统管理程序执行的两种不同模式。这两种模式是通过 CPU 的特权级别(Privilege Levels)来实现的,用于保护系统的核心资源和数据免受用户程序的误操作或恶意攻击。
用户态(User Mode)
内核态(Kernel Mode)
内核空间与用户空间的区别:
用户态和内核态之间的切换是通过系统调用(System Call)、中断(Interrupts)和异常(Exceptions)来实现的。具体过程如下:
int 0x80
或 syscall
指令(在 x86 架构上)触发 CPU 从用户态切换到内核态,执行对应的内核函数。iret
(中断返回指令)或 sysret
指令切换回用户态,继续执行被暂停的用户程序。系统调用(System Call)是用户态程序请求内核执行某项操作的主要接口。由于用户态的程序不能直接访问硬件资源或执行特权操作(如文件操作、进程管理、内存分配等),它们需要通过系统调用与操作系统内核交互。系统调用的过程涉及用户态和内核态的切换。
以下是系统调用的一般过程:
printf
、malloc
、read
等)。这些库函数最终会调用一个与操作系统相关的系统调用接口,例如 Linux 下的 read()
。read()
对应的系统调用号是 0。参数(如文件描述符、缓冲区、字节数等)通过寄存器或栈传递给内核。int 0x80
(x86 架构下的中断指令)或 syscall
指令,将 CPU 从用户态切换到内核态。iret
或类似指令将 CPU 切换回用户态,并恢复用户态程序的上下文。在 Linux/类 Unix 系统上,文件链接(File Link)是一种特殊的文件类型,可以在文件系统中指向另一个文件。常见的文件链接类型有两种:
硬链接(Hard Link)
硬链接是直接指向文件数据块的多个目录项,所以修改文件内容是同步的。只有当所有的硬链接都被删除时,数据才会被真正删除。硬链接与原文件共享相同的 inode,因此它们是同一文件的多个名字。
ln
命令用于创建硬链接。软链接(Symbolic Link 或 Symlink)
软连接是一个特殊的文件,它包含指向另一个文件的路径。它类似于 Windows 系统中的快捷方式。
ln -s
命令用于创建软链接。僵尸进程(Zombie Process)
wait()
或 waitpid()
读取该状态。wait()
读取该信息。wait()
,则子进程的状态不会被清除,进而在进程表中残留为僵尸进程。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
的数量。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。