Linux 操作系统原理 — 虚拟内存

目录

虚拟内存

在早期的计算机系统中,程序员会直接对主存储器的物理地址进行操作,这种编程方式导致了当程序出现寻址错误时有可能会导致整个系统崩溃,当一个进程出现寻址错误时也可能会导致另一个进程崩溃。显然,直接操作主存的物理地址不是一个好的方法。而且,由于不存在分页或分段的存储空间管理手段,所以 CPU 寻址宽度就成为了存储容量的限制,例如:32 位 CPU 只支持 4GB 内存的寻址。这导致了该计算机无法运行存储空间需求大于实际内存容量的应用程序。

为了解决这些问题,现代计算机系统通过操作系统和硬件的结合,把主存储器和辅存储器从逻辑上统一成了一个整体,这就是虚拟存储器,或称为虚拟存储系统。虚拟存储器是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供了一个大的、一致的和私有的地址空间。

虚拟存储器的两大特点

  • 允许用户程序使用比实际主存空间大得多的空间来访问主存
  • 每次访存都要进行虚实地址转换。
    • 物理地址,即物理主存的地址空间。主存被组织成一个由 M 个连续的、字节大小的单元组成的数组,每字节都有一个唯一的物理地址(Physical Address,PA)。第一个字节的地址为 0,接下来的字节的地址为 1,依此类推。给定这种简单的结构,CPU 访问存储器的最自然的方式就是使用物理地址,即物理寻址。
    • 虚拟地址,即虚拟存储地址空间,它能够让应用程序误以为自己拥有一块连续可用的 “物理” 地址,但实际上从程序视角所见的都是虚拟地址,而且这些虚拟地址对应的物理主存空间通常可能是碎片的,甚至有部分数据还可能会被暂时储存在外部磁盘设备上,在需要时才进行数据交换。

虚拟存储器的核心思路是根据程序运行时的局部性原理,一个程序运行时,在一小段时间内,只会用到程序和数据的很小一部分,仅把这部分程序和数据装入主存即可,更多的部分可以在需要用到时随时从辅存调入主存。在操作系统和相应硬件的支持下,数据在辅存和主存之间按程序运行的需要自动成批量地完成交换。

局部性原理是虚拟内存技术的基础,正是因为程序运行具有局部性原理,才可以只装入部分程序到内存就开始运行。早在 1968 年的时候,就有人指出我们的程序在执行的时候往往呈现局部性规律,也就是说在某个较短的时间段内,程序执行局限于某一小部分,程序访问的存储空间也局限于某个区域。

局部性原理表现在以下两个方面:

  1. 时间局部性 :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。

  2. 空间局部性 :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。

时间局部性是通过将近来使用的指令和数据保存到高速缓存存储器中,并使用高速缓存的层次结构实现。空间局部性通常是使用较大的高速缓存,并将预取机制集成到高速缓存控制逻辑中实现。虚拟内存技术实际上就是建立了 “内存一外存”的两级存储器的结构,利用局部性原理实现髙速缓存。

基于局部性原理,在程序装入时,可以将程序的一部分装入内存,而将其他部分留在外存,就可以启动程序执行。由于外存往往比内存大很多,所以我们运行的软件的内存大小实际上是可以比计算机系统实际的内存大小大的。在程序执行过程中,当所访问的信息不在内存时,由操作系统将所需要的部分调入内存,然后继续执行程序。另一方面,操作系统将内存中暂时不使用的内容换到外存上,从而腾出空间存放将要调入内存的信息。这样,计算机好像为用户提供了一个比实际内存大的多的存储器 —— 虚拟存储器。

实际上,拟内存同样是一种时间换空间的策略,你用 CPU 的计算时间,页的调入调出花费的时间,换来了一个虚拟的更大的空间来支持程序的运行。不得不感叹,程序世界几乎不是时间换空间就是空间换时间。

虚拟存储器提供了三个重要的能力

  1. 它将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。
  2. 它为每个进程提供了一致的地址空间,从而简化了存储器管理。
  3. 它保护了每个进程的地址空间不被其他进程破坏。

虚拟存储器解决了三个根本需求

  1. 确保可以运行存储空间需求比实际主存储容量大的应用程序
  2. 确保可执行程序被装载后占用的内存空间是连续的。因为 PC 程序计数器是自增的,换句话说就是程序执行必须顺序存放在存储器中,PC 才能够按照程序语句,一条一条的读取指令,不错乱。
  3. 确保同时加载多个程序的时候不会造成内存地址冲突。虽然在生成可执行文件时指令已经有了对应的内存地址,但实际加载的时候,其实没办法保证程序一定就运行在这些内存地址上,因为多个程序同时运行的话,预期的内存地址很可能已经被其他程序占用了。

NOTE:虚拟存储器指的是 “主存-辅存” 层次,由软硬件结合实现,而 “缓存-主存” 是由存硬件实现的。

虚拟内存的实现需要建立在离散分配的内存管理方式的基础上。 虚拟内存的实现有以下三种方式:

  1. 请求分页存储管理 :建立在基本分页系统基础之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。
  2. 请求分段存储管理
  3. 请求段页式存储管理

不管是上面那种实现方式,我们一般都需要:

  • 一定容量的内存和外存:在载入程序的时候,只需要将程序的一部分装入内存,而将其他部分留在外存,然后程序就可以执行了;
  • 页中断:如果需执行的指令或访问的数据尚未在内存(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段调入到内存,然后继续执行程序;
  • 虚拟地址空间 :逻辑地址到物理地址的变换。

地址映射过程中,若在页面中发现所要访问的页面不在内存中,则发生缺页中断。缺页中断 就是要访问的页不在主存,需要操作系统将其调入主存后再进行访问。在这个时候,被内存映射的文件实际上成了一个分页交换文件。当发生缺页中断时,如果当前内存中并没有空闲的页面,操作系统就必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。用来选择淘汰哪一页的规则叫做页面置换算法,我们可以把页面置换算法看成是淘汰页面的规则。

  • OPT 页面置换算法(最佳页面置换算法) :理想情况,不可能实现,一般作为衡量其他置换算法的方法。
  • FIFO 页面置换算法(先进先出页面置换算法) : 总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
  • LRU 页面置换算法(最近未使用页面置换算法) :LRU(Least Currently Used)算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间T,当须淘汰一个页面时,选择现有页面中其T值最大的,即最近最久未使用的页面予以淘汰。
  • LFU 页面置换算法(最少使用页面排序算法) : LFU(Least Frequently Used)算法会让系统维护一个按最近一次访问时间排序的页面链表,链表首节点是最近刚刚使用过的页面,链表尾节点是最久未使用的页面。访问内存时,找到相应页面,并把它移到链表之首。缺页时,置换链表尾节点的页面。也就是说内存内使用越频繁的页面,被保留的时间也相对越长。

主存-辅存间信息交换单位和存储管理方式

虚拟内存管理方式可以简单分为连续分配管理方式和非连续分配管理方式这两种。连续分配管理方式是指为一个用户程序分配一个连续的内存空间,常见的如 块式管理 。同样地,非连续分配管理方式允许一个程序使用的内存分布在离散或者说不相邻的内存中,常见的如页式管理和段式管理。

  • 块式管理 :远古时代的计算机操系统的内存管理方式。将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为碎片。
  • 页式管理 :把主存分为大小相等且固定的一页一页的形式,页较小,相对相比于块式管理的划分力度更大,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址和物理地址。
  • 段式管理 :页式管理虽然提高了内存利用率,但是页式管理其中的页实际并无任何实际意义。段式管理把主存分为一段段的,每一段的空间又要比一页的空间小很多 。但是,最重要的是段是有实际意义的,每个段定义了一组逻辑信息,例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。段式管理通过段表对应逻辑地址和物理地址。

段页式管理机制:结合了段式管理和页式管理的优点。简单来说段页式管理机制就是把主存先分成若干段,每个段又分成若干页,也就是说 段页式管理机制 中段与段之间以及段的内部的都是离散的。

页表管理机制中有两个很重要的概念:快表和多级页表。在分页内存管理中,很重要的两点是:

  1. 虚拟地址到物理地址的转换要快。
  2. 解决虚拟地址空间大,页表也会很大的问题。

快表:为了解决虚拟地址到物理地址的转换速度,操作系统在 页表方案 基础之上引入了 快表 来加速虚拟地址到物理地址的转换。我们可以把块表理解为一种特殊的高速缓冲存储器(Cache),其中的内容是页表的一部分或者全部内容。作为页表的 Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地址转换,读写内存数据时 CPU 要访问两次主存。有了快表,有时只要访问一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。

使用快表之后的地址转换流程是这样的:

  1. 根据虚拟地址中的页号查快表;
  2. 如果该页在快表中,直接从快表中读取相应的物理地址;
  3. 如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,同时将页表中的该映射表项添加到快表中;
  4. 当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。

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

多级页表:引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多空间,特别是那些根本就不需要的页表就不需要保留在内存中。多级页表属于时间换空间的典型场景,具体可以查看下面这篇文章:https://www.polarxiong.com/archives/多级页表如何节约内存.html。

为了提高内存的空间性能,提出了多级页表的概念;但是提到空间性能是以浪费时间性能为基础的,因此为了补充损失的时间性能,提出了快表(即 TLB)的概念。不论是快表还是多级页表实际上都利用到了程序的局部性原理,局部性原理在后面的虚拟内存这部分会介绍到。

  • 共同点 :

    • 分页机制和分段机制都是为了提高内存利用率,较少内存碎片。
    • 页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中的内存是连续的。
  • 区别 :

    • 页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的程序。
    • 分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息的单位,在程序中可以体现为代码段,数据段,能够更好满足用户的需要。

段式虚拟存储管理方式

段式存储管理是一种把主存按段分配的存储管理方式,主存-辅存间信息传送单位是不定长的段。优点是段的分界与程序的自然分界是相对应的。例如:过程、子程序、数据表和阵列等待程序的模块化性质都可以与段对应起来。于是段作为独立的逻辑单位可以被其他程序段调用,这样就形成了段间连接,产生规模较大的程序。这样的特性使得段易于编译、管理、修改和保护,也便于多道程序共享。而缺点是容易在段间留下许多空余的存储空间碎片,且不好收集利用。除此之外,段式存储管理还存在交换性能较低的问题。因为辅存的访问速度比主存慢得多,而每一次交换,我们都需要把一大段连续的内存数据写到硬盘上,导致了当内存交换一个较大的段时,会让机器显得卡顿。
在这里插入图片描述

页式虚拟存储管理方式

页式存储管理是一种把主存按页分配的存储管理方式,主存-辅存间信息传送单位是定长的页。对比段而言,因为管理的粒度更细致,所以造成内存页碎片的浪费也会小很多。而缺点也正好相反,由于页不是程序独立模块对应的逻辑实体,所以处理、保护和共享都不及段来得方便。同时也因为页要比段小得多,在 Linux 下通常默认设置为 4KB,所以页在进行交换时,不会出现段交换那般卡顿。所以,页式存储管理方式会更加的受到欢迎,Linux 操作系统采用的就是页式存储管理方式。

在这里插入图片描述

更进一步的,页式存储管理方式使得加载程序的时候,不再需要一次性都把程序加载到内存中,而是在程序运行中需要用到的对应虚拟内存页里面的指令和数据时,再将其加载到内存中,这些操作由操作系统来完成。当 CPU 要读取特定的页,但却发现页的内容却没有加载时,就会触发一个来自 CPU 的缺页错误(Page Fault)。此时操作系统会捕获这个错误,然后找到对应的页并加载到内存中。通过这种方式,使得我们可以运行哪些远大于实例物理内存的程序,但相对的执行效率也会有所下降。

在这里插入图片描述
通过虚拟存储器、内存交换、内存分页三个技术的结合。我们最终得到了一个不需要让程序员考虑实际的物理内存地址、大小和当前分配空间的程序运行环境。这些技术和方式对于程序员和程序的编译、链接过程而言都是透明的,印证了那句著名的话:所有计算机问题都可以通过插入一个中间层来解决。

页式虚拟存储器

虚拟缓存器(Virtual Memory,VM)的实现思想就是将主存作为辅存的缓存,使得计算机系统拥有了 “主存+辅存(交换空间)” 大小的存储空间,同时也拥有了接近于主存的访问速度。从概念上而言,虚拟存储器被组织为一个由存放在磁盘上 N 个连续的字节大小的单元组成的数组。每个字节都有一个唯一的虚拟地址,这个唯一的虚拟地址作为定向到数组的索引。磁盘上的数组的内容被缓存在主存中。和存储器层次结构中其他缓存一样,磁盘上的数据被分割成块,这些块作为磁盘(较低层)和主存(较高层)之间的传输单元。

虚页与实页

在页式虚拟存储器中,通过将虚拟存储空间分割成为了大小固定的虚拟页(Vitual Page,VP),简称虚页,每个虚拟页的大小为 P=2^n 字节。类似地,物理存储空间被分割为物理页(Physical Page,PP)也称为页帧(Page Frame),简称实页,大小也为 P 字节。在任意时刻,虚拟页面的集合都分为三个不相交的子集:

  • 未分配的(unallocated):虚拟存储系统还未分配(或创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。
  • 缓存的(cached):当前缓存在物理主存中的已分配页。
  • 未缓存的(uncached):没有缓存在物理主存中的已分配页。

如下图,虚拟页 0 和 3 还没有被分配,因此在磁盘上还不存在。虚拟页 1、4 和 6 被缓存在物理主存中。页 2、5 和 7 已经被分配了,但是当前并未缓存在物理主存中,而是只存在于磁盘中。

在这里插入图片描述

基于页表的虚实地址转换原理

同任何缓存设计一样,虚拟存储器系统必须有某种方法来判定一个虚拟页是否存放在物理主存的某个地方。如果存在,系统还必须确定这个虚拟页存放在哪个物理页中。如果物理主存不命中,系统必须判断这个虚拟页存放在磁盘的哪个位置中,并在物理主存中选择一个牺牲页,然后将目标虚拟页从磁盘拷贝到物理主存中,替换掉牺牲页。这些功能是由许多软硬件联合提供,包括操作系统软件,MMU(存储器管理单元)地址翻译硬件和一个存放在物理主存中的叫做页表(Page Table)的数据结构,页表将虚拟页映射到物理页。页表的本质就是一个页表条目(Page Table Entry,PTE)数组。

CPU 通过虚拟地址(Virtual Address,VA)来访问存储空间,这个虚拟地址在被送到存储器之前需要先转换成适当的物理地址。将一个虚拟地址转换为物理地址的任务叫做地址翻译(Address Translation)。就像异常处理一样,地址翻译需要 CPU 硬件和操作系统之间的紧密合作。比如:Linux 操作系统的交换空间(Swap Space)。如果当 CPU 寻址时发现虚拟地址找不到对应的物理地址,那么就会触发一个异常并挂起寻址错误的进程。在这个过程中,对其他进程没有任何影响。

虚拟地址与物理地址之间的转换主要有 CPU 芯片上内嵌的存储器管理单元(Memory Management Unit,MMU)完成,它是一个专用的硬件,利用存放在主存中的查询表(地址映射表)来动态翻译虚拟地址,该表的内容由操作系统管理。

在这里插入图片描述
由于页的大小为 2 的整数次幂,所以页的起点都落在低位字段为 0 的地址上,可以把虚拟地址分为两个字段,高位字段位虚拟页号,低位字段为虚页内地址。在页表中,对应每一个虚页号都有一个条目,格式为 (虚页号,实页号,控制字)。
在这里插入图片描述
实页号即为实页地址,被作为物理地址的高字段,而物理地址的低字段则同为虚拟地址的低字段(虚页内地址)。拼接成为了主存物理地址之后,就可以据此访问主存储器数据了。
在这里插入图片描述
通常的,页面中还包括有装入位、修改位、替换控制位以及其他保护位组成的控制字。e.g.

  • 装入位为 1:表示该条目对应的虚页以及辅存调入主存;
  • 装入位为 0:表示对应的虚页尚未装入主存,如果此时 CPU 访问该页就会触发页面失效中断,启动 I/O 子系统,根据外页表项目中查找到的辅存地址,进行辅存到主存的页面交换;
  • 修改位:表示主存页面的内容是否被修改过,从主存交换到辅存时是否要写回辅存。
  • 替换位:表示需要替换的页。

应用 TLB 快表提升虚实地址转换速度

当页表已经存放在主存中,那么当 CPU 访问(虚拟)存储器时,首先要查询页面得到物理主存地址之后再访问主存完成存取。显然,地址转换机制让 CPU 多了一次访问主存的操作,相当于访问速度下降一半。而且当发生页面失效时,还要进行主存-辅助的页面交换,那么 CPU 访问主存的次数就更多了。为了解决这个问题,在一些影响访问速度的关键地方引入了硬件的支持。例如:采用按内容查找的相联存储器并行查找。此外,还进一步提出了 “快表” 的概念。把页表中最活跃的部分存放在快速存储器中组成快表,是减少 CPU 访问时间开销的一种方法。

快表由硬件(门电路和触发器)组成,属于 MMU 的部件之一,通常称为转换旁路缓冲器(Translation lookaside buffer,TLB)。TLB 的本质也是一个 Cache,它比页表小得多,一般在 16 个条目 ~ 128 个条目之间,快表只是页表的一个小小的副本。查表时,带着虚页好同时差快表和慢表(原页面),当在快表中找打条目时,则马上返回主存物理地址到主存地址寄存器,并使慢表查询作废。此时,虽然使用了虚拟存储器但实际上 CPU 访问主存的速度几乎没有下降(CPU 不再需要多次访问主存)。如果快表不命中,则需要花费一个访主存时间查慢表,然后再返回主存物理地址到主存地址寄存器,并将此条目送入到快表中,替换到快表的某一行条目。

在这里插入图片描述

页式虚拟存储器工作的全过程

内页表:虚拟地址与主存地址的映射。
外页表:虚拟地址与辅存地址的映射。
虚地址格式:(虚页号,虚页内地址)
主存地址格式:(实页号,实页内地址)
辅存地址格式:(磁盘机号,磁头号,柱面号,块号,块内地址)

从三种地址格式可见,虚地址-主存地址的转换是虚实页号替换,有内页表完成;虚地址-辅存地址的转换是虚页号与 “磁盘机号,磁头号,柱面号,块号” 的替换,由外页表完成。

在这里插入图片描述
1、2:虚拟存储器每次访问主存时都需要将多用户虚地址转换层主存实地址,这个由虚页号转换为实页号的内部地址转换由内页表来完成;
3:当对应内页表条目的有效位为 1 时,就按照物理主存地址 np 进行主存储器访问。
4:如果对应内存表条目的装入位为 0 时,表示该虚页对应的实页不再主存中,那么就触发一个页面失效中断。有中断处理器到辅存中调用对应的实页。
5:到辅存中调页,首先要进行外部地址转换,查找外页表,将多用户虚拟地址转换为辅存实页地址 Nvd。
6:根据辅存实页地址 Nvd 到辅存中选页。
7:将选中的辅存实页经过 I/O 处理机送出到物理主存中。
9:此时还要确定调入的辅存实页应该放置到主存的什么位置上,这通过查找实存页面表来完成。
10:当主存对应的目标地址仍然空闲时,就会找到空页面。
11、12:但当主存已经装满时,就是执行页面替换操作,由替换算法来决定替换哪一个主存实页到辅存中。
13:把待替换主存实页放入 I/O 处理机,待替换主存页是否被修改了是可以通过页表替换位知道的,此时如果待替换的主存实页没有被修改过,那么是不需要回写到辅存的。
14:但如果待替换的主存实页被修改了,那么就需要写回辅存。
7:继续将目标实页写入到物理主存中,完成替换。新页调入主存时,需要修改相应的页表条目。
8:如果待替换页没能装入缓存,那么还要继续进入中断,进行出错处理或其他处理。

大页内存

在页式虚拟存储器中,会在虚拟存储空间和物理主存空间都分割为一个个固定大小的页,为线程分配内存是也是以页为单位。比如:页的大小为 4K,那么 4GB 存储空间就需要 4GB/4KB=1M 条记录,即有 100 多万个 4KB 的页。我们可以相待,如果页太小了,那么就会产生大量的页表条目,降低了查询速度的同时还浪费了存放页面的主存空间;但如果页太大了,又会容易造成浪费,原因就跟段式存储管理方式一般。所以 Linux 操作系统默认的页大小就是 4KB,可以通过指令查看:

$ getconf PAGE_SIZE
4096

但在某些对性能要求非常苛刻的场景中,页面会被设置得非常的大,比如:1GB、甚至几十 GB,这些页被称之为 “大页”(Huge Page)。大页能够提升性能的主要原因有以下几点:

  • 减少页表条目,加快检索速度。
  • 提升 TLB 快表的命中率,TLB 一般拥有 16 ~ 128 个条目之间,也就是说当大页为 1GB 的时候,TLB 能够对应 16GB ~ 128GB 之间的存储空间。

值得注意的是,首先使用大页的同时一般会禁止主存-辅存页面交换(Swap),原因跟段式存储管理方式一样,大容量交换会让辅存读写成为 CPU 处理的瓶颈。 虽然现今在数据中心闪存化的环境中,这个问题得到了缓解,但代价就是昂贵的 SSD 存储设备。再一个就是大页也会使得页内地址检索的速度变慢,所以并非是页面的容量越大越好,而是需要对应用程序进行大量的测试取得页面容量与性能的曲线峰值才对。

启用 HugePage 的优点

  • 无需交换,不存在页面由于内存空间不足而换入换出的问题。
  • 减轻 TLB Cache 的压力,也就是降低了 CPU Cache 可缓存的地址映射压力。
  • 降低 Page Table 的负载。
  • 消除 Page Table 地查找负载。
  • 提高内存的整体性能。

启用 HugePage 的缺点

  • HugePages 会在系统启动时,直接分配并保留对应大小的内存区域
  • HugePages 在开机之后,如果没有管理员的介入,是不会释放和改变的。

Linux 的大页内存

在 Linux 中,物理内存是以页为单位来管理的。默认的,页的大小为 4KB。 1MB 的内存能划分为 256 页; 1GB 则等同于 256000 页。 CPU 中有一个内置的内存管理单元(MMU),用于存储这些页的列表(页表),每页都有一个对应的入口地址。4KB 大小的页面在 “分页机制” 提出的时候是合理的,因为当时的内存大小不过几十兆字节。然而,当前计算机的物理内存容量已经增长到 GB 甚至 TB 级别了,操作系统仍然以 4KB 大小为页面的基本单位的话,会导致 CPU 中 MMU 的页面空间不足以存放所有的地址条目,则会造成内存的浪费。

同时,在 Linux 操作系统上运行内存需求量较大的应用程序时,采用的默认的 4KB 页面,将会产生较多 TLB Miss 和缺页中断,从而大大影响应用程序的性能。当操作系统以 2MB 甚至更大作为分页的单位时,将会大大减少 TLB Miss 和缺页中断的数量,显著提高应用程序的性能。

为了解决上述问题,自 Linux Kernel 2.6 起,引入了 Huge pages(巨型页)的概念,目的是通过使用大页内存来取代传统的 4KB 内存页面, 以适应越来越大的内存空间。Huge pages 有 2MB 和 1GB 两种规格,2MB 大小(默认)适合用于 GB 级别的内存,而 1GB 大小适合用于 TB 级别的内存。

大页的实现原理

为了能以最小的代价实现大页面支持,Linux 采用了 hugetlb 和 hugetlbfs 两个概念。其中,hugetlb 是记录在 TLB 中的条目并指向 hugepages,而 hugetlbfs 则是一个特殊文件系统(本质是内存文件系统)。hugetlbfs 主要的作用是使得应用程序可以根据需要灵活地选择虚拟存储器页面的大小,而不会全局性的强制使用某个大小的页面。在 TLB 中通过 hugetlb 来指向 hugepages,可以通过 hugetlb entries 来调用 hugepages,这些被分配的 hugepages 再以 hugetlbfs 内存文件系统的形式提供给进程使用

  • Regular Page 的分配:当一个进程请求内存时,它需要访问 PageTable 去调用一个实际的物理内存地址,继而获得内存空间。
    在这里插入图片描述
  • Huge Page 的分配:当系统配置 Huge pages 后,进程依然通过普通的 PageTable 来获取到实际的物理内存地址。但不同的是,在 Process PageTable 和 System PageTable 第增加了 Hugepage(HPage)属性。
    在这里插入图片描述
    可见,进程当需要使用 Huge pages 时,只需要声明 Hugepage 属性,让系统分配 PageTable 中的 Huge pages 条目即可实现。所以,实际上 Regular page 和 Huge page 是共享一个 PageTable 的,这就是所谓的以最小的代码来支持 Huge pages。

使用 Huge pages 的好处是很明显的,假设应用程序需要 2MB 的内存,如果操作系统以 4KB 作为分页的单位,则需要 512 个页面,进而在 TLB 中需要 512 个表项,同时也需要 512 个页表项,操作系统需要经历至少 512 次 TLB Miss 和 512 次缺页中断才能将 2MB 应用程序空间全部映射到物理内存;然而,当操作系统采用 2MB 作为分页的基本单位时,只需要一次 TLB Miss 和一次缺页中断,就可以为 2MB 的应用程序空间建立虚实映射,并在运行过程中无需再经历 TLB Miss 和缺页中断(假设未发生 TLB 项替换和 Swap)。

此外,使用 Huge pages 还能减少系统管理和处理器访问内存页的时间(扩大了 TLB 快页表查询的内存地址范围),Linux 内核中的 Swap(内存交换)守护进程也不会管理大页面占用的这部分空间。合理设置大页面能减少内存操作的负担,减少访问页表造成性能瓶颈的可能性,从而提升系统性能。由此,如果你的系统经常碰到因为 Swap 而引发的性能问题,或者你的计算机内存空间非常大时,都可以考虑启用大页内存。

大页内存配置

大页面配置需要连续的内存空间,因此在开机时就分配是最可靠的设置方式。配置大页面的参数有:

  • hugepages :在内核中定义了开机启动时就分配的永久大页面的数量。默认为 0,即不分配。只有当系统有足够的连续可用页时,分配才会成功。由该参数保留的页不能用于其他用途。

  • hugepagesz: 在内核中定义了开机启动时分配的大页面的大小。可选值为 2MB 和 1GB 。默认是 2MB 。

  • default_hugepagesz:在内核中定义了开机启动时分配的大页面的默认大小。

  • Step 1. 查看 Linux 操作系统是否启动了大页内存,如果 HugePages_Total 为 0,意味着 Linux 没有设置或没有启用 Huge pages。

$ grep -i HugePages_Total /proc/meminfo
HugePages_Total:       0
  • Step 2. 查看是否挂载了 hugetlbfs
$ mount | grep hugetlbfs
hugetlbfs on /dev/hugepages type hugetlbfs (rw,relatime)
  • Step 3. 如果没有挂载则手动挂载
$ mkdir /mnt/huge_1GB
$ mount -t hugetlbfs nodev /mnt/huge_1GB

$ vim /etc/fstab
nodev /mnt/huge_1GB hugetlbfs pagesize=1GB 0 0
  • Step 4. 修改 grub2,例如为系统配置 10 个 1GB 的大页面
$ vim /etc/grub2.cfg
# 定位到 linux16 /vmlinuz-3.10.0-327.el7.x86_64 在行末追加
default_hugepagesz=1G hugepagesz=1G hugepages=10

NOTE:配置大页面后,系统在开机启动时会首选尝试在内存中找到并预留连续的大小为 hugepages * hugepagesz 的内存空间。如果内存空间不满足,则启动会报错 Kernel Panic, Out of Memory 等错误。

  • Step 5. 重启系统,查看更详细的大页内存信息
$ cat /proc/meminfo | grep -i Huge
AnonHugePages:   1433600 kB     # 匿名 HugePages 数量
HugePages_Total:       0        # 分配的大页面数量
HugePages_Free:        0        # 没有被使用过的大页面数量
HugePages_Rsvd:        0        # 已经被分配预留但是还没有使用的大页面数目,应该尽量保持 HugePages_Free - HugePages_Rsvd = 0
HugePages_Surp:        0        # surplus 的缩写,表示大页内存池中大于 /proc/sys/vm/nr_hugepages 中值的大页面数量
Hugepagesize:       1048576 kB     # 每个大页面的 Size,与 HugePages_Total 的相乘得到大页面池的总容量

如果大页面的 Size 一致,则可以通过 /proc/meminfo 中的 HugepagesizeHugePages_Total 计算出大页面所占内存空间的大小。这部分空间会被算到已用的内存空间里,即使还未真正被使用。因此,用户可能观察到下面现象:使用 free 命令查看已用内存很大,但 top 或者 ps 中看到 %mem 的使用总量加起来却很少。

  • Step 6. 如果上述输出看见 Hugepagesize 已经设置成 1GB,但 HugePages_Total 还是为 0,那么需要修改内核参数设定大页面的数量
$ sysctl -w vm.nr_hugepages=10

# 或者
$ echo 'vm.nr_hugepages = 10' > /etc/sysctl.conf 
$ sysctl -p

NOTE:一般情况下,配置的大页面可能主要供特定的应用程序或服务使用,其他进程是无法共享这部分空间的(如 Oracle SGA)。 请根据系统物理内存和应用需求来设置合适的大小,避免大页面使用的浪费;以及造成其他进程因竞争剩余可用内存而出现内存溢出的错误,进而导致系统崩溃的现象。默认的,当存在大页面时,会在应用进程或者内核进程申请大页内存的时候,优先为它们分配大页面,大页面不足以分配时,才会分配传统的 4KB 页面。查看哪个程序在使用大页内存:

grep -e AnonHugePages /proc/*/smaps | awk '{if(2>4)print0}' | awk -F "/" '{print0;system("ps−fp"3)}'

透明巨型页 THP

Transparent Huge pages(THP,透明大页) 自 RHEL 6 开始引入。由于传统的 Huge pages 很难手动的管理,对于程序而言,可能需要修改很多的代码才能有效的使用。THP 的引入就是为了便于系统管理员和开发人员使用大页内存。THP 是一个抽象层,能够自动创建、管理和使用传统大页。操作系统将大页内存看作是一种系统资源,在 THP 开启的情况下,其他的进程也可以申请和释放大页内存。

Huge pages 和 Transparent Huge pages 在大页内存的使用方式上存在区别,前者是预分配的方式,而后者则是动态分配的方式,显然后者更适合程序使用。需要注意的是,THP 虽然方便,但在某些场景种仍然会建议我们关闭,这个需要结合实际应用场景慎重考虑。

手动关闭 THP

$ echo never > /sys/kernel/mm/transparent_hugepage/enabled 
$ echo never > /sys/kernel/mm/transparent_hugepage/defrag

$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

# - [always] 表示启用了 THP
# - [never] 表示禁用了 THP
# - [madvise] 表示只在 MADV_HUGEPAGE 标志的 VMA 中使用 THP

永久关闭 THP

vim /etc/grub2.cfg

# 在 cmdline 追加:
transparent_hugepage=never

大页面对内存的影响

需要注意的是,为大页面分配的内存空间会被计算到已用内存空间中,即使它们还未真正被使用。因此,你可能观察到下面现象:使用 free 命令查看已用内存很大,但 top 或者 ps 指令中看到 %MEM 的使用总量加起来却很少。

例如:总内存为 32G,并且分配了 12G 大页面的 free 如下

[root@localhost ~]# free -g
              total        used        free      shared  buff/cache   available
Mem:             31          16          14           0           0          14
Swap:             3           0           3

命令 top 输出, Shift+m 按内存使用量进行排序:
在这里插入图片描述
命令 ps -eo uid,pid,rss,trs,pmem,stat,cmd,查看进程的内存使用量:
在这里插入图片描述
这种情况就导致了一个问题,如果盲目的去提高大页内存空间的占比,就很可能会出现胖的胖死,饿的饿死的问题。导致大页内存空间的浪费,因为普通程序是未必能够使用大页内存的

大页内存的性能问题

在页式虚拟存储器中,会在虚拟存储空间和物理主存空间都分割为一个个固定大小的页,为线程分配内存是也是以页为单位。比如:页的大小为 4K,那么 4GB 存储空间就需要 4GB/4KB=1M 条记录,即有 100 多万个 4KB 的页。我们可以相待,如果页太小了,那么就会产生大量的页表条目,降低了查询速度的同时还浪费了存放页面的主存空间;但如果页太大了,又会容易造成浪费,原因就跟段式存储管理方式一般。所以 Linux 操作系统默认的页大小就是 4KB,可以通过指令查看:

$ getconf PAGE_SIZE
4096

但在某些对性能要求非常苛刻的场景中,页面会被设置得非常的大,比如:1GB、甚至几十 GB,这些页被称之为 “大页”(Huge Page)。大页能够提升性能的主要原因有以下几点:

  • 减少页表条目,加快检索速度。
  • 提升 TLB 快表的命中率,TLB 一般拥有 16 ~ 128 个条目之间,也就是说当大页为 1GB 的时候,TLB 能够对应 16GB ~ 128GB 之间的存储空间。

值得注意的是,首先使用大页的同时一般会禁止主存-辅存页面交换,原因跟段式存储管理方式一样,大容量交换会让辅存读写成为 CPU 处理的瓶颈。再一个就是大页也会使得页内地址检索的速度变慢,所以并非是页面的容量越大越好,而是需要对应用程序进行大量的测试取得页面容量与性能的曲线峰值才对。

虚拟内存与进程

虚拟内存地址和用户进程紧密相关,一般来说不同进程里的同一个虚拟地址指向的物理地址是不一样的,所以离开进程谈虚拟内存没有任何意义。每个进程所能使用的虚拟地址大小和 CPU 位数有关。在 32 位的系统上,虚拟地址空间大小是 2^32=4G,在 64 位系统上,虚拟地址空间大小是 2^64=16G,而实际的物理内存可能远远小于虚拟内存的大小。

每个用户进程维护了一个单独的页表(Page Table),虚拟内存和物理内存就是通过这个页表实现地址空间的映射的。

在这里插入图片描述

当进程执行一个程序时,需要先从内存中读取该进程的指令,然后执行,获取指令时用到的就是虚拟地址。这个虚拟地址是程序链接时确定的(内核加载并初始化进程时会调整动态库的地址范围)。

为了获取到实际的数据,CPU 需要将虚拟地址转换成物理地址,CPU 转换地址时需要用到进程的页表(Page Table),而页表(Page Table)里面的数据由操作系统维护。其中页表(Page Table)可以简单的理解为单个内存映射(Memory Mapping)的链表(当然实际结构很复杂)。

里面的每个内存映射(Memory Mapping)都将一块虚拟地址映射到一个特定的地址空间(物理内存或者磁盘存储空间)。每个进程拥有自己的页表(Page Table),和其他进程的页表(Page Table)没有关系。

Linux 的虚拟内存

Linux 的虚拟存储器涉及三个概念: 虚拟存储空间,磁盘空间,内存空间。
在这里插入图片描述
可以认为虚拟空间都被映射到了磁盘空间中,(事实上也是按需要映射到磁盘空间上,通过 mmap),并且由页表记录映射位置,当访问到某个地址的时候,通过页表中的有效位,可以得知此数据是否在内存中,如果不是,则通过缺页异常,将磁盘对应的数据拷贝到内存中,如果没有空闲内存,则选择牺牲页面,替换其他页面。

mmap 是用来建立从虚拟空间到磁盘空间的映射的,可以将一个虚拟空间地址映射到一个磁盘文件上,当不设置这个地址时,则由系统自动设置,函数返回对应的内存地址(虚拟地址),当访问这个地址的时候,就需要把磁盘上的内容拷贝到内存了,然后就可以读或者写,最后通过 man_map 可以将内存上的数据换回到磁盘,也就是解除虚拟空间和内存空间的映射,这也是一种读写磁盘文件的方法,也是一种进程共享数据的方法,即共享内存。

  1. 每个进程都有自己独立的 4G 内存空间,各个进程的内存空间具有类似的结构。
  2. 一个新进程建立的时候,将会建立起自己的内存空间,此进程的数据,代码等从磁盘拷贝到自己的进程空间,哪些数据在哪里,都由进程控制表中的 task_struct 记录,task_struct 中记录中一条链表,记录中内存空间的分配情况,哪些地址有数据,哪些地址无数据,哪些可读,哪些可写,都可以通过这个链表记录。
  3. 每个进程已经分配的内存空间,都与对应的磁盘空间映射。

在这里插入图片描述

  1. 每个进程的 4G 内存空间只是虚拟内存空间,每次访问内存空间的某个地址,都需要把地址翻译为实际物理内存地址。
  2. 所有进程共享同一物理内存,每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上。
  3. 进程要知道哪些内存地址上的数据在物理内存上,哪些不在,还有在物理内存上的哪里,需要用页表来记录。
  4. 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)。
  5. 当进程访问某个虚拟地址,去看页表,如果发现对应的数据不在物理内存中,则缺页异常。
  6. 缺页异常的处理过程,就是把进程需要的数据从磁盘上拷贝到物理内存中,如果内存已经满了,没有空地方了,那就找一个页覆盖,当然如果被覆盖的页曾经被修改过,需要将此页写回磁盘。

在这里插入图片描述

用户进程申请并访问物理内存(或磁盘存储空间)的过程总结如下:

  1. 用户进程向操作系统发出内存申请请求。
  2. 系统会检查进程的虚拟地址空间是否被用完,如果有剩余,给进程分配虚拟地址。
  3. 系统为这块虚拟地址创建内存映射(Memory Mapping),并将它放进该进程的页表(Page Table)。
  4. 系统返回虚拟地址给用户进程,用户进程开始访问该虚拟地址。
  5. CPU 根据虚拟地址在此进程的页表(Page Table)中找到了相应的内存映射(Memory Mapping),但是这个内存映射(Memory Mapping)没有和物理内存关联,于是产生缺页中断。
  6. 操作系统收到缺页中断后,分配真正的物理内存并将它关联到页表相应的内存映射(Memory Mapping)。中断处理完成后,CPU 就可以访问内存了
  7. 当然缺页中断不是每次都会发生,只有系统觉得有必要延迟分配内存的时候才用的着,也即很多时候在上面的第 3 步系统会分配真正的物理内存并和内存映射(Memory Mapping)进行关联。

另外,值得注意的是,在每个进程创建加载时,内核只是为进程 “创建” 了虚拟内存的布局,具体就是初始化进程控制表中内存相关的链表,实际上并不立即就把虚拟内存对应位置的程序数据和代码(e.g. .text、.data 段)拷贝到物理内存中,只是建立好虚拟内存和磁盘文件之间的映射就好(叫做存储器映射),等到运行到对应的程序时,才会通过缺页异常,来拷贝数据。还有进程运行过程中,要动态分配内存,比如:malloc 时,也只是分配了虚拟内存,即为这块虚拟内存对应的页表项做相应设置,当进程真正访问到此数据时,才引发缺页异常。

在用户进程和物理内存(磁盘存储器)之间引入虚拟内存主要有以下的优点:

  • 地址空间:提供更大的地址空间,并且地址空间是连续的,使得程序编写、链接更加简单。
  • 进程隔离:不同进程的虚拟地址之间没有关系,所以一个进程的操作不会对其他进程造成影响。
  • 数据保护:每块虚拟内存都有相应的读写属性,这样就能保护程序的代码段不被修改,数据块不能被执行等,增加了系统的安全性。
  • 内存映射:有了虚拟内存之后,可以直接映射磁盘上的文件(可执行文件或动态库)到虚拟地址空间。这样可以做到物理内存延时分配,只有在需要读相应的文件的时候,才将它真正的从磁盘上加载到内存中来,而在内存吃紧的时候又可以将这部分内存清空掉,提高物理内存利用效率,并且所有这些对应用程序都是透明的。
  • 共享内存:比如动态库只需要在内存中存储一份,然后将它映射到不同进程的虚拟地址空间中,让进程觉得自己独占了这个文件。进程间的内存共享也可以通过映射同一块物理内存到进程的不同虚拟地址空间来实现共享。
  • 物理内存管理:物理地址空间全部由操作系统管理,进程无法直接分配和回收,从而系统可以更好的利用内存,平衡进程间对内存的需求。
  • 既然每个进程的内存空间都是一致而且固定的,所以链接器在链接可执行文件时,可以设定内存地址,而不用去管这些数据最终实际的内存地址,这是有独立内存空间的好处。
  • 当不同的进程使用同样的代码时,比如库文件中的代码,物理内存中可以只存储一份这样的代码,不同的进程只需要把自己的虚拟内存映射过去就可以了,节省内存。
  • 在程序需要分配连续的内存空间的时候,只需要在虚拟内存空间分配连续空间,而不需要实际物理内存的连续空间,可以利用碎片。

Linux 的页式虚拟存储器系统

Linux 操作系统采用了页式虚拟存储器。Linux 上所有的进程都工作在一个 4G 的地址空间上,同时 Linux 会为每个进程维护一个单独的虚拟地址空间。其中 0 ~ 3G 是应用进程可以访问的 User 地址空间,是某个进程独有的,进程之间互相隔离;剩下 3G ~ 4G 是 Kernel 地址空间,所有进程都会共享这部分地址空间。所以,我们习惯的将 Linux 虚拟存储器系统分为 “内核虚拟存储器” 和 “进程虚拟存储器”。

其中内核虚拟存储器包含了内核的代码和数据结构,内核虚拟存储器的某些区域被映射到所有进程共享的物理主存页面。例如,每个进程共享内核的代码和全局数据结构。内核虚拟存储器的其他区域包含每个进程都不相同的数据。例如,页表、内核在进程的上下文中执行代码时使用的栈(内核栈),以及记录虚拟地址空间当前组织的各种数据结构。
在这里插入图片描述

由于每个进程都有 3G 的私有进程空间,所以操作系统的物理内存无法对这些地址空间进行一一映射,因此 Kernel 需要一种机制,把进程地址空间映射到物理内存上。当一个进程请求访问内存时,操作系统通过存储在 Kernel 中的进程页表把这个虚拟地址映射到物理地址,如果还没有为这个地址建立页表项,那么操作系统就为这个访问的地址建立页表项。最基本的映射单位是 Page,对应的是页表项 PTE。由于页表项和物理地址是多对一的关系,即多个页表项可以对应一个物理页面,因而支持共享内存的实现(几个进程同时共享物理内存)

在 Linux 操作系统使用文件或管道来进行进程之间的通信会有很多局限性,比如效率问题,以及数据处理使用文件描述符不如内存地址访问来得方便,于是多个进程间以共享内存的方式进行通信就成了一个不错的选择。Linux 在编程上为我们准备了多种手段的共享内存方案。包括:

  • mmap 内存共享映射。
  • XSI 共享内存。
  • POSIX 共享内存。

Linux 下的 KSM 内存页共享的性能问题

KSM(Kernel Shared Memory)是 Linux Kernel 的一种内存共享机制,在 2.6.36 版本引入。简而言之,KSM 用于合并具有相同内容的物理主存页面以减少页面冗余。在 Kernel 中有一个 KSM 守护进程 ksmd,它会定期扫描用户向它注册的内存区域,寻找到相同的页面就会将其合并,并用一个添加了写保护的页面来代替。当有进程尝试写入该页面时,Kernel 会自动为其分配一个新的页面,然后将新数据写入到这个新页面,这就是典型的 COW 机制。类似的,存储技术中有一个称为去耦合(de-duplication)的技术,通过删除冗余数据(基于数据块,或者基于更大的数据片段,比如文件)来减少已存储的数据。公共数据片段被合并(以一种 COW 方式),释放空间供其他用途。使用这种方法,存储成本更低,最终需要的存储器也更少。

KSM 最初被应用到 KVM 上,因为事实证明,如果虚拟化了许多相同的操作系统和应用程序组,那么宿主机上许多内存页面都是相同的。假如操作系统和应用程序代码以及常量数据在 VMs 之间相同,那么这个特点就很有用。当页面惟一时,它们可以被合并,从而释放内存,供其他应用程序使用。将多个 VMs 具有的相同内存页合并(共享),可以腾出更多的可用物理内存。

在这里插入图片描述
但是事实上,KSM 可以应用于任何应用。KSM 仅仅会合并匿名页面,不会对文件映射的页面做处理,经过 KSM 合并的页面最初是被锁定的内存中的,但是现在已经可以像其他页面一样被换出到交换区了。但是共享页一经换出,其共享的特性就被打破,再次换入的时候,ksmd 必须重新对其处理。前面提到,KSM 仅仅会扫描那些向其注册的区域,就是向 KSM 模块注册了如果条件允许可以被合并的区域,通过 madvise 系统调用可以做到这点 int madvise(addr, length, MADV_MERGEABLE)。同时,应用也可以通过调用 int madvise(addr, length, MADV_UNMERGEABLE) 来取消这个注册,从而让页面恢复私有特性。但是该调用可能会造成内存超额,造成 unmerge 失败,很大程度上会造成唤醒 Out-Of-Memory killer,杀死当前进程。如果 KSM 没有在当前运行的 Kernel 启用,那么前面提到的 madvise 调用就会失败,如果内核配置了 CONFIG_KSM=y,调用一般是会成功的。

KSM 的管理和监控通过 sysfs(位于根 /sys/kernel/mm/ksm)执行。

  • pages_to_scan:定义一次给定扫描中可以扫描的页面数。
  • sleep_millisecs:定义执行另一次页面扫描前 ksmd 休眠的毫秒数。
  • max_kernel_pages:定义 ksmd 可以使用的最大页面数(默认值是可用内存的 25%,但可以写入一个 0 来指定为无限)。
  • merge_across_nodes:控制不同 NUMA 节点内存的合并,如果被设置成 0,则只合并当前 NUMA 节点的内存。
  • run:控制 ksmd 的运行
    • 0 表示停止 ksmd,但是保持合并的页面;
    • 1 表示运行 ksmd;
    • 2 表示停止 ksmd 并请求取消合并所有合并页面。

KSM 合并效果会实时显示在下面文件:

  • pages_shared:KSM 正在使用的不可交换的内核页面的数量。
  • pages_sharing:一个内存存储指示。
  • pages_unshared :为合并而重复检查的惟一页面的数量。
  • pages_volatile:频繁改变的页面的数量。
  • full_scans:表明已经执行的全区域扫描的次数。

KSM 作者定义:较高的 pages_sharing/pages_shared 比率表明高效的页面共享(反之则表明资源浪费)。

需要注意的是,应用 KSM 的时候要慎重考虑,因为 KSM 扫描相同的页面的过程会消耗较多的 CPU 资源,在对虚拟机性能要求苛刻的环境中一般都会禁用 KSM。关闭 KSM,可以让作为 Hypervisor 的 Linux Kernel(KVM)在负载增加时候,保证虚拟机的响应速度。这里再次印证了一句名言:计算机艺术永远是时间与空间的较量

通过动态链接来节省内存

从上文我们知道,链接(Link)是程序被装载到内存运行之前需要完成的一个步骤。链接又分为动态链接(Dynamic Link)和静态链接(Static Link)两种方式。在动态链接的过程中,我们希望链接的不是存储在磁盘上的目标文件代码,而是链接到了内存中的共享库(Shard Libraries)。这个加载到内存中的共享库会被很多程序的指令调用。在 Windows 中,这个共享库文件就是 .dll(Dynamic-Link Libary,动态链接库)文件。而在 Linux 下,这些共享文件就是 .so(Shared Object)文件,我们一般也称之为动态链接库文件。在这里插入图片描述
不过,要想在程序中运行时加载共享库代码,就要求这些共享库代码是 “地址无关” 的。也就是说,我们编译出来的共享库文件的指令代码,是地址无关吗。换句话说,共享库无论加载到那个内存地址,都能够正常的运行。否则,就是地址相关代码。幸运的是,大部分函数库代码都是可以做到地址无关的,因为它们都被实现为接收特定的输入,进行确定的操作,然后在返回结果。这些函数的代码逻辑和输入数据存放在内存什么位置并无所谓。

有了动态链接方式之后,我们得以把内存利用得更加的极致,动态链接库是有如共享单车一般的存在。

SWAP 交换内存

通常,Linux 的内存已满并且内核没有可写空间时,系统就会崩溃。但如果系统拥有交换分区,那么 Linux 内核和程序就会使用它,但是速度会慢很多。因此,拥有 SWAP 交换空间更安全。

为了支持多个用户使用内存,有时会出现可用内存被消耗光的情况。由于这个原因,页面可以移出内存并放入磁盘中。这个过程称为交换,因为页面会被从内存交换到硬盘上。内存管理的源代码可以在 ./linux/mm 中找到。

交换分区有一个缺点:它比 RAM 慢很多,因此,添加交换空间不会使你的计算机运行速度更快,它只会帮助克服一些内存不足带来的限制。

在这里插入图片描述
虚拟内存的 SWAP 特性并不总是有益,放任进程不停地将数据在内存与磁盘之间大量交换会极大地占用 CPU,降低系统运行效率,所以有时候我们并不希望使用 SWAP。可以修改 vm.swappiness=0 来设置内存尽量少使用 SWAP,或者干脆使用 swapoff 命令禁用掉 SWAP。

虚拟内存的分配

虚拟内存的分配,包括用户空间虚拟内存和内核空间虚拟内存。

注意,分配的虚拟内存还没有映射到物理内存,只有当访问申请的虚拟内存时,才会发生缺页异常,再通过上面介绍的伙伴系统和 slab 分配器申请物理内存。

用户空间内存分配(malloc)

malloc 用于申请用户空间的虚拟内存,当申请小于 128KB 小内存的时,malloc 使用 sbrk 或 brk 分配内存;当申请大于 128KB 的内存时,使用 mmap 函数申请内存;

存在的问题:由于 brk/sbrk/mmap 属于系统调用,如果每次申请内存都要产生系统调用开销,CPU 在用户态和内核态之间频繁切换,非常影响性能。而且,堆是从低地址往高地址增长,如果低地址的内存没有被释放,高地址的内存就不能被回收,容易产生内存碎片。

解决:因此,malloc 采用的是内存池的实现方式,先申请一大块内存,然后将内存分成不同大小的内存块,然后用户申请内存时,直接从内存池中选择一块相近的内存块分配出去。

在这里插入图片描述

内核空间内存分配

先来回顾一下内核地址空间。

在这里插入图片描述
kmalloc 和 vmalloc 分别用于分配不同映射区的虚拟内存。
在这里插入图片描述

kmalloc

kmalloc() 分配的虚拟地址范围在内核空间的直接内存映射区。

按字节为单位虚拟内存,一般用于分配小块内存,释放内存对应于 kfree ,可以分配连续的物理内存。函数原型在 <linux/kmalloc.h> 中声明,一般情况下在驱动程序中都是调用 kmalloc() 来给数据结构分配内存。

kmalloc 是基于 Slab 分配器的,同样可以用 cat /proc/slabinfo 命令,查看 kmalloc 相关 slab 对象信息,下面的 kmalloc-8、kmalloc-16 等等就是基于 Slab 分配的 kmalloc 高速缓存。

在这里插入图片描述

vmalloc

vmalloc 分配的虚拟地址区间,位于 vmalloc_start 与 vmalloc_end 之间的动态内存映射区。

一般用分配大块内存,释放内存对应于 vfree,分配的虚拟内存地址连续,物理地址上不一定连续。函数原型在 <linux/vmalloc.h> 中声明。一般用在为活动的交换区分配数据结构,为某些 I/O 驱动程序分配缓冲区,或为内核模块分配空间。

CPU 是如何访问内存的?

在这里插入图片描述

在这里插入图片描述

从图中可以清晰地看出,CPU、MMU、DDR 这三部分在硬件上是如何分布的。首先 CPU 在访问内存的时候都需要通过 MMU 把虚拟地址转化为物理地址,然后通过总线访问内存。MMU 开启后 CPU 看到的所有地址都是虚拟地址,CPU 把这个虚拟地址发给 MMU 后,MMU 会通过页表在页表里查出这个虚拟地址对应的物理地址是什么,从而去访问外面的 DDR(内存条)。

所以搞懂了 MMU 如何把虚拟地址转化为物理地址也就明白了 CPU 是如何通过 MMU 来访问内存的。

MMU 是通过页表把虚拟地址转换成物理地址,页表是一种特殊的数据结构,放在系统空间的页表区存放逻辑页与物理页帧的对应关系,每一个进程都有一个自己的页表。

CPU 访问的虚拟地址可以分为:p(页号),用来作为页表的索引;d(页偏移),该页内的地址偏移。现在我们假设每一页的大小是 4KB,而且页表只有一级,那么页表长成下面这个样子(页表的每一行是 32 个 bit,前 20 bit 表示页号 p,后面 12 bit 表示页偏移 d):

在这里插入图片描述
CPU,虚拟地址,页表和物理地址的关系如下图:
在这里插入图片描述

页表包含每页所在物理内存的基地址,这些基地址与页偏移的组合形成物理地址,就可送交物理单元。

上面我们发现,如果采用一级页表的话,每个进程都需要 1 个 4MB 的页表(假如虚拟地址空间为 32 位,即 4GB、每个页面映射 4KB 以及每条页表项占 4B,则进程需要 1M 个页表项,4GB/4KB = 1M),即页表(每个进程都有一个页表)占用 4MB(1M * 4B = 4MB)的内存空间)。

然而对于大多数程序来说,其使用到的空间远未达到 4GB,何必去映射不可能用到的空间呢?也就是说,一级页表覆盖了整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 0.804MB(1K * 4B + 0.2 * 1K * 1K * 4B = 0.804MB)。

除了在需要的时候创建二级页表外,还可以通过将此页面从磁盘调入到内存,只有一级页表在内存中,二级页表仅有一个在内存中,其余全在磁盘中(虽然这样效率非常低),则此时页表占用了8KB(1K * 4B + 1 * 1K * 4B = 8KB),对比上一步的 0.804MB,占用空间又缩小了好多倍!总而言之,采用多级页表可以节省内存。

二级页表就是将页表再分页。仍以之前的 32 位系统为例,一个逻辑地址被分为 20 位的页码和 12 位的页偏移 d。因为要对页表进行再分页,该页号可分为 10 位的页码 p1 和 10 位的页偏移 p2。其中 p1 用来访问外部页表的索引,而 p2 是是外部页表的页偏移。

在这里插入图片描述

在这里插入图片描述

相关阅读:

原文地址:https://www.cnblogs.com/hzcya1995/p/13309295.html