聊聊虚拟内存

前提的内存知识

内存是什么?说白了就是一长串字节数组。编程的时候难免申请一段内存空间,有了内存空间才能存放数据、存放指令代码。
早期不存在操作系统提供的存储器抽象,每一个程序直接访问物理内存,即从0到某个上限的地址集合,也称为物理地址空间。

        int[] array = new int[4];

程序员使用编辑器写了一段代码,在堆中申请一块连续的内存空间,并且在栈内存中使用一块4字节的内存保证这个堆中对象的引用(地址值)。
写完之后,保存到磁盘,那么这个程序是静态的,保存在磁盘中,它并没有被运行。

当一个程序被执行后,如果程序中的指令想要被执行,它必须被从磁盘加载/复制到内存中,因为机器语言不能够将磁盘地址作为参数,只能将内存地址作为参数。
程序中的指令最终虚拟机解释为CPU可以看懂的汇编指令,而指针或值类型最终也会被转换为一个具体的地址(符号引用转直接引用),CPU对汇编指令的执行总是从若干个地址中取出数据,计算后生成一个新地址。CPU拿到的地址是一个逻辑地址,在内存条上是找不到对应位置的,所以它必须通过某些硬件(内存管理单元MMU)将这个逻辑地址转换内存条上对应的物理地址(地址的映射),然后去物理内存地址取出数据。
MMU通常是CPU芯片的一部分,负责执行将虚拟内存地址映射为物理内存地址,通常包含着用于高速缓存映射对的寄存器TLB。

通常说的占用内存资源,指的是这个资源已经加载到内存中去了,它可以对应上一个物理内存地址。而程序编译后,数据仅对应一个逻辑内存地址,运行之前它是存放在磁盘中的(点击运行后,它的一个副本载入内存,并且绑定物理内存地址),占用外存资源、不占用内存资源。
读取一个文件,需要启动IO将它调入内存。关闭时,如果没有进行修改或者是一个只读文件,直接释放内存资源。否则需要启动IO使磁盘中的文件副本同步后是否内存资源。

因此如果需要访问内存中存放的内容,则必须将已有的逻辑内存地址,转换为物理内存地址。一个程序,代码本身的存放需要占用地址空间,数据本身也要占用地址空间(如int i= 4需要占用四字节的地址范围),因此程序执行时总是需要占用一部分内存。

将程序用到的内存区分为逻辑内存和物理内存,防止程序被编译后就完全被限定死“只能在某个物理地址”,这也是内存复用的基础。把物理内存直接暴露给进程的两个后果:不利于实现内存复用(虚拟内存)、不安全。而地址空间本质上是对物理内存的一种抽象

程序经过编译后,一般都对应一个虚拟/逻辑地址,既然都说是虚拟的了,那肯定管够(当然了,也不是随便分配的,必须限定在CPU的寻址范围之内的,如果2位的CPU,它撑死能访问四个内存地址00 01 10 11,分配个1111的内存地址,CPU的寄存器也放不下这个四位的地址值),这些逻辑地址的集合就是逻辑地址空间。而物理地址空间就是实际可用的内存地址空间。程序如果能够运行,最终一定是运行在物理内存上的,逻辑地址可以看作是一种逻辑视图。
地址空间是一个进程可以用于内存寻址的一套地址集合,每个进程都有一个独立的地址空间。使用基址寄存器(start)界限寄存器(len)可以实现地址空间的抽象,前者存储程序的物理地址,而后者存储程序的长度。通过以上两个寄存器,可以实现程序的重定位保护,从而保证了多个应用程序可以同时存在内存,并且不互相影响。

程序源代码编译时就进行地址映射,那么程序的位置运行期间被定死了,如果需要变动地址,必须重新编译程序。
地址绑定可以推迟到加载阶段,编译后仅仅为更指令生成一个逻辑地址,程序加载到内存时(此时也可以称为进程),一次性为为其分配其所需要的物理地址,然后这个程序便获取了所需的内存资源,可以看作就绪的进程。如果它需要变更地址,那么需要重新加载该程序(为其重新分配物理地址空间)
大多数计算机操作系统将地址绑定推迟到程序执行时,程序运行时,并不需要将所有逻辑内存进行物理内存的映射,只有当程序片段被运行时才进行内存映射(边执行边映射),这时实现虚拟内存的基础

以上谈论的都是连续内存分配,运行在内存中的多个程序,全部被装入内存中,那么如果一个程序达到无法装入内存呢?另一方面,许多程序并不是所有数据代码都需要被一直使用,这部分将是对内存资源的浪费。即使存在以进程为单位进行换入换出的交换技术,也并不能很好解决上面的问题。那么就需要引入虚拟内存技术了,而虚拟内存技术的基础是离散内存分配。

离散的内存分配也是实现虚拟内存的基础。连续内存分配方式最大的不足就是存在外部碎片,而离散内存分配可以解决这个问题。

分段和分页就是离散内存分配的一种方式,不过他们常被组合使用。

虚拟内存是一种容量扩充技术,而虚拟地址空间和逻辑地址空间都是一个意思,是经过程序运算/编译器编译得到的地址空间,最终需要转换/映射为物理地址空间

分段和分页

分页是什么,一个字符串转换为字符数组,这个数组可以看作一个内存空间,那么每一单元就可以看作一个页面,是一维结构的,而且数组单元没有逻辑可言(java字符是utf-16编码的,那么一个emoji表情就可能占用两个单元)。
分段是什么,二维数组,arr[0]是数据段,arr[1]是代码段…每一个段都是一个独立的地址空间,可以理解为把一个总的地址空间分为许多小段,各小段都是独立的地址空间。

一维的虚拟内存可以看作一个段,虚拟地址从1到最大地址,每个单元都是地址空间紧密相连。而分段的最大特点是,每个段都是独立的,运行期间可以动态改变(堆栈段和数据段),并且不会影响到其他的段。
单独看分段,它属于一种连续内存分配策略,不存在“缺页中断”,而如果分页内存分配涉及函数调用或对象分配导致的内存空间变化,将会引起缺页中断。
如果每一个过程/函数都位于一个独立的段,并且起始地址都是从0开始的,那么把单独编译好的过程链接起来的操作就可以大大简化。分段有助于进程之间的数据共享,而分页中的数据共享起始是通过模拟分段而实现的,并且可以对不同的段进行差异化的保护。

分段的出现,是为了使程序和数据可以被划分为逻辑上独立的内存空间,并且更加容易实现共享与保护。而分页的出现,是为了将一个程序的内存分成若干小份,为每个程序提供虚拟地址空间,装入必要部分而不是一次性装入全部物理内存块,从而支持虚拟内存(好像将多个程序全部装入内存)

分段先于分页,分段存在的问题是:外部碎片明显,且段大小不一,大的段中往往有些内存不总被访问却占用物理内存(内部碎片),同时换入换出内存时占用大量IO时间。分页解决了外部碎片和IO对换效率低的缺点,仅仅存在少量的部分碎片。其实分段有很多痛点都源于连续内存分配的特点。因此后面有采用段页式内存分配的OS,但是分页更加主流。

分段和分页都是为了更好的利用内存资源。
其中分页服务于操作系统的管理需求,将逻辑地址空间划分为大小相同的若干逻辑页面,同时将物理地址空间划分为大小相同且大小也与页面大小相同的若干个物理块。将页面和块编上号,一个进程可以被划分为多个页面,分配内存时将页面与物理块进行地址映射即可

分段服务于程序员的编程和使用需求,将逻辑地址根据逻辑关系划分若干个大小不等的、逻辑地址连续的逻辑段,而物理地址通常是不连续的,如堆段、栈段、共享段、数据段、代码段等,编译器对源程序进行编译时,会分析程序并自动构造段,加载程序会加载段并为之分配段号。(创建对象就划入堆段,元信息就划入共享段等)

偏正式的回答:
对内存进行分页和分段,目的都是为了更好的利用内存资源。分页服务于系统,为了更好的管理内存(分配、回收),而分段服务于用户,为了更好的根据开发逻辑关系,去使用内存
现代操作系统通常将一个进程的地址空间分为大小不等、逻辑地址连续的若干逻辑段,然后对逻辑空间再进行分页,将逻辑地址空间分为若干大小相等的页面,而将物理地址空间分为若干大小相等的物理块,其中物理块大小等于页面大小,当程序执行时将用到的页面映射到一个物理块上,实现内存的离散分配。

对比:
分页会造成内部碎片,因为进程地址空间的最后一个页面可能无法被完全利用(用不完),而分段会造成外部碎片,当申请不到足够的连续内存的时候会出现报错(堆内存溢出),(有内存但用不了)。

假设堆内存3M,而目前堆中最大还可以连续内存是1M,如果这时申请一个1.5M的对象就会OOM

分页是信息的物理单位,而分段是信息的逻辑单位。因此分页的大小是收到内存硬件和操作系统软件的因素约束,而分段的对象取决于用户编写的程序。
分页是一维的,是系统行为(页号|页内地址)而分段是二维的,是用户行为(段号|段内地址+段的逻辑名字)
分段更易实现信息共享和保护,而一个共享代码区可以涉及多个页面,分页实现相对困难。

页表

在不使用虚拟内存的情况下,系统直接将虚拟地址送入内存总线,读写操作使用具有相同虚拟地址的物理地址(不存在所谓缺页中断,程序运行时虚拟地址以及完成映射),而使用虚拟内存时,虚拟内存由CPU交给MMU得到内存物理地址,再送入存储器。

操作系统维护的页面非常多,不可能全部存入寄存器,因此使用一个常驻内存的页表(页面映射表)去管理。

cpu维护一个保存页表基址的寄存器,而地址映射交给硬件内存管理单元MMU完成,系统为每个进程维护一个页表。每当CPU发出一个虚拟地址,MMU翻译出一个物理地址(物理块号)并且添加到缓存(TLB)中,CPU先去内存中查页表,根据查出的物理块号在根据页内偏移量拼出物理内存地址,再去对应的物理内存访问真实数据

CPU访问读取某个内存中的数据,通常需要先访问内存中的页表,查询页表项中页号对应的物理块号,根据物理块号+偏移量得到目标物理地址。然后再次访问内存中的目标物理地址。
至少需要两次访存。如果涉及多级页表将增加更多访存次数。

页表占用过多内存,主要有两种解决办法
【1】分割它:多级页表,时间换空间,将占用连续内存空间的大页表分割成可以离散存储的小页表。但是总的占用空间仍然没变
【2】请求调页策略:部分页表项驻留外存,直到真正用到时再调入内存。
两种方式可以结合使用。

另一种针对页层级增多的解决方案:倒排页表
【1】线性倒排
每个物理页框对应一个页表项(页框-页号),虽然节省了大量空间,但是内存装换变得困难,而且每一次内存访问操作都需要执行一次“整个倒排页表的检索”。解决方法仍然是使用TLB
【2】散列倒排
使用散列表进行优化,建立一张散列表(虚拟页号-倒排页表索引号即框号),先根据页号(多对一)拿到框号,然后再根据框号拿到页表项对应的页号(一对多)。如果散列表的槽数和物理块数一样多,那么哈希冲突概率将会减小

解决页表项占用内存过多的问题,往往会引入新的问题:如更长的查询时间(使用TLB缓解)、难以实现共享内存

页表必须覆盖整个虚拟内存空间,不分级的情况下页表占用大量内存,而如果考虑分级,可以仅仅创建一级页表保证页表覆盖所有内存空间,而二级页表可以在“真正用到时”再进行懒调入相应页表项,同理,页表分级越多,粒度分的越细占用内存越少。(每一级页表负责若干域位的转换,页表经过打散,高级页表可以不常驻内存中)

大多数CPU在一个时钟周期内可以执行多条指令,而访问内存通常要占用CPU多个时钟周期,为了弥补速度差,引入快表TLB。TLB一般存放在CPU内部的高速缓存寄存器中,CPU寻址时首先会查询快表,查询时间很快,可以忽略不计,如果没有命中才会查询内存中的页表。如果命中则可以得到目标物理地址。快表只包含少量的页表条目,如果慢了会根据某个策略淘汰一些条目。
(对TLB的管理和失效处理完全由MMU硬件实现)

操作系统为每个进程都维护了一个页表,因此一旦切换进程,则页表地址指针(页表基址寄存器PTBR的内容)也会跟着切换,TLB会被刷新(其实发生任意CPU上下文切换,从内核栈切回来时就可能被抢占导致进程切换)。CPU将不得不查询内存中的页面,这降低了CPU利用率。
>问进程和线程切换代价对比,TLB刷新的代价很大,这点很重要!
有的TLB会在条目中包含地址空间标识符,会唯一标识一个进程,如果进程切换会导致TLB未命中,不会刷新TLB。

分页太小,可以更好的利用内存,但是每个分页在页表中都对应一个页表项,而页表通常是常驻内存的(占用物理块),因此可用内存减少。而且页表项太多,检索的时间也上升的,还有一方面,页面小了,存储的数据也就少了,一段时间内,换入换出的页面数量也要增加,IO次数增加,效率下降。
而分页太大,虽然使得页面变小,但是页内碎片会增大。
页面大小通常为2的幂。

理解虚拟内存

虚拟内存,虚拟?什么是虚拟的。理解虚拟之前,先谈谈真实内存。真实内存就是能用到的,说白了就是一个内存条可以反映的内存,其实就是之前一直提到的物理内存空间。
虚拟内存=主存内存+辅存内存。
虚拟内存并不是从物理上扩充内存,而是从逻辑上扩充内存。它给用户一个假象:我电脑内存很大,可以运行整个GTA5(说不定只是装入了一个菜单程序)。它也给程序(进程)一个假象:整个地址空间很大,而且我独占了,而且是连续的,不用给别的程序让路,可以放心运行程序(其实只有极少部分与物理地址进行映射,另外一部分并没有被装入)。
说白了,你以为当前电脑中此时同时运行着A B C,其实可能内存中仅包含A 5%、B10%和C 2%的代码和数据。

虚拟内存的基本思想
每个程序都有自己独立的地址空间,这些空间被分成若干相等的页面,并不是一个程序的所有页面全部装入内存,程序才可以运行。每当有程序的一部分代码需要被使用时(虚拟地址被使用),硬件(MMU)必须执行一个虚拟地址到物理地址的映射。如果虚拟地址对应的页面不在内存(一个页面就对应一个虚拟地址的集合,即虚拟地址空间),则OS必须负责将页面调入内存,并装入一个物理块,并且需要重新执行引发缺页异常的指令。

虚拟内存使得整个地址空间可以使用相对较小的单元(按需),映射到物理内存,而不是为代码段和物理段分别进行重定位。

偏正式的回答:
虚拟内存管理让每个进程任务自己独占了整个地址空间,其实这个地址空间是主存和磁盘地址空间的抽象,目的是逻辑上扩充内存容量,逻辑容量等于主存容量与磁盘容量之和。同时,让每个进程拥有一致的虚拟内存空间简化了内存的管理,不同进程的同一个虚拟地址可以被内存管理单元(MMU)映射到同一个物理地址上。而且保证了进程之间不会被互相干扰,无需考虑内存冲突的问题。
回答细节不重要,一定要体现“逻辑上扩容内存”、“内存+外存”、“进程逻辑上独占整个内存空间”、“虚拟地址被硬件映射到独立的物理地址上,不会发生冲突(除非设计共享信息)”

简述一下如何实现

虚拟存储器中,进程的内存映射是推迟到运行时的,也就是说,一个程序三行代码,只有第一行代码经过地址映射了,执行到第二行时就执行不到了。CPU拿到虚拟地址,然后委托MMU芯片进行地址转换,当MMU查询页表时发现 存在位是false,页面没有驻留内存,这时会发出缺页中断(缺页异常 page fault),CPU陷入内核转去处理中断,最终缺页从外存调入内存。物理块不够用则会执行页面置换策略,将低优先级页面换出内存,并将物理块与目标页面进行映射(其实就是修改页表)。此处仅是简单说明,后面会详细叙述过程。

虚拟内存管理使得进程以为自己独占了整个内存空间,可以放心执行,它以为自己的页面都是可以被映射到物理内存上的,其他的进程也是这么想的,“页多块少”。而实际上,块总是被映射到被使用的页面,当进程执行时地址转换得不到满足,发出page fault,OS为它调页后恢复上下文,进程好像什么都没发生,继续向下执行。

当然了,上面只是便于理解,肯定不能就这么说,问你简单描述一下如何实现,还没必要答得特别具体,其实就是想引导你说出“缺页中断”、“请求调页”和“页面置换”,如果能扯一下局部性原理就更好了。后面提问者和有可能就会问你“发生缺页中断会发生什么”

局部性原理(规律)是程序执行时反映出的一组规律,主要体现在空间和时间两方面
时间方面,如果某行代码被执行后,那么不久后有可能再次被执行(如循环操作)
空间方面,如果某个地址被访问,那么它附近的地址有可能会在不久后被访问(顺序执行)

偏正式回答:
根据局部性原理,应用程序运行之前,没必要将全部数据装入内存,只需要装入必要的页面即可,进程运行前,采用预调页策略调入必要的页面,运行时基于请求调页策略将需要的页面从外存调入内存。通过缺页中断引起操作系统干预。

程序是静态的,是存入外存的,在外存是有外存地址(物理块)与之映射的,装入内存说白了,就是将外存的副本读入内存,与主存的物理地址映射上(将页面放入主存的某个地址块)。当发生置换或者正在关闭程序时,将脏页更新到外存(磁盘)。

随着内存的动态分配,运行堆向上增长,随着子程序的不断调用,运行栈向下增长。只有在堆和栈增长时才需要物理块,触发缺页异常,中间部分可以存放共享对象(动态链接库)

当缺页中断发生时,操作系统必须通过读硬件寄存器来确定哪一个虚拟地址造成缺页中断,并且根据虚拟地址拿到页表项,然后从页表项拿到页面在磁盘中的地址。找到合适的页框存入从磁盘调入的页面。最后,还需要回退程序计数器PC,使程序计数器指向引起缺页中断的指令,重新执行指令。

后备存储

当进程被换出时,页表不需要驻留内存,但是当进程运行时,它必须在内存中,保证覆盖内存的地址空间。操作系统还需要在磁盘交换区中分配空间,以便在一个进程换出时,在磁盘上有放置此进程的空间,操作系统会将有关页表和磁盘交换区的信息保存在进程表中。

【1】对静态交换区分页
磁盘上的交换区与进程虚拟地址空间一样大,每个页面都有固定的位置,当页面从内存换出时则写入到对应的位置。

当写回一个页面时,需要计算其在交换区的地址:虚拟内存空间中的页面偏移量 + 交换区的起始地址
因为数据和堆栈都是动态变化的,因此通常需要为数据、堆栈等保留额外的交换区空间。
内存中的页面通常在磁盘上具有副本,并且当页面换出内存时被更新同步。
【2】动态备份页面
另一种方案是不进行提前分配,而是当页面换出时为其分配磁盘空间,并在换入时回收磁盘空间。
页面在磁盘上没有固定的地址,当页面换出内存时,需要及时选择一个空的磁盘页面,并且更新磁盘映射(每个虚拟页面都会映射到一个磁盘地址空间,这个磁盘映射的哈希表保存在内存中)。
内存中的页面在磁盘上没有副本,每一次的映射都是动态创建,并随时可能被释放的。

由于不能总是保证可以分配到交换分区,当没有磁盘分区可以时,可以利用文件系统的文件。
每个进程的程序都来自文件系统中的某个文件,这个文件就可以作为对换区。当页面换出内存时可以直接丢弃,需要换入内存时直接从文件中读入即可,共享库利用此方式工作。

缺页中断处理过程

什么是缺页中断?请求分页系统中,通过查询页表发现页面没有驻留内存,则触发缺页中断,引起操作系统将页面调入内存的行为。
缺页中断就是:虚拟地址没有和物理地址产生映射关系时,通知操作系统调入缺失页面的信号

缺页中断和一般中断比有一些特点:一条指令执行期间可能产生多次缺页中断、指令执行期间可以产生和处理缺页中断、缺页中断恢复后会再次执行原指令(访问页表),而不是向下执行。

add(1,2,3)中,1,2,3可能在不同页面中,当发生缺页中断,add需要重新执行

OS处理缺页中断:
当程序进行地址映射时,MMU查询页表发现页表项存在位为0,则发出缺页中断。CPU响应中断信号,保存上下文后转入执行进程内核态。分析中断原因后转入具体的中断处理程序。如果内存中没有足够的物理块,则根据页面置换程序选出某个页面,如果页表项的修改为1,则将页面刷新到外存,并将物理块释放。当由足够的内存资源时,则启动磁盘IO,根据目标页面的页表项查出页面所在的外存地址,将副本调入内存。IO完成后,操作系统修改页表项存在位为1,并且写入物理块号。还需要刷新TLB(全刷新还是局部刷新看具体OS实现)。恢复上下文,重新执行引起缺页中断的命令(会再次查询页表)。(快表刷新了,没有命中,再次查询页表)

虚拟存储管理的两种调页策略:
为了防止进程启动后产生大量缺页,会执行预调页策略。但是预调页可能存在调页失效的问题,可以借助工作集来优化
请求调页策略发生在程序执行时,所需页面没有驻留内存的情况。

系统抖动是频繁缺页中断的表现,如果抛开操作系统层面,服务器抖动通常是由于内存不够用,(运行的程序)进程(线程)太多。过多的缺页导致很多进程等待磁盘IO将页面调入的内存而主动放弃CPU,CPU利用率很低。可以考虑主动暂停一部分进程的运行(暂时从内存中取得一些进程)或者限制进程的创建

进程的虚拟地址空间不是每个地址都有物理地址与之对应的,当地址第一次被使用后,会触发缺页中断,创建进程、线程都会涉及内存的使用。(广义上的创建进程其实是创建单线程进程)

如果考虑OS,则全局置换算法会导致抖动,因为一个进程缺页会抢占别人的物理块,导致别人页缺页,缺页太多导致排队过长,磁盘利用率提升,CPU利用率下降。而CPU利用率下降,可能导致OS错误的创建更多进程。
可以采用局部置换算法。也可以在CPU调度中使用工作集算法,将程序的全部工作集——任意一个时刻T,都会存在一个集合,包含所有最近K次内存访问过的页面全部装入内存(进程在某段时间内实际所要访问页面的集合),预调页策略也一定程度上依赖工作集。

共享

并不是所有页面都适合共享,对于只读页面如程序文件适合共享,而数据页面也不适合共享。
写时复制思想(CopyOnWrite cow)可以避免最初的请求调页需要。系统调用fork()便具有写时复制的思想.
当fork()创建了一个子进程时,子进程共享父进程的地址空间,并将共享的页面标记为共享页面,一旦子进程进行写入操作时(触发只读保护,引发缺页异常陷阱),则发出缺页中断,为子进程创建共享页面的副本。

两个进程还可以将同一物理块映射到各自的虚拟地址空间,来作为共享内存。但是需要进行线程安全问题避免。
还有一种和虚拟内存有关的进程通信是内存映射文件,将多个通信进程的虚拟内存页面映射到同一物理地址块,而物理块又与磁盘物理块进行了映射,当关闭文件时,所有的内存映射数据都会同步写入磁盘,并从虚拟内存中删除。
内存映射文件使得访问磁盘文件像访问常规内存一样简单。内存映射文件是实现共享内存的一种方式。

思想:将一个文件映射到某些进行虚拟地址空间的一部分,仅当访问页面时才会以页为单位的读,磁盘文件则被当做后备存储。当进程退出或显式解除映射的时候,所有被改动的页面会被写回到磁盘文件中。

相当于把一个文件当做一个内存中的大字符数组来访问。

如果一个程序两次启动,大多数操作系统会自动共享所有的代码页面,而在内存中只保存一份代码页面副本。
一个更加通用的技术是动态链接库——当一个动态链接库被装载和使用时,整个库并不是被一次性读入内存的,而是按需,以页面为单位进行装载的,没有被调用的函数不会被装载到内存中

编译动态链接库时,通常只使用相对偏移量,而避免使用绝对地址——位置无关代码

  • 4
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值