数据对象可以拥有多个独立的地址,每个地址选自一个不同的地址空间,这就是虚拟内存的基本思想,例如虚拟地址空间和物理地址空间(需要经过地址翻译),虚拟内存的的三个重要能力:
VM系统将虚拟内存分割成虚拟页(Virtual Page,VP,大小固定的块),每个虚拟页大小为字节,物理内存也被分割为物理页(Physical Page,PP,大小也为P),物理页被称为页帧。虚拟页在任何时刻,都有三个互不相交的子集:
SRAM缓存表示位于CPU和主存之间的L1、L2、L3高速缓存,DRAM缓存表示虚拟内存系统的缓存,它在主存中缓存虚拟页。DRAM比SRAM慢了大约 10 倍,磁盘比 DRAM 慢了大约 100 000 倍,因此 DRAM 缓存不命中非常致命。因为 SRAM 需要 DRAM 服务,而 DRAM 是基于磁盘服务的,从磁盘的一个扇区读取第一个字节的时间开销比读这个扇区连续字节慢了 100 000 倍。因此虚拟页大约为 4KB-2MB,以增加命中率。同时,虚拟页和物理页的替换策略页十分重要。因为对磁盘访问时间很长,DRAM缓存采用写回(更新高速缓存),而不是直写(直接写到DRAM)。
页表(存放在物理内存中)将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表,操作系统负责维护页表的内容,以及在磁盘和DRAM之间来回传送页。页表是一个页表条目(Page Table Entry,PTE)的数组,虚拟地址空间的每个页在页表中一个固定偏移量处都有一个PTE。
局部性原则保证:程序在一个较小的活动页面集合上工作,这个集合叫做工作集或这常驻集合,在初始开销,也就是工作集页面调度到内存中之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。如果工作集的大小超过了物理内存的大小,就会出现抖动,即页面不断地换进和换出。如果程序运行很慢,就要考虑是不是发生了抖动。 通过虚拟内存系统可以实现两个进程修改同一块物理空间。
页表结构如下:
虚拟地址包括两部分:p位的虚拟页面偏移(Virtual Page Offset,VPO)和一个 n-p 位的虚拟页号(Virtual Page Number,VPN)。MMU则利用VPN选择适当的PTE。物理地址类似。上图显示了命中和缺页异常的情况。
VM与高速缓存之间使用物理寻址。
翻译后备缓冲器(Translation Lookaside Buffer,TLB,存在于MMU中,所以非常快)是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,TLB标记是由VPN的剩余的位组成。
对于一个32位的地址空间、4KB的页面和一个4字节的 PTE,总需要4MB的页面驻留在内存中:
2 32 / 4 x 2 10 = 2 20 4 x 2 20 = 4 M B 2^{32}/4x2^{10}=2^{20} \\4x2^{20}=4MB 232/4x210=2204x220=4MB
使用多级页表后,每个一级页表和二级页表都是4KB,刚好和一个页面大小一致。对于一级页表的一个条目,可以映射处4MB大小的空间。
多级页表减少了内存的要求:
对于k级页表,MMU必须访问k个PTE才能确定PPN(物理地址)。
Core i7地址翻译过程,CR3为页表基址寄存器
PTE由三个权限位,控制对页的访问:
注意:优化地址翻译:
因为VPO和PPO相同,所有在寻址时,cpu发送VPN到MMU,VPO到L1缓存,当MMU向TLB请求一个条目并得到PPN,缓存已经准备好了组,并与标记进行匹配
Linux将虚拟内存组织成一些区域(也叫做段)的集合,一个段就是已经存在的虚拟内存的连续片,这些页是以某种方式相关联的,比如代码段、数据段、堆、共享库段、用户栈。每个存在的虚拟页面都保存在某个区域,区域,即段允许虚拟地址空间由间隙。内核不用记录哪些不存在的虚拟页,这样的页不占用内存、磁盘、内核资源。
内核为每个进程维护一个单独的任务结构(task_struct),任务结构中的元素包含或指向内核运行该进程所需要的所有信息(PID、指向用户栈的指针、可执行目标文件的名字、程序计数器)。
任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态,其中pgd指向第一级页表的机制,mmap指向一个vm_area_stucts的链表,该链表描述了当前虚拟地址空间的一个区域,pgd则存放在CR3控制寄存器中。
一旦一个虚拟页面被初始化,它就由一个内核维护的专门的交换文件之间换来换去,交换文件也叫交换空间或交换区域,交换空间限制着当前运行的进程能够分配的虚拟页面总数。
两个线程将一个私有的写时复制对象映射到自己的虚拟内存中,共享这个对象的同一物理副本。当进程2写这个对象时,触发故障处理程序,该程序在物理内存中创建这个页面的一个新的副本,更新页表条目指向这个新的副本,然后恢复该页面的写权限。fork出的子进程和父进程共享同一物理内存,并在虚拟内存标记为私有的写时复制。则执行execve函数时的步骤有:
mmap unmap
假设系统以8字节对齐,那么 malloc(5 * sizeof(int)) 实际返回 6 个int大小的内存
碎片分为内部碎片和外部碎片
为了方便两个空闲块的合并,增加了一个脚部,脚部是内存块头部的副本信息。
增加了脚部信息,就可以很方便的合并空闲块,例如当块 n 需要释放时,有以下四种情况,虽然脚部可以很方便的合并空闲块,但是很产生很大的内存开销
堆采用隐式链表,链表的格式如下图所示
第一个字是一个双字边界对齐的不使用的填充字。填充后面紧跟着一个特殊的序言块(prologue block),这是一个 8 字节的已分配块,只由一个头部和一个脚部组成。序言块是在初始化时创建的,并且永不释放。在序言块后紧跟的是零个或者多个由 malloc 或者 free 调用创建的普通块。堆总是以一个特殊的结尾块(epilogue block)来结束,这个块是一个大小为零的已分配块,只由一个头部组成。序言块和结尾块是一种消除合并时边界条件的技巧。分配器使用一个单独的私有(static)全局变量(heap_listp),它总是指向序言块。(作为一个小优化,我们可以让它指向下一个块,而不是这个序言块。)
mem_init 函数将对于堆来说可用的虚拟内存模型化为一个大的、双字对齐的字节数组。在 mem_heap 和 mem_brk 之间的字节表示已分配的虚拟内存。mem_brk 之后的字节表示未分配的虚拟内存。
分配器通过调用 mem_sbrk 函数来请求额外的堆内存,这个函数和系统的 sbrk 函数的接口相同,而且语义也相同,
除了它会拒绝收缩堆的请求。
其中包含了一些基本常熟和宏,需要注意的是分配的空间总是 WSIZE(4) 的倍数,所以刚好头部的后三位可以用于记录分配位。
PACK 宏 将大小和已分配位结合起来并返回一个值,可以把它存放在头部或者脚部中。
GET 宏 读取和返回参数 p 引用的字。这里强制类型转换是至关重要的。参数 P 典型地是一个(viod*) 指针,不可以直接进行间接引用。
PUT 宏 将 val 存放在参数 p 指向的字中。
GET_SIZE 宏 从地址 p 处的头部或者脚部分别返回大小。
GET_ALLOC 宏 从地址 p 处的头部或者脚部分别返回已分配位。
HDRP 和 FTRP 宏 分别返回指向这个块的头部和脚部的指针。NEXT_BLKP 和 PREV_BLKP 宏 分别返回指向后面的块和前面的块的块指针。
mm_init 函数初始化分配器,如果成功就返回 0,否则就返回 -1。
mm_free 函数来释放一个以前分配的块,这个函数释放所请求的块(bp),然后使用边界标记合并技术将之与邻接的空闲块合并起来
mm_malloc 函数(见图 9-47)来向内存请求大小为 size 字节的块。在检査完请求的真假之后,分配器必须调整请求块的大小,从而为头部和脚部留有空间,并满足双字对齐的要求。第 12 ~ 13 行强制了最小块大小是 16 字节:8 字节用来满足对齐要求,而另外 8 个用来放头部和脚部。对于超过 8 字节的请求(第 15 行),一般的规则是加上开销字节,然后向上舍入到最接近的 8 的整数倍。
使用某种显示的数据结构表示堆块:
使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。
一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。
另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比 LIFO 排序的首次适配有更高的内存利用率,接近最佳适配的利用率。
参考自己设计的内存池
垃圾收集器将内存视为一张有向可达图(reachability graph),其形式如下图所示。该图的节点被分成一组根节点(root node)和一组堆节点(heap node)。每个堆节点对应于堆中的一个已分配块。有向边 p→q 意味着块 p 中的某个位置指向块 q 中的某个位置。根节点对应于这样一种不在堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存器、栈里的变量,或者是虚拟内存中读写数据区域内的全局变量。
当存在一条从任意根节点出发并到达 p 的有向路径时,我们说节点 p 是可达的(reachable)。在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来定期地回收它们。
诸如 C 和 C++ 这样的语言的收集器通常不能维持可达图的精确表示。这样的收集器也叫做保守的垃圾收集器(conservative garbage collector)。从某种意义上来说它们是保守的,即每个可达块都被正确地标记为可达了,而一些不可达节点却可能被错误地标记为可达。收集器可以按需提供它们的服务,或者它们可以作为一个和应用并行的独立线程,不断地更新可达图和回收垃圾。将一个 C 程序的保守的收集器加入到已存在的 malloc 包中,如下图所示。
无论何时需要堆空间时,应用都会用通常的方式调用 malloc。如果 malloc 找不到一个合适的空闲块,那么它就调用垃圾收集器,希望能够回收一些垃圾到空闲链表。收集器识别出垃圾块,并通过调用 free 函数将它们返回给堆。关键的思想是收集器代替应用去调用 free。当对收集器的调用返回时,malloc 重试,试图发现一个合适的空闲块。如果还是失败了,那么它就会向操作系统要求额外的内存。最后,malloc 返回一个指向请求块的指针(如果成功)或者返回一个空指针(如果不成功)。
Mark&Sweep 垃圾收集器由标记(mark)阶段和清除(sweep)阶段组成,标记阶段标记出根节点的所有可达的和已分配的后继,而后面的清除阶段释放每个未被标记的已分配块。块头部中空闲的低位中的一位通常用来表示这个块是否被标记了。
我们对 Mark&Sweep 的描述将假设使用下列函数,其中 ptr 定义为 typedef void* ptr:
标记阶段为每个根节点调用一次 mark 函数。如果 p 不指向一个已分配并且未标记的堆块,mark 函数就立即返回。否则,它就标记这个块,并对块中的每个字递归地调用它自己。每次对 mark 函数的调用都标记某个根节点的所有未标记并且可达的后继节点。在标记阶段的末尾,任何未标记的已分配块都被认定为是不可达的,是垃圾,可以在清除阶段回收。
清除阶段是对 sweep 函数的一次调用。sweep 函数在堆中每个块上反复循环,释放它所遇到的所有未标记的已分配块(也就是垃圾)。
void mark(ptr p) {
if ((b = isPtr(p)) == NULL)
return;
if (blockMarked(b))
return;
markBlock(b);
len = length(b);
for (i = 0; i < len; i++)
mark(b[i]);
return;
}
void sweep(ptr b, ptr end) {
while (b < end) {
if (blockMarked(b))
unmarkBlock(b);
else if (blockAllocated(b))
free(b);
b = nextBlock(b);
}
return;
}
初始情况下,上图中的堆由六个已分配块组成,其中每个块都是未分配的。第 3 块包含一个指向第 1 块的指针。第 4 块包含指向第 3 块和第 6 块的指针。根指向第 4 块。在标记阶段之后,第 1 块、第 3 块、第 4 块和第 6 块被做了标记,因为它们是从根节点可达的。第 2 块和第 5 块是未标记的,因为它们是不可达的。在清除阶段之后,这两个不可达块被回收到空闲链表。
对于C语言该方法是保守的,因为:第一,C 不会用任何类型信息来标记内存位置。因此,对 isPtr 没有一种明显的方式来判断它的输入参数 p 是不是一个指针。第二,即使我们知道 p 是一个指针,对 isPtr 也没有明显的方式来判断 p 是否指向一个已分配块的有效载荷中的某个位置。后一种问题可以采用平衡二叉树的方法解决,但会产生外部碎片。
C 程序的 Mark&Sweep 收集器必须是保守的,其根本原因是 C 语言不会用类型信息来标记内存位置。因此,像 int 或者 float 这样的标量可以伪装成指针。例如,假设某个可达的已分配块在它的有效载荷中包含一个 int,其值碰巧对应于某个其他已分配块 b 的有效载荷中的一个地址。对收集器而言,是没有办法推断出这个数据实际上是 int 而不是指针。因此,分配器必须保守地将块 b 标记为可达,尽管事实上它可能是不可达的。
在进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常中止程序。而且,虚拟内存的某些区域是只读的。试图写这些区域将会以保护异常中止这个程序。
间接引用坏指针的一个常见示例是经典的 scanf 错误。假设我们想要使用 scanf 从 stdin 读一个整数到一个变量。正确的方法是传递给 scanf 一个格式串和变量的地址:
scanf(“%d”, &val)
然而,对于 C 程序员初学者而言(对有经验者也是如此!),很容易传递 val 的内容,而不是它的地址:
scanf(“%d”, val)
在这种情况下,scanf 将把 val 的内容解释为一个地址,并试图将一个字写到这个位置。在最好的情况下,程序立即以异常终止。在最糟糕的情况下,val 的内容对应于虚拟内存的某个合法的读/写区域,于是我们就覆盖了这块内存,这通常会在相当长的一段时间以后造成灾难性的、令人困惑的后果。
虽然 bss 内存位置(诸如未初始化的全局 C 变量)总是被加载器初始化为零,但是对于堆内存却并不是这样的。一个常见的错误就是假设堆内存被初始化为零。
如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误(buffer overflow bug)
例如 : sizeof(int *) 写成了 sizeof(int)
/* Create an nxm array */
int **makeArray2(int n, int m)
{
int i;
int **A = (int **)Malloc(n * sizeof(int *));
for (i = 0; i <= n; i++)
A[i] = (int *)Malloc(m * sizeof(int));
return A;
}
i <= n 导致该段代码数组越界,
如果不太注意 C 操作符的优先级和结合性,我们就会错误地操作指针,而不是指针所指向的对象。
指针的算术操作(p++,如果是 int 就是指向下一个 int 对象)是以它们指向的对象的大小为单位来进行的,而这种大小単位并不一定是字节。
没有太多经验的 C 程序员不理解栈的规则,有时会引用不再合法的本地变量。
int *stackref ()
{
int val;
return &val;
}
这个函数返回一个指针(比如说是 p),指向栈里的一个局部变量,然后弹出它的栈帧。尽管 p 仍然指向一个合法的内存地址,但是它已经不再指向一个合法的变量了。当以后在程序中调用其他函数时,内存将重用它们的栈帧。再后来,如果程序分配某个值给 *p,那么它可能实际上正在修改另一个函数的栈帧中的一个条目,从而潜在地带来灾难性的、令人困惑的后果。
通过 new 或 malloc 来分配新的地址,防止该错误。
一个相似的错误是引用已经被释放了的堆块中的数据。
忘记释放申请的内存