聊聊虚拟内存
早在上大学时,学习计算机组成原理的时候,老师就有讲过虚拟内存,但是由于当时过于年轻对于它的理解几乎是皮毛,工作多年后发现对虚拟内存的理解还是非常重要的,在分析应用内存问题时也许能够用到相关的知识,所以今天来复习一下曾经忘记掉的知识(或许上学时都没有学会😂)。
在早期的计算机是没有虚拟内存的概念的,程序都是直接操作物理内存的,不过在那个时候也不存在太多的进程,这样问题还不是很大,但是后来随着计算机的发展,出现了支持多进程的计算机,当这些进程都直接对物理内存进行读写的时候就很难管理,假如同时有 A 和 B 两个进程对同一个物理内存地址进行修改就会出现问题。为了让多进程使用内存更加安全(可能还有别的原因😄),就提出了虚拟内存的概念,每个进程都有一个独立的虚拟内存,就好像每个进程都自己独立拥有这一块内存,没有其他线程的干扰,不同进程的虚拟内存地址就算是一样的他们储存的数据也是不一样,但是真实的数据存储都是要依赖物理内存的,计算机有专门的模块来处理虚拟内存和物理内存的这种映射关系,这个模块简称 MMU
,当程序把虚拟地址传给 CPU
后,CPU
需要借助 MMU
把虚拟地址翻译成物理地址,然后对物理地址进行读写操作。操作虚拟地址和物理地址的映射有两种方式内存分页(Paging
)和内存分段 。在 Linux
中主要使用的是内存分页,我们今天也主要讲讲内存分页。
内存分页
操作系统会把内存划分成一个个的基本单位来管理,就像是一个个内存块儿,这个块儿叫做页。(Page
)在 Linux
中这个页的大小通常是 4KB
,当应用想要使用内存时,就需要通过虚拟地址在在页表(记录虚拟地址和物理地址的映射)中去查询,查询到对应的物理地址后再进行操作,如果这个过程中没有查到呢?这个时候就会产生一个缺页异常,这时操作系统就会去为当前进程的虚拟地址分配一个物理内存页,当分配成功后,然后应用进程就得以继续执行。
具体虚拟内存是怎么映射的呢?如下图:
虚拟地址分为页号和页内的偏移量,在页表中查询到虚拟页号对应的物理页号后,然后把物理页号的初始地址加上偏移量就是目标的地址。
内存分页在很大的程度上解决了内存碎片化的问题,这里简单描述一下内存碎片化的问题:假如我需要启动一个程序需要 500MB 的内存,由于系统之前回收了 2 个程序,他们分别占用 200MB 和 300MB,但是这两块内存并不是连续的,所以新的应用不能加载。但是通过分页是能够解决这个问题的,分页后操作系统管理的基本单位是一个内存页。在虚拟内存中看到的自己的内存是连续的,而虚拟内存映射到物理内存页后可就不一定是连续的了。它能够很大程度上提升内存的使用率。如下图:
虚拟内存中分配的内存在没有使用前是不会映射到物理内存的,这里举一个例子:假如我想要绘制一个 1MB 的 bitmap
,然后我就申请了 1MB
的内存,这个内存是在虚拟内存中的,这时并没有真正使用物理内存,当我真正想要绘制时就涉及到这块内存的写,这时就会触发缺页错误,然后才会真正地分配映射物理内存。通过这种只有在真正使用时才分配物理内存的方式,也能够提高内存的使用效率。
我们前面说到每个进程都有一个虚拟内存,不同的进程而且是互相独立的,以 32 位系统,4G 的物理内存为例,通常他们的虚拟内存大小也是 4G,每个假如这个时候其他程序占用了 3.5G 的物理内存,这时我想要再加载一个 600MB 的应用程序,能够加载吗?大部分情况下是可以加载的,首先按照前面说的 600MB 的程序页不一定立刻就要 600MB 物理内存,只有在要使用时才分配。当后续使用的内存超过了物理内存后才会出现问题,在 Linux
中设计了 Swap
空间(可以手动设置这个空间的大小),也就是把本地磁盘的一部分空间拿来当内存使用(当然不是真正的当内存,磁盘的速度太慢了不行,CPU
不能直接和磁盘交换数据)。当申请的内存超过剩余的物理内存后,操作系统就会查找到最不常用的页,然后把它的数据存储到 Swap
空间的磁盘上,然后这部分页就空闲出来了,这部分页就可以分配给申请的内存,这就被称为 Swap-out
。当下次要使用这部分内存时,又会把它从磁盘中加载到内存页中去,这被称为 Swap-in
。 参考下图:
那如果是这样我们不是可以把磁盘当内存,就可以拥有超大的内存了,洗洗睡吧。当出现大量的 Swap
操作时,就表示内存不足了,由于磁盘的读写速度远远小于内存,也就是出现大量 Swap
是你会感觉到电脑明显的卡顿。这些年很多 Android
手机厂商也把这个当成重要的卖点声称 8 + 4
( 8G 物理内存 + 4G Swap
空间) 的内存,不明真相的用户还以为这是多么高级的技术,其实好多年前 Linux
就支持了,那为什么手机厂商近几年才开放呢?因为现在手机闪存技术发展,手机磁盘的读写速度越来越快了,所以可以这么玩儿了,但是当你真正地使用 Swap
空间后,你的体验应该也会很差。
简单内存分页也是存在问题的,以 32 位系统 4GB
物理内存为例,假如页的大小为 4KB
,那么就需要分页 4*2^30 / 4*2^10 = 2^20
个,大概就是需要 100 万个,页表中每一项需要占用 4 Bytes
的内存,那么总共就需要 4 * 2^20 Bytes
内存,也就是 4MB
,4MB
的内存看上去还可以接受,但是每一个进程都需要这么大的内存来存放页表,在现在的操作系统中有 100 进程是很常见的事情,那这里就是 400MB
,什么都不干就需要 400MB
的内存来存放页表,这是完全不可以接受的,然后这群地球上最聪明的人又发明了多级页表来解决这个问题。
多级页表
假如我们是 32 位系统下的二级页表,虚拟地址需要存放 一级页号
+ 二级页号
+ 页内偏移
,开始通过一级页号在一级页表中查询到二级页表的地址,然后通过二级页表的地址读取到二级页表,然后通过二级页号在二级页表中查询到对应页的物理地址,最后通过物理地址加偏移获取到最终的物理地址,如下图:
通常这个时候可能你要说了二级页表同样的要 4MB
的空间啊,加上一级页表占用的空间不是比简单页表占用的空间还大吗?二级页表是可以动态加载的,当需要时才会去加载,大部分时间是不用全部加载所有的二级页表的,一级页表占用 4KB
,这样就比简单分页占用的空间小得多了。但是这时你可能又会问简单页表如果也动态加载,不是可以更加节省内存,一级页表是不能动态加载的,一级页表必须对虚拟内存地址全覆盖。
如果是 64 位的系统,会有 4 级页表,虽然能够继续降低内存的使用,但是由于页表的层级增多,会导致地址的查询速度变慢,这也就是计算机中常见的时间和空间的问题,所以又开发了专门的页表缓存 TLB
,借助它能够快速查询到读取比较频繁的物理地址,它的命中率非常高,只有当它查询失败后才会走常规的查询逻辑。