为什么要有虚拟内存?
虚拟内存
单道程序的操作系统直接引用的物理地址,无法同时运行两个程序,程序会因为内存地址错乱而崩溃,单道程序的操作系统使用队列,做完一个任务开始下一个任务。
现代操作系统的做法是把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,大家自己玩自己的地址就行,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。 操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。
程序中使用的地址: 虚拟内存地址 硬盘中使用的地址: 物理内存地址
操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示: 操作系统使用内存分段和内存分页来管理虚拟地址与物理地址之间的关系
补充: 虚拟内存有什么作用
- 提高内存使用率: 虚拟内存可以使得进程运行内存超过物理内存大小,因为程序运行符合局部性原则,cpu访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的的内存,我们可以将他们换出物理内存外,到磁盘上的swap区域
- 进程隔离: 每个进程都有自己的页表,所以每个进程的虚拟内存相互独立,一个进程没有办法访问其他进程的页表,这些页表都是私有的,这就解决了多进程之间地址冲突的问题
- 安全性保证: 页表里面的页表项除了物理地址之外,还有一些标志属性,比如控制一个页的读写权限标记该页是否存在,在内存访问方面,操作系统为操作系统提供了更好的安全支持。
内存分段
程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。 分段机制下的虚拟地址由两部分组成,段选择因子和段内偏移量。
段选择因子和段内偏移量:
- 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
- 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。
问题
- 内存碎片化问题
- 内存交换效率低
碎片化
- 外部内存碎片,产生了多个不连续的小物理内存,导致新的程序无法被装载
- 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;
内存交换,将部分内存写到磁盘上再次读取进来紧挨着已经被占用的防止出现外部碎片,这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
交换率低 对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。 因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。
内存分页
分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。 要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页(Paging)。
分页是把整个虚拟和物理空间切成一段段固定尺寸的大小,这样一个连续的并且尺寸固定的空间我们叫做页。 虚拟地址和物理地址之间通过页表来映射 和分段管理的段表类似,分页管理的页表也存储在MMU(内存管理单元)中
当进程访问的虚拟地址在页表查不到的时候,系统会产生一个缺页中断,进入系统内核空间分配物理内存,更新进程页表,最后返回用户空间,恢复进程的运行
由于内存控件都是预先划分好的,也就不会像分段会产生非常细小的内存,这正是分段会产生内存碎片的原因,而采用了分页, 那么释放的内存都是以页为释放单元,也就不会产生无法给进程使用的小内存了
如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。
在分页机制下,虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。 内存地址转化三个步骤
- 把虚拟内存地址,切分成页号和偏移量
- 根据页号去MMU中查找对应的物理页号
- 直接拿到物理页的页号(物理内存的基地址), 加上前面的偏移量, 就得到了物理内存地址
有空间上的缺陷。 因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。 在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。 这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。 那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。 ps: 内存分页和内存分段的逻辑和链表和数组有点相似,分页采用的不是连续的内存区域,分段采用的是连续的内存区域
多级页表
如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?
64位的系统二级分页不够使用,变成了四级
- 全局页目录项 PGD(Page Global Directory);
- 上层页目录项 PUD(Page Upper Directory);
- 中间页目录项 PMD(Page Middle Directory);
- 页表项 PTE(Page Table Entry);
内存地址局部性原则 我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。
段页式内存管理
段页式内存管理的实现方式
- 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制
- 接着再将每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页 地址结构由段号,段内页号,页内偏移三个部分组成
段页式地址转化中要得到物理地址需要进过三次内存访问
- 段表得到页表的起始地址
- 访问页表得到物理页号
- 物理页号与页内偏移组合得到物理地址
ps: 逻辑地址和线性地址
- 程序中所使用的地址,通常是没有被段式内存管理映射的地址,称为逻辑地址
- 通过段式内存管理映射的地址,称为线性地址,也叫做虚拟地址
逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。
- 程序文件段(.text),包括二进制可执行代码;
- 已初始化数据段(.data),包括静态常量;
- 未初始化数据段(.bss),包括未初始化的静态变量;
- 堆段,包括动态分配的内存,从低地址开始向上增长;
- 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关 (opens new window));
- 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;
总结
为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套虚拟地址空间,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。
每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过内存交换技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。
那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。
那么对于虚拟地址与物理地址的映射关系,可以有分段和分页的方式,同时两者结合都是可以的。
内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致内存碎片和内存交换效率低的问题。
于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 4KB。由于分了页后,就不会产生细小的内存碎片。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。
再来,为了解决简单分页产生的页表过大的问题,就有了多级页表,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的局部性原理,在 CPU 芯片中加入了 TLB,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。