[csapp] 第九章 虚拟内存

为了更有效的管理内存并且少出错,现代系统提供了一种对主存的抽象概念,叫做虚拟内存(VM)。

虚拟内存提供了三个重要的能力:

1.将主存看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保留活动区域,并根据需要在磁盘和主存间来回传送数据。

2.为每个进程提供了一致的地址空间,从而简化内存管理。

3.它保护了每个进程的地址空间不被其他进程破坏。

这一章从两个角度来看虚拟内存。前一部分描述虚拟内存是如何工作的,后一部分描述的是应用程序如何使用和管理虚拟内存。

9.1 物理和虚拟寻址

计算机系统的主存被组织成一个由M个连续的字节大小的单元组成的数组。每个字节都有唯一的物理地址。CPU访问内存最自然的方式就是使用物理地址,我们把这种方式称为物理寻址。

早期的PC使用物理寻址,目前数字信号处理器,嵌入式微控制器等这样的系统仍然是。现代处理器使用的是虚拟寻址。

CPU通过生成一个虚拟地址(VA)来访问主存,这个虚拟地址在被送到主存之前先转换成适当的物理地址。将一个虚拟地址转换位物理地址的任务叫做地址翻译。CPU上的内存管理单元(MMU),利用存放在主存中的查询表来动态翻译虚拟地址。

9.2 地址空间

地址空间是一个非负整数地址的有序集合,我们假设使用的是线性地址空间,即空间中的整数是连续的。

9.3 虚拟内存作为缓存的工具

虚拟内存被分为虚拟页(VP),物理内存被分为物理页(PP),被称为页帧。

虚拟页面的集合分为三个不相交的子集:

1.未分配的:VM系统还未分配的页。没有任何数据和他们相关联,因此也就不占用任何磁盘空间。

2.缓存的:当前已缓存在物理内存中的已分配页。

3.未缓存的: 未缓存在物理内存中的已分配页。

9.3.1 DRAM缓存的组织结构

为了有助于清晰理解存储层次结构中不同的缓存概念,我们使用SRAM缓存来表示L1,L2,L3缓存,并用术语DRAM缓存来表示虚拟内存系统的缓存,它在主存中缓存虚拟页。

因为大的不命中处罚,虚拟页往往很大,DRAM是全相联的。最后,因为对磁盘的访问时间很长,DRAM总是使用写回而不是直写。

9.3.2 页表

页表将虚拟页映射到物理页,每次地址翻译硬件将一个虚拟地址转换为物理地址时,都会读取页表。操作系统负责维护页表的内容,以及在磁盘和DRAM之间来回传送页。

页表条目(PTE)。每个PTE是由一个有效位和一个n位地址字段组成的。有效位表明该虚拟页当前是否被缓存在DRAM中。如果没有设置有效位,那么一个空地址表示这个虚拟页还未被分配。否则,这个地址就指向该虚拟页在磁盘上的起始位置。

9.3.3 页命中

地址翻译软件将虚拟地址作为索引来定位页表条目,并从内存中读取它。因为设置了有效位,地址翻译软件知道缓存在内存中了。所以它使用PTE的物理内存地址构造出这个字的物理地址。

9.3.4 缺页

DRAM缓存不命中称为缺页。缺页异常调用内核中的缺页异常处理程序,该程序会选择一个牺牲页。内核从磁盘复制相应VP到内存中的PP,随后返回,重新启动导致缺页的指令。

虚拟内存的发明远在CPU-内存差距加大引发SRAM缓存之前。在磁盘和内存之间传送页的活动叫做交换或者页面调度。直到有不命中发生时才换入页面的策略称为按需页面调度。也可以尝试预测不命中,但是所有现代系统使用的都是按需页面调度。

9.3.5 分配页面

调用malloc的结果:在磁盘上创建空间并更新PTE5,使它指向磁盘上这个新创建的页面。

9.4 虚拟内存作为内存管理的工具

虚拟内存简化了内存管理,并提供了一种保护内存的方法。

实际上,操作系统为每个进程提供了独立的页表,因而也就是独立的虚拟地址空间。主义,多个虚拟页面可以映射到同一个共享物理页面上。

VM简化了链接和加载,代码和数据共享,以及应用程序的内存分配。

简化链接:独立的地址空间允许每个进程的内存映像使用基本相同的格式,而不管代码和数据实际存放在物理内存的何处。这样的一致性简化了链接器的设计和实现,允许链接器生成完全链接的可执行文件,这些可执行文件独立于物理内存中代码和数据的最终位置。

简化加载:加载器为代码和数据分配虚拟页,并把他们标记为未被缓存的,将页表条目指向目标文件中适当的文字。加载器不从磁盘到内存实际复制任何数据。虚拟内存系统会按照需要自动地调入数据页。

将一组连续的虚拟页映射到任意一个文件中的任意位置的表示法称作内存映射。

简化共享:操作系统通过将不同进程中适当的虚拟页面映射到相同的物理页面,从而安排多个进程共享这部分代码的副本。

简化内存分配:当程序要求额外的堆空间时,操作系统分配k个连续的虚拟内存页面,并将他们映射到物理内存中k个任意的物理页面,物理页面可以不连续。

9.5 虚拟内存作为内存保护的工具

提供独立的地址空间使得区分不同进程的私有内存变得容易。每次CPU生成一个地址时,MMU都会读一个PTE,所以通过在PTE上添加一些额外的许可位来控制对一个虚拟页面内容的访问十分简单。

SUP位表示进程是否必须运行在内核模式下才能访问该页。如果违反了这些许可条件,那么CPU久触发一个一般保护故障。

9.6 地址翻译

CPU中的一个控制寄存器,页表基址寄存器(PTBR)指向当前页表。

9.6.1 结合高速缓存和虚拟内存

在任何既有虚拟内存又使用SRAM高速缓存的系统中,都有应该使用虚拟地址还是物理地址来访问SRAM告诉缓存的问题。大多数系统是选择使用物理寻址的。使用物理寻址,多个进程在高速缓存中共享来自相同虚拟页面的块成为很简单的事情。而且,高速缓存无需处理保护问题,因为访问权限的检查是地址翻译的一部分。注意,页表条目可以缓存,就像其他的数据字一样。

9.6.2 利用TLB加速地址翻译

CPU每产生一个虚拟地址,MMU就必须查阅一个PTE,在最糟糕的情况下,这会要求从内存多取一次数据。许多系统都试图消除这样的开销,他们在MMU中包括了一个关于PTE的缓存,称为快表(TLB)。

9.6.3 多级页表

用来压缩页表的常用方法是使用层次结构的页表。

这种方法从两个方面减少了内存要求。1.如果一级页表中的一个PTE是空的,那么相应的二级页表就根本不会存在。这代表一种巨大的潜在节约,因为4GB的虚拟地址空间大部分都会是未分配的。

2.只有一级页表才需要总是在主存中;虚拟内存系统可以在需要时创建、页面调入或调出二级页表,减少了主存压力。

k级页表层次结构的地址翻译。访问k个PTE,看上去昂贵。然而,这里TLB能够起作用,正是通过将不同层次上页表的PTE缓存起来。实际上,带多级页表的地址翻译并不比单级页表慢很多。

9.6.4 端到端的地址翻译

【重要计算】 见书

9.7 案例研究

9.7.1 core i7地址翻译

corei7采用四级页表层次结构。允许页表换进换出但是与已分配了的页相关联的页表都驻留在内存中。CR3控制寄存器指向第一级页表(L1)的起始位置。CR3的值是每个进程上下文的一部分,每次上下文切换时,CR3的值都会被恢复。

通过限制只能执行只读代码段,使得操作系统内核降低了缓冲区攻击的风险。

当MMU翻译了每个虚拟地址时,他还会更新另外两个内核缺页处理程序会用到的位。每次访问一个页时,MMU都会设置A位,称为引用位。内核可以用这个引用位实现它的页替换算法。每次对一个页进行了写之后,MMU都会设置D位,称为修改位或者脏位。内核可以通过调用一条特殊的内核模式指令来清除引用位或修改位。

如何使用四级页表:

36位VPN被分成四个9位的片,每个片被用作到一个页表的偏移量。

9.7.2 linux虚拟内存系统

1.linux虚拟内存区域

linux将虚拟内存组织成一些区域(也叫做段)的集合。一个区域就是已分配的虚拟内存的连续片,这些页是以某种方式相关联的。区域的概念很重要,因为它允许虚拟地址空间有间隙。

内核为系统中的每个进程维护一个单独的任务结构(源代码中的task_struct)。任务结构中的元素包含或者指向内核运行该进程所需要的所有信息。

pgd指向第一级页表的基址,mmap指向一个区域结构的链表,其中每个区域结构都描述了当前虚拟地址空间的一个区域。

一个具体区域的区域结构包含下面字段:

1. vm_start: 区域起始处。

2. vm_end: 区域结束处。

3.vm_port: 区域内所有页的读写许可权限。

4.vm_flags: 描述这个区域是否共享等信息。

5.vm_next:指向下一个区域结构。

2. linux缺页异常处理

触发缺页时的处理程序执行下面步骤:

1.虚拟地址是否合法。即A是否在某个区域结构定义的区域内。缺页处理程序搜索区域结构链表,检查起始和结束。

顺序搜索链表花销可能很大,因此linux在链表中构建了一棵树。

2.试图进行的内存访问是否合法。

3.处理缺页。

9.8 内存映射

Linux将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存的内容,称为内存映射。虚拟内存区域可以映射到两种类型对象中的一种:

1.普通文件

2.匿名文件。匿名文件是由内核创建的。包含的全是二进制零。第一次引用这样一个区域的虚拟页面时,内核就在物理内存中找到一个牺牲页面。注意磁盘和内存之间并没有实际的数据传送。所以映射到匿名文件区域中的页面有时也叫做请求二进制零的页。

无论哪种情况,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件之间换来换去。交换文件也叫做交换空间或交换区域。任何时刻交换空间都限制着当前运行进程能够分配的虚拟页面的总数。

9.8.1 再看共享对象

一个对象可以被映射到虚拟内存的一个区域,要么作为共享对象,要么作为私有对象。如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对于这个区域的任何写操作对其他共享的进程是可见的,而且也会反映在磁盘的原始对象中。

因为每个对象都有唯一的文件名,内核可以迅速判断有进程已经映射了这个对象,而且可以使进程2的页表条目指向相应的物理页面。即使对象被映射到多个共享区域,物理内存也只需要存放共享对象的一个副本。

私有对象使用一种叫做写时复制(copy on write)。 私有对象开始生命周期的方式基本与共享对象的一样,在物理内存中只保存私有对象的一份副本。对于每个映射私有对象的进程,相应私有区域的页表条目都会标记为只读,并且区域结构被标记为私有的写时复制。只要没有进程试图写,那么他们就可以继续共享物理内存的单独一个副本。

只要有一个进程试图写私有区域的某个页面,那么这个写操作就会触发保护故障。当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域的一个页面,他就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的副本,然后恢复这个页面的可写权限。

通过延迟私有对象的副本直到最后可能的时刻,写时复制最充分的利用了稀有的物理内存。

9.8.2 再看fork函数

当fork被当前进程调用时,内核为新进程创建各种数据结构,并分配给他唯一的PID。为了给这个新进程创建虚拟内存,它创建了当前进程的mm_struct,区域结构和页表的原样副本。他将两个进程中的每个页面都标记为只读,并将每个区域结构都标记为写时复制。

9.8.3 再看execve函数

1.删除已存在的用户区域。

2.映射私有区域。为新程序创建新的区域结构,所有这些区域都是私有写时复制的。

3.映射共享区域。

4.设置程序计数器。使之指向代码区域的入口点。

9.8.4 使用mmap函数的用户级内存映射

#include<unistd.h>
#include<sys/mman.h>
void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offset);

调用成功则返回新区域的地址。      

mmap要求内核创建一个新的虚拟内存区域,建议内核从地址start开始,我们总是假设起始地址为Null。将文件描述符fd指定的对象的一个连续的片映射到这个新的区域。片大小为length字节,从据文件开始处偏移量为offset字节的地方开始映射。

参数prot包含访问权限位:

PROT_EXEC:可执行。PROT_READ,PROT_WRITE

PROT_NONE:这个区域的页面不能被访问。

参数flags描述被映射对象类型:

MAO_ANON: 被映射的对象是匿名对象,而相应的虚拟页面是请求二进制零的。MAP_PRIVATE:表示被映射的对象是私有写时复制的。MAP_SHARED表示是共享对象。

MAP_PRIVATE| MAP_ANON: 私有请求二进制零。

int munmap(void *start, size_t length);

删除虚拟地址从start开始的,length字节组成的区域。接下来对已删除区域的引用会导致段错误。

9.9 动态内存分配

虽然可以使用低级的mmap和munmap函数来创建和删除虚拟内存的区域,但是C程序需要额外虚拟内存时使用动态内存分配器更方便。

动态内存分配器维护着一个进程的虚拟内存区域,称为堆。分配器将堆视为一组不同大小的块的集合来维护。

分配器有两种风格。两种风格都要求应用显式地分配块。他们的不同之处在于由哪个实体来负责释放已分配的块。

显式分配器:要求应用显式地释放任何已分配的块。例如malloc分配一个块,并通过调用free函数来释放一个块。

隐式分配器:也叫垃圾收集器,自动释放未使用的已分配的块叫做垃圾收集。

9.9.1 malloc和free函数

#include<stdlib.h>
void *malloc(size_t size);

malloc返回一个指针,指向至少大小为size的内存块。在32位中,malloc返回的块的地址是8的倍数。64位中是16的倍数。

intel将4字节对象称为双字。本节中我们假设字是4字节,双字8字节.

malloc不初始化它返回的内存。想要已初始化的可以使用calloc,它将分配的内存初始化为0.想要改变一个以前已分配块的大小,可以使用realloc函数。

动态内存分配器例如malloc可以使用mmap和munmap,或者使用sbrk函数:

#include<unistd.h>
void *sbrk(intptr_t incr);

sbrk指针通过将内核的brk指针增加incr来扩展和收缩。如果成功他就返回brk的旧值。否则就返回-1并设置errno为ENOMEM。

void free( void * ptr);

ptr必须指向一个已分配块的起始位置。糟糕的是它什么都不返回,不会告诉应用出现了错误。

假设一个字一个块,返回的块双字对齐。

9.9.2 为什么要使用动态内存分配

经常直到程序实际运行时才知道某些数据结构的大小。

动态分配数组:

9.9.3 分配器的要求和目标

显式分配器必须在严格的约束条件下工作,分配器的编写者试图实现吞吐率最大化和内存使用率最大化,这两个性能目标通常是冲突的。

吞吐率定义为每个单位时间里完成的请求数。

Pk聚集有效载荷是当前已分配的块的有效载荷之和。Hk表示堆的当前大小。

前k+1个请求的峰值利用率

Uk=(maxPi)/Hk

分配器的目标就是在整个序列中使Un-1最大化。分配器设计一个挑战就是在两个目标之间找到平衡。

9.9.4 碎片

造成堆利用率很低的原因是碎片现象。

内部碎片是在一个已分配块比有效载荷大时发生的。(分配的块比本来要用的容量大)

 内部碎片的数量只取决于以前请求的模式和分配器的实现方式。

外部碎片是空闲内存合起来够,但是没有一个单独的空闲块可以处理这个请求。

因为外部碎片难以量化且不可能预测。分配器通畅采用启发式策略维持少量的大空闲块而不是大量的小空闲块。

9.9.5 实现问题

空闲块组织。如何记录空闲块。

放置,如何选择空闲块。

分割,如何处理空闲块的剩余部分。

合并。如何处理刚刚释放的块。

9.9.6 隐式空闲链表

一个块是由一个字的头部,有效载荷,以及可能的额外填充组成的。

如果我们加一个双字约束的条件,那么块大小总是8的倍数,最低3位总是0。因此我们可以用最低位来指明这个块是已分配的还是空闲的。

我们称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含链接的。隐式空闲链表的优点是简单。缺点是操作的开销,例如放置分配的块,要求对空闲链表进行搜索,所需时间与堆中块的总数成线性关系。

系统对齐要求和分配器对块格式的选择会对分配器的最小块大小有强制要求。

9.9.7 放置已分配的块

分配器搜索可以放置所请求块的空闲块,是由放置策略决定的。

首次适配(first fit),从头开始搜索空闲链表,选择第一个合适的空闲块。

下一次适配(next fit), 从上一次查询结束的地方开始,选择第一个合适的空闲块。

最佳适配(best fit),检查每个空闲看,选择适合所需大小的最小空闲块。

首次适配的特点是它趋向于将较大的空闲块保留在链表的后面,增加了对较大块的搜索时间。提出了下一次适配。下一次适配比首次适配快,但是内存利用率低的多。

最佳适配内存利用率比另两个高,但是需要对堆进行彻底的搜索。

9.9.8 分割空闲块

第一部分变成分配块,剩下的变成新的空闲块。

9.9.9 获取额外的堆内存

如果分配器不能为请求块找到合适的空闲块,一种选择是合并那些在物理上相邻的空闲块来创建更大的空闲块。如果空闲块已经最大限度的合并了,那么分配器就会通过调用sbrk函数向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中,然后将被请求的块放置到新的空闲块中。

9.9.10 合并空闲块

任何实际的分配器都必须合并相邻空闲块,分配器可以选择立即合并或者推迟合并,比如分配请求失败时再扫描整个堆合并所有空闲块。

立即合并可以在常数时间完成,但是会产生抖动,块会反复的合并和分割。

9.9.11 带边界标记的合并

设我们想要释放的块为当前块。

合并下一个块很简单。当前块的头部指向下一个块的头部,可以检查下一个块是否空闲,是就将它的大小加到当前块头部的大小上。

合并前面的块?

边界标记:允许在常数时间内对前面块合并。在每个块的结尾加一个脚部。脚部是头部的一个副本。这个脚部总是在距当前块开始位置一个字的距离。

在应用程序操作多个小块时,会产生显著的内存开销。

有一种优化方法,回想一下,我们只有在前面的块时空闲时才回用到它的脚部。如果我们把前面块的已分配/空闲位存放在当前位多出来的低位中,那么已分配的块就不需要脚部了。

9.9.12 实现简单的分配器

第一个字是一个双字边界对齐的不使用的填充字。填充后面紧跟着一个特殊的预言块(prologue block),这是一个8字节的已分配块,只由一个头部和一个脚步组成。序言块是在初始化时创建的,而且永不释放。堆总是以一个特殊的结尾块来结束,这个块是一个大小为零的已分配块,只由一个头部组成。序言块和结尾块是一种消除合并时边界条件的技巧。     

2.操作空闲链表的基本常数和宏

在空闲链表中操作头部和脚部是很麻烦的,因为他要求大量使用强制类型转换和指针运算。所以定义了一组宏。

3.创建初始空闲链表

最后,在很可能前一个堆以一个空闲块结束的情况下,我们调用coalesce函数来合并两个空闲块。并返回指向合并后的块的块指针。

4.释放和合并块

5.分配块

9.9.13 显式空闲链表

因为块分配与堆块的总数呈线性关系,所以对于通用的分配器,隐式空闲链表是不适合的。

一种更好的方式是将【空闲块】组织为某种形式的显式数据结构。根据定义,程序不需要一个空闲块的主体,所以实现这个数据结构的指针可以放在空闲块的主体里。

使用双向链表可以将首次适配的分配时间从块总数的线性时间减少到空闲块数量的线性时间。

不过释放一个块的时间可以是线性的也可以是常数,取决于空闲链表中的排序策略。

后进先出: 将新释放的块放在链表的开始处,释放一个块可以在常数时间。

按照地址顺序维护链表:线性时间,按照地址排序的首次适配比LIFO首次适配有更高的内存利用率。

显式链表的缺点是空闲块必须足够大以包含所有需要的指针,这就导致了更大的最小块大小,潜在提高了内部碎片的程度。

9.9.14 分离的空闲链表

一种流行的减少分配时间的方法,称为分离存储。

维护多个空闲链表,其中每个链表中的块有大致相等的大小。一般的思路是将所有可能的块大小分成等价类,也叫做大小类(Size class)。有多种方式定义大小类,例如根据2的幂划分:

{1},{2},{3,4}...

或者将较小的块分配到自己的大小类,大块按照2的幂分类:

{1}{2}..{1024},{1025~2048}...

分配器维护一个空闲链表数组,每个大小类一个空闲链表,按照大小的升序排列。当分配器需要一个大小为n的块时,他就搜索相应的空闲链表。如果找不到就搜下一个链表。

有关分离存储,主要的区别在于如何定义大小类,何时合并,何时请求额外的堆内存,是否允许分割等等。我们描述两种基本方法:简单分离存储和分离适配。

1.简单分离存储

每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小。

如果链表非空,我们简单地分配其中第一块的全部,不分隔。

如果链表为空,分配器就向操作系统请求一个固定大小的额外内存片,将这个片分成大小相等的块,并将这些块链接起来形成新的空闲链表。

要释放一个块,简单的将这个块插入到空闲链表的前部。

优点:分配和释放都很快。由于每个片只有大小相同的块,那么一个已分配块的大小就可以从它的地址推断出来。因为没有合并,所以不需要标志位,因此不需要头部,没有合并,所以不需要脚部。分配和释放都在起始处,只需要单向链表。

缺点:空闲块不分割,造成内部碎片,空闲块不合并,造成外部碎片。

2. 分离适配

为了分配一个块,必须确定请求的大小类,并对适当的空闲链表做首次适配。如果找到了一个合适的块,就(可选的)分割它,并将剩余的部分插入到适当的空闲链表中。如果找不到就找下一个空闲链表。如果最终没有合适的块,就请求额外的堆内存,从这个新的堆内存分配一个块,将剩余部分放置在适当的大小类中。

释放一个块,我们执行合并并将结果放到相应的空闲链表。

搜索时间少了,因为搜索被限制在堆的某个部分而不是整个堆。

对分离空闲链表的简单的首次适配搜索,内存利用率近似于对整个堆的最佳适配。

3.伙伴系统

每个大小类都是2的幂。

请求块大小向上舍入到2的幂。

一开始只有一个大小为2^m个字的空闲块。

为了分配大小为2^k的块,我们找到一个2^j的块,如果k=j,那么完成分配。否则递归地二分割这个块,直到j=k。每个剩下的半块(也叫做伙伴)被放置到相应的空闲链表中。

要释放一个大小为2^k的块,我们继续合并空闲的伙伴。当遇到一个已分配的伙伴时,就停止合并。

一个关键是给定地址和块的大小,很容易计算出它的伙伴的地址。

例如一个块大小为:xxx...x0000,它的伙伴为xxx...x1000。

伙伴系统分配器的优点是它的快速搜索和快速合并,缺点是导致内部碎片。对于预先知道块大小是2的幂比较适合。

9.10 垃圾收集

对于显式分配器,应用要负责释放所有不再需要的已分配块。如果不释放会一直占用。

垃圾收集器是一种动态内存分配器,它自动释放不再需要的已分配块。垃圾收集器定期识别垃圾块并调用free,将这些块放回空闲链表。

9.10.1 垃圾收集器的基本知识

垃圾收集器将内存视为一张有向可达图。该图的节点被分为一组根节点和一组堆节点。每个堆节点对应于堆中的一个已分配块。有向边p->q意味着块p中的某个位置指向块q中的某个位置。根节点对应于不在堆中的位置,他们中包含指向堆中的指针。这些位置可以是寄存器,栈里的变量或者是虚拟内存中读写数据区域内的全局变量。

不可达点对应于垃圾。垃圾收集器的角色是维护可达图的某种表示,并释放不可达节点。

有些垃圾收集器不能维持可达图的精确表示。这样的垃圾收集器叫做保守的垃圾收集器,即米格可达块都是可达,但是一些不可达节点可能也被标记为可达。

收集器可以按需提供它们的服务,或者它们可以作为和一个应用并行的独立线程,不断的更新可达图和回收垃圾。无论何时需要堆空间,应用都会通过调用malloc,如果找不到合适的空闲块就调用垃圾收集器,希望回收一些垃圾。关键思想是收集器代替应用去调用free。如果还是失败了,那么久向操作系统要求额外的内存。

9.10.2 mark&sweep垃圾收集器

由标记和清除阶段组成,标记阶段标记出根节点和所有可达的和已分配的后继,而后面的清除阶段释放每个未被标记的已分配块。

ptr isPtr(ptr p)。如果p指向一个已分配块中的某个字,那么就返回一个指向这个块的起始位置的指针b。

标记阶段为每个根节点调用mark函数,如果p不指向一个已分配并且未标记的堆块,mark就立即返回。否则就标记这个块,并对块中的每个字递归的调用自己。每次对mark函数的调用都标记某个根节点的所有未标记并且可达的后继节点。在标记阶段的末尾,任何未标记的已分配块都被认定不可达。

9.10.3 保守Mark&sweep

C语言为isptr实现造成了一些挑战。

1.C语言不会用任何类型信息来标记内存位置,因此无法判断它的参数p是不是指针。

2.即使我们知道是指针,对isptr也没有办法判断是否指向一个已分配块的有效载荷中的某个位置。

一种解决方法是将已分配块集合维护成一棵平衡二叉树。左子树的所有块都放在较小的地址处,右子树较大。这要求每个已分配块的头部有两个附加字段(left和right)。每个字段指向某个已分配块的头部。

根本原因是C语言不会用类型信息来标记内存位置。像int或float这样的标量可以伪装成指针。假设某个可达的已分配块在它的有效载荷里包含一个int,其值碰巧对应于某个其他已分配块b的有效载荷的一个地址。对收集器而言,无法判断。因此分配器必须保守的标记b为可达。

9.11 C语言中常见的与内存有关的错误

9.11.1 间接引用坏指针

假设我们想使用scanf从stdin读一个整数到变量,正确的方法是:

scanf("%d",&val);

但是如果传递的是val的内容,它把内容解释为地址,并试图写一个字到这个位置。最好的情况下是引发异常,最糟糕的情况是val刚好对应虚拟内存某个合法的读写区域。

9.11.2 读初始化内存

虽然bss内存位置总是被加载器初始化为零。但是对于堆内存却不是这样的。

9.11.3 允许栈缓冲区溢出

如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误。gets复制任意长度的串到缓冲区。我们必须使用fgets函数限制输入串的大小。

9.11.4 假设指针和他们指向的对象是相同的

第五行sizeof(int*)写成了sizeof(int)。如果指针大小大于整型大小,那么可能写到超出A数组结尾的地方。因为这些字中的一个可能是已分配块的边界标记脚部,所以我们可能不会发现这个错误,直到在这个程序后面很久释放这个块时,分配器中的合并代码会戏剧性失败。

9.11.5 造成错位错误

创建了n个元素的指针数组,但是试图初始化n+1个元素。覆盖了数组后面的某个内存位置。

9.11.6 引用指针,而不是他所指向的对象

*size--,实际减少的是指针自己的值

应该是(*size)--;

9.11.7 误解指针运算

指针的算术操作是以他们指向对象的大小为单位进行的。

9.11.8 引用不存在的变量

引用不再合法的本地变量。当以后再程序中调用其他函数时,内存将重用他们的栈帧。那么它可能实际上正在修改另一个函数的栈帧中的条目。

9.11.9 引用空闲堆块的数据

9.11.10 引起内存泄漏

不小心忘记释放已分配块。渐渐的堆里会充满垃圾。对于像守护进程和服务器这样的程序来说,内存泄漏非常严重,根据定义这些程序不会被终止。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值