物理地址和虚拟地址
Caching
- 虚拟内存实际上是储存在磁盘上的数组,虚拟地址为数组的索引
- 使用第六章cache的方法,将较底层的磁盘上的数据分割为一个个块(被称为虚拟页VP),而内存则作为他们的cache
- 物理内存(内存)同样地被分割为物理页,每个物理页与虚拟页大小相同,因此可以储存任意虚拟页
- 虚拟页的集合被分为三个类别
- 未创建页的(有效位为0,且不占用空间)
- 未分配的,指虚拟内存在磁盘中,但还没有缓存在内存中
- 已分配的,指已经在内存中映射了同样的物理页的虚拟页
- 如果在读取虚拟页里的内容时,未命中物理页,意味着需要从磁盘中读取,而这比直接从内存中读取要慢10万倍
- 因此,虚拟页往往很大,已获得更好的空间caching
- 写入虚拟内存时,内存总是采用写回而不是直写(见第六章)
- 如上图,程序要使用某一虚拟内存时,首先通过在内存中的页表结构查找物理内存
- 如果没有找到,调用缺页异常,从磁盘中找到虚拟页并且写入内存中,然后再次调用当前程序
- 程序的局部性原理使得我们的内存读取往往在一个虚拟页中,极大地减少了磁盘调用
- 但是如果某个程序的工作集大于了物理内存的大小,则会出现“抖动”现象,虚拟页将会不断换进换出,极大减慢运行速度
- 系统为每一个进程提供了一个单独的页表,也就是一个单独的虚拟地址空间
- 不同的虚拟页可以映射到同一个物理页,从而实现共享代码和数据
- 例如Linux内核和C标准库
- 不同的进程虽然存储在不同的磁盘位置,但是虚拟内存世道每个程序的虚拟位置是符合同样构造的,极大地简化了链接器的实现
- 当程序用malloc请求一段额外的内存时,操作系统可以分配一段连续的虚拟内存,但是在关联到物理内存时,物理内存确是可以不连续的
- 不同的虚拟页可以映射到同一个物理页,从而实现共享代码和数据
- 虚拟内存通过带许可位的页表可以控制每个进程对某些内存的调用权限
- 例如有些内存时只读的,有些是只有内核可以操作的
- 例如有些内存时只读的,有些是只有内核可以操作的
地址翻译
-
虚拟地址到物理地址的翻译
- 首先CPU通过一个寄存器指向当前进程的页表
- 虚拟地址的VPN部分作为索引指向某一PTE(里面储存了物理页号)
- 物理页号和虚拟地址的VPO部分共同组成了唯一的磁盘中的物理地址
-
具体硬件执行步骤
-
现代CPU会通过SRAM高速缓存和TLB缓存加速PTE的读取
-
多级页表
- 如果只使用单个页表进行地址翻译,那么驻存在内存中的页表大小会很大
- 因为即使PTE为空,我们也必须为它预留空间
- 通过多级列表,将连续的PTE视为一个块
- 当一个块中的PTE都没有分配时,无需创建二级页表
- 如果只使用单个页表进行地址翻译,那么驻存在内存中的页表大小会很大
-
具体的翻译步骤
内存映射
- 一般来说,要操作磁盘上的文件,需要将文件的内容通过fstream等写入内存,然后修改内存,再写回文件中,而内存映射是指将某一块虚拟内存与磁盘上的文件一一对应。这样,当我们操作虚拟内存时,实际上实在操作文件,相当于给磁盘上的文件也加了个指针,于是可以用指针操作内存一样直接操作磁盘上的文件
- 内存映射的好处在于省去了把磁盘内容读入内存的步骤
- 虚拟内存的存在是由于物理内存一般来说小于进程的地址空间,所以通过将暂时不用的内存存放在磁盘上以实现更大的内存空间
- 内存映射的存在是由于对很多大文件来说,地址空间任然不够大,无法将一个文件全部读入,并且这样十分消耗时间,于是通过内存映射直接操作文件,作用相当于将文件读入内存再访问
- 共享对象
- 私有对象的写时复制机制
- 当私有对象被映射到多个进程的内存空间而我们想要写入某一个进程时,为了不影响其余的进程,我们开了一个新的物理内存,并且将被修改的虚拟内存指向新的物理内存
动态内存分配
-
堆的分配器有显式和隐式分配两种,他们的区别在于是否有系统自动释放内存
-
堆中分配的内存地址也需要对齐
-
堆从序言块(8字节已分配块)开始,到结尾块(大小为0的已分配块)结束
-
分配器的编写视图实现两个性能目标
- 最大化吞吐量
- 最大化内存利用率
- 两者往往是矛盾的
-
碎片
- 内部碎片
- 通常处于对齐原因
- 外部碎片
- 通常由于出现多个小的空闲块
- 内部碎片
-
隐式空闲链表法
- 每个块的头部维护这个块的消息
- 由于对齐的关系,块的地址都是8的倍数(假定的双字对齐),故头部的低3位一定是0,因此我们可以利用这3位储存别的信息
- 我们用最低位来表示该块是被分配过得还是空闲的
- 头部剩下的部分表示该块的大小
- 缺点在于对于任何操作,例如放置分配的块,都要对链表进行搜索,其花费与块的总数线性相关
-
放置块的策略
- 首次适配
- 从头开始搜索第一个适合的空闲块
- 下一次适配
- 与上一种方法不同处在于每次不是从头开始搜索,而是从上一次查询结束位置
- 最佳适配
- 检查每个空闲块,选择合适的最小空闲块
- 首次适配
-
合并空闲块
- 当释放一个分配块时,可能和相邻的空闲块形成“假碎片”,即其大小大于待分配的内存,但是会请求失败
- 合并空闲块有两种模式
- 立即合并
- 可能产生抖动,产生大量不必要的分割和合并
- 推迟合并
- 等到某个分配请求失败后,扫描整个堆,合并所有的空闲堆
- 立即合并
- 待边界标记的合并
- 用上面的分配器构造方法,合并下一个空闲块很方便,但是这样无法合并前面的块
- 于是我们使用边界标记技术,即在每个块的结尾加一个边界标记,他是头部的副本
- 这样分配器可以通过检查上一个块的脚部来判断前面一个块的起始位置和状态
- 脚部总是距当前块一个字的距离
- 缺点在于对于每个块都要维护一个头部和脚部,因此在操作小块是会产生显著的内存开销
- 一个解决方法是注意到已经分配的块不需要脚部,只有空闲快需要脚部
-
显式空闲链表法
- 空闲快的主体部分对程序无用,因此可以在空闲块的主体部分存放一些数据结构的指针
- 使用pred和succ两个指针指向前一个和后一个空闲块
- 将分配时间从块总数的线性时间减少到空闲块数量的线性时间
- 分配块的释放
- 用先进先出(LIFO)的顺序维护空闲链表
- 将新释放的块放在链表额开始
- 时间快
- 按照地址顺序维护链表
- 内存利用率更高
- 用先进先出(LIFO)的顺序维护空闲链表
- 空闲快的主体部分对程序无用,因此可以在空闲块的主体部分存放一些数据结构的指针
- 分离式空闲列表
- 维护数个空闲列表,每个列表的块大致相等
垃圾收集
- 通过前述的无用的第三位中的一位来表示一个节点是否可达