前言
- 那么到底什么是虚拟内存地址 ?
- Linux 内核为啥要引入虚拟内存而不直接使用物理内存 ?
- 虚拟内存空间到底长啥样?
- 操作系统是如何管理虚拟地址与物理地址之间的关系?
![](http://image109.360doc.cn/DownloadImg/2023/11/0217/274766217_2_20231102053934133.jpeg)
那么到底什么是虚拟内存地址 ?
举个栗子
最近看到张雪峰老师提到过一个大学要改名的事情:滨州学院改名山东航空学院。学校改完名字之后,录取的分数可能会提高。(确实山东航空学院听着更加的高大上一些,不过这就能影响到录取的分数吗??)不过今天我们不讨论这些内容。
这所学校位于山东省滨州市滨城区黄河五路391号(坐标:12992256,4884226):这就是物理地址,滨州学院可以理解为是虚拟地址,未来改名为山东航空学院也是一个虚拟的地址,也就说虚拟地址可以人为的变来变去,但是物理地址永远是不变的。
计算机的世界
现在让我们把视角在切换到计算机的世界,在计算机的世界里内存地址用来定义数据在内存中的存储位置的,内存地址也分为虚拟地址和物理地址。而虚拟地址也是人为设计的一个概念,类比我们现实世界中的收货地址,而物理地址则是数据在物理内存中的真实存储位置,类比现实世界中的城市,街道,小区的真实地理位置。
如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示:
![](http://image109.360doc.cn/DownloadImg/2023/11/0217/274766217_3_20231102053934273.jpeg)
虚拟内存地址长什么样子
我们还是以收获地址做类比,我们都很熟悉收货地址的格式:xx省xx市xx区xx街道xx小区xx室,它是按照地区层次递进的。同样,在计算机世界中的虚拟内存地址也有这样的递进关系。
这里我们以 Intel Core i7 处理器为例,64 位虚拟地址的格式为:全局页目录项(9位)+ 上层页目录项(9位)+ 中间页目录项(9位)+ 页表项(9位)+ 页内偏移(12位)。共 48 位组成的虚拟内存地址。
![](http://image109.360doc.cn/DownloadImg/2023/11/0217/274766217_4_20231102053934461.jpeg)
虚拟内存地址中的全局页目录项就类比我们日常生活中收获地址里的省,上层页目录项就类比市,中间层页目录项类比区县,页表项类比街道小区,页内偏移类比我们所在的楼栋和几层几号。
32 位虚拟地址的格式为:页目录项(10位)+ 页表项(10位) + 页内偏移(12位)。共 32 位组成的虚拟内存地址。
![](http://image109.360doc.cn/DownloadImg/2023/11/0217/274766217_5_20231102053934773.jpeg)
Linux 内核为啥要引入虚拟内存而不直接使用物理内存 ?
先上结论
为什么需要虚拟内存?因为虚拟内存提供了三项能力,解决了物理内存系统面临的两个问题。
内存系统面临哪些问题?
- 内存(memory)资源永远都是稀缺的,当越来越多的进程需要越来越来内存时,某些进程会因为得不到内存而无法运行;
- 内存容易被破坏,一个进程可能误踩其他进程的内存空间;
虚拟内存提供了哪些能力?
正如软件工程中的其他抽象,虚拟内存是操作系统物理内存和进程之间的中间层。它为进程隐藏了物理内存这一概念,为进程提供了更加简洁和易用的接口。这个中间层提供了三个重要的能力:
- 虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 简化内存管理:VM为每个进程提供了一致的地址空间,从而简化了链接、加载、内存共享等过程;
- 内存保护:保护每个进程的地址空间不被其他进程破坏。
一个简单的 Java 程序
public static void main(String[] args) {
string i = args[0];
..........
}
在程序代码相同的情况下,我们用这份代码同时启动三个 JVM 进程,我们暂时将进程依次命名为 a , b , c 。
这三个进程用到的代码是一样的,都是我们提前写好的,可以被多次运行。由于我们是直接操作物理内存地址,假设变量 i 保存在 0x354 这个物理地址上。这三个进程运行起来之后,同时操作这个 0x354 物理地址,这样这个变量 i 的值不就混乱了吗? 三个进程就会出现变量的地址冲突。
![](http://pubimage.360doc.com/wz/default.gif)
在直接操作物理内存的情况下,我们需要知道每一个变量的位置都被安排在了哪里,而且还要注意和多个进程同时运行的时候,不能共用同一个地址,否则就会造成地址冲突。
现实中一个程序会有很多的变量和函数,这样一来我们给它们都需要计算一个合理的位置,还不能与其他进程冲突,这就很复杂了。
虚拟内存的引入正是要解决上述的问题,虚拟内存引入之后,进程的视角就会变得非常开阔,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。每个进程都认为自己独占所有内存空间,自己想干什么就干什么。
系统上还运行了哪些进程和我没有任何关系。这样一来我们就可以将多进程之间协同的相关复杂细节统统交给内核中的内存管理模块来处理,极大地解放了程序员的心智负担。这一切都是因为虚拟内存能够提供内存地址空间的隔离,极大地扩展了可用空间。
![](http://pubimage.360doc.com/wz/default.gif)
这样进程就以为自己独占了整个内存空间资源,任何一个虚拟内存里所存储的数据,本质上还是保存在真实的物理内存里的。只不过内核帮我们做了虚拟内存到物理内存的这一层映射,将不同进程的虚拟地址和不同内存的物理地址映射起来。
当 CPU 访问进程的虚拟地址时,经过地址翻译硬件将虚拟地址转换成不同的物理地址,这样不同的进程运行的时候,虽然操作的是同一虚拟地址,但其实背后写入的是不同的物理地址,这样就不会冲突了。
虚拟内存空间到底长啥样?
在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:
![](http://pubimage.360doc.com/wz/default.gif)
通过这里可以看出:
- 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
- 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。
再来说说,内核空间与用户空间的区别:
- 进程在用户态时,只能访问用户空间内存;
- 只有进入内核态后,才可以访问内核空间的内存;
虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。
![](http://pubimage.360doc.com/wz/default.gif)
我们看看用户空间分布的情况,以 32 位系统:
![](http://pubimage.360doc.com/wz/default.gif)
通过这张图你可以看到,用户空间内存,从低到高分别是 6 种不同的内存段:
- 0x0000 0000 到 0x0804 8000 这段虚拟内存地址是一段不可访问的保留区,因为在大多数操作系统中,数值比较小的地址通常被认为不是一个合法的地址,这块小地址是不允许访问的。比如在 C 语言中我们通常会将一些无效的指针设置为 NULL,指向这块不允许访问的地址。
- 代码段,包括二进制可执行代码;
- 数据段,包括已初始化的静态常量和全局变量;
- BSS 段,包括未初始化的静态变量和全局变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 堆空间的上边是一段待分配区域,用于扩展堆空间的使用
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;
在上面的内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存。
再来看下64位虚拟内存空间
我们知道在 32 位机器上,指针的寻址范围为 2^32,所能表达的虚拟内存空间为 4 GB。
那么我们可能会认为在 64 位机器上,指针的寻址范围为 2^64,所能表达的虚拟内存空间为 16 EB 。虚拟内存地址范围为:0x0000 0000 0000 0000 0000 - 0xFFFF FFFF FFFF FFFF 。
事实上在目前的 64 位系统下只使用了 48 位来描述虚拟内存空间,寻址范围为 2^48 ,所能表达的虚拟内存空间为 256TB。
![](http://pubimage.360doc.com/wz/default.gif)
从上图中我们可以看出 64 位系统中的虚拟内存布局和 32 位系统中的虚拟内存布局大体上是差不多的。主要不同的地方有三点:
- 由高 16 位空闲地址造成的 canonical address 空洞。在这段范围内的虚拟内存地址是不合法的,因为它的高 16 位既不全为 0 也不全为 1,不是一个 canonical address,所以称之为 canonical address 空洞。
canonical address 空洞是如何形成的呢?
我们都知道在 64 位机器上的指针寻址范围为 2^64,但是在实际使用中我们只使用了其中的低 48 位来表示虚拟内存地址,那么这多出的高 16 位就形成了这个地址空洞。
如果虚拟内存地址中的高 16 位全部为 0 (表示用户空间虚拟内存地址)或者全部为 1 (表示内核空间虚拟内存地址),这种地址的形式我们叫做 canonical form,对应的地址我们称作 canonical address 。
- 在代码段跟数据段的中间还有一段不可以读写的保护段,它的作用是防止程序在读写数据段的时候越界访问到代码段,这个保护段可以让越界访问行为直接崩溃,防止它继续往下运行。
- 用户态虚拟内存空间与内核态虚拟内存空间分别占用 128T,其中低128T 分配给用户态虚拟内存空间,高 128T 分配给内核态虚拟内存空间。
如何管理虚拟地址与物理地址之间的关系?
主要有两种方式,分别是内存分段和内存分页,分段是比较早提出的,我们先来看看内存分段。
内存分段
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。
分段机制下,虚拟地址和物理地址是如何映射的?
分段机制下的虚拟地址由两部分组成,段选择因子和段内偏移量。
![](http://pubimage.360doc.com/wz/default.gif)
段选择因子和段内偏移量:
- 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
- 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
在上面,知道了虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:
![](http://pubimage.360doc.com/wz/default.gif)
如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。
分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
- 第一个就是内存碎片的问题。
- 第二个就是内存交换的效率低的问题。
接下来,说说为什么会有这两个问题。
我们先来看看,分段为什么会产生内存碎片的问题?
我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中:
- 游戏占用了 512MB 内存
- 浏览器占用了 128MB 内存
- 音乐占用了 256 MB 内存。
这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。
如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。
![](http://pubimage.360doc.com/wz/default.gif)
内存分段会出现内存碎片吗?
内存碎片主要分为,内部内存碎片和外部内存碎片。
内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以不会出现内部内存碎片。
但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部内存碎片的问题。
解决「外部内存碎片」的问题就是内存交换。
可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。
这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
再来看看,分段为什么会导致内存交换效率低的问题?
对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页。
内存分页
段的好处就是能产生连续的内存空间,但是会出现「外部内存碎片和内存交换的空间太大」的问题。
要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页(Paging)。
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
虚拟地址与物理地址之间通过页表来映射,如下图:
![](http://pubimage.360doc.com/wz/default.gif)
页表是存储在内存里的,内存管理单元 (MMU)就做将虚拟内存地址转换成物理地址的工作。
而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
分页是怎么解决分段的「外部内存碎片和内存交换效率低」的问题?
内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。
但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片的现象。
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
![](http://pubimage.360doc.com/wz/default.gif)
分页机制下,虚拟地址和物理地址是如何映射的?
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址
这看起来似乎没什么毛病,但是放到实际中操作系统,这种简单的分页是肯定是会有问题的。
简单的分页有什么缺陷吗?
有空间上的缺陷。
因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。
那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。
多级页表
要解决上面的问题,就需要采用一种叫作多级页表(Multi-Level Page Table)的解决方案。
在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 4KB 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。如下图所示:
![](http://pubimage.360doc.com/wz/default.gif)
你可能会问,分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?
当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。
每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配。
如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。
那么为什么不分级的页表就做不到这样节约内存呢?
我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(二级页表在需要时创建)。
我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。
程序局部性原理表现为:时间局部性和空间局部性。时间局部性是指如果程序中的某条指令一旦执行,则不久之后该指令可能再次被执行;如果某块数据被访问,则不久之后该数据可能再次被访问。空间局部性是指一旦程序访问了某个存储单元,则不久之后,其附近的存储单元也将被访问。
从程序局部性原理的描述中我们可以得出这样一个结论:进程在运行之后,对于内存的访问不会一下子就要访问全部的内存,相反进程对于内存的访问会表现出明显的倾向性,更加倾向于访问最近访问过的数据以及热点数据附近的数据。
根据这个结论我们就清楚了,无论一个进程实际可以占用的内存资源有多大,根据程序局部性原理,在某一段时间内,进程真正需要的物理内存其实是很少的一部分,我们只需要为每个进程分配很少的物理内存就可以保证进程的正常执行运转。
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
- 全局页目录项 PGD(Page Global Directory);
- 上层页目录项 PUD(Page Upper Directory);
- 中间页目录项 PMD(Page Middle Directory);
- 页表项 PTE(Page Table Entry);