虚拟存储(虚拟存储系统是指什么)

文章目录

  • 摘要
  • 页面表
  • C/C++Linux服务器开发/后台架构师【零音教育】-学习视频教程-腾讯课堂
  • 页面点击
  • 缺少的一页
  • 多级页表
  • 地址翻译的过程
  • TLB
  • Linux中的虚拟内存系统
  • 存储器交换
  • 共享对象
  • 存储器分配
  • 内存碎片
  • 空自由链表
  • 垃圾回收
  • 摘要

虚拟存储(虚拟存储系统是什么意思)

摘要

我们都知道一个进程与其他进程共享CPU和内存资源。为此,操作系统需要一套完善的内存管理机制来防止进程间的内存泄漏。

为了更有效地管理内存,减少错误,现代操作系统提供了一个抽象的主存概念,即虚拟内存。虚拟内存为每个进程提供了一个一致且私有的地址空空间,这给每个进程一种享受自己主存的错觉(每个进程都有一个连续且完整的内存空空间)。

没有深入了解的人会认为虚拟内存只是一种“用硬盘空扩展内存”的技术,这是错误的。虚拟内存的意义在于它定义了一个连续的虚拟地址空,降低了编程难度。而且,将内存扩展到硬盘空只是使用虚拟内存的必然结果。虚拟内存空将存在于硬盘中,并将被内存缓存(根据需要)。有些操作系统在切换到进程时会将某个进程的所有内存放入硬盘空。

虚拟内存主要提供以下三种重要功能:

它将主内存视为硬盘上存储的虚拟地址空之间的缓存,只缓存主内存中的活动区域(按需缓存)。

它为每个进程提供了一致的地址空,从而降低了程序员管理内存的复杂性。

它还保护每个进程的地址空不被其他进程损坏。

在介绍了虚拟内存的基本概念后,接下来的内容将逐渐从虚拟内存如何在硬件中工作过渡到它在Linux中的实现。

中央处理器寻址

内存通常被组织为m个连续字节大小的单元的数组,每个字节都有一个唯一的物理地址PA作为数组的索引。CPU访问内存最简单、最直接的方式就是使用物理地址,这就是所谓的物理寻址。

现代处理器使用一种称为虚拟寻址的寻址方法。利用虚拟寻址,中央处理器需要将虚拟地址转换成物理地址,以便访问真实的物理内存。

虚编址

虚拟寻址需要硬件和操作系统之间的合作。中央处理器包含一个叫做内存管理单元的硬件,其功能是将虚拟地址转换成物理地址。MMU需要通过存储在内存中的页表动态转换虚拟地址,该页表由操作系统管理。

页面表

虚拟内存/

操作系统将虚拟内存划分为固定大小的块,作为硬盘和内存之间的传输单元。这个块称为虚拟页,每个虚拟页的大小为p = 2 p字节。物理内存也是这样划分物理页面(PP),大小也是P字节。

CPU获得虚拟地址后,需要通过MMU将虚拟地址翻译成物理地址。在翻译的过程中,也需要页表。所谓页表,就是存储在物理内存中的数据结构,记录虚拟页和物理页之间的映射关系。

页面是页面表条目(PTE)的集合,每个虚拟页面在页面表中有一个固定偏移量的PTE。下面是只有一个有效位标签的PTE的页表结构,表示这个虚拟页是否缓存在物理内存中。

虚拟页VP 0、VP 4、VP 6和VP 7缓存在物理内存中,而虚拟页VP 2和VP 5分配在页表中,但未缓存在物理内存中,虚拟页VP 1和VP 3尚未分配。

动态内存分配时,如malloc()函数或其他高级语言中的new关键字,操作系统会在硬盘中创建或申请一个虚拟内存空并将其更新到页表中(分配一个PTE指向硬盘上新创建的虚拟页)。

由于CPU每次执行地址转换都需要经过PTE,如果想要控制内存系统的访问,可以增加一些额外的权限位(比如读写权限、内核权限等)。)放在PTE上,这样如果有任何指令违反了这些权限条件,CPU就会触发一般的保护故障,并将控制权传递给内核中的异常处理程序。通常,这种异常被称为“分段故障”。

【文章福利】:边肖整理了一些我觉得比较好的学习书籍和视频,放在群里分享。如果需要,可以自己添加!跳跃(需要振作起来)

在这里,我推荐大家看一下Linux C/C++高级开发架构的[免费]课程:

C/C++Linux服务器开发/后台架构师【零音教育】-学习视频教程-腾讯课堂

如果将课程内容与腾讯C++后台开发的T8 rank技术栈进行对比,将围绕数据结构与算法、数据库、网络、操作系统、网络编程、分布式架构等进行全面提升。值得学习一波~

页面点击

页面点击

如上图所示,MMU根据虚拟地址对页表中的PTE 4进行寻址,PTE的有效位为1,表示虚拟页已经缓存在物理内存中。最后,MMU得到PTE中的物理内存地址(指向PP 1)。

缺少的一页

缺少的一页

如上图所示,MMU根据虚拟地址对页表中的PTE2进行寻址,PTE 2的有效位为0,表示虚拟页没有缓存在物理内存中。未缓存在物理内存中的虚拟页面(缓存未命中)称为缺失页面。

当CPU遇到缺页时,会触发缺页异常。缺页异常将控制权转移到操作系统内核,然后调用内核中的缺页异常处理程序,该程序将选择一个受害页。如果受害页面已经被修改,内核会先将其复制回硬盘(用回写机制代替直接写也是为了尽量减少对硬盘的访问次数),然后将虚拟页面覆盖到受害页面的位置并更新PTE。

当页面错误异常处理程序返回时,它将重新启动导致页面错误的指令,这将向MMU重新发送导致页面错误的虚拟地址。现在,缺页异常已经成功处理,最终结果是页面命中并获得物理地址。

这种在硬盘和内存之间转移页面的行为称为分页:页面从硬盘切换到内存,从内存切换到硬盘。当异常发生时,将页面交换到内存中的策略称为按需分页。所有现代操作系统基本上都使用按需分页的策略。

虚拟内存,像CPU缓存(或其他使用缓存的技术),依赖于局部性原则。虽然处理缺页会消耗大量的性能(毕竟还是需要从硬盘中读取),并且程序在运行过程中引用的不同虚拟页面的总数可能会超过物理内存的大小,但是局部性原则保证了程序在任何时候都会倾向于在一个小的活动页面集上工作,这个页面集称为工作集。根据空之间的局部性原则(被访问的内存地址及其周围的内存地址将有很大的机会被再次访问)和时间局部性原则(被访问的内存地址稍后将有很大的机会被再次访问),只要工作集被缓存在物理内存中,下一个地址转换请求将有很大的机会在其中,从而减少额外的硬盘流量。

如果一个程序没有良好的局部性,它会使工作集的大小不断扩大,直到超过物理内存的大小。这时候程序会产生一种叫做颠簸的状态,页面会不断的进出切换。有这么多倍的硬盘读写开销,性能自然会“惨不忍睹”。因此,要写出高性能的程序,首先要保证程序的时间局部性和空局部性。

多级页表

到目前为止,我们只讨论了一页表,但在实际环境中,虚拟空之间的地址非常大(32位系统的地址空之间有2^32 = 4GB,更不用说64位系统了)。在这种情况下,使用单页表显然是低效的。

常见的方法是使用分层页表。假设我们的环境是一个32位虚拟地址空,其形式如下:

虚拟地址空分为4KB页面,每个PTE为4字节。

前2K页内存分配给代码和数据。

接下来的6K页尚未分配。

接下来的1023页不分配,下一页分配给用户栈。

下图显示了为该虚拟地址空构建的二级页表的层次结构(在实际情况下,有四级或更多级)。一级页表的每个PTE(1024个PTE刚好覆盖4GB的虚拟地址空,每个PTE只有4个字节,所以一级页表和二级页表的大小和一页完全一样,都是4KB)负责每个PTE。辅助页表中的每个PTE负责映射一个4KB的虚拟内存页。

这种结构看起来像一个B树,这种分层结构有效地降低了内存需求:

如果一级页表的PTE是空,那么对应的二级页表将不存在。这代表着巨大的潜在节约(对于一个普通的程序,大部分虚拟地址空将是未分配的)。

只有一级页表始终需要缓存在内存中,这样虚拟内存系统就可以在需要时创建、调入或调出二级页表(只有常用的二级页表才会缓存在内存中),减少了内存压力。

地址翻译的过程

形式上,地址转换是n个元素的虚拟地址空中的元素和m个元素的物理地址空中的元素之间的映射。

下图显示了利用页表进行MMU寻址的过程:

页基寄存器(PTBR)指向当前页表。具有n位的虚拟地址由两部分组成,具有p位的虚拟页面偏移量(VPO)和具有n-p位的虚拟页面号(VPN)。

MMU根据VPN选择对应的PTE,例如,VPN 0代表PTE 0,VPN 1代表PTE 1…因为物理页面和虚拟页面的大小相同,所以物理页面偏移量与VPO相同。然后,只要将PTE中的物理页码(PPN)和虚拟地址中的VPO串联起来,就可以得到对应的物理地址。

多级页表的地址转换也是如此,只是因为有多级,所以需要把VPN分成多个段。假设有一个K级页表,虚拟地址将分为K个VPN和1个VPO,每个VPN i是I级页表的索引。为了构造物理地址,MMU需要访问K个PTEs来获得相应的PPN。

TLB

页面缓存在内存中。虽然内存的速度和硬盘相比已经很快了,但还是落后于CPU。为了防止每次地址转换操作都访问内存,CPU使用缓存和TLB来缓存PTE。

最坏的情况下(不包括页面未命中),MMU需要访问内存才能得到对应的PTE,代价大概是几十到几百个周期。如果PTE恰好缓存在L1缓存中(如果L1缺失,会从L2查找,但我们会忽略多级缓存的细节),那么性能开销会下降到一两个周期。然而,许多系统甚至需要消除如此小的开销,于是TLB应运而生。

TLB(Translation latch aware Buffer),称为翻译后备缓冲区或翻译旁路缓冲区,是MMU中的一个缓冲区,其中每行保存一个由单个PTE组成的块。用于组选择和行匹配的索引和标签字段是从虚拟专用网中提取的。如果TLB中存在T = 2^t组,则TLB索引(TLBI)由VPN的t个最低位组成,而TLB标签(TLBT)由VPN中的剩余位组成。

下图显示了地址转换的流程(在TLB命中的情况下):

第一步,CPU给MMU一个虚拟地址进行地址转换。

在第二步和第三步中,MMU通过TLB获得相应的PTE。

第四步:MMU通过PTE翻译物理地址,并发送到缓存/内存。

第五,缓存将数据返回给CPU(如果缓存命中,否则需要访问内存)。

当TLB未命中时,MMU必须从缓存/内存中获取相应的PTE,并将新获取的PTE存储在TLB(如果TLB已满,现有的PTE将被覆盖)。

Linux中的虚拟内存系统

Linux为每个进程维护一个单独的虚拟地址空空间。虚拟地址空房间分为内核空房间和用户空房间。用户空房间包括代码、数据、堆、共享库和栈。kernel 空 room包括内核中的代码和数据结构,kernel 空 Linux还将一组连续的虚拟页面(大小等于总内存)映射到对应的一组连续的物理页面,为内核访问物理内存中的任何特定位置提供了一种便捷的方式。

Linux虚拟内存被组织成一组区域(也称为段)。区域的概念允许虚拟地址空之间存在间隙。区域是已分配虚拟内存的现有区块。例如,代码段、数据段、堆、共享库段、用户栈都属于不同的区域,每个现有的虚拟页面都存储在某个区域,而不属于任何区域的虚拟页面都不存在,不能被进程引用。

为系统中的每个进程维护一个单独的任务结构(task_struct)。任务结构中的元素包含或指向内核运行进程所需的所有信息(PID、指向用户堆栈的指针、可执行对象文件的名称、程序计数器等)。).

描述虚拟内存的当前状态。Pgd指向一级页表的基址(当内核运行此进程时,pgd将存储在CR3控制寄存器,即页表基址寄存器中),mmap指向VM _ area _ structures的链表,其中每个VM _ area _ structures描述当前虚拟地址空之间的一个区域。

Vm_starts:指向该区域的开头。

Vm_end:指向该区域的末尾。

Vm_prot:描述此区域中包含的所有页面的读写权限。

Vm_flags:描述该区域中的页面是否与其他进程共享,该进程是否是私有的,以及其他一些信息。

Vm_next:指向链表的下一个区域结构。

存储器交换

Linux通过将虚拟内存区域与硬盘上的文件相关联来初始化虚拟内存区域的内容。这个过程叫做内存映射。这种将虚拟内存系统集成到文件系统中的方法可以简单有效地将程序和数据加载到内存中。

一个区域可以映射到普通硬盘文件的连续部分,如可执行目标文件。一个部分被分成页面大小的片段,每个片段包含虚拟页面的初始内容。由于按需页面调度的策略,在CPU引用的虚拟地址在该区域范围内之前,这些虚拟页面实际上不会交换到物理内存中。如果该区域大于文件区域,则用零填充其余区域。

一个区域也可以映射到一个匿名文件,该文件由内核创建,包含所有二进制零。当CPU第一次引用这样一个区域的虚拟页面时,内核会在物理内存中找到合适的受害页面。如果页面已被修改,它将首先被写回硬盘,然后受害页面将被二进制零覆盖,并且页面表将被更新以将该页面标记为缓存在内存中。

简单来说,普通的文件映射就是在文件和内存块之间建立映射关系。对文件的IO操作可以绕过内核,直接在用户模式下完成(用户模式在虚拟地址区读写相当于读写文件)。匿名文件映射一般来说,当用户空需要分配一段内存来存储数据时,内核会创建匿名文件并将其与内存进行映射,然后用户模式可以通过操作这个虚拟地址来操作内存。匿名文件映射最常见的应用场景是动态内存分配(malloc()函数)。

Linux中很多地方都采用了“懒加载”机制,这自然包括内存映射。不管是正常的文件映射还是匿名映射,Linux只会先划分虚拟内存地址。只有当CPU第一次访问这个区域的虚拟地址时,才会真正与物理内存建立映射关系。

只要虚拟页面被初始化,它就会在内核维护的交换文件之间切换。文件交换也称为交换空空间或交换区。交换区不仅用于页面交换,还用于在物理内存不足时交换一些内存数据到交换区(使用硬盘扩展内存)。

共享对象

虚拟内存系统为每个进程提供了一个私有的虚拟地址空空间,可以保证进程之间不会出现错误的读写。然而,许多过程也有相同的部分。例如,每个C程序都使用C标准库。如果每个进程都将这些代码的副本保存在物理内存中,将会造成内存资源的极大浪费。

内存映射提供了一种共享对象的机制,以避免浪费内存资源。一个对象被映射到一个虚拟内存区域,作为共享对象或私有对象。

如果一个进程将一个共享对象映射到它的虚拟地址空之间的一个区域,那么这个进程对这个区域的任何写操作对于同样将这个共享对象映射到它们的虚拟内存的其他进程也是可见的。相比之下,对映射到私有对象的区域的任何写操作对其他进程都是不可见的。映射到共享对象的虚拟内存区域称为共享区域。同样,也有私人区域。

为了节省内存,私有对象的生命周期与共享对象的生命周期基本相同(物理内存中只保留私有对象的一个副本),采用写时复制技术处理多个进程的写冲突。

只要没有进程尝试写入自己的私有区域,多个进程就可以继续在物理内存中共享私有对象的单个副本。但是,每当进程试图写入私有区域中的页面时,它都会触发保护异常。在上图中,进程B试图写入私有区域中的页面,这触发了保护异常。异常处理程序将在物理内存中创建此页面的新副本,更新PTE以指向此新副本,然后恢复此页面的可写权限。

另一个典型的例子是fork()函数,它用于创建子进程。当前进程调用fork()函数时,内核会为新进程创建各种必要的数据结构,并为其分配唯一的PID。为了给新进程创建虚拟内存,它复制了当前进程的mm_struct、vm_area_struct和页表的原始副本。并将两个进程的每一页标记为只读,并将两个进程的每个区域标记为私有区域(写时复制)。

这样,父进程和子进程的虚拟内存空是完全一致的,只有当这两个进程中的任何一个正在写入时,我们才能使用写时复制来保证每个进程的虚拟地址空之间的私有抽象概念。

存储器分配

虽然内存映射(mmap()函数)可以用来创建和删除虚拟内存区域,以满足运行时的动态内存分配问题。然而,为了更好的可移植性和便利性,需要更高层次的抽象,即动态内存分配器。

动态内存分配器维护进程的虚拟内存区域,也称为“堆”。内核还维护一个指向堆顶部的指针brk(break)。动态内存分配器将堆视为连续虚拟内存块的集合,每个块有两种状态,已分配和空空闲。分配的块是为应用程序显式保留的,而空空闲块可以用于分配,并且它的空空闲状态是直到它被应用程序显式分配。分配的块或者由应用程序显式释放,或者由垃圾收集器释放。

本文只解释了动态内存分配的一些概念,动态内存分配的实现超出了本文的范围。如果你对它感兴趣,可以参考dlmalloc的源代码,这是一个由Doug Lea(编写Java并发出契约的人)实现的设计巧妙的内存分配器,源代码中有很多注释。

内存碎片

空堆之间利用率低的主要原因是一种叫做碎片化的现象。当存在未使用的内存,但该内存无法满足分配请求时,将出现碎片。碎片有两种类型:

内部碎片:当分配的块大于有效负载时发生。比如程序请求一个5字块(这里我们不关心字大小,假设一个字是4字节,堆大小是16个字,边界双字对齐是有保证的),内存分配器为了保证空空闲块是双字边界对齐的(具体实现中对齐的规定可能略有不同,但对齐肯定会存在)。在这个例子中,分配的块是6个字,有效载荷是5个字,内部片段是分配的块减去有效载荷,有效载荷是1个字。

外部碎片:当空空闲内存足以满足分配请求,但没有单个空空闲块大到足以处理此请求时,就会发生这种情况。外部碎片难以量化且不可预测,因此分销商通常会尝试通过启发式策略来维护少量大型空自由块,而不是维护大量小型空自由块。分配器还将根据策略和分配请求的匹配来划分空自由块和合并的空自由块(它们必须是相邻的)。

空自由链表

分配器被组织成一个连续的已分配块和空空闲块序列,称为空空闲链表。空自由链表分为隐式空自由链表和显式空自由链表。

隐式空自由链表是单向链表,每个空自由块只通过表头的大小字段进行隐式连接。

Explicit 空自由链表意味着空自由块被组织成某种形式的显式数据结构(以便更有效地合并和划分空自由块)。例如,将堆组织成一个双向空空闲链表,每个空空闲块包含一个前一个节点的指针和一个后一个节点的指针。

有几种策略可以找到空空闲块:

第一次适配:从头开始搜索空自由链表,选择你最先遇到的合适的空自由块。它的优点是倾向于在链表后面保留大的空空闲块,但它的缺点是倾向于在链表前面附近留下碎片。

下一步适应:每次从上一次查询结束的地方开始搜索,直到遇到合适的空空闲块。这种策略通常比第一次适配更有效,但内存利用率要低得多。

最佳适配:检查每个空空闲块,并选择适合所需请求大小的最小空空闲块。最佳匹配的内存利用率是三种策略中最高的,但它需要彻底搜索堆。

搜索链表的效率是线性的。为了减少分配请求匹配空空闲块的时间,分配器通常采用隔离存储的策略,即维护多个空空闲链表,其中每个链表的块大小大致相等。

一个简单的单独存储策略:分配器维护一个空自由链表数组,然后把所有可能的块分成一些等价的类(也叫大小类),每个大小类代表一个空自由链表,每个大小类的空自由链表包含大小相等的块,每个块的大小都在这个大小类中。

当有分配请求时,我们检查对应的空自由链表。如果链表不是空,则分配整个第一块。如果链表是空,分配器向操作系统请求一个固定大小的额外内存片,将这个片分成大小相等的块,然后将这些块链接起来,形成一个新的空空闲链表。

要释放一个块,分配器只需将该块插入相应的空自由链表的头部。

垃圾回收

编写C程序时,一般只可能显式分配和释放堆中的内存(malloc()和free())。程序员不仅需要分配内存,还需要负责内存的释放。

很多现代编程语言都内置了自动内存管理机制(C/C++也可以通过引入自动内存管理库来实现自动内存管理)。所谓自动内存管理,就是自动判断不再需要的堆内存(称为垃圾内存),然后自动释放垃圾内存。

自动内存管理的实现是垃圾收集器,它是一个动态内存分配器,它会自动释放应用程序不再需要的已分配块。

垃圾收集器通常采用以下两种策略之一来确定堆内存是否是垃圾内存:

引用计数器:在数据的物理空空间增加一个计数器。当有其他数据与之相关时(引用),计数器将递增1,否则将递减1。通过定期检查计数器的值,只要它是0,它就被认为是垃圾内存,并且它所占用的分配块可以被释放。使用引用计数器,实现简单直接,但缺点很明显。它不能回收循环引用的两个对象(假设有对象A和对象B,它们相互引用,但实际上对象A和对象B都是无用的对象)。

可达性分析:垃圾收集器将堆内存看作一个有向图,然后选择一组根节点(例如在Java中,一般是类加载器、全局变量、运行时常量池中的引用类型变量等。),并且根节点必须是足够“活跃”的对象。然后从根节点集计算可达路径。只要根节点的不可达节点被视为垃圾内存。

有以下几种垃圾收集器回收算法:

标记-清除:算法分为两个阶段:标记和清除。首先,标记所有需要回收的对象,然后在标记完成后统一回收所有标记的对象。Mark-clear算法实现简单,但效率不高,会产生大量内存碎片。

标记-排序:标记-排序算法与标记-清除算法基本相同,只是下一步不是直接清理可回收物,而是将所有有生命的物体移动到一端,然后直接清理边界外的内存。

Copy:将程序拥有的内存空分成两个大小相等的块,每次只使用其中一个。当这个内存块用完时,将幸存的对象复制到另一个内存块,然后清理已使用的内存空。这种方法不需要考虑内存碎片的问题,但是内存利用率很低。这个比例不是绝对的。例如,为了避免浪费,HotSpot虚拟机将内存划分为Eden空和两个survivor空,每次只使用Eden和一个survivor。回收时,将伊甸园和幸存者中幸存的对象一次性复制到另一个幸存者空房间,然后清理伊甸园和刚刚使用的幸存者空之间的房间。HotSpot虚拟机中Eden与Survivor的默认比例为8: 1,只会浪费10%的内存空。

生成:生成算法根据对象生命周期的不同,将内存划分为多个块,从而可以针对不同的年龄采用不同的回收算法。一般分为新生代和旧时代。新生代存储存活率低的对象,可以使用复制算法。老年储存存活率高的物品。如果使用复制算法,内存空空间将不够,因此必须使用标记清除或标记排序算法。

摘要

虚拟内存是内存的抽象。支持虚拟内存的CPU需要通过虚拟寻址来引用内存中的数据。中央处理器加载一个虚拟地址,然后将其发送给内存管理单元进行地址转换。地址转换需要硬件和操作系统的密切配合,MMU通过页表的方式获取物理地址。

首先,MMU将虚拟地址发送到TLB,以获得PTE(根据VPN寻址)。

如果PTE恰好缓存在TLB,它将返回给MMU,否则MMU需要从缓存/内存中获取PTE,然后将缓存更新到TLB。

MMU获取PTE后,可以从PTE中获取对应的PPN,然后结合VPO构造物理地址。

如果在PTE中发现虚拟页面没有缓存在内存中,则会触发页面错误异常。缺页异常处理程序将虚拟页缓存到物理内存中,并更新PTE。异常处理程序返回后,中央处理器将重新加载这个虚拟地址并翻译它。

虚拟内存系统简化了内存管理、链接、加载、代码和数据共享以及访问权限保护:

简化的链接、独立的地址空允许每个进程的内存映像使用相同的基本格式,无论代码和数据实际存储在物理内存的什么位置。

虚拟内存简化了加载,使可执行文件和共享对象文件更容易加载到内存中。

简化且独立的地址空为操作系统提供了一致的机制来管理用户进程和内核之间的共享。

访问权限保护,每个虚拟地址都要经过查询PTE的过程,在PTE中设置访问权限的标签位,简化内存的访问权限保护。

操作系统通过将虚拟内存与文件系统相结合来初始化虚拟内存区域。这个过程叫做内存映射。应用程序显式分配内存的区域称为堆,堆内存由动态内存分配器直接操作。

(0)
上一篇 2022年4月27日
下一篇 2022年4月27日

相关推荐