浅谈虚拟内存

操作系统相关的书籍最近确实看了几本,但是不是特别敢写成博客,因为我总感觉自己并没有领悟的很透彻,总隐隐约约觉得有点似懂非懂,所以这也是我今天把标题起为浅谈的原因吧。

虚拟内存

我们首先来回答两个问题:1. 什么是虚拟内存 2. 虚拟内存存在的意义。

什么是虚拟内存?

虚拟内存是计算机系统内存管理的一种技术, 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换 。不要单纯认为虚拟内存只是“使用硬盘空间来扩展内存“的技术。虚拟内存的重要意义是它定义了一个连续的虚拟地址空间,并且把内存扩展到硬盘空间。 目前,大多数操作系统都使用了虚拟内存,如Windows家族的“虚拟内存”;Linux的“交换空间”等。

简单点说,虚拟内存是操作系统物理内存和进程之间的中间层,它为进程隐藏了物理内存这一概念,为进程提供了更加简洁和易用的接口以及更加复杂的功能。

虚拟内存存在的意义

在没有出现虚拟内存以前,程序是直接运行在物理地址上的,但是将物理地址暴露给进程会带来下面几个严重的问题。

  1. 如果用户程序可以寻址内存里的每个字节,它们就可以很容易地(故意或偶然地)破坏操作系统,从而使得系统慢慢地停止运行(对于C/C++程序可以很容易从段错误中体会到这一点)。
  2. 使用这种模型,想要同时运行多个程序是很困难的,因为每次运行前都要在物理内存中找一块合适的区域供进程运行,而且还需要保证其他程序不会访问此区域。基址寄存器与界限寄存器解决了这个问题,当使用基址寄存器和界限寄存器时可以很容易的给每个进程提供私有地址空间,因为每个内存地址在送到内存之前,都会自动的加上基址地址的内容。

尽管基地址寄存器和界限寄存器可以创建地址空间的抽象,但是还存在个重要的问题并没有得到解决,那就是随着软件体积的膨胀,内存容量的增速已经不能很好的适应当下的软件体积了。

虚拟内存存在的目的是:

  1. 为了让物理内存扩充为更大的逻辑内存,从而让程序获得更多的内存。
  2. 为了更好的管理内存,操作系统将内存抽象为地址空间。每个程序都拥有自己的地址空间,这个地址空间被分割成很多块,每个块就是一页(一般大小为4KB)。这些被映射的内存,不需要映射到连续的内存中,也并不是所有的页都必须在内存中才能运行。当进程引用一部分在物理内存中的地址空间时,由硬件立刻执行必要的映射。当程序引用到一部分不在物理内存中的地址空间时,由操作系统会执行缺页中断,将需要的页装载到内存中。这样对于进程而言,逻辑上似乎有很大的内存空间,实际上虚拟内存中的每一页都会对应物理内存上的一帧(也叫页框,与页的大小相等,只不过页是描述逻辑地址的,而帧是来描述物理内存空间的),还有一些没有加载在内存中的对应在硬盘上。

从上面的描述我们可以看出,虚拟内存是允许进程不用将地址空间中的每一页都映射到物理内存中,也就是说一个程序不需要全部调入到内存中就可以正常运行,这使得有限内存运行大程序成为可能。例如,有一台计算机可以产生16位的地址,那么进程的地址空间范围就是0~64k。若该计算机只有32kb的内存,由于虚拟内存的存在使得该计算机可以运行64k大小的程序。

虚拟内存可以为正在运行的进程提供独立的内存空间,制造一种每个进程的内存都是独立的假象,在 64 位的操作系统上,每个进程都会拥有 256 TiB 的内存空间,内核空间和用户空间分别占 128 TiB,部分操作系统使用 57 位虚拟地址以提供 128 PiB 的寻址空间。因为每个进程的虚拟内存空间是完全独立的,所以它们都可以完整的使用 0x0000000000000000 到 0x00007FFFFFFFFFFF 的全部内存。

虚拟内存的优点
  1. 将一部分不使用的虚拟页面不映射到物理内存上,扩大了程序的可用物理内存大小。
  2. 内存保护:每个进程运行再各自的虚拟内存地址空间,互相不干扰。虚拟内存还对特定的内存地址提供了写保护,防止代码或则数据被恶意程序修改。
  3. 公平内存分配。采用虚拟内存之后,每个进程都拥有同样的可寻址空间。
  4. 在程序需要分配连续的内存空间时,只需要虚拟内存空间分配连续空间,而不需要实际物理内存的连续空间,可以很好的利用物理内存上的碎片。
  5. 当不同的进程使用同样的代码时,如库文件中的代码,物理内存中可以只用存储一份这样的代码,不同的进程只需要把自己的虚拟内存映射过去就可以了,从而大大节省物理内存。
虚拟内存的代价
  1. 虚拟内存的管理需要数据结构,这些数据结构占据了额外空间。
  2. 由于CPU始终是要访问物理内存才能正确的处理数据,而虚拟地址到物理地址的转换,增加了指令的执行时间。
  3. 页面的交换需要磁盘IO,而磁盘IO是低速的,所以这种交换是很耗费时间的。
  4. 由于页面的大小是固定,若某个页只存储很少的数据则会浪费空间。如4K的页只存放1字节的字符。

页表

为了更好的管理内存,操作系统将内存抽象为地址空间。每个进程都拥有自己的地址空间,这个地址空间被分割成多个块,每个块称为一页,一般来说一页的大小为4KB。

当CPU访问虚拟内存时,会先访问MMU(内存管理单元)去匹配对应的物理地址,如果虚拟内存对应的页并不存在于物理内存中,则会产生缺页中断,从磁盘中取得缺失的页放入内存,若内存已满,则会根据相应的页面置换算法将某些页换出,再把磁盘上的页换入到物理内存中。

MMU实现从虚拟地址到物理地址的转换,页表是虚拟内存系统中的重要数据结构,每一个进程的页表中都存储了从虚拟内存到物理内存页的映射关系,为了存储 64 位操作系统中 128 TiB 虚拟内存的映射数据, Linux 在 2.6.10 中引入了四层的页表辅助虚拟地址的转换,在 4.11 中引入了五层的页表结构,在未来还可能会引入更多层的页表结构以支持 64 位的虚拟地址。

为什么要使用两级页表?

每个页面映射4Kib以及每个页表项是4Bit.(在32位的机器上讨论)

一级页表:进程需要1M的页表项(4G / 4 KB = 1M),即页表就需要4MB的空间。

二级页表: 在有二级页表的系统中,一个进程的一级页表映射4MB、二级页表映射4KB,则需要1K个一级页表项,每个一级页表项对应1K个二级页表项,这样页表项就占用4.004MB的内存空间。多级页表的占用额外的空间似乎还变大了?

那为什么还要使用二级页表呢?

  • 因为二级页表是可以不存在于内存空间中的。每个进程都有4GB的虚拟地址空间,而显然对于大多数程序来说,其使用的空间远小于4GB,所以何必去映射不可能用到的空间呢?也就是说,一级页表覆盖了整个4GB的虚拟地址空间,但如果某个一级页表的页表项没有被使用,那么久不需要去创建这个页表项对应的二级页表(1对1000的关系,一下节省999个页表项)。从而用这种即使用才创建的方式节约了内存空间。

为什么不分级的页表就做不到这样的节约内存呢?

从页表的性质来看,保存在贮存中的页表承担的职责是将虚拟地址翻译成物理地址,假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部的虚拟地址空间,不分级的页表就需要有1M的页表项来映射(一级页表寻址范围大,若有二级页表的话,没有使用到的页表就无需加载到内存中),而二级页表则最少只需要1K个页表项。此时,一级页表已覆盖到了全部的虚拟地址空间,而二级页表在有需要的时候才创建。

页面置换算法

地址映射过程中,若在页面中发现所要访问的页面不在内存中,则产生缺页中断 。当发生缺页中断时,如果操作系统内存中没有空闲页面,则操作系统 必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。而用来选择淘汰哪一页的规则叫做页面置换算法,页面置换算法的作用是实现虚拟存储管理。

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

颠簸的本质上是指频繁的页调度行为(即缺页率高),具体来讲,进程发生缺页中断,这时必须通过页面置换算法置换某一页。然而,其他所有的页都在使用,它置换一个页,但又立刻再次需要使用该页。因此会不断的产生缺页中断,导致整个系统的效率急剧下降,这个现象被称为颠簸(抖动)。