虚拟内存
本文转载自 https://juejin.im/post/59f8691b51882534af254317
参考:https://juejin.im/post/59f8691b51882534af254317
1、cpu运行过程中为什么可以动态寻址?
处理器的寻址方式就是如何获取程序运行过程中操作数来源问题、一般操作数来源于存储器。这是指的是内存。
java代码被编译成汇编代码之后、就会将代码中的变量转化为当前进程所在的虚拟内存的地址信息,当操作系统调度到该代码执行的时候、代码被装载进入CPU寄存器的时候、代码中数据的寻址地址是已经确定的。所以CPU在代码 执行过程中是已知具体代码所引用的数据在内存中的物理地址的。
内存通常被组织为一个由M个连续的字节大小的单元组成的数组,每个字节都有一个唯一的物理地址(Physical Address PA),作为到数组的索引。CPU访问内存最简单直接的方法就是使用物理地址,这种寻址方式被称为物理寻址。
现代处理器使用的是一种称为虚拟寻址(Virtual Addressing)的寻址方式。使用虚拟寻址,CPU需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。
2、内存的定义
内存通常被组织为一个由M个连续的字节大小的单元组成的数组,每个字节都有一个唯一的物理地址(Physical Address PA),作为到数组的索引。CPU访问内存最简单直接的方法就是使用物理地址,这种寻址方式被称为物理寻址。
3、CPU寻址的过程如图所示
虚拟内存寻址由程序产生的地址被称为虚拟地址,它们构成了一个虚拟地址空间。在使用虚拟存储器的情况下,虚拟地址不是被直接送到内存总线上,而且是被送到内存管理单元(Memory Management Unt,MMU),MMU把虚拟地址映射为物理内存地址。
虚拟寻址需要硬件与操作系统之间互相合作。CPU中含有一个被称为内存管理单元(Memory Management Unit, MMU)的硬件,它的功能是将虚拟地址转换为物理地址。MMU需要借助存放在内存中的页表来动态翻译虚拟地址,该页表由操作系统管理。
4、页表
虚拟内存空间被组织为一个存放在硬盘上的M个连续的字节大小的单元组成的数组,每个字节都有一个唯一的虚拟地址,作为到数组的索引(这点其实与物理内存是一样的)。
操作系统通过将虚拟内存分割为大小固定的块来作为硬盘和内存之间的传输单位,这个块被称为虚拟页(Virtual Page, VP),每个虚拟页的大小为P=2^p
字节。物理内存也会按照这种方法分割为物理页(Physical Page, PP),大小也为P
字节。
CPU在获得虚拟地址之后,需要通过MMU将虚拟地址翻译为物理地址。而在翻译的过程中还需要借助页表,所谓页表就是一个存放在物理内存中的数据结构,它记录了虚拟页与物理页的映射关系。
页表是一个元素为页表条目(Page Table Entry, PTE)的集合,每个虚拟页在页表中一个固定偏移量的位置上都有一个PTE。下面是PTE仅含有一个有效位标记的页表结构,该有效位代表这个虚拟页是否被缓存在物理内存中。
[图片上传失败...(image-b6af28-1539909202897)]
虚拟页VP0
、VP4
、VP6
、VP7
被缓存在物理内存中,虚拟页VP2
和VP5
被分配在页表中,但并没有缓存在物理内存,虚拟页VP1
和VP3
还没有被分配。
在进行动态内存分配时,例如malloc()
函数或者其他高级语言中的new
关键字,操作系统会在硬盘中创建或申请一段虚拟内存空间,并更新到页表(分配一个PTE,使该PTE指向硬盘上这个新创建的虚拟页)。【同理Java中NIO的ByteBuffer调用allocate分配内存也是同理,这就叫做内存分配不是在物理内存上分配、而是在分配一块虚拟内存出来、等真正使用的时候操作系统调入物理内存 ****https://www.zhihu.com/question/48960471/answer/122540835****】
由于CPU每次进行地址翻译的时候都需要经过PTE,所以如果想控制内存系统的访问,可以在PTE上添加一些额外的许可位(例如读写权限、内核权限等),这样只要有指令违反了这些许可条件,CPU就会触发一个一般保护故障,将控制权传递给内核中的异常处理程序。一般这种异常被称为“段错误(Segmentation Fault)”。
5、页命中
[图片上传失败...(image-ebc73a-1539909202897)]
如上图所示,MMU根据虚拟地址在页表中寻址到了PTE4
,该PTE的有效位为1,代表该虚拟页已经被缓存在物理内存中了,最终MMU得到了PTE中的物理内存地址(指向PP 1
)。
6、缺页
[图片上传失败...(image-510867-1539909202897)]
如上图所示,MMU根据虚拟地址在页表中寻址到了PTE 2
,该PTE的有效位为0,代表该虚拟页并没有被缓存在物理内存中。虚拟页没有被缓存在物理内存中(缓存未命中)被称为缺页
当CPU遇见缺页时会触发一个缺页异常,缺页异常将控制权转向操作系统内核,然后调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果牺牲页已被修改过,内核会先将它复制回硬盘(采用写回机制而不是直写也是为了尽量减少对硬盘的访问次数),然后再把该虚拟页覆盖到牺牲页的位置,并且更新PTE。
当缺页异常处理程序返回时,它会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送给MMU。由于现在已经成功处理了缺页异常,所以最终结果是页命中,并得到物理地址。
这种在硬盘和内存之间传送页的行为称为页面调度(paging):页从硬盘换入内存和从内存换出到硬盘。当缺页异常发生时,才将页面换入到内存的策略称为按需页面调度(demand paging),所有现代操作系统基本都使用的是按需页面调度的策略。
虚拟内存跟CPU高速缓存(或其他使用缓存的技术)一样依赖于局部性原则。虽然处理缺页消耗的性能很多(毕竟还是要从硬盘中读取),而且程序在运行过程中引用的不同虚拟页的总数可能会超出物理内存的大小,但是局部性原则保证了在任意时刻,程序将趋向于在一个较小的活动页面(active page)集合上工作,这个集合被称为工作集(working set)。根据空间局部性原则(一个被访问过的内存地址以及其周边的内存地址都会有很大几率被再次访问)与时间局部性原则(一个被访问过的内存地址在之后会有很大几率被再次访问),只要将工作集缓存在物理内存中,接下来的地址翻译请求很大几率都在其中,从而减少了额外的硬盘流量。
如果一个程序没有良好的局部性,将会使工作集的大小不断膨胀,直至超过物理内存的大小,这时程序会产生一种叫做抖动(thrashing)的状态,页面会不断地换入换出,如此多次的读写硬盘开销,性能自然会十分“恐怖”。所以,想要编写出性能高效的程序,首先要保证程序的时间局部性与空间局部性。
7、多级页表 多级页表是如何节约内存的 多级页面占用内存分析
通过一个顶级页表为真正有用的页表提供索引,这是我所理解的二级页表的本质**
我们目前为止讨论的只是单页表,但在实际的环境中虚拟空间地址都是很大的(一个32位系统的地址空间有2^32 = 4GB
,更别说64位系统了)。在这种情况下,使用一个单页表明显是效率低下的并且也是很浪费内存空间的。
常用方法是使用层次结构的页表。假设我们的环境为一个32位的虚拟地址空间,它有如下形式:
-
虚拟地址空间被分为4KB的页,每个PTE都是4字节。
-
内存的前2K个页面分配给了代码和数据。
-
之后的6K个页面还未被分配。
-
再接下来的1023个页面也未分配,其后的1个页面分配给了用户栈。
下图是为该虚拟地址空间构造的二级页表层次结构(真实情况中多为四级或更多),一级页表(1024个PTE正好覆盖4GB的虚拟地址空间,同时每个PTE只有4字节,这样一级页表与二级页表的大小也正好与一个页面的大小一致都为4KB)的每个PTE负责映射虚拟地址空间中一个4MB的片(chunk),每一片都由1024个连续的页面组成。二级页表中的每个PTE负责映射一个4KB的虚拟内存页面。
[图片上传失败...(image-ae2ac9-1539909202897)]
8、地址翻译的过程
从形式上来说,地址翻译是一个N元素的虚拟地址空间中的元素和一个M元素的物理地址空间中元素之间的映射。
下图为MMU利用页表进行寻址的过程:
image页表基址寄存器(PTBR)指向当前页表。一个n位的虚拟地址【也就是CPU的寻址地址】包含两个部分,一个p位的虚拟页面偏移量(Virtual Page Offset, VPO)和一个(n - p)位的虚拟页号(Virtual Page Number, VPN)。
MMU根据VPN来选择对应的PTE,例如VPN 0
代表PTE 0
、VPN 1
代表PTE 1
....因为物理页与虚拟页的大小是一致的,所以物理页面偏移量(Physical Page Offset, PPO)与VPO是相同的。那么之后只要将PTE中的物理页号(Physical Page Number, PPN)与虚拟地址中的VPO串联起来,就能得到相应的物理地址。
多级页表的地址翻译也是如此,只不过因为有多个层次,所以VPN需要分成多段。假设有一个k级页表,虚拟地址会被分割成k个VPN和1个VPO,每个VPN i
都是一个到第i级页表的索引。为了构造物理地址,MMU需要访问k个PTE才能拿到对应的PPN。
9、TLB
页表是被缓存在内存中的,尽管内存的速度相对于硬盘来说已经非常快了,但与CPU还是有所差距。为了防止每次地址翻译操作都需要去访问内存,CPU使用了高速缓存与TLB来缓存PTE。
在最糟糕的情况下(不包括缺页),MMU需要访问内存取得相应的PTE,这个代价大约为几十到几百个周期,如果PTE凑巧缓存在L1高速缓存中(如果L1没有还会从L2中查找,不过我们忽略多级缓冲区的细节),那么性能开销就会下降到1个或2个周期。然而,许多系统甚至需要消除即使这样微小的开销,TLB由此而生。
imageLB(Translation Lookaside Buffer, TLB)被称为翻译后备缓冲器或翻译旁路缓冲器,它是MMU中的一个缓冲区,其中每一行都保存着一个由单个PTE组成的块。用于组选择和行匹配的索引与标记字段是从VPN中提取出来的,如果TLB中有T = 2^t
个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
下图为地址翻译的流程(TLB命中的情况下):
image-
第一步,CPU将一个虚拟地址交给MMU进行地址翻译。
-
第二步和第三步,MMU通过TLB取得相应的PTE。
-
第四步,MMU通过PTE翻译出物理地址并将它发送给高速缓存/内存。
-
第五步,高速缓存返回数据到CPU(如果缓存命中的话,否则还需要访问内存)。
当TLB未命中时,MMU必须从高速缓存/内存中取出相应的PTE,并将新取得的PTE存放到TLB(如果TLB已满会覆盖一个已经存在的PTE)。
image10、Linux中的虚拟内存系统
Linux为每个进程维护了一个单独的虚拟地址空间。虚拟地址空间分为内核空间与用户空间,用户空间包括代码、数据、堆、共享库以及栈,内核空间包括内核中的代码和数据结构,内核空间的某些区域被映射到所有进程共享的物理页面。Linux也将一组连续的虚拟页面(大小等于内存总量)映射到相应的一组连续的物理页面,这种做法为内核提供了一种便利的方法来访问物理内存中任何特定的位置。
imageLinux将虚拟内存组织成一些区域(也称为段)的集合,区域的概念允许虚拟地址空间有间隙。一个区域就是已经存在着的已分配的虚拟内存的连续片(chunk)。例如,代码段、数据段、堆、共享库段,以及用户栈都属于不同的区域,每个存在的虚拟页都保存在某个区域中,而不属于任何区域的虚拟页是不存在的,也不能被进程所引用。
内核为系统中的每个进程维护一个单独的任务结构(task_struct)。任务结构中的元素包含或者指向内核运行该进程所需的所有信息(PID、指向用户栈的指针、可执行目标文件的名字、程序计数器等)。
image
-
mm_struct:描述了虚拟内存的当前状态。pgd指向一级页表的基址(当内核运行这个进程时,pgd会被存放在CR3控制寄存器,也就是页表基址寄存器中),mmap指向一个vm_area_structs的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域。
-
vm_starts:指向这个区域的起始处。
-
vm_end:指向这个区域的结束处。
-
vm_prot:描述这个区域内包含的所有页的读写许可权限。
-
vm_flags:描述这个区域内的页面是与其他进程共享的,还是这个进程私有的以及一些其他信息。
-
vm_next:指向链表的下一个区域结构。
11、内存映射 nmap的概念理解 java内存映射文件
内存映射、java读取磁盘文件、必须每次调用操作系统提供的底层标准IO系统调用函数 read()、write(),此时调用此函数的进程(在JAVA中即java进程)由当前的用户态切换到内核态,然后OS的内核代码负责将相应的文件数据读取到内核的IO缓冲区,(这里的内核IO缓冲区是进程虚拟内存的内核部分数据、映射到同一片物理内存当中),然 后再把数据从内核IO缓冲区拷贝到进程的私有地址空间中去,这样便完成了一次IO操作
Linux通过将一个虚拟内存区域与一个硬盘上的文件关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。这种将虚拟内存系统集成到文件系统的方法可以简单而高效地把程序和数据加载到内存中。
一个区域可以映射到一个普通硬盘文件的连续部分,例如一个可执行目标文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页的初始内容。由于按需页面调度的策略,这些虚拟页面没有实际交换进入物理内存,直到CPU引用的虚拟地址在该区域的范围内。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。
一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。当CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就先将它写回到硬盘,之后用二进制零覆盖牺牲页并更新页表,将这个页面标记为已缓存在内存中的。
简单的来说:普通文件映射就是将一个文件与一块内存建立起映射关系,对该文件进行IO操作可以绕过内核直接在用户态完成(用户态在该虚拟地址区域读写就相当于读写这个文件)。匿名文件映射一般在用户空间需要分配一段内存来存放数据时,由内核创建匿名文件并与内存进行映射,之后用户态就可以通过操作这段虚拟地址来操作内存了。匿名文件映射最熟悉的应用场景就是动态内存分配(malloc()函数)。
Linux很多地方都采用了“懒加载”机制,自然也包括内存映射。不管是普通文件映射还是匿名映射,Linux只会先划分虚拟内存地址。只有当CPU第一次访问该区域内的虚拟地址时,才会真正的与物理内存建立映射关系。
只要虚拟页被初始化了,它就在一个由内核维护的交换文件(swap file)之间换来换去。交换文件又称为交换空间(swap space)或交换区域(swap area)。swap区域不止用于页交换,在物理内存不够的情况下,还会将部分内存数据交换到swap区域(使用硬盘来扩展内存)。
11、匿名映射(malloc分配内存的原理)
在内核里,用户空间的进程要访问内存或磁盘里的数据要通过映射的方式将内存的物理地址和用户空间的虚拟地址联系起来.用户通过访问这样的虚拟地址就可以访问到实际的物理地址,也就是实际的物理内存.映射在实现虚拟地址到物理地址中扮演重要角色. 内核中映射分为文件映射和匿名映射.
文件映射就是磁盘中的数据通过文件系统映射到内存再通过文件映射映射到虚拟空间.这样,用户就可以在用户空间通过open ,read, write 等函数区操作文件内容.
匿名映射就是用户空间需要分配一定的物理内存来存储数据,这部分内存不属于任何文件,内核就使用匿名映射将内存中的某段物理地址与用户空间(用户进程的虚拟空间)一一映射,这样用户就可用直接操作虚拟地址来范围这段物理内存.至于实际的代码,文件映射的操作就是: open,read,write,close,mmap... 操作的虚拟地址都属于文件映射.malloc 分配的虚拟地址属于匿名映射
Linux很多地方都采用了“懒加载”机制,自然也包括内存映射。不管是普通文件映射还是匿名映射,Linux只会先划分虚拟内存地址。只有当CPU第一次访问该区域内的虚拟地址时,才会真正的与物理内存建立映射关系。
只要虚拟页被初始化了,它就在一个由内核维护的交换文件(swap file)之间换来换去。交换文件又称为交换空间(swap space)或交换区域(swap area)。swap区域不止用于页交换,在物理内存不够的情况下,还会将部分内存数据交换到swap区域(使用硬盘来扩展内存)。
CPU 第一次访问匿名映射的空间,所谓的访问就是读或者写这部分区域.根据 glibc 也就是用户空间对 malloc 分配的内存,如果不进行实际的访问,内核只分配虚拟地址,而不将虚拟地址通过匿名映射 与物理地址一一对应.只有 CPU 实际访问了这些区域,才真正发生匿名映射,也才发生缺页处理. CPU 第一次引用匿名区域时,由于匿名虚拟区还没有和实际的物理区域映射,内核就会发生一个缺页错误.于是内核就会 通过一些策略,从 swap 区获得一块物理内存页与之映射.这样虚拟地址就和物理地址挂钩,也就会生成相应的页表, 页表相关信息会被存储到 TLB 或 CACHE 中以假设虚拟地址到物理地址的转换.
内核为了管理内存,会将物理内存分成 4K 大小的数据块进行管理,如果这块物理页已经分配出去了,这样的统计计数就会加 1, 其状态就会变成使用中,其也会被加入到正在使用物理页链表中.由于该页已经通过匿名映射分配出去了.
12、swap区域
swap 区叫做交换分区,是 Linux 采取的一种内存策略,通过把磁盘的内存当做物理内存来用.这样做的目的就是增大了物理内存的大小,其明显的缺点就是速度没有物理内存快.
swap 区域一般分作两部分,一部分位于物理内存,叫做 swapcache 区域,另外一部分位于磁盘上.
这里涉及的到换页,简单来说就是把不经常使用的匿名页帧置换到磁盘上.注意,内存存在两种映射: 匿名映射和文件映射,文件映射还和虚拟文件系统有关,它有自己的文件缓存和缓冲,文件映射的物理页帧是不会来自 swap区域 所以 swap 区域就是匿名映射的物理页帧.这个区域由内核创建并管理.
13、共享对象 详细解释
虚拟内存系统为每个进程提供了私有的虚拟地址空间,这样可以保证进程之间不会发生错误的读写。但多个进程之间也含有相同的部分,例如每个C程序都使用到了C标准库,如果每个进程都在物理内存中保持这些代码的副本,那会造成很大的内存资源浪费。
内存映射提供了共享对象的机制,来避免内存资源的浪费。一个对象被映射到虚拟内存的一个区域,要么是作为共享对象,要么是作为私有对象的。
如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的。相对的,对一个映射到私有对象的区域的任何写操作,对于其他进程来说是不可见的。一个映射到共享对象的虚拟内存区域叫做共享区域,类似地,也有私有区域。
为了节约内存,私有对象开始的生命周期与共享对象基本上是一致的(在物理内存中只保存私有对象的一份副本),并使用写时复制的技术来应对多个进程的写冲突。
image只要没有进程试图写它自己的私有区域,那么多个进程就可以继续共享物理内存中私有对象的一个单独副本。然而,只要有一个进程试图对私有区域的某一页面进行写操作,就会触发一个保护异常。在上图中,进程B试图对私有区域的一个页面进行写操作,该操作触发了保护异常。异常处理程序会在物理内存中创建这个页面的一个新副本,并更新PTE指向这个新的副本,然后恢复这个页的可写权限。
还有一个典型的例子就是fork()
函数,该函数用于创建子进程。当fork()
函数被当前进程调用时,内核会为新进程创建各种必要的数据结构,并分配给它一个唯一的PID。为了给新进程创建虚拟内存,它复制了当前进程的mm_struct
、vm_area_struct
和页表的原样副本。并将两个进程的每个页面都标为只读,两个进程中的每个区域都标记为私有区域(写时复制)。
这样,父进程和子进程的虚拟内存空间完全一致,只有当这两个进程中的任一个进行写操作时,再使用写时复制来保证每个进程的虚拟地址空间私有的抽象概念。
13、动态内存分配
虽然可以使用内存映射(mmap()
函数)来创建和删除虚拟内存区域来满足运行时动态内存分配的问题。然而,为了更好的移植性与便利性,还需要一个更高层面的抽象,也就是动态内存分配器(dynamic memory allocator)。
动态内存分配器维护着一个进程的虚拟内存区域,也就是我们所熟悉的“堆(heap)”,内核中还维护着一个指向堆顶的指针brk(break)。动态内存分配器将堆视为一个连续的虚拟内存块(chunk)的集合,每个块有两种状态,已分配和空闲。已分配的块显式地保留为供应用程序使用,空闲块则可以用来进行分配,它的空闲状态直到它显式地被应用程序分配为止。已分配的块要么被应用程序显式释放,要么被垃圾回收器所释放。
image本文只讲解动态内存分配的一些概念,关于动态内存分配器的实现已经超出了本文的讨论范围。如果有对它感兴趣的同学,可以去参考dlmalloc的源码,它是由Doug Lea(就是写Java并发包的那位)实现的一个设计巧妙的内存分配器,而且源码中的注释十分多。