目录
FIFO(First In First Out) 页面置换算法(先进先出页面置换算法)
NRU(Not Recently Used)最近未使用页面置换算法
LRU (Least Recently Used)页面置换算法(最近最久未使用页面置换算法)
LFU/NFU (Least/Not Frequently Used)页面置换算法(最少使用页面置换算法)
内存管理介绍
操作系统的内存管理主要负责内存的分配与回收(malloc 函数:申请内存,free 函数:释放内存),另外地址转换也就是将逻辑地址转换成相应的物理地址等功能也是操作系统内存管理做的事情。
什么是虚拟内存(Virtual Memory)?
这个在我们平时使用电脑特别是 Windows 系统的时候太常见了。很多时候我们使用点开了很多占内存的软件,这些软件占用的内存可能已经远远超出了我们电脑本身具有的物理内存。为什么可以这样呢? 正是因为 虚拟内存 的存在,通过 虚拟内存 可以让程序可以拥有超过系统物理内存大小的可用内存空间。另外,虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。这样会更加有效地管理内存并减少出错。
虚拟内存是计算机系统内存管理的一种技术,我们可以手动设置自己电脑的虚拟内存。不要单纯认为虚拟内存只是“使用硬盘空间来扩展内存“的技术。虚拟内存的重要意义是它定义了一个连续的虚拟地址空间,并且 把内存扩展到硬盘空间。
虚拟内存 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如 RAM)的使用也更有效率。目前,大多数操作系统都使用了虚拟内存,如 Windows 家族的“虚拟内存”;Linux 的“交换空间”等。
注意:虚拟存储器又叫做虚拟内存,都是 Virtual Memory 的翻译,属于同一个概念。
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
于是,这里就引出了两种地址的概念:
我们程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)。
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
逻辑(虚拟)地址和物理地址
我们编程一般只有可能和逻辑地址打交道,比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的逻辑地址,逻辑地址由操作系统决定。物理地址指的是真实物理内存中地址,更具体一点来说就是内存地址是寄存器中的地址。物理地址是内存单元真正的地址。
CPU 寻址了解吗?为什么需要虚拟地址空间?
现代处理器使用的是一种称为 虚拟寻址(Virtual Addressing) 的寻址方式。使用虚拟寻址,CPU 需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。 实际上完成虚拟地址转换为物理地址转换的硬件是 CPU 中含有一个被称为 内存管理单元(Memory Management Unit, MMU) 的硬件。如下图所示:
为什么要有虚拟地址空间呢?
先从没有虚拟地址空间的时候说起吧!没有虚拟地址空间的时候,程序都是直接访问和操作的都是物理内存 。但是这样有什么问题呢?
用户程序可以访问任意内存,寻址内存的每个字节,这样就很容易(有意或者无意)破坏操作系统,造成操作系统崩溃。
想要同时运行多个程序特别困难,比如你想同时运行一个微信和一个 QQ 音乐都不行。为什么呢?举个简单的例子:微信在运行的时候给内存地址 1xxx 赋值后,QQ 音乐也同样给内存地址 1xxx 赋值,那么 QQ 音乐对内存的赋值就会覆盖微信之前所赋的值,这就造成了微信这个程序就会崩溃。
总结来说:如果直接把物理地址暴露出来的话会带来严重问题,比如可能对操作系统造成伤害以及给同时运行多个程序造成困难。
通过虚拟地址访问内存有以下优势:
程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。
程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。
不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。
局部性原理
要想更好地理解虚拟内存技术,必须要知道计算机中著名的局部性原理。另外,局部性原理既适用于程序结构,也适用于数据结构,是非常重要的一个概念。
局部性原理是虚拟内存技术的基础,正是因为程序运行具有局部性原理,才可以只装入部分程序到内存就开始运行。
早在 1968 年的时候,就有人指出我们的程序在执行的时候往往呈现局部性规律,也就是说在某个较短的时间段内,程序执行局限于某一小部分,程序访问的存储空间也局限于某个区域。
局部性原理表现在以下两个方面:
时间局部性 :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。
空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。
时间局部性是通过将近来使用的指令和数据保存到高速缓存存储器中,并使用高速缓存的层次结构实现。空间局部性通常是使用较大的高速缓存,并将预取机制集成到高速缓存控制逻辑中实现。虚拟内存技术实际上就是建立了 “内存一外存”的两级存储器的结构,利用局部性原理实现髙速缓存。
操作系统是如何管理虚拟地址与物理地址之间的关系?
主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的,我们先来看看内存分段。
内存分段
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。
分段机制下,虚拟地址和物理地址是如何映射的?
分段机制下的虚拟地址由两部分组成,段选择子和段内偏移量。
段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
在上面了,知道了虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:
如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
第一个就是内存碎片的问题。
第二个就是内存交换的效率低的问题。
接下来,说说为什么会有这两个问题。
我们先来看看,分段为什么会产生内存碎片的问题?
我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中:
游戏占用了 512MB 内存
浏览器占用了 128MB 内存
音乐占用了 256 MB 内存。
这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。
如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。
这里的内存碎片的问题共有两处地方:
外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;
针对上面两种内存碎片的问题,解决的方式会有所不同。
解决外部内存碎片的问题就是内存交换。
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
再来看看,分段为什么会导致内存交换效率低的问题?
对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap
内存区域,这个过程会产生性能瓶颈。
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。
内存分页
分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。
要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页(Paging)。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB
。
虚拟地址与物理地址之间通过页表来映射,如下图:
页表实际上存储在 CPU 的内存管理单元 (MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
分页是怎么解决分段的内存碎片、内存交换效率低的问题?
由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
分页机制下,虚拟地址和物理地址是如何映射的?
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。
总结一下,对于一个内存地址转换,其实就是这样三个步骤:
把虚拟内存地址,切分成页号和偏移量;
根据页号,从页表里面,查询对应的物理页号;
直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。
下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图:
这看起来似乎没什么毛病,但是放到实际中操作系统,这种简单的分页是肯定是会有问题的。
简单的分页有什么缺陷吗?
有空间上的缺陷。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB
的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。
那么,100
个进程的话,就需要 400MB
的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
段页式内存管理
内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理。
段页式内存管理实现的方式:
先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
这样,地址结构就由段号、段内页号和页内位移三部分组成。
用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:
段页式地址变换中要得到物理地址须经过三次内存访问:
第一次访问段表,得到页表起始地址;
第二次访问页表,得到物理页号;
第三次将物理页号与页内位移组合,得到物理地址。
可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。
常见的几种内存管理机制
简单分为连续分配管理方式和非连续分配管理方式这两种。连续分配管理方式是指为一个用户程序分配一个连续的内存空间,常见的如 块式管理 。同样地,非连续分配管理方式允许一个程序使用的内存分布在离散或者说不相邻的内存中,常见的如页式管理 和 段式管理。
块式管理 : 远古时代的计算机操系统的内存管理方式。将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为碎片。
页式管理 :把主存分为大小相等且固定的一页一页的形式,页较小,相对相比于块式管理的划分力度更大,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址和物理地址。
段式管理 : 页式管理虽然提高了内存利用率,但是页式管理其中的页实际并无任何实际意义。 段式管理把主存分为一段段的,每一段的空间又要比一页的空间小很多 。但是,最重要的是段是有实际意义的,每个段定义了一组逻辑信息,例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 段式管理通过段表对应逻辑地址和物理地址。
段页式管理机制: 段页式管理机制结合了段式管理和页式管理的优点。简单来说段页式管理机制就是把主存先分成若干段,每个段又分成若干页,也就是说 段页式管理机制 中段与段之间以及段的内部的都是离散的。
分页机制和分段机制的共同点和区别
共同点 :
分页机制和分段机制都是为了提高内存利用率,较少内存碎片。
页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中的内存是连续的。
区别 :
页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的程序。
分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息的单位,在程序中可以体现为代码段,数据段,能够更好满足用户的需要。
虚拟内存的技术实现
虚拟内存的实现需要建立在离散分配的内存管理方式的基础上。 虚拟内存的实现有以下三种方式:
请求分页存储管理 :建立在分页管理之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。请求分页存储管理系统中,在作业开始运行之前,仅装入当前要执行的部分段即可运行。假如在作业运行的过程中发现要访问的页面不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页面调入到主存,同时操作系统也可以将暂时不用的页面置换到外存中。
请求分段存储管理 :建立在分段存储管理之上,增加了请求调段功能、分段置换功能。请求分段储存管理方式就如同请求分页储存管理方式一样,在作业开始运行之前,仅装入当前要执行的部分段即可运行;在执行过程中,可使用请求调入中断动态装入要访问但又不在内存的程序段;当内存空间已满,而又需要装入新的段时,根据置换功能适当调出某个段,以便腾出空间而装入新的段。
请求段页式存储管理:请求段页式存储管理是建立在段页式存储管理基础上的一种段页式虚拟存储管理。根据段页式存储管理的思想,请求段页式存储管理首先按照程序自身的逻辑结构,将其划分为若干个不同的分段,在每个段内则按页的大小划分为不同的页,内存空间则按照页的大小划分为若干个物理块。
内存以物理块为单位进行离散分配,不必将进程所有的页装入内存就可启动运行。当进程运行过程中,访问到不在内存的页时,若该页所在的段在内存,则只产生缺页中断,将所缺的页调入内存;若该页所在的段不在内存,则先产生缺段中断再产生缺页中断,将所缺的页调入内存。若进程需要访问的页已在内存,则对页的管理与段页式存储管理相同。
分页与分页存储管理的不同呢
请求分页存储管理建立在分页管理之上。他们的根本区别是是否将程序全部所需的全部地址空间都装入主存,这也是请求分页存储管理可以提供虚拟内存的原因,我们在上面已经分析过了。
它们之间的根本区别在于是否将一作业的全部地址空间同时装入主存。请求分页存储管理不要求将作业全部地址空间同时装入主存。基于这一点,请求分页存储管理可以提供虚存,而分页存储管理却不能提供虚存。
不管是上面那种实现方式,我们一般都需要:
一定容量的内存和外存:在载入程序的时候,只需要将程序的一部分装入内存,而将其他部分留在外存,然后程序就可以执行了;
缺页中断:如果需执行的指令或访问的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调入到内存,然后继续执行程序;
虚拟地址空间 :逻辑地址到物理地址的变换。
快表和多级页表
页表管理机制中有两个很重要的概念:快表和多级页表,这两个东西分别解决了页表管理中很重要的两个问题。在分页内存管理中,很重要的两点是:
虚拟地址到物理地址的转换要快。
解决虚拟地址空间大,页表也会很大的问题。
快表(TLB)
为了解决虚拟地址到物理地址的转换速度,操作系统在 页表方案 基础之上引入了 快表 (TLB)来加速虚拟地址到物理地址的转换。我们可以把快表理解为一种特殊的高速缓冲存储器(Cache),其中的内容是页表的一部分或者全部内容。作为页表的 Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地址转换,读写内存数据时 CPU 要访问两次主存。有了快表,有时只要访问一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。
使用快表之后的地址转换流程是这样的:
根据虚拟地址中的页号查快表;
如果该页在快表中,直接从快表中读取相应的物理地址;
如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,同时将页表中的该映射表项添加到快表中;
当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。
看完了之后你会发现快表和我们平时经常在我们开发的系统使用的缓存(比如 Redis)很像,的确是这样的,操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。
多级页表
引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多空间,特别是那些根本就不需要的页表就不需要保留在内存中。多级页表属于时间换空间的典型场景。
把页表换成多级页表了就能节约内存了?不是还是得映射所有的虚拟地址空间么?
比如做个简单的数学计算,假如虚拟地址空间为32位(即4GB)、每个页面映射4KB以及每条页表项占4B,则进程需要1M个页表项(4GB / 4KB = 1M),即页表(每个进程都有一个页表)占用4MB(1M * 4B = 4MB)的内存空间。而假如我们使用二级页表,还是上述条件,但一级页表映射4MB、二级页表映射4KB,则需要1K个一级页表项(4GB / 4MB = 1K)、每个一级页表项对应1K个二级页表项(4MB / 4KB = 1K),这样页表占用4.004MB(1K * 4B + 1K * 1K * 4B = 4.004MB)的内存空间。多级页表的内存空间占用反而变大了?
其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的局部性原理么?
如何节约内存
我们分两方面来谈这个问题:第一,二级页表可以不存在;第二,二级页表可以不在主存。
二级页表可以不存在
我们反过来想,每个进程都有4GB的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到4GB,何必去映射不可能用到的空间呢?
也就是说,一级页表覆盖了整个4GB虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有20%的一级页表项被用到了,那么页表占用的内存空间就只有0.804MB(1K * 4B + 0.2 * 1K * 1K * 4B = 0.804MB),对比单级页表的4M是不是一个巨大的节约?
那么为什么不分级的页表就做不到这样节约内存呢?我们从页表的性质来看,保存在主存中的页表承担的职责是将虚拟地址翻译成物理地址;假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有1M个页表项来映射,而二级页表则最少只需要1K个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。
二级页表可以不在主存
其实这就像是把页表当成了页面。回顾一下请求分页存储管理,当需要用到某个页面时,将此页面从磁盘调入到内存;当内存中页面满了时,将内存中的页面调出到磁盘,这是利用到了程序运行的局部性原理。我们可以很自然发现,虚拟内存地址存在着局部性,那么负责映射虚拟内存地址的页表项当然也存在着局部性了!
这样我们再来看二级页表,根据局部性原理,1024个第二级页表中,只会有很少的一部分在某一时刻正在使用,我们岂不是可以把二级页表都放在磁盘中,在需要时才调入到内存?我们考虑极端情况,只有一级页表在内存中,二级页表仅有一个在内存中,其余全在磁盘中(虽然这样效率非常低),则此时页表占用了8KB(1K * 4B + 1 * 1K * 4B = 8KB),对比上一步的0.804MB,占用空间又缩小了好多倍!
我们把二级页表再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。
回头想想,这么大幅度地解决内存空间,我们失去了什么呢?计算机的很多问题无外乎就是时间换空间和空间换时间了,而多级页表就是典型的时间换空间的例子了,动态创建二级页表、调入和调出二级页表都是需要花费额外时间的,远没有不分级的页表来的直接;而我们也仅仅是利用局部性原理让这个额外时间开销降得比较低了而已。
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
全局页目录项 PGD(Page Global Directory);
上层页目录项 PUD(Page Upper Directory);
中间页目录项 PMD(Page Middle Directory);
页表项 PTE(Page Table Entry);
什么是 DMA
DMA 的中文名称是直接内存访问,它意味着 CPU 授予 I/O 模块权限在不涉及 CPU 的情况下读取或写入内存。也就是 DMA 可以不需要 CPU 的参与。这个过程由称为 DMA 控制器(DMAC)的芯片管理。由于 DMA 设备可以直接在内存之间传输数据,而不是使用 CPU 作为中介,因此可以缓解总线上的拥塞。DMA 通过允许 CPU 执行任务,同时 DMA 系统通过系统和内存总线传输数据来提高系统并发性。
大页内存
“大内存页”有助于 Linux 系统进行虚拟内存管理。顾名思义,除了标准的 4KB 大小的页面外,它们还能帮助管理内存中的巨大的页面。使用“大内存页”,你最大可以定义 1GB 的页面大小。
在系统启动期间,你能用“大内存页”为应用程序预留一部分内存。这部分内存,即被“大内存页”占用的这些存储器永远不会被交换出内存。它会一直保留其中,除非你修改了配置。这会极大地提高像 Oracle 数据库这样的需要海量内存的应用程序的性能。
为什么使用“大内存页”?
在虚拟内存管理中,内核维护一个将虚拟内存地址映射到物理地址的表,对于每个页面操作,内核都需要加载相关的映射。如果你的内存页很小,那么你需要加载的页就会很多,导致内核会加载更多的映射表。而这会降低性能。
TLB是有限的,这点毫无疑问。当超出TLB的存储极限时,就会发生 TLB miss,之后,OS就会命令CPU去访问内存上的页表。如果频繁的出现TLB miss,程序的性能会下降地很快。
为了让TLB可以存储更多的页地址映射关系,我们的做法是调大内存分页大小。
如果一个页4M,对比一个页4K,前者可以让TLB多存储1000个页地址映射关系,性能的提升是比较可观的。
调整OS内存分页
在Linux和windows下要启用大内存页,有一些限制和设置步骤。
Linux:
限制:需要2.6内核以上或2.4内核已打大内存页补丁。
确认是否支持,请在终端敲如下命令:
# cat /proc/meminfo | grep Huge
HugePages_Total: 0
HugePages_Free: 0
Hugepagesize: 2048 kB
如果有HugePage字样的输出内容,说明你的OS是支持大内存分页的。Hugepagesize就是默认的大内存页size。
接下来,为了让JVM可以调整大内存页size,需要设置下OS 共享内存段最大值 和 大内存页数量。
共享内存段最大值
建议这个值大于Java Heap size,这个例子里设置了4G内存。
# echo 4294967295 > /proc/sys/kernel/shmmax
大内存页数量
# echo 154 > /proc/sys/vm/nr_hugepages
这个值一般是 Java进程占用最大内存/单个页的大小 ,比如java设置 1.5G,单个页 10M,那么数量为 1536/10 = 154。
注意:因为proc是内存FS,为了不让你的设置在重启后被冲掉,建议写个脚本放到 init 阶段(rc.local)。
虚拟存储器
定义:具有请求调入功能和置换功能,能从逻辑上对内存容量加以扩充的一种存储系统。
即,程序在运行之前,没必要全部装入内存,仅把当前要运行的页装入即可,当程序运行时,如果需要其它页面,再进行页面调入或者置换。
例子:内存为1G,硬盘为200G,每个程序的大小为2G。那么该os可以同时装100个程序进内存(甚至可以更多,此处是100,是因为硬盘大小的限制)。而此前的os一个程序也装不下。也就是说,在用户看来,内存的容量变为了200G,因为有100个2G的程序被装入内存了。但实际的内存只有1G,因此将这种存储系统称为虚拟存储器。
在没有虚拟存储器之前,os根据文件名通过文件系统将程序的全部内容载入内存,现在仅装入了一部分,剩下的部分在需要时os该怎么找到?
每个进程都有一张页表。页表的作用是实现程序页号到实地址块号的映射。页表是放在内存中的。
请求分页系统的页表项:
页号 | 实地址块号 | 状态位 | 修改位 | 外存地址 |
这样os就可以根据外存地址将所需的页面从硬盘中找到装入对应的内存中了。
综上,逻辑地址跟硬盘一点关系都没有。
逻辑地址使每个进程可以独立对程序的指令进行了编号,这样使每个进程都感觉自己在独占内存。到执行的时候容易将其映射为物理地址。
虚拟存储器的引入 使程序可以不必完全装入内存就能运行。虚拟存储器一般会用到分页、分段,但是,分页、分段并不是因为虚拟存储器才被发明的。
页面置换算法
请求分页系统建立在基本分页系统基础之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。
在请求分页系统中,只要求将当前需要的一部分页面装入内存,以便可以启动作业运行。在作业执行过程中,当所要访问的页面不在内存时,再通过置换功能将其调入,同时还可以通过置换功能将暂时不用的页面换出到外存上,以便腾出内存空间。
页面置换算法的主要目标是使页面置换频率最低(也可以说缺页率最低)。
OPT 页面置换算法(最佳页面置换算法)
最佳(Optimal, OPT)置换算法所选择的被淘汰页面将是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。但由于人们目前无法预知进程在内存下的若千页面中哪个是未来最长时间内不再被访问的,因而该算法无法实现。一般作为衡量其他置换算法的方法。
FIFO(First In First Out) 页面置换算法(先进先出页面置换算法)
总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
该算法实现简单,但会将那些经常被访问的页面也被换出,从而使缺页率升高。
FIFO算法还会产生 当分配的物理块数增大而页故障数不减反增的异常现象,称为Belady异常。FIFO算法可能出现Belady异常,而LRU和OPT算法永远不会。
SC(Second Chance)第二次机会算法
FIFO 算法可能会把经常使用的页面置换出去,为了避免这一问题,对该算法做一个简单的修改:
当页面被访问 (读或写) 时设置该页面的 R 位为 1。需要替换的时候,检查最老页面的 R 位。如果 R 位是 0,那么这个页面既老又没有被使用,可以立刻置换掉;如果是 1,就将 R 位清 0,并把该页面放到链表的尾端,修改它的装入时间使它就像刚装入的一样,然后继续从链表的头部开始搜索。
NRU(Not Recently Used)最近未使用页面置换算法
当页面被访问(读或写)时设置R位,页面被写入(修改)时设置M位。
当启动一个进程时,它的所有页面的两个位都由操作系统设为0,R位被定期地(比如在每次时钟中断时)清零,以区别最近没有被访问的页面和被访问的页面。
当发生缺页中断时,操作系统检查所有的页面并根据它们当前的R位和M位的值,把它们分为4类:
第0类:没有被访问,没有被修改。
第1类:没有被访问,已被修改(M)。
第2类:已被访问,没有被修改(R)。
第3类:已被访问,已被修改(RM)。
NRU(Not Recently Used)算法随机地从类编号最小的非空类中挑选一个页面淘汰。在一个时间滴答中(大约20ms)淘汰一个没有被访问的已修改页面要比淘汰一个被频繁使用的“干净”页面好。NRU算法的主要优点是易于理解和能够有效地被实现,虽然它的性能不是最好的,但是已经够用了。
LRU (Least Recently Used)页面置换算法(最近最久未使用页面置换算法)
LRU算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的,即最近最久未使用的页面予以淘汰。
实现方式一:
在内存中维护一个所有页面的链表。当一个页面被访问时,将这个页面移到链表表头。这样就能保证链表表尾的页面时最近最久未访问的。
实现方式二:
为每个页面设置一个访问字段,来记录页面自上次被访问以来所经历的时间,淘汰页面时选择现有页面中值最大的予以淘汰。
LRU性能较好,但需要寄存器和栈的硬件支持,LRU是堆栈类的算法,理论上可以证明,堆栈类算法不可能出现Belady异常,FIFO算法基于队列实现,不是堆栈类算法。
LFU/NFU (Least/Not Frequently Used)页面置换算法(最少使用页面置换算法)
用一个软件模拟LRU,该算法将每个页面与一个软件计数器相关联。计数器的初值为0。每次时钟中断时,由操作系统扫描内存中所有的页面,将每个页面的R位(它是0或1)加到它的计数器上。这个计数器大体上跟踪了各个页面被访问的频繁程度。发生缺页中断时,则置换计数器值最小的页面。
NFU的缺点是它不从不忘记任何事,比如一个页面之前频繁被访问,导致这个它的计数器很大,但是后来它不被访问了,而它的计数器的值还是很大,所以它一直不会被置换出去。
解决这个问题的方法之一是定期的将计时器右移,以形成指数衰减的平均使用次数。
注意LFU和LRU算法的不同之处,LRU的淘汰规则是基于访问时间,而LFU是基于访问次数的。
老化算法
老化算法是对NFU算法的修改,其修改包括两个部分,首先,在R位被加进之前将计数器右移一位,其次,将R位加到计数器最左端的位而不是最右端的位。
老化算法中的计数器只有有限位数,如果时钟滴答是20ms,8位一般是够用的。假如一个页面160ms没有被访问过,那么它很可能并不重要。
Clock时钟算法
第二次机会算法需要在链表中移动页面,降低了效率。时钟算法使用环形链表将页面链接起来,再使用一个指针指向最老的页面。
最简单的时钟策略需要给每一页框关联一个附加位,称为使用位。当某一页首次装入内存中时,则将该页页框的使用位设置为1;当该页随后被访问到时(在访问产生缺页中断之后),它的使用位也会被设置为1。
该方法中,用于置换的候选页框集合(当前进程:局部范围;整个内存;全局范围)被看做是一个循环缓冲区,并且有一个指针针与之相关联。当一页被置换时,该指针针被设置成指向缓冲区中的下一页框。当需要置换一页时,操作系统扫描缓冲区,以查找使用位被置为0的一页框。每当遇到一个使用位为1的页框时,操作系统就将该位重新置为0;如果在这个过程开始时,缓冲区中所有页框的使用位均为0时,则选择遇到的第一个页框置换;如果所有页框的使用位均为1时,则指针针在缓冲区中完整地循环一周,把所有使用位都置为0,并且停留在最初的位置上,置换该页框中的页。当需要使用的页已经存在时,则指针不会受到影响,不会发生转动。
可见该策略类似于FIFO(先进先出),唯一不同的是,在时钟策略中使用位为1的页框被跳过,该策略之所以称为时钟策略,是因为可以把页框形象地想象成在一个环中。许多操作系统都采用这种简单时钟策略的某种变体。
以下是一个使用实例,其中*号表示相应的使用位为1,红色单元格表示指针指向的位置
改进时钟算法
在页面中增加了修改位,1为修改过,0为未修改过。因为当发生缺页中断时,把未修改过的页面替换进外存就相当于把页面直接从内存中删除,因为内存和外存中所对应的该页面的内容相同,处理时间只有一次缺页中断访问外存的时间。而修改过的页面则还需要向外存中写入一次,再加上缺页中断的时间,相当于访问了两次外存,是上述未修改的两倍。所以避免把修改过的页面替换下去可以提高性能。
由访问位A和修改位M可以组合成下面四种类型的页面:
1类(A=0, M=0): 表示该页最近既未被访问, 又未被修改, 是最佳淘汰页。
2类(A=0, M=1): 表示该页最近未被访问, 但已被修改, 并不是很好的淘汰页。
3类(A=1, M=0): 最近已被访问, 但未被修改, 该页有可能再被访问。
4类(A=1, M=1): 最近已被访问且被修改, 该页可能再被访问。
可能有人会发现第2类这种情况根本不会出现,如果一个页帧被修改,其修改位会被置1,同时它也被使用了,其使用位也会被置1;即不会出现被修改但是没有被使用的情况。真实情况是,页帧的使用位可能会被清零,这样第3组经过一次清零就会变成第2组。
算法执行如下操作步骤:
从指针的当前位置开始,扫描帧缓冲区。在这次扫描过程中,对使用位不做任何修改。选择遇到的第一类页(A=0,M=0)作为淘汰页。
如果第1)步失败,则开始第二轮扫描,查找(A=0,M=1)的第二类页。选择遇到的第一个这样的页作为淘汰页。在这个扫描过程中,对所有经过的页,把它的访问位A设置成0。
如果第2)步失败,指针将回到它的最初位置,并且集合中所有页的访问位均为0。重复第1步,并且如果有必要,重复第2步。这样将可以找到被淘汰的页。
改进型的CLOCK算法优于简单CLOCK算法之处在于替换时首选没有变化的页。由于修改过的页在被替换之前必须写回,因而这样做会节省时间。
工作集页面置换算法
什么是工作集
在单纯的分页系统里,刚启动进程时,在内存中并没有页面。在CPU试图读取第一条指令时就会产生一次缺页中断,使操作系统装入含有第一条指令的页面。其他由访问全局数据和堆栈引起的缺页中断通常会紧接着发生。一段时间后,进程需要的大部分页面都已经在内存了,进程开始在较少缺页中断的情况下运行。这个策略称为请求调页,因为页面是在需要时被调入的,而不是预先装入。
大部分进程在工作时表现出了一种局部性访问行为,即在进程运行的任何阶段,它都只是访问较少的一部分页面。而进程当前正在使用的页面的集合被称作它的工作集。
显而易见,如果整个工作集都被装入到了内存中,那么进程在运行到下一运行阶段之前,不会产生很多缺页中断。但若内存太小而无法容纳整个工作集,那么进程在运行过程中就会产生大量的缺页中断。若每执行几条指令程序就发生一次缺页中断,就称这个程序发生了颠簸。
工作集模型
多道程序设计中,经常会把进程转移到磁盘上(即从内存中移走所有页面),这样可以让其他的进程有机会占有CPU,但是当进程再次调回来时,还要重新处理缺页中断,浪费时间。所以不少分页系统会设法跟踪进程工作集,以确保让进程运行以前它的工作集就在内存中,该方法称为工作集模型,其目的在于减少缺页中断率。在让进程运行前预先装入其工作集页面称为预先调页(注意工作集是随时间变化的)。
在任意时刻t,都存在一个集合,包含了最近所有k次所访问过得页面。这个集合w(k, t)就是工作集。因为最近1次访问过后的工作集肯定包含最近2次访问的工作集中的页面。所以w(k, t)是一个单调非递减函数。工作集不可能无限变大,因为程序不可能访问比它的地址空间所能容纳的页面数目上限还多的页面。
为了实现工作集模型,操作系统必须跟踪哪些页面在工作集中。然后通过这些信息推导出一个合理的页面置换算法。当发生缺页中断时,淘汰一个不在工作集中的页面。
工作集的另一种定义
根据定义,工作集就是最近k次内存访问所使用过的页面的集合。所以为了实现工作集算法,必须预先k的值。k值被确定后,页面集合就是惟一确定的了。
向后找最近k次的内存访问
设想有一个长度为k次的移位寄存器,每进行一次内存访问就左移一位,然后再最右端插入刚才所访问过的页面号。移位寄存器中的k个页面号的集合就是工作集。理论上,当缺页中断发生时,只要读出移位寄存器中的内容,并排序删除重复页面后,结果就是工作集。然而,维护寄存器并在缺页中断时处理它所需要的开销很大,因此该技术没有被使用过。
考虑执行时间
按照以前的方法,定义工作集为前1000万次内存访问所使用过的页面集合,那么现在可以定义:工作集是过去10ms中的内存访问所用到的页面的集合。进程只计算自己的执行时间,如果一个进程在T时刻开始,在T+100 ms的时刻使用了40ms的CPU时间,工作集只是40ms。一个进程从它开始执行到当前所实际使用的CPU时间的总数通常称作当前实际运行时间。
在这里,进程的工作集可以被称为在过去t秒实际运行时间中它所访问过的页面的集合。
要注意到,每个进程只计算它自己的执行时间。因此,如果一个进程在T时刻开始,在(T+100ms)的时刻使用了40msCPU时间,对工作集而言,它的时间就是40ms。一个程序从它开始执行到当前所实际使用的CPU时间总数通常称作当前实际运行时间。通过这个近似的方法,进程的工作集可以被称为在过去的τ秒实际运行时间中它所访问过的页面的集合。
工作集页面置换算法
基本思路是找出一个不在工作集中的页面并淘汰它。在页表中,每个表项至少包含了两条信息:上次使用该页面的近似时间和R(访问)位。其他的域如页框号、保护位、M(修改)位在该算法中不需要,被忽略(用空白域表示)。
假设使用硬件来设置R(访问)位和M(修改)位。同样,假设在每个时钟滴答中,有一个定期的时钟中断会用软件的方法来清除R位。每当缺页中断发生时,扫描页表以找出一个合适的页面淘汰之。
在处理每个表项时,需要检查R位。如果是1,就把当前实际时间写进页表项的”上次使用时间”域,以表示缺页中断时该页面正在被使用。既然该页在当前时钟滴答中已经被访问过,那么很明显它应该出现在工作集中,并且不应该被删除(假设t足够大并横跨多个时钟滴答)。
如果R是0,那么表示在当前时钟滴答中,该页面还没有被访问过,则它就可以作为候选者被置换。我们需要计算它的生存时间(即当前实际运行时间减去上次使用时间),然后与t做比较。如果生存时间较大,那么它就应该被淘汰。
如果R是0,但是生存时间小于等于t,那么该页面应该留在工作集中。并记录下生存时间最长的页面。如果整个表中都没有适合淘汰的页面,只能淘汰生存时间最长的页面。
存在一种情况,所有页面的R位都为1,即都在工作集中,这时需要随机选择一个页面淘汰。最好选择一个没有被有被修改过的干净页面,因为这样可以直接被淘汰,而不需要将该页面在写回磁盘,可以节省时间。
工作集时钟页面置换算法
在工作集页面置换算法中中,当缺页中断发生后,需要扫描整个页表才能确定被淘汰的页面,因此基本工作集算法是比较费时的。
基于时钟算法,并且使用了工作集信息,被称为WSClock(工作集时钟)算法。由于它实现简单,性能较好,所以在实际工作中得到了广泛应用。
与时钟算法一样,所需的数据结构是一个以页框为元素的循环表。
最初,该表是空的当装入第一个页面时,把它加到该表中。随着更多的页面的加入,它们形成一个环。每个表项包含来自基本工作集算法的上次使用时间,以及R(访问)位和M位(修改位,图中由每个表项上面的空白域表示)。
与时钟页面置换算法一样,每次缺页中断时,首先检查指针指向的页面。如果R为被置位1,该页面在当前工作集中,不该被淘汰。然后把该页面R置为0,指针指向下一个页面,重复该算法。
当指针指向的页面R=0时,同样是比较生存时间与t。
若生存时间较大,并且页面是干净的,就淘汰该页面,并把新页面放在其中。
如果该页面被修改过,不能立即申请页框,为了避免写磁盘操作引起的进程切换,指针继续向前走,找到一个旧的并干净的页面。
存在一种可能,所有的页面因为磁盘I/O在某个时钟周期被调度。为了降低磁盘阻塞,所以需要设置一个值n,目的是限制最大只允许写回n个页面。一旦到达该限制,不允许调度新的写操作。
如果指针经过一圈并返回起始点,存在两种情况
至少调度了一次写操作
对于这种情况,指针仅仅是不停地移动,寻找一个干净页面。既然已经调度了一个或多个写操作,最终一定会有一个某个写操作完成,并且它的页面会被标记为干净。置换遇到的第一个干净页面,这个页面不一定是第一个被调度写操作的页面。因为磁盘驱动程序可能把写操作重排序。
没有调度过写操作
对于这种情况,所有页面都在工作集中。简单的方法是随便选择一个干净页面淘汰,所以在上一步的扫描过程中,需要记录所有干净页面的位置。如果不存在干净页面,那么就把当前页面淘汰。
抖动
在页面置换过程中的一种最糟糕的情形是,刚刚换出的页面马上又要换入主存,刚刚换入的页面马上就要换出主存,这种频繁的页面调度行为称为抖动,或颠簸。如果一个进程在换页上用的时间多于执行时间,那么这个进程就在颠簸。
可以在内存中保留更多的进程以提高系统频繁的发生缺页中断,其主要原因是某个进程频繁访问的页面数目高于可用的物理页帧数目。虚拟内存技术可效率。但系统必须很“聪明”地管理页面分配方案。在稳定状态,几乎主存的所有空间都被进程块占据,处理机和操作系统可以直接访问到尽可能多的进程。但如果管理不当,处理机的大部分时间都将用于交换块,即请求调入页面的操作,而不是执行进程的指令,这就会大大降低系统效率。
Linux的内存管理机制
Intel 处理器的发展历史
早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。
因此,在不久以后的 80386 中就实现了对页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。
但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的的地址上再加上一层地址映射。
由于此时段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。
这里说明下逻辑地址和线性地址:
程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址;
通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址;
逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。
Linux 采用了什么方式管理内存?
Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。
这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。
但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
Linux 的虚拟地址空间是如何分布的?
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:
通过这里可以看出:
32
位系统的内核空间占用 1G
,位于最高处,剩下的 3G
是用户空间;
64
位系统的内核空间和用户空间都是 128T
,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
再来说说,内核空间与用户空间的区别:
进程在用户态时,只能访问用户空间内存;
只有进入内核态后,才可以访问内核空间的内存;
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。
我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:
通过这张图你可以看到,用户空间内存,从低到高分别是 7 种不同的内存段:
程序文件段,包括二进制可执行代码;
已初始化数据段,包括静态常量;
未初始化数据段,包括未初始化的静态变量;
堆段,包括动态分配的内存,从低地址开始向上增长;
文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关)
栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB
。当然系统也提供了参数,以便我们自定义大小;
在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()
或者 mmap()
,就可以分别在堆和文件映射段动态分配内存。
内存映射
定义
关联,进程中的1个虚拟内存区域 & 1个磁盘上的对象,使得二者存在映射关系
上述的映射过程 = 初始化该虚拟内存区域
虚拟内存区域被初始化后,就会在交换空间中换你来还去
被映射的对象称为:共享对象(普通文件 / 匿名文件)
作用
若存在上述映射关系,则具备以下特征
在多个进程的虚拟内存区域 已和同1个共享对象 建立映射关系的前提下
若其中1个进程对该虚拟区域进行写操作,那么,对于也把该共享对象映射到其自身虚拟内存区域的进程 也是可见的
示意图如下
假设进程1、2的虚拟内存区域同时映射到同1个共享对象;
当进程1对其虚拟内存区域进行写操作时,也会映射到进程2中的虚拟内存区域
实现过程
内存映射的实现过程主要是通过Linux
系统下的系统调用函数:mmap()
该函数的作用 = 创建虚拟内存区域 + 与共享对象建立映射关系
其函数原型、具体使用 & 内部流程 如下
/**
* 函数原型
*/
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
/**
* 具体使用(用户进程调用mmap())
* 下述代码即常见了一片大小 = MAP_SIZE的接收缓存区 & 关联到共享对象中(即建立映射)
*/
mmap(NULL, MAP_SIZE, PROT_READ, MAP_PRIVATE, fd, 0);
/**
* 内部原理
* 步骤1:创建虚拟内存区域
* 步骤2:实现地址映射关系,即:进程的虚拟地址空间 ->> 共享对象
* 注:
* a. 此时,该虚拟地址并没有任何数据关联到文件中,仅仅只是建立映射关系
* b. 当其中1个进程对虚拟内存写入数据时,则真正实现了数据的可见
*/
特点
提高数据的读、写 & 传输的时间性能
减少了数据拷贝次数
用户空间 & 内核空间的高效交互(通过映射的区域 直接交互)
用内存读写 代替 I/O读写
提高内存利用率:通过虚拟内存 & 共享对象
应用场景
在Linux系统下,根据内存映射的本质原理 & 特点,其应用场景在于:
实现内存共享:如 跨进程通信
提高数据读 / 写效率 :如 文件读 / 写操作
实例讲解
下面,我将详细讲解 内存映射应用在跨进程通信 & 文件操作的实例
文件读 / 写操作
传统的Linux系统文件操作流程如下
使用了内存映射的 文件读 / 写 操作
从上面可看出:使用了内存映射的文件读/写 操作方式效率更加高、性能最好!
跨进程通信
传统的跨进程通信
使用了内存映射的 跨进程通信
从上面可看出:使用了内存映射的跨进程通信 效率最高、性能最好!