【深入理解计算机系统】第九章 虚拟内存

数据对象可以拥有多个独立的地址,每个地址选自一个不同的地址空间,这就是虚拟内存的基本思想,例如虚拟地址空间和物理地址空间(需要经过地址翻译),虚拟内存的的三个重要能力:

  1. 它将主存视为高速缓存,根据需要在主存和磁盘之间传送数据
  2. 提供了一致的地址空间,简化内存管理
  3. 保护每个进程的地址空间不被其他进程破坏
    【深入理解计算机系统】第九章 虚拟内存_第1张图片

1、虚拟内存作为缓存工具

VM系统将虚拟内存分割成虚拟页(Virtual Page,VP,大小固定的块),每个虚拟页大小为字节,物理内存也被分割为物理页(Physical Page,PP,大小也为P),物理页被称为页帧。虚拟页在任何时刻,都有三个互不相交的子集:

  • 未分配的:VM系统还未分配的页。未分配的块没有任何数据和它们相关联,因此不占用任何磁盘空间。
  • 缓存的:当前已经缓存在物理内存中的已分配页
  • 未缓存的:未缓存在物理内存中的已分配页 【深入理解计算机系统】第九章 虚拟内存_第2张图片

DRAM缓存

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。
【深入理解计算机系统】第九章 虚拟内存_第3张图片

局部性原则保证:程序在一个较小的活动页面集合上工作,这个集合叫做工作集或这常驻集合,在初始开销,也就是工作集页面调度到内存中之后,接下来对这个工作集的引用将导致命中,而不会产生额外的磁盘流量。如果工作集的大小超过了物理内存的大小,就会出现抖动,即页面不断地换进和换出。如果程序运行很慢,就要考虑是不是发生了抖动。 通过虚拟内存系统可以实现两个进程修改同一块物理空间。
【深入理解计算机系统】第九章 虚拟内存_第4张图片

2、地址翻译

页表结构如下:
【深入理解计算机系统】第九章 虚拟内存_第5张图片
虚拟地址包括两部分:p位的虚拟页面偏移(Virtual Page Offset,VPO)和一个 n-p 位的虚拟页号(Virtual Page Number,VPN)。MMU则利用VPN选择适当的PTE。物理地址类似。上图显示了命中和缺页异常的情况。

高速缓存和虚拟内存

【深入理解计算机系统】第九章 虚拟内存_第6张图片

VM与高速缓存之间使用物理寻址。

利用TLB加速地址翻译

翻译后备缓冲器(Translation Lookaside Buffer,TLB,存在于MMU中,所以非常快)是一个小的、虚拟寻址的缓存,其中每一行都保存着一个由单个PTE组成的块。用于组选择和行匹配的索引和标记字段是从虚拟地址中的虚拟页号中提取出来的。如果TLB有个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,TLB标记是由VPN的剩余的位组成。
【深入理解计算机系统】第九章 虚拟内存_第7张图片

多级页表

对于一个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
【深入理解计算机系统】第九章 虚拟内存_第8张图片

使用多级页表后,每个一级页表和二级页表都是4KB,刚好和一个页面大小一致。对于一级页表的一个条目,可以映射处4MB大小的空间。

多级页表减少了内存的要求:

  • 如果一级页表中的一个PTE是空的,那么二级页表根本不存在。对于一个程序,4GB的虚拟地址空间大部分的未分配
  • 一级页表总是存在于主存中,虚拟内存系统可以在需要时创建、页面调入或调出二级页表,这样就减少了主存的压力,只有经常使用的二级页表才会存在于主存中。

对于k级页表,MMU必须访问k个PTE才能确定PPN(物理地址)。

端到端的地址翻译案例

【深入理解计算机系统】第九章 虚拟内存_第9张图片

【深入理解计算机系统】第九章 虚拟内存_第10张图片
【深入理解计算机系统】第九章 虚拟内存_第11张图片

4、Intel Core i7/Linux系统内核

【深入理解计算机系统】第九章 虚拟内存_第12张图片

  • 处理器封装包括四个大核
  • 一个大的所有核共享 L3 高速缓存
  • 一个DDR3 内存控制器
  • 每个核包含一个层次结构的 TLB,虚拟寻址,4路相连
  • 一个层次结构的数据和指令高速缓存
  • 以及一组快速的点到点链路(基于QuickPath技术),直接与其他核外部 I/O 桥直接通信
  • L1 L2 L3高速缓存物理寻址,块大小 4 字节,L1 L2是8路相连,L3是16路相连。
  • 页大小是 4KB,使用4级页表 【深入理解计算机系统】第九章 虚拟内存_第13张图片

Core i7地址翻译过程,CR3为页表基址寄存器

  • CR0:控制CPU的一些重要特性,
    • 0位是保护允许位PE(Protedted Enable),用于启动保护模式,如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行。
    • 1 位是监控协处理位MP(Moniter coprocessor),它与第3位一起决定:当TS=1时操作码WAIT是否产生一个“协处理器不能使用”的出错信号。
    • 2位是模拟协处理器位 EM (Emulate coprocessor),如果EM=1,则不能使用协处理器,如果EM=0,则允许使用协处理器。
    • 3位是任务转换位(Task Switch),当一个任务转换完成之后,自动将它置1。随着TS=1,就不能使用协处理器。
    • 4位是微处理器的扩展类型位 ET(Processor Extension Type),其内保存着处理器扩展类型的信息,如果ET=0,则标识系统使用的是287协处理器,如果 ET=1,则表示系统使用的是387浮点协处理器。
    • 16位是写保护未即WP位(486系列之后),只要将这一位置0就可以禁用写保护,置1则可将其恢复。
    • 31位是分页允许位(Paging Enable),它表示芯片上的分页部件是否允许工作。
  • CR1是未定义的控制寄存器,供将来的处理器使用。
  • CR2是页故障线性地址寄存器,保存最后一次出现页故障的全32位线性地址
  • CR3是页目录基址寄存器,保存页目录表的物理地址
  • CR4在Pentium系列(包括486的后期版本)处理器中才实现,它处理的事务包括诸如何时启用虚拟8086模式等 【深入理解计算机系统】第九章 虚拟内存_第14张图片【深入理解计算机系统】第九章 虚拟内存_第15张图片

PTE由三个权限位,控制对页的访问:

  • R/W确定页的内容读写模式
  • U/S确定是否能够在用户模式中访问该页,从而保护操作系统内核中代码和数据不被用户程序修改
  • XD在64位系统中引入,禁止从某些内存页取指令,通过限制只能执行只读代码段,使操作系统内核降低了缓冲区溢出攻击的风险
  • A引用位,MMU利用该位实现页替换算法
  • D位,对页进行修改后MMU设置该位,又称修改位或脏位,告诉内核如果该页作为牺牲页必须写回磁盘
  • 内核可以通过一条特殊的模式指令清除引用位和修改位
    【深入理解计算机系统】第九章 虚拟内存_第16张图片

注意:优化地址翻译:

因为VPO和PPO相同,所有在寻址时,cpu发送VPN到MMU,VPO到L1缓存,当MMU向TLB请求一个条目并得到PPN,缓存已经准备好了组,并与标记进行匹配

Linux虚拟内存

Linux将虚拟内存组织成一些区域(也叫做段)的集合,一个段就是已经存在的虚拟内存的连续片,这些页是以某种方式相关联的,比如代码段、数据段、堆、共享库段、用户栈。每个存在的虚拟页面都保存在某个区域,区域,即段允许虚拟地址空间由间隙。内核不用记录哪些不存在的虚拟页,这样的页不占用内存、磁盘、内核资源。
【深入理解计算机系统】第九章 虚拟内存_第17张图片

内核为每个进程维护一个单独的任务结构(task_struct),任务结构中的元素包含或指向内核运行该进程所需要的所有信息(PID、指向用户栈的指针、可执行目标文件的名字、程序计数器)。
【深入理解计算机系统】第九章 虚拟内存_第18张图片

任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态,其中pgd指向第一级页表的机制,mmap指向一个vm_area_stucts的链表,该链表描述了当前虚拟地址空间的一个区域,pgd则存放在CR3控制寄存器中。

  • vm_start:区域起始处
  • vm_end:区域结束处
  • vm_prot:这个区域所有页的读写读写许可权限
  • vm_flags:这个区域的页面是否与其他进程共享
  • vm_next:下一个区域

linux缺页异常处理流程:

  • 检查虚拟地址A是否合法,核区域结构中的vm_start和vm_end比较,如果非法就触发段错误
  • 检查是否在进行非法内存访问,例如用户模式下的进程试图从内核虚拟内存中读取字,如果非法触发保护异常
  • 正常缺页,选择一个牺牲页面,如果该页面修改过,就写回磁盘并更新页表
  • 缺页处理程序返回,CPU重新启动缺页指令,这条指令发送A到MMU,MMU正常翻译A,不再触发缺页中断

一旦一个虚拟页面被初始化,它就由一个内核维护的专门的交换文件之间换来换去,交换文件也叫交换空间或交换区域,交换空间限制着当前运行的进程能够分配的虚拟页面总数。

写时复制
【深入理解计算机系统】第九章 虚拟内存_第19张图片

两个线程将一个私有的写时复制对象映射到自己的虚拟内存中,共享这个对象的同一物理副本。当进程2写这个对象时,触发故障处理程序,该程序在物理内存中创建这个页面的一个新的副本,更新页表条目指向这个新的副本,然后恢复该页面的写权限。fork出的子进程和父进程共享同一物理内存,并在虚拟内存标记为私有的写时复制。则执行execve函数时的步骤有:

  1. 删除已存在的用户区域,删除当前进程虚拟地址空间的用户部分中的已存在的区域结构
  2. 映射私有区域,为新程序的代码、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时复制。
  3. 映射共享区域
  4. 设置程序计数器,使之指向代码的区域的入口点

动态分配内存

mmap unmap

malloc free sbrk
【深入理解计算机系统】第九章 虚拟内存_第20张图片

假设系统以8字节对齐,那么 malloc(5 * sizeof(int)) 实际返回 6 个int大小的内存

碎片

碎片分为内部碎片和外部碎片

  • 内部碎片:因为堆分配的空间可能很大,多次申请和释放空间后,有的中间空隙没有被使用到,这就是内部碎片
  • 外部碎片:虽然内部碎片的空间之和可以满足申请的空间大小,但是单个碎片过小,内核不得不申请新的虚拟内存

分配堆块的结构,如果有对齐约束,那么块的大小总是8的倍数。
【深入理解计算机系统】第九章 虚拟内存_第21张图片

合并

为了方便两个空闲块的合并,增加了一个脚部,脚部是内存块头部的副本信息。
【深入理解计算机系统】第九章 虚拟内存_第22张图片

增加了脚部信息,就可以很方便的合并空闲块,例如当块 n 需要释放时,有以下四种情况,虽然脚部可以很方便的合并空闲块,但是很产生很大的内存开销

通用分配器

堆的格式采用下图表示:
【深入理解计算机系统】第九章 虚拟内存_第23张图片

堆采用隐式链表,链表的格式如下图所示

【深入理解计算机系统】第九章 虚拟内存_第24张图片

第一个字是一个双字边界对齐的不使用的填充字。填充后面紧跟着一个特殊的序言块(prologue block),这是一个 8 字节的已分配块,只由一个头部和一个脚部组成。序言块是在初始化时创建的,并且永不释放。在序言块后紧跟的是零个或者多个由 malloc 或者 free 调用创建的普通块。堆总是以一个特殊的结尾块(epilogue block)来结束,这个块是一个大小为零的已分配块,只由一个头部组成。序言块和结尾块是一种消除合并时边界条件的技巧。分配器使用一个单独的私有(static)全局变量(heap_listp),它总是指向序言块。(作为一个小优化,我们可以让它指向下一个块,而不是这个序言块。)

memlib文件

mem_init 函数将对于堆来说可用的虚拟内存模型化为一个大的、双字对齐的字节数组。在 mem_heap 和 mem_brk 之间的字节表示已分配的虚拟内存。mem_brk 之后的字节表示未分配的虚拟内存。

分配器通过调用 mem_sbrk 函数来请求额外的堆内存,这个函数和系统的 sbrk 函数的接口相同,而且语义也相同,
除了它会拒绝收缩堆的请求。

mm文件

其中包含了一些基本常熟和宏,需要注意的是分配的空间总是 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 的整数倍。

显示空闲链表

使用某种显示的数据结构表示堆块:

【深入理解计算机系统】第九章 虚拟内存_第25张图片

使用双向链表而不是隐式空闲链表,使首次适配的分配时间从块总数的线性时间减少到了空闲块数量的线性时间。不过,释放一个块的时间可以是线性的,也可能是个常数,这取决于我们所选择的空闲链表中块的排序策略。

一种方法是用后进先出(LIFO)的顺序维护链表,将新释放的块放置在链表的开始处。使用 LIFO 的顺序和首次适配的放置策略,分配器会最先检查最近使用过的块。在这种情况下,释放一个块可以在常数时间内完成。如果使用了边界标记,那么合并也可以在常数时间内完成。

另一种方法是按照地址顺序来维护链表,其中链表中每个块的地址都小于它后继的地址。在这种情况下,释放一个块需要线性时间的搜索来定位合适的前驱。平衡点在于,按照地址排序的首次适配比 LIFO 排序的首次适配有更高的内存利用率,接近最佳适配的利用率。

分离的空闲链表

参考自己设计的内存池

垃圾收集

垃圾收集器将内存视为一张有向可达图(reachability graph),其形式如下图所示。该图的节点被分成一组根节点(root node)和一组堆节点(heap node)。每个堆节点对应于堆中的一个已分配块。有向边 p→q 意味着块 p 中的某个位置指向块 q 中的某个位置。根节点对应于这样一种不在堆中的位置,它们中包含指向堆中的指针。这些位置可以是寄存器、栈里的变量,或者是虚拟内存中读写数据区域内的全局变量。

【深入理解计算机系统】第九章 虚拟内存_第26张图片

当存在一条从任意根节点出发并到达 p 的有向路径时,我们说节点 p 是可达的(reachable)。在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来定期地回收它们。

诸如 C 和 C++ 这样的语言的收集器通常不能维持可达图的精确表示。这样的收集器也叫做保守的垃圾收集器(conservative garbage collector)。从某种意义上来说它们是保守的,即每个可达块都被正确地标记为可达了,而一些不可达节点却可能被错误地标记为可达。收集器可以按需提供它们的服务,或者它们可以作为一个和应用并行的独立线程,不断地更新可达图和回收垃圾。将一个 C 程序的保守的收集器加入到已存在的 malloc 包中,如下图所示。
【深入理解计算机系统】第九章 虚拟内存_第27张图片

无论何时需要堆空间时,应用都会用通常的方式调用 malloc。如果 malloc 找不到一个合适的空闲块,那么它就调用垃圾收集器,希望能够回收一些垃圾到空闲链表。收集器识别出垃圾块,并通过调用 free 函数将它们返回给堆。关键的思想是收集器代替应用去调用 free。当对收集器的调用返回时,malloc 重试,试图发现一个合适的空闲块。如果还是失败了,那么它就会向操作系统要求额外的内存。最后,malloc 返回一个指向请求块的指针(如果成功)或者返回一个空指针(如果不成功)。

Mark&Sweep 垃圾收集器

Mark&Sweep 垃圾收集器由标记(mark)阶段和清除(sweep)阶段组成,标记阶段标记出根节点的所有可达的和已分配的后继,而后面的清除阶段释放每个未被标记的已分配块。块头部中空闲的低位中的一位通常用来表示这个块是否被标记了。

我们对 Mark&Sweep 的描述将假设使用下列函数,其中 ptr 定义为 typedef void* ptr:

  • ptr isPtr (ptr p)。如果 p 指向一个已分配块中的某个字,那么就返回一个指向这个块的起始位置的指针 b。否则返回 NULL。
  • int blockMarked(ptr b)。如果块 b 是已标记的,那么就返回 true。
  • int blockAllocated (ptr b)。如果块 b 是已分配的,那么就返回 true。
  • void markBlock (ptr b)。标记块 b。
  • int length (b)。返回块 b 的以字为单位的长度(不包括头部)。
  • void unmarkBlock (ptr b)。将块 b 的状态由已标记的改为未标记的。
  • ptr nextBlock (ptr b)。返回堆中块 b 的后继。

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

清除阶段是对 sweep 函数的一次调用。sweep 函数在堆中每个块上反复循环,释放它所遇到的所有未标记的已分配块(也就是垃圾)。

mark函数

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;
}

sweep

void sweep(ptr b, ptr end) {
    while (b < end) {
        if (blockMarked(b))
            unmarkBlock(b);
        else if (blockAllocated(b))
            free(b);
        b = nextBlock(b);
    }
    return;
}

【深入理解计算机系统】第九章 虚拟内存_第28张图片

初始情况下,上图中的堆由六个已分配块组成,其中每个块都是未分配的。第 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 标记为可达,尽管事实上它可能是不可达的。

C 程序中常见的与内存有关的错误

1、间接引用坏指针

在进程的虚拟地址空间中有较大的洞,没有映射到任何有意义的数据。如果我们试图间接引用一个指向这些洞的指针,那么操作系统就会以段异常中止程序。而且,虚拟内存的某些区域是只读的。试图写这些区域将会以保护异常中止这个程序。

间接引用坏指针的一个常见示例是经典的 scanf 错误。假设我们想要使用 scanf 从 stdin 读一个整数到一个变量。正确的方法是传递给 scanf 一个格式串和变量的地址:

scanf(“%d”, &val)

然而,对于 C 程序员初学者而言(对有经验者也是如此!),很容易传递 val 的内容,而不是它的地址:

scanf(“%d”, val)

在这种情况下,scanf 将把 val 的内容解释为一个地址,并试图将一个字写到这个位置。在最好的情况下,程序立即以异常终止。在最糟糕的情况下,val 的内容对应于虚拟内存的某个合法的读/写区域,于是我们就覆盖了这块内存,这通常会在相当长的一段时间以后造成灾难性的、令人困惑的后果。

2、读未初始化的内存

虽然 bss 内存位置(诸如未初始化的全局 C 变量)总是被加载器初始化为零,但是对于堆内存却并不是这样的。一个常见的错误就是假设堆内存被初始化为零。

3、允许栈缓冲区溢出

如果一个程序不检查输入串的大小就写入栈中的目标缓冲区,那么这个程序就会有缓冲区溢出错误(buffer overflow bug)

4、假设指针和它们指向的对象是相同大小的

例如 : sizeof(int *) 写成了 sizeof(int)

5、造成错位错误

/* 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 导致该段代码数组越界,

6、引用指针,而不是它所指向的对象

如果不太注意 C 操作符的优先级和结合性,我们就会错误地操作指针,而不是指针所指向的对象。

7、误解指针运算

指针的算术操作(p++,如果是 int 就是指向下一个 int 对象)是以它们指向的对象的大小为单位来进行的,而这种大小単位并不一定是字节。

8、引用不存在的变量

没有太多经验的 C 程序员不理解栈的规则,有时会引用不再合法的本地变量。

int *stackref ()
{
int val;

return &val;

}

这个函数返回一个指针(比如说是 p),指向栈里的一个局部变量,然后弹出它的栈帧。尽管 p 仍然指向一个合法的内存地址,但是它已经不再指向一个合法的变量了。当以后在程序中调用其他函数时,内存将重用它们的栈帧。再后来,如果程序分配某个值给 *p,那么它可能实际上正在修改另一个函数的栈帧中的一个条目,从而潜在地带来灾难性的、令人困惑的后果。

通过 new 或 malloc 来分配新的地址,防止该错误。

9、引用空闲堆块中的数据

一个相似的错误是引用已经被释放了的堆块中的数据。

10、引起内存泄漏

忘记释放申请的内存

你可能感兴趣的:(书籍阅读,系统架构)