为什么要有虚拟内存
一、引言
本文参考 小林coding 的《图解操作系统》,也是我十分喜欢的一个公众号博主,为他打 call
老读者知道我之前再写 Kafka
的博文,为什么突然开始写操作系统的呢?
原因在于:
当我看到 Kafka
服务端的一些 IO 操作时,我发现我看不懂了,了解之后发现这里 Netty
的概念。
当我尝试了解 IO 时,我发现一些内存、磁盘的交换,搞的我焦头烂额,于是,想静下心来从头开始。
当我把 小林coding 的 《图解操作系统》看完之后,我发现对操作系统的理解更上一层楼。
用一段话,作为今天的开场白:
- 读书的根本目的,未必是解决现实问题,它更像一场心灵的抚慰。
- 一个喜欢读书的人,可能不会记得自己读过哪些书。
- 但是那些看过的故事、收获的感悟、浸染过的气质,
- 就像一颗种子,会在你的身体里慢慢发芽长大,不断提升你的认知,打开你的视野。
二、虚拟内存
之前大学学过一段时间的单片机,比如:51单片机、MSP430单片机,当我们使用单片机的时候,需要借助工具把程序烧录进去,这样程序也可以跑起来。
另外,单片机的 CPU 是直接操作内存的 物理地址
在这种情况下,要想在内存中同时运行两个程序是不可能的。如果一个程序在 2000 的位置写入一个新的值,将会擦掉另外一个程序相同位置的所有内容。
所以,我们每更改一次程序都要进行烧录一次。
那现代的操作系统是如何避免这个问题的呢?
通过上述描述我们发现,两个程序产生冲突的原因在于:都使用了绝对的物理地址,导致地址冲突。
我们可以把 进程 所使用的地址隔离开,让操作系统为每一个进程分配独立的一套 虚拟地址,人人都有,大家自己玩自己的就可以,互不干涉。
前提是每个进程都不能直接访问物理地址,至于虚拟地址如何落到物理内存中,操作系统的内存管理安排的明明白白。
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
如果程序要访问虚拟地址时,由操作系统转换成不同的物理地址,这样不同的进程在运行的时候,写入的是不同的物理地址,就不会出现冲突了。
于是,对于两种地址的概念:
- 虚拟内存地址:程序所使用的内存
- 物理内存地址:实际存在硬件空间地址
操作系统引进了虚拟内存,进程所持有的虚拟内存会通过 CPU 芯片中的内存管理单元(MMU)的映射关系来转换为物理地址,然后通过物理地址访问内存。
那问题来了,操作系统是如何管理虚拟内存地址和物理内存地址之间的关系的呢?
主要通过内存分段和内存分页
三、内存分段
对于程序而言,一个程序由若干个逻辑分段组成的,比如:代码分段、数据分段、栈段、堆段 组成
不同的段具有不同的属性,所以使用分段的形式将这些段分离出来
当我们的虚拟内存在分段的机制下,虚拟地址和物理地址是如何进行映射的?
分段机制下的虚拟地址主要由两部分组成:段选择子和段内偏移量
- 段选择因子主要存在于段寄存器中,段选择因子中,最重要的是 段号,用作段表的索引。而我们的段表,保存了该段的一些信息:段基地址、段界限、特权级 DPL
- 虚拟地址的段内偏移量应该位于
0 - 段界限
之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址
虚拟地址通过段表与物理地址进行映射,分段机制会把程序的虚拟地址分为 4 个段,分别为:代码段、数据段、堆段、栈段,如下所示:
比如,当前我需要计算 段三中偏移量为 500 的虚拟地址。
- 我们 CPU 首先通过 段寄存器 在段表中,找到 段号为 3 的信息
- 在段基地址的基础上加上 500,最终物理地址为:7500
- 分段的方法简单易懂,但同样也存在一些不足之处:
- 内存碎片
- 内存交换的效率低
1. 内存碎片
假设有 1G 的物理内存,用户执行了多个程序,其中:
- 游戏:512MB
- 浏览器:128MB
- 音乐:256MB
这个时候,如果我们关闭掉浏览器,则内存会自动回收,空闲内存为:1024 - 512 - 256 = 256MB
但如果这个 256MB 不是连续的,而是被分成了两个 128MB 内存,这样会导致我们的程序无法再次打开一个 200MB 的程序。
这里的内存碎片的问题共有两个地方:
- 外部内存碎片:多个不连续的小物理内存,导致新的程序无法被加载
- 内部内存碎片:程序所有的内存都被加载进了物理内存,但是这个程序有部分内存是不经常用的,也会导致内存的崩溃
解决外部内存碎片的方式:内存交换
如果我们把上面图中的音乐占用的 256MB 内存写到磁盘上,然后再从磁盘读回来到内存里。不过读回的时候,我们不能装载会原来的位置,而是紧紧跟着那 512MB 内存后面。这样就能空缺出 256MB 内存,于是新的 200MB 的程序可以加载进来了。
这个内存交换空间,在 Linux 系统中,也就是我们常见到的 Swap 空间,这块空间是从硬盘划分出来的,用于 内存和硬盘的交换
2. 内存交换效率低
对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap
内存区域,这个过程会产生性能瓶颈。
因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。
所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。
四、内存分页
分段的好处就是能产生连续的内存空间,但是会出现 内存碎片和内存交换效率降低 的问题
怎么解决这些问题呢?
如果我们能让 内存和磁盘 交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决这个问题了。这个方法被称为:内存分页
分页是把整个物理内存和虚拟内存切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫:页(Page)。在 Linux 下,每一页的大小为:4KB
虚拟地址和物理地址的映射通过 页表 来映射,如图:
页表是存储在内存中的,内存管理单元(MMU)就做将虚拟内存转换成物理内存地址的工作。
而当我们的进程访问的虚拟地址不在页表中时,将会产生一个 缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。
那么,分页是如何解决内存碎片和内存交换效率低的问题呢?
由于内存空间都是划分好的,也就不会像分段那样产生间隙非常小的内存,这正是分段产生内存碎片的原因。
而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。
如果内存空间不够,操作系统会把其他正在运行的进程中的 最近没被使用 的内存页面给释放掉,也就是暂时写在磁盘上,称为:换出
一旦需要的时候,再加载进来,称为:换入。所以一次性写磁盘的也只有少数的几个页,不会花费太多的时间,内存交换的效率就相对比较高。
更进一步,分页的方式使我们加载程序的时候,不再需要一次性都把程序加载到物理内存中。
我们完全可以在虚拟内存和物理内存进行映射后,并不真的把页加载到物理内存中。
而是 只有在程序运行时,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
分页机制下,虚拟内存和物理内存是如何映射的?
在分页机制下,虚拟地址分为两部分:页号 和 页面偏移。
页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。如图:
总结:对于虚拟内存转换的步骤
- 把 虚拟内存 地址,切分为 页号 和 页内偏移量
- 根据 页号,从 页表 里面查询对应的 物理页号
- 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址
- 例子如下:
但这种简单的分页也是有缺陷的,有什么缺陷呢?
有空间上的缺陷,
在 32 位的环境下,虚拟内存一般会分配 4G 的地址空间。如果一个页的大小为 4KB,那么虚拟内存中一共含有 100 万个页,每一个页都需要一个页表项维护,也就是要 100 万个页表项,每一个页表项大概需要 4 个字节来存储,这样我们页表的大小为:4MB
这 4MB 大小的页表,看起来也不是很大。但是要知道,每一个进程都具有自己的虚拟地址空间的,也就是都含有自己的页表。
那么,100 个进程的话,存储页表的内存需要:400MB,这是一个十分大的内存消耗。
1. 多级页表
要解决我们上面的问题,需要用到我们的另一种解决方案:多级页表
前面我们讲到,对于单页表的实现,在 32 位环境和页大小 4KB
的环境下,一个进程大概需要100万个页表项,每个页表项 4 字节的话,每张页表需要耗费 4MB 的内存。
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024
个页表(二级页表),每个表(二级页表)中包含 1024
个「页表项」,形成二级分页。如下图所示:
你可能会问,分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?
当然,如果我们 4GB 的虚拟地址全部映射到物理内存上的话,二级分页占用空间确实更大了,但是,我们往往不会为一个进程分配那么多内存。
计算机组成原理中有一个局部性原理
每个进程都有 4GB 的内存,但对于大多数程序来说,这 4GB 的内存一般根本用不完,会存在部分对应的页表项为空的情况。对于已分配的页表项,如果最近一段时间不访问的话,在物理内存资源紧张的情况下,操作系统会将页面换出磁盘,也就是不会占用物理内存
如果我们使用了 二级分页,那么我们的一级页表就可以覆盖 4GB 的虚拟地址空间,但 如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项的二级页表了,既可以在需要使创建二级页表。
做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB
,这对比单级页表的 4MB
是不是一个巨大的节约?
但这里大家一定会有一个疑问?为什么一级页表不这样做呢?
我找了好久网上的文章,发现写的都让人难以理解,总之就是不说人话,我们先给出结论,在进行解释:
我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项**(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。**
上面就是网上流行的结论,读完让人晦涩难懂,我们先看下面正常的一级页表:
我们想,页表是做什么的?
答:虚拟内存地址到物理内存的映射
缺页中断什么时候发生?
答:软件试图访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断
缺页异常什么时候发生?
答:访问的虚拟内存地址是合法的,但是该虚拟内存地址还未分配物理页(如上图)
我们回到最初的问题:为什么一级页表没办法当用到时才进行分配?
最主要的原因在于,我们的虚拟地址一定要全部在页表中存在,也就是和上图一样,我们可以允许右边的物理内存不存在,但是,我们不允许左边的虚拟页号不存在。
这里可能有人不理解,我不能不存在嘛?
假如,你当前访问虚拟页 5 这个地方,你在页表中都找不到,你的程序不直接死机了嘛。有人可能会提 缺页异常、缺页中断,可以去上面看一下这两个的理论。
所以,这就是我们 一级页表没办法做上面的操作。重点理解 虚拟地址必须全部映射到页表中
我们继续回到多级页表,Linux 系统中,通过四级页表的结构:
- 全局页目录项 PGD(Page Global Directory)
- 上层页目录项 PUD(Page Upper Directory);
- 中间页目录项 PMD(Page Middle Directory);
- 页表项 PTE(Page Table Entry);
2. TLB
多级页表虽然解决了空间的问题,但是虚拟地址到物理地址的转换就多了几道工序,降低了转换的效率。
同样,我们基于 局部性原理。程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。
img
我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
五、段页式内存管理
当然,分段和分页并不是互斥的,也是可以及合作的
- 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制
- 接着把每一个段划分为多个页
这样,地址结构:段号、页号、偏移地址
段页式地址变换中要得到物理地址须经过三次内存访问:
- 第一次:访问段表,找到页表的起始位置
- 第二次:访问页表,找到对应的物理页号
- 第三次:将物理页号与偏移地址组合,找到物理内存
六、Linux 内存管理
Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制。
这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。
但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。
Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),,也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。
七、总结
为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。
那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。
那么对于虚拟地址与物理地址的映射关系,可以有分段和分页的方式,同时两者结合都是可以的。
内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题。
于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB
。由于分了页后,就不会产生细小的内存碎片。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。
再来,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度
linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理。于是 Linux 就把所有段的基地址设为 0
,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。
另外,Linux 系统中虚拟空间分布可分为用户态和内核态两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。