深入理解计算机系统—虚拟内存(3)
9.9 动态内存分配
虽然可以使用低级的 mmap 和 munmap 函数来创建和删除虚拟内存的区域,但是 C程序员还是会觉得当运行时需要额外虚拟内存时,用 动态内存分配器 更方便,也有更好的可移植性。
动态内存分配器维护着一个进程的虚拟内存区域,称为 堆(heap)。系统之间细节不同,但是不失通用性,假设堆是一个请求二进制零的区域,它紧接在未初始化的数据区域后开始,并向上生长(向更高的地址)。对于每个进程 内核维护着一个变量 brk(读做 ‘ break '),它指向堆的顶部
分配器将堆视为一组不同大小的块(block)的集合来维护。每个块就是一个连续的虚拟内存片(chunk),要么是已分配的,要么是空闲的。已分配的块显示地保留为供应用程序使用。空闲块可用来分配。空闲块保持空闲,直到它显示地被应用所分配。一个已分配的块保持已分配状态,直到它被释放,这种释放要么是应用程序显示执行的,要么是内存分配器自身隐式执行的。
分配器有两种基本风格。两种风格都要求应用显示地分配块,它们不同之处在于由哪个实体来负责释放已分配的块。
① 显示分配器:要求应用显示地释放任何已分配的块。例如,C标准库提供一种 malloc 程序包的显示分配器。C 程序通过调用 malloc 函数来分配一个块,并通过调用 free 函数来释放一个块。 C++ 中的 new 和 delete 操作符与 C 中的 malloc 和 free 相当。
② 隐式分配器:另一方面,要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块。隐式分配器也叫做 垃圾收集器,而自动释放未使用的已分配块的过程叫做垃圾收集。例如,诸如 Lisp ML 以及 Java 之类的高级语言就依赖垃圾收集来释放已分配的块。
9.9.1 malloc 和 free 函数
C标准库提供了一个称为 malloc 程序包的显示分配器。程序通过调用 malloc 函数从堆中分配块。 malloc 函数返回一个指针,指针大小为至少 size 字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。实际中,对齐依赖于编译代码在 32 位模式 (gcc -m32)还是 64位模式(默认)中运行的。 在 32 位模式中,malloc 返回的块的地址总是 8 的倍数。 在 64位模式中,该地址总是16的倍数。
如果 malloc 遇到问题(例如,程序要求的内存块比可用的虚拟内存还要大),那么它就返回 NULL,并设置 errno。 malloc 不初始化它返回的内存。那些想要已初始化的动态内存的应用程序可以使用 calloc,calloc 是一个基于 malloc 的瘦包装函数,它将分配的内存初始化为零。想要改变一个以前已分配的大小,可以使用 realloc 函数。
动态内存分配器,例如 malloc,可以通过使用 mmap 和 munmap 函数,显示地分配和释放堆内存,或者还可以使用 sbrk 函数:
sbrk 函数通过将内核的 brk 指针增加 incr 来扩展和收缩堆。如果成功,它就返回 brk 的旧值,否则返回 -1,并将 errno 设置为 ENOMEM。如果 incr 为 0,那么 sbrk 就返回 brk 的当前值。用一个为负的 incr 来调用 sbrk 是合法的,而且很巧妙,因为返回值(brk 的旧值)指向距新堆顶向上 abs(incr)字节处。
程序是通过调用 free 函数来释放已分配的堆块
ptr 参数必须指向一个从 malloc、calloc 或者 realloc 获得的已分配块的起始位置。如果不是,那么 free 行为就是未定义的。更糟的是,既然它什么都不返回,free 就不会告诉应用出现了错误。就像在 9.11节看到的,会产生一些令人迷惑的运行时错误。
图 9-34 展示了一个 malloc 和 free 的实现是如何管理一个 C 程序的 16字的(非常)小的堆的。每个方框代表了一个 4字节的字。粗线标出的矩形对应于已分配块(有阴影的)和空闲块(无阴影的)。初始时,堆是由一个大小为 16 个字的,双字对齐的,空闲块组成的。(假设分配器返回的块是 8字节双字边界对齐的)。
9.9.2 为什么要使用动态内存分配
程序使用动态内存分配的最重要原因是经常直到程序实际运行时,才知道某些数据结构的大小。 例如,假设编写一个 C 程序,它读一个 n 个 ASCII 码整数的链表,每一行一个整数,从 stdin 到一个 C数组。输入是由整数 n 和接下来要读和存储到数组中的 n 个整数组成的。最简单的方法就是静态地定义这个数组,它的最大数组大小是硬编码的:
像这样用硬编码的大小来分配数组通常不是一种好方法。MAXN 的值是任意的,与机器上可用的虚拟内存的实际数量没有关系。而且,如果这个程序的使用者想读取一个比 MAXN 大的文件,唯一的办法就是用一个更大的 MAXN 值来重新编译这个程序。虽然对于简单的示例来说不成问题,但是硬编码数组界限的出现对于拥有百万行代码和大量使用者的大型软件产品而言,会变成一场维护的噩梦。
一种更好的方法是在运行时,在已知了 n 的值之后,动态地分配这个数组。使用这种方法,数组大小的最大值就只由可用的虚拟内存数量来限制了。
9.9.3 分配器的要求和目标
显示分配器必须在一些相当严格的约束条件下工作:
① 处理任意请求序列。一个应用可以由任意的分配请求和释放请求序列,只要满足约束条件:每个释放请求必须对应于一个当前已分配的块,这个块是由一个以前的分配请求获得的。因此,分配器不可以假设分配和释放请求的顺序。例如,分配器不能假设所有的分配请求都有相匹配的释放请求,或者有相匹配的分配和空闲请求是嵌套的。
② 立即响应请求。分配器必须立即响应分配请求。因此,不允许分配器为了提高性能重新排列或者缓冲请求。
③ 只使用堆。为了使分配是可扩展的,分配器使用的任何非标量数据结构都必须保存在堆里
④ 对齐块(对齐要求)。分配器必须对齐块,使得它们可以保存任何类型的数据对象。
⑤ 不修改已分配的块。 分配器只能操作或者改变空闲块。特别是:一旦块被分配了,就不允许修改或者移动它了。因此,诸如压缩已分配块这样的技术是不允许使用的。
这些限制条件下,分配器的编写者试图实现吞吐率最大化和内存使用率最大化,而这两个性能目标通常是相互冲突的。
目标1:最大化吞吐率。假定 n 个分配和释放请求的某种序列:
希望一个分配器的吞吐率最大化,吞吐率定义为每个单位时间里完成的请求数。例如,如果一个分配器在 1 秒内完成 500 个分配请求和释放请求,那么它的吞吐率就是每秒 1000 次操作。一般而言,可以通过使满足分配和释放请求的平均时间最小化来使吞吐量最大化。正如看到的,开发一个具有合理性能的分配器并不困难,所谓合理性能是指一个分配请求的最糟运行时间与空闲块的数量成线性关系,而一个释放请求的运行时间是个常数。
目标2:最大化内存利用率。实际上,一个系统中被所有进程分配的虚拟内存的全部数量是受磁盘上交换空间的数量限制的。好的程序员知道虚拟内存是一个有限的空间,必须高效地使用。对于可能被要求分配和释放大块内存的动态内存分配器来说,尤其这样。
有很多方式来描述一个分配器使用堆的效率如何:最有用的标准是 峰值利用率。给定 n 个分配和释放请求的某种顺序
如果一个应用程序请求一个 p 字节的块,那么得到的已分配块的有效载荷是 p字节。在请求 Rk 完成之后,聚集有效载荷表示为 Pk,为当前已分配的块的有效载荷之和,而 Hk 表示堆的当前的(单调非递减的)大小。 那么,前 k+1 个请求的峰值利用率,表示为 Uk,通过下式得到:
那么分配器的目标就是在整个序列中使峰值利用率 Un-1 最大化。在最大化吞吐率和最大化利用率之间是互相牵制的,特别是,以堆利用率为代价,很容易编写出吞吐率最大化的分配器。分配器设计中的一个挑战就是在两个目标之间找到一个适当的平衡。
9.9.4 碎片
造成堆利用率很低的主要原因是一种称为 碎片 的现象,当虽然有未使用的内存但不能用来满足分配请求时,就发生这种现象。有两种形式的碎片:内部碎片和外部碎片。
内部碎片是一个已分配块比有效载荷大时发生的。很多原因都可能造成这个问题。例如,一个分配器的实现可能对已分配块强加一个最小的大小值,而这个大小要比某个请求的有效载荷大。或者如图9-34b中看到,分配器可能增加块大小以满足对齐约束条件。
内部碎片的量化是简单明了的。它就是已分配块大小和它们的有效载荷大小之差的和。因此,在任何时刻,内部碎片的数量只取决于以前请求的模式和分配器的实现模式。
外部碎片是当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求时发生的。 例如,如图9-34e中的请求要求 6 个字,而不是两个字,那么如果不向内核请求额外的虚拟内存就无法满足这个请求,即使在堆中仍然有 6 个空闲的字。问题的产生是由于这 6 个字是分在两个空闲块中的。
外部碎片比内部碎片的量化困难的多,因为它不仅取决于以前请求的模式和分配器的实现方式,还取决于将来请求的模式。例如,假设在 k 个请求之后,所有空闲块的大小都恰好是 4 个字。堆会有外部碎片吗? 这取决于将来请求的模式。如果将来所有的分配请求都要求小于或者等于 4 个字的块,那么就不会有外部碎片。另一方面,如果有一个或多个请求要求比 4个字大的块,这个堆就会有外部碎片。
因为外部碎片难以量化且不可能预测,所以分配器通常采用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。
9.9.5 实现问题
最简单的分配器会把堆组织成一个大的字节数组,还有一个指针p,初始指向这个数组的第一个字节。为了分配 size 个字节,malloc 将 p 的当前值保存在栈里,将 p 增加 size,并将 p 的旧值返回到调用函数。 free 只是简单地返回到调用函数,而不做其他任何事情。
这个简单的分配器是设计中的一种极端情况。因为每个 malloc 和 free 只执行很少量的指令,吞吐率会极好。然而,因为分配器从不重复使用任何块,内存利用率极差,一个实际的分配器要在吞吐量和利用率之间把握好平衡,就必须考虑以下问题:
① 空闲块组织:如何记录空闲块?
② 放置:如何选择一个合适的空闲块来放置一个新分配的块
③ 分割:在将一个新分配的块放置到某个空闲块之后,如何处理这个空闲块中的剩余部分
④ 合并:如何处理一个刚刚被释放的块?
因为放置,分割以及合并这样的基本技术贯穿在许多不同的空闲块组织中,所以我们将在一种叫做隐式空闲链表的简单空闲块组织结构中来介绍,
9.9.6 隐式空闲链表
任何实际的分配器都需要一些数据结构,允许它来区别块边界,以及区别已分配块和空闲块。大多数分配器将这些信息嵌入块本身。一个简单的方法如图9-35所示。
在这种情况,一个块是由一个字的头部,有效载荷,以及可能的一些额外的填充组成的。头部编码了这个块的大小(包括头部和所有的填充),以及这个块是已分配的还是空闲的。如果强加一个双字的对齐约束条件,那么块大小就总是 8 的倍数,且块大小的最低 3 位总是零。因此,我们只需要内存大小的 29 个高位,释放剩余的 3 位来编码其他信息。这种情况中,用其中的最低位(已分配位)来指明这个块是已分配的还是空闲的。
例如,假设有一个已分配的块,大小为 24(0x18)字节。它的头部将是 0x00000018 | 0x1 = 0x00000019
类似地 一个块大小为 40(0x28)字节的空闲块有如下的头部: 0x00000028 | 0x0 = 0x00000028
头部后面就是应用调用 malloc 时请求的有效载荷。有效载荷后面是一片不使用的填充块,其大小可以是任意的。 需要填充有很多原因。比如,填充可能是分配器策略的一部分,用来对付外部碎片。或者也需要用它来满足对齐要求。
假设块的格式如图9-35,可以将堆组织为一个连续的已分配块和空闲块的序列,如图9-36。
称这种结构为隐式空闲链表,是因为空闲块是通过头部中的大小字段隐含地连接的。分配器可以通过遍历堆中所有块,从而间接地遍历整个空闲块的集合。注意,需要某种特殊标记的结束块,在这个示例中,就是一个设置了已分配位而大小为零的终止头部。
隐式空闲链表的优点是简单,显著缺点是任何操作的开销,例如放置分配的块,要求对空闲链表进行搜索,该搜索所需时间与堆中已分配块和空闲块的总数呈线性关系。
很重要的一点就是意识到系统对齐要求和分配器对块格式的选择会对分配器上的最小块大小有强制要求。没有 已分配块或者空闲块可以比这个最小值还小。例如,如果我们假设一个双字的对齐要求,那么每个块大小都必须是双字(8字节)的倍数。因此,图9-35的块格式就导致最小的块大小为 两个字:一个字作头,另一个字维持对齐要求。即使应用只请求一字节,分配器也仍然要创建两个字的块。
9.9.7 放置已分配的块
当一个应用请求一个 k 字节的块时,分配器搜索空闲链表,查找一个足够大可以放置所请求块的空闲块。分配器执行这种搜索的方式是由放置策略确定的。一些常见的策略是 首次适配(first fit),下一次适配(next fit)和 最佳适配(best fit)
首次适配从头开始搜索空闲链表,选择第一个合适的空闲块,下一次适配 和 首次适配很相似,只不过是从链表的起始处开始每次搜索,而是从上一次查询结束的地方开始。最佳适配 检查每个空闲块,选择适合所需请求大小的最小空闲块。
首次适配的优点是它趋向于将大的空闲块保留在链表的后面。缺点是它趋向于在靠近链表起始处留下小空闲块的碎片,增加了对较大块的搜索时间。下一次适配是由 Donald Knuth 作为首次适配的一种代替品最早提出的,源于这样的想法:如果我们上一次在某个空闲块已经发现了一个匹配,那么很可能下一次我们也能在这个剩余块中发现匹配。下一次适配比首次适配运行起来明显要快一些,尤其是当链表的前面布满了许多小的碎片时。 然而,一些研究表明,下一次适配的内存利用率要比首次适配低很多。研究还表明,最佳适配比首次适配和下一次适配的内存利用率要高一些。然而,在简单空闲链表组织结构中(比如隐式空闲链表),使用最佳适配的缺点是他要求对堆进行彻底的搜索。在后面,将看到分离式空闲链表组织,它接近于最佳适配策略,不需要进行彻底的堆搜索。
9.9.8 分割空闲块
一旦分配器找到一个匹配的空闲块,它就必须做另一个策略决定,分配这个空闲块中多少空间。 一个选择是用整个空闲块。虽然这种方法简单而快捷,但是主要的缺点是它会造成内部碎片。如果放置策略趋向于产生好的匹配,那么额外的内部碎片也是可以接受的。
然而,如果匹配不太好,那么分配器通常会选择将这个空闲块分割为两部分。第一部分变成分配块,而剩下的变成一个新的空闲块。 图 9-37 展示了分配器如何分割图 9-36 中 8个字的空闲块,来满足一个应用的对堆内存 3个字的请求
9.9.9 获取额外的堆内存
如果分配器不能为请求块找到合适的空闲块将会发生什么呢?一个选择是通过合并那些在内存中物理上相邻的空闲块来创建一些更大的空闲块(下一节描述)。然而,如果这样还是不能生成一个足够大的块,或者如果空闲块已经最大程度地合并了,那么分配器就会调用 sbrk 函数,向内核请求额外的堆内存。分配器将额外的内存转化成一个大的空闲块,将这个块插入到空闲链表中,然后被请求的块放置在这个新的空闲块中。
9.9.10 合并空闲块
当分配器释放一个已分配块时,可能有其他空闲块与这个新释放的空闲相邻。这些邻接的空闲块可能引起一种现象,叫做假碎片,就是有许多可用的空闲块被切割成为小的,无法使用的空闲块。比如,图 9-38 展示了释放图 9-37 中分配的块后得到的结果。结果是两个相邻的空闲块,每一个的有效载荷都为 3 个字。因此,接下来对一个 4 字有效载荷的请求就会失败,即使两个空闲块的合计大小足够大,可以满足这个请求。
为了解决加碎片问题,任何实际的分配器都必须合并相邻的空闲块,这个过程称为合并。这时出现了一个重要的策略决定,那就是何时执行合并。分配器可以选择立即合并,也就是在每次一个块被释放时,就合并所有的相邻块。或者也可以选择 推迟合并,就是等到某个稍晚的时候再合并空闲块。例如,分配器可以推迟合并,知道某个分配请求失败,然后扫描整个堆,合并所有的空闲块
立即合并简单明了,可以在常数时间内执行完成,但是对于某些请求模式,这种方式会产生一种形式的抖动,块会反复地合并,然后分割。例如,图 9-38 中,反复地分配和释放一个 3 字的块将产生大量不必要的分割和合并。 在对分配器的讨论中,我们会假设使用立即合并,但是应该了解, 快速的分配器通常会选择某种形式的推迟合并。
9.9.11 带边界标记的合并
分配器是如何实现合并的?称当前想要释放的块为当前块。那么,合并(内存中的)下一个空闲块很简单而且高效。当前块的头部指向下一个块的头部,可以检查这个指针以判断下一个指针是否空闲的,如果是,就将它的大小简单地加到当前块头部的大小上,这两个块在常数时间内被合并。
如何合并前面的块呢? 给定一个带头部的隐式空闲链表,唯一的选择将是搜索整个链表,记住前面块的位置,直到我们到达当前块。使用隐式空闲链表,意味着每次调用 free 需要的时间都与堆的大小成线性关系。即使使用更复杂精细的空闲链表组织,搜索时间也不会是常数。
knuth 提出了一种聪明而通用的技术,叫做 边界标记,允许在常数时间内进行对前面块的合并。这种思想,如图9-39 所示,是在每个块的结尾处添加一个脚部(footer,边界标记),其中脚部就是头部的一个副本。如果每个块包括这样一个脚部,那么分配器就可以通过检查它的脚部,判断前面一个块的起始位置和状态,这个脚部总是在距当前块开始位置一个字的距离。
考虑当分配器释放当前块时所有可能存在的情况:
在情况1中,两个邻接的块都是已分配的,因此不可能进行合并。所以当前块的状态只是简单地从已分配变成空闲。在情况2中,当前块与后面的块合并。用当前块和后面块的大小的和来更新当前块的头部和后面块的脚部。 在情况3中,前面的块和当前块合并。用两个块大小的和来更新前面块的头部和当前块的脚部。在情况4中,要合并所有的三个块形成一个单独的空闲块,用三个块大小的和来更新前面块的头部和后面块的脚部。在每种情况中,合并都是在常数时间内完成的。
边界标记的概念是简单优雅的,它对许多不同类型的分配器和空闲链表组织都是通用的。然而,也存在一个潜在的缺陷。它要求每个块都保持一个头部和一个脚部,在应用程序操作许多小块时,会产生显著的内存开销。例如,如果一个图形应用通过反复调用 malloc 和 free 来动态地创建和销毁图形节点,并且每个图形节点都只要求两个内存字,那么头部和脚部将占用每个已分配块的一半的空间。
有一种非常聪明的边界标记的优化方法,能够使得在已分配块中不再需要脚部。回想一下,当试图在内存中合并当前块以及前面的块和后面的块时,只有在前面的块是空闲时,才会需要它的脚部。如果把前面块的已分配/空闲位存放在当前块中多出来的低位中(?),那么已分配块就不需要脚部了,这样我们就可以将这个多出来的空间用作有效载荷了,不过请注意,空闲块仍然需要脚部。
9.9.12 综合:实现一个简单的分配器
构造一个分配器是一件富有挑战性的任务,设计空间巨大,有多种块格式,空闲链表格式,以及放置,分割和合并策略可供选择。另一个挑战就是经常被迫在类型系统的安全和熟悉的限定之外编程,依赖于容易出错的指针强制类型转换和指针运算,这些操作都属于典型的低层系统编程。
虽然分配器不需要大量的代码,但是还是细微而不可忽视的。熟悉诸如 C++ 或者 Java 之类高级语言的学生通常在它们第一次遇到这种类型的编程时,会遭遇概念上的障碍。将基于隐式空闲链表,使用立即边界标记合并方式,从头到尾地讲述一个简单分配器的实现。最大的块大小为 2^32=4GB。 代码是 64位干净的,即代码能不加修改地运行在 32位(gcc -m32) 或 64位(gcc -m64)的进程中。
1. 通用分配器设计
我们的分配器使用如图 9-41 所示的 memlib.c 包所提供的一个内存系统模型。模型的目的在于我们在不干涉已存在的系统层 malloc 包的情况下,运行分配器。
mem_init 函数 将对于堆来说可用的虚拟内存模型化为一个大的,双字对齐的字节数组。在 mem_heap 和 mem_brk 之间的字节表示已分配的虚拟内存。 mem_brk 之后的字节表示未分配的虚拟内存。 分配器通过调用 mem_sbrk 函数来请求额外的堆内存,这个函数和系统的 sbrk 函数的接口相同,而且语义相同,除了它会拒绝收缩堆的请求。
分配器包含在一个源文件(mm.c),用户可以编译和链接这个源文件到它们的应用之中。分配器输出三个函数到应用程序:
mm_init 函数初始化分配器,如果成功就返回 0,否则就返回 -1。 mm_malloc 和 mm_free 函数与它们对应的系统函数有相同的接口和语义。分配器使用如图 9-39 所示的块格式。最小块的大小为 16 字节。空闲链表组织成一个隐式空闲链表,如图 9-42所示的恒定形式。
第一个字是一个双字边界对齐的不适用填充字。填充后面紧跟着一个特殊的序言块,这是一个 8 字节的已分配块,只由一个头部和一个脚部组成。序言块是在初始化时创建的,并且永不释放。在序言块后边紧跟的是零个或者多个由 malloc 或 free 调用创建的普通块。 堆总是以一个特殊的结尾块来结束,这个块是一个大小为 0 的已分配块,只由一个头部组成。序言块和结尾块是一种消除合并时边界条件的技巧。分配器使用一个单独的私有全局变量,他总是指向序言块。(作为一个小优化,我们可以让他指向下一个块,而不是这个序言块)
2. 操作空闲链表的基本常数和宏
图 9-43 展示了一些我们在分配器编码中将要使用的基本常数和宏。第 2~4 行定义了一些基本的大小常数:字的大小(WSIZE) 和双字的大小(DSIZE),初始空闲块的大小和扩展堆时的默认大小(CHUNKSIZE)。
在空闲链表中操作头部和脚部可能是很麻烦的,因为它要求大量使用强制类型转换和组织运算。因此,我们发现定义一小组宏来访问和遍历空闲链表是很有帮助的(第 9~25 行)。PACK 宏(第 9 行)将大小和已分配位结合起来并返回一个值,可以把它存放在头部或者脚部中。
GET 宏(第 12 行)读取和返回参数 p 引用的字。这里强制类型转换是至关重要的。参数 p 典型地是一个(void * )指针,不可以直接进行间接调用,类似的,PUT 宏 (第 13 行)将 val 存放在参数 p 指向的字中。
GET_SIZE 和 GET_ALLOC 宏(第 16~17 行)从地址 p 处的头部或者脚部分别返回大小和已分配位。剩下的宏是对 块指针(block pointer 用 bp 表示)的操作,块指针指向第一个有效载荷字节。给定一个块指针 bp,HDRP 和 FTRP(第 20~21 行)分别返回指向这个块的头部和脚部指针。NEXT_BLKP 和 PREV_BLKP宏(第 24~25 行)分别返回指向后面的块和前面的块的块指针。
可以用多种方式来编辑宏,以操作空闲链表。比如,给定一个指向当前块的指针 bp,可以使用下面的代码行来确定内存后面的块的大小。
size_t size = GET_SIZE(HDRP(NEXT_BLKP(bp)));
3. 创建初始空闲链表
在调用 mm_malloc 或者 mm_free 之前,应用必须通过调用 mm_init 函数来初始化堆(图9-44)
mm_init 函数从内存系统得到 4 个字,并将它们初始化,创建一个空的空闲链表(第4~10行)然后它调用 extend_heap 函数(图9-45),这个函数将堆扩展 CHUNKSIZE 字节,并且创建初始的空闲块。此刻,分配器已经初始化了,并且准备好接受来自应用的分配和释放请求。
extend_heap 函数会在两种不同的环境中被调用:1)当堆被初始化时;2)当 mm_malloc 不能找到一个合适的匹配块时。为了保持对齐,extend_heap 将请求大小向上舍入为最接近的 2 字(8字节)的倍数,然后向内存系统请求额外的堆空间(第 7~9 行)
extend_heap 函数的剩余部分(第 12~17 行)有点微妙。堆开始于一个双字对齐的边界,并且每次对 extend_heap 的调用都返回一个块,该块的大小是双字的整数倍。因此,对 mem_sbrk 的每次调用都返回一个双字对齐的内存片,紧跟在结尾块的头部后面。这个头部变成了新的空闲块的头部(第12行),并且这个片的最后一个字变成了新的结尾块的头部(第 14 行)。最后,在很可能出现的前一个堆以一个空闲块结束的情况中,我们调用 coalesce 函数来合并两个空闲块。并返回指向合并后的块的块指针(第 17 行)
4.释放和合并块
应该通过调用 mm_free 函数(图9-46),来释放一个以前分配的块,这个函数释放所请求的块(bp),然后使用9.9.11节描述的边界标记合并技术将之与邻接的空闲块合并起来。
coalesce 函数中的代码是图 9-40 中勾画的四种情况的一种简单直接的实现。这里也有一个微妙的方面。我们选择的空闲链表格式(它的序言块和结尾块总是标记为已分配)允许我们忽略潜在的麻烦边界情况, 也就是,请求块 bp 在堆的起始处或者是在堆的结尾处。如果没有这些特殊块,代码将混乱得多,更加容易出错,并且更慢,因为我们不得不在每次释放请求时,都去检查这些并不常见的边界情况。
5. 分配块
一个应用通过调用 mm_malloc 函数(图9-47)来向内存请求大小为 size 字节的块。在检查完请求的真假之后,分配器必须调整请求块的大小,从而为头部和脚部留有空间,并满足双字对齐的要求。第 12~13 行强制了最小块大小是 16 字节:8字节用来满足对齐要求,另外 8个用来放头部和脚部,对于超过 8 字节的请求(第 15 行),一般的规则是加上开销字节,然后向上舍入到最接近的 8 的整数倍。
一旦分配器调整了请求的大小,他就会搜索空闲链表,寻找一个合适的空闲块(第 18 行)。如果有合适的,那么分配器就放置这个请求块,并可选地分割出多余的部分(第 19 行),然后返回新分配块的地址。
如果分配器不能够发现一个匹配的块,那么就用一个新的空闲块来扩展堆(第 24~26 行),把请求块放置在这个新的空闲块里,可选地分割这个块(第27行),然后返回一个指针,指向这个新分配的块。
相关文章:
深入理解计算机系统—虚拟内存(3)
9.9 动态内存分配 虽然可以使用低级的 mmap 和 munmap 函数来创建和删除虚拟内存的区域,但是 C程序员还是会觉得当运行时需要额外虚拟内存时,用 动态内存分配器 更方便,也有更好的可移植性。 动态内存分配器维护着一个进程的虚拟内存区域&…...
Vue项目整合与优化
前几篇文章,我们讲述了 Vue 项目构建的整体流程,从无到有的实现了单页和多页应用的功能配置,但在实现的过程中不乏一些可以整合的功能点及可行性的优化方案,就像大楼造完需要进行最后的项目验收改进一样,有待我们进一步…...
MyBatis 与 MyBatis-Plus 的区别
MyBatis 和 MyBatis-Plus 都是用于简化 Java 应用程序与数据库交互的持久层框架,但它们在功能、易用性和性能优化方面存在显著差异。下面将详细介绍两者之间的区别,并通过具体的代码示例进行对比。 概述 MyBatis:作为一款经典的持久层框架&a…...
如何让大模型不再“已读乱回”——RAG技术助力生成更精确的答案
随着大语言模型(LLM) 的迅猛发展,越来越多的领域开始受益于其强大的自然语言处理能力。从写作到编程,LLM已成为我们日常生活和工作的得力助手。然而,这些看似无所不能的大模型,却有一个致命的弱点ÿ…...
Anaconda环境配置(Windows11+python3.9)
文章目录 一、 下载ANACONDA(1)点击**Free Download**。(2)点击“skip registration”,跳过登录。(3)下载对应操作系统的ANACONDA版本。 二、 安装ANACONDA(1)双击运行安…...
Spring Boot 中的虚拟线程
什么是虚拟线程? 虚拟线程(Virtual Threads)是 Java 19 引入的一项新特性,它属于 Project Loom 项目的一部分。与传统的线程(平台线程)不同,虚拟线程并不是由操作系统直接管理,而是…...
el-table 实现纵向多级表头
为了实现上图效果,最开始打算用el-row、el-col去实现,但发现把表头和数据分成两大列时,数据太多时会导致所在格高度变高。但由于每一格数据肯定不一样,为保持高度样式一致,就需要我们手动去获取最高格的高度之后再设置…...
探秘Kafka源码:关键内容解析
文章目录 一、以kafka-3.0.0为例1.1安装 gradle 二、生产者源码2.1源码主流程图2.2 初始化2.3生产者sender线程初始化2.4 程序入口2.5生产者 main 线程初始化2.6 跳转到 KafkaProducer构造方法 一、以kafka-3.0.0为例 打开 IDEA,点击 File->Open…->源码包解…...
Promise编码小挑战
题目 我们将实现一个 createImage 函数,该函数返回一个 Promise,用于处理图片加载的异步操作。此外,还会实现暂停执行的 wait 函数。 Part 1: createImage 函数 该函数会: 创建一个新的图片元素。将图片的 src 设置为提供的路径…...
PyQt实战——将pcm文本数据转换成.pcm的二进制文件
系类往期文章: PyQt5实战——多脚本集合包,前言与环境配置(一) PyQt5实战——多脚本集合包,UI以及工程布局(二) PyQt5实战——多脚本集合包,程序入口QMainWindow(三&…...
数据结构之线性表
1.什么是线性表 线性表的概念 定义:线性表是由n个数据元素组成的有限序列。每个数据元素(除了第一个和最后一个)都有且仅有一个前驱和一个后继。逻辑结构:线性表的逻辑结构可以用一个序列来表示,例如 L(a1,a2,…,an)。…...
量子行走的干涉性和叠加性
需要注意公式的一些特殊情况,举例,当dj2和dj3 dj2 dj3...
Fabric环境部署-安装Go
安装go语言环境 国内镜像:Go下载 - Go语言中文网 - Golang中文社区 1.选择版本下载后解压:注意go1.11.linux-amd64.tar.gz换成你下的 sudo tar zxvf go1.21.linux-amd64.tar.gz -C /usr/local 2.. 创建Go目录 mkdir $HOME/go 3. 用vi打开~./bashrc&…...
网站设计总结后期维护与更新的重要性
当我们谈论网站设计时,往往会聚焦在初始阶段的创意和实现上。然而,一旦网站建成并上线,后期维护与更新的重要性就显得尤为突出。一个网站的成功不仅取决于其初始设计,更在于持续的维护与更新。 首先,后期维护能够确保网…...
『SQLite』详解运算符
内容摘要:本节讲解运算符,包括:算术运算符、比较运算符、逻辑运算符和位运算符。 什么是运算符? 运算符是一个保留字或字符,主要用于 SQLite 语句的 WHERE 子句中执行操作。它用于指定 SQLite 语句中的条件࿰…...
计算机网络--根据IP地址和路由表计算下一跳
一、必备知识 1.无分类地址IPV4地址网络前缀主机号 2.每个IPV4地址由32位二进制数组成 3. /15这个地址表示网络前缀有15位,那么主机号32-1517位。 4.地址掩码(子网掩码):所对应的网络前缀为1,主机号为0。 5.计算下…...
如何使用 Ansys OptiSlang 同时运行多个参数化设计研究
了解如何通过使用 OptiSLang 同时运行多个参数化设计研究来提高工作效率。 了解参数化设计研究的重要性 参数化设计研究在工程和设计过程中起着至关重要的作用。通过改变输入参数,工程师可以探索不同设计选择的效果,并优化其设计以满足性能、成本或其他…...
《 拼数 》
题目描述 设有 nn 个正整数 a1…ana1…an,将它们联接成一排,相邻数字首尾相接,组成一个最大的整数。 输入格式 第一行有一个整数,表示数字个数 nn。 第二行有 nn 个整数,表示给出的 nn 个整数 aiai。 输出格…...
Memcached CAS 命令
Memcached CAS(Check-And-Set 或 Compare-And-Swap) 命令用于执行一个"检查并设置"的操作 它仅在当前客户端最后一次取值后,该key 对应的值没有被其他客户端修改的情况下, 才能够将值写入。 检查是通过cas_token参数进…...
ElasticSearch基础-文章目录
ElasticSearch学习总结1(环境安装) ElasticSearch学习总结2(基础查询) ElasticSearch学习总结3(.NetCore操作ES) ElasticSearch学习总结4(sql操作ES) ElasticSearch学习总结5&am…...
后台管理系统动态面包屑Breadcrumb组件的实现
在后管理系统开发中,面包屑导航是一个非常常见的功能,通常是根据当前的 url 自动生成面包屑导航菜单,当跳转路由发生变化时,面包屑导航都会随之发生变化,即动态面包屑。 要完成动态面包屑我们需要制作一个动态数组&am…...
java项目之校园管理系统的设计与实现(源码+文档)
风定落花生,歌声逐流水,大家好我是风歌,混迹在java圈的辛苦码农。今天要和大家聊的是一款基于springboot的校园管理系统的设计与实现。项目源码以及部署相关请联系风歌,文末附上联系信息 。 项目简介: springboot校园…...
浅谈棋牌游戏开发流程八:运维与数据分析
一、前言:为什么“云端运维”和“数据分析”如此重要? 在前面几篇文章中,我们已经从客户端、后端架构、用户系统、房间匹配与对局流程、数据库设计与优化、支付与充值、安全与反外挂等角度,系统性地搭建了一个棋牌游戏的基本框架…...
uniapp:微信小程序文本长按无法出现复制菜单
一、问题描述 在集成腾讯TUI后,为了能让聊天文本可以复制,对消息组件的样式进行修改,主要是移除下面的user-select属性限制: user-select: none;-webkit-user-select: none;-khtml-user-select: none;-moz-user-select: none;-ms…...
跨物种筛选同源基因
工具:R:biomaRt 原始文件:human、mouse、macaque、marmoset四个物种的gene list,有些是用ensembl ID,有的是用gene name来表示。 目的:找到四个物种的gene list之间的1v1同源基因 1. 找到物种间的1v1同源…...
大模型数据采集和预处理:把所有数据格式,word、excel、ppt、jpg、pdf、表格等转为数据
大模型数据采集和预处理:把所有数据格式,word、excel、ppt、jpg、pdf、表格等转为数据 文本/图片/表格,分别提取处理工具选择不同格式文件,使用不同工具处理1. 确认目标2. 分析过程(目标-手段分析法)3. 实现步骤4. 代码封装效果展…...
k8s修改存储目录-介绍
k8s修改存储目录-介绍 文章目录 k8s修改存储目录-介绍总结:介绍指定 Docker 或 containerd 镜像和容器存储目录Docker 存储目录containerd 存储目录 指定 Kubelet 的存储目录指定 Pod 和容器存储目录 docker 运行时,迁移目录实操:https://blo…...
【电源专题】为什么测试电源的SW波形上冲振荡之前的0V电位要先来个小的下降
在同步电源的开关节点SW波形测试中,你可能会发现周期性的SW波形在上升前的一小段时间时间内会有一个小小的下跌,这个下跌会低于0V。那么这个下跌是怎么来的呢? 如下所示为某降压转换器的SW开关节点波形: 其展开后可以看到在上升之前有20ns左右的时间,SW电压是下跌…...
常见的反规范化技术
在数据库设计中,数据规范化和反规范化是两种重要的策略,它们在一定程度上存在权衡。规范化通过组织表结构,减少数据冗余,提高数据一致性和降低更新异常,使数据存储更加高效、可靠。然而,过度的规范化会导致…...
Linux中隐藏操作身法
从历史记录中删除指定的命令 假设历史记录中已经包含了一些你不希望记录的命令。这种情况下我们怎么办?很简单。通过下面的命令来删除: history | grep "keyword"例如:history | grep set o history 批量第二条和第四条删除: sed…...
Transformer知识梳理
Transformer知识梳理 文章目录 Transformer知识梳理什么是Transformer?语言模型迁移学习 Transformer结构注意力层原始结构 总结 什么是Transformer? 语言模型 Transformer模型本质上都是预训练语言模型,大部分采用自监督学习(S…...
Nexus Message Transaction Services(MTS)
Nexus 系列交换机遇到以下情形时,可以尝试查看是否是 MTS 消息卡在缓冲区过多,因为 MTS 负责处理模块内以及跨模块(包括跨管理引擎)的各服务之间的消息路由和排队。 • CPU 高 • 命令行无响应、响应慢 • 控制平面中断 • 流量问…...
网络编程基础:连接Java的秘密网络
1 网络编程的重要性 网络编程允许Java应用程序与其他计算机或设备进行通信。这包括从简单的数据传输到复杂的分布式系统和Web服务。 2 Java网络编程的核心类 Java提供了多个类来支持网络编程: InetAddress:表示网络上的IP地址。 URL:表示统…...
uniapp中判断设备类型
全局变量: 在 UniApp 中,你可以通过 uni.getDeviceInfo 获取设备信息,并将设备类型全局存放。通常,这些信息可以存放在 app.vue 的全局变量中,以便在整个应用中访问。 以下是如何在 app.vue 中实现这一功能的完整代码…...
数据可视化分析详解
数据可视化分析是一种通过图形、表格、图标和其他视觉元素来呈现数据的方式,使得数据更易于理解和分析。以下是关于数据可视化分析的一些关键点: 一、定义与目的 数据可视化分析是指利用图形化手段,清晰地有效地传达与沟通信息。它将数据以…...
_使用CLion的Vcpkg安装SDL2,添加至CMakelists时报错,编译报错
语言:C20 编译器:gcc 14.2 摘要:初次使用Vcpkg添加SDL2,出现CMakelists找不到错误、编译缺失main错误、运行失败错误。 CMakelists缺失错误: 使用CLion的Vcpkg安装SDL2时,按照指示把对应代码添加至CMakel…...
QT中Qstring和QByteArray有什么区别?
数据存储内容方面 QString: 主要用于存储和处理Unicode编码的文本字符串。它能够很好地处理包含各种语言字符的文本信息,如中文、日文、韩文等多种语言文字。例如,QString str "你好,世界!";可以方便地存储…...
Viggle AI:支持小孩或者卡通人物吗? [Viggle AI实战教程] – 第2篇
历史文章 Suno AI API接入 - 将AI音乐接入到自己的产品中,支持120并发任务 万物皆能舞,AI让你秒变“舞”林高手 – Viggle AI“舞”所不能 Viggle AI:打造爆款 AI 视频,让照片 “踢” 起足球 Viggle AI:开启3D动画…...
庐山派K230学习日记4 PWM控制
1 本节介绍 📝本节您将学习如何通过将K230开发板的GPIO引脚复用为PWM功能并输出PWM信号;实现输出PWM信号及控制板载无源蜂鸣器发出声音。 🏆学习目标 1️⃣如何将GPIO引脚配置为PWM模式,通过40Pin排针中的部分引脚来输出PWM信号…...
Android配件应用默认启动与USB权限申请区别
使用效果: USB配件授权演示 选择USB配件默认打开应用 申请USB配件使用权限...
【车载开发系列】GPIO模式分类
【车载开发系列】GPIO模式分类 这里写目录标题 【车载开发系列】GPIO模式分类一. GPIO概念二. GPIO的模式区分三. GPIO的八大模式1)推挽输出(Output push-pull)2)开漏输出(Output open-drain)3)…...
uniapp--HBuilder开发
提示:本文为学习内容,若有错误,请联系作者,谦虚受教。 文章目录 前言一、下载HBuilder二、添加modbus相关库1.下载nodejs2.下载modbus库3.项目添加modbus库 三、HBuilder相关功能语句1.文件夹说明2.消息信息框3.开关按钮4.选中按钮…...
学习笔记|arduino uno r3|点亮|hello world|Atmega328P|开发板学习:概述
目录 arduino uno r3开发板学习开发板概述重要引脚介绍配置开发环境安装 Arduino IDE 编程环境介绍Arduino 介绍 实操连接选择程序程序代码编译和执行 总结课后练习 arduino uno r3开发板学习 开发板概述 Arduino UNO 是一款基于Atmega328P 的微控制器开发板。它有 14 个数字…...
Go语言的 的注解(Annotations)核心知识
Go语言的注解(Annotations)核心知识 Go语言是一种简洁且高效的编程语言,广泛应用于后端开发、云计算和微服务架构。在探索Go语言的特性时,我们不可忽视一个重要的概念:注解(Annotations)。虽然…...
WinRAR中“自动加密”如何使用?
WinRAR加密大家都不陌生,那么自动加密功能大家熟悉嘛?如何使用自动加密功能?今天介绍详细教程给大家。 打开WinRAR软件之后选择工具栏中的【选项】,点击设置 然后切换到【压缩】选项卡,点击【创建默认配置】ÿ…...
`http_port_t
http_port_t 是 SELinux(Security-Enhanced Linux)中的一种端口类型标签,用于标识哪些端口可以被 HTTP 和 HTTPS 服务使用。SELinux 是一种强制访问控制(MAC)安全模块,它通过定义安全策略来限制进程对系统资…...
C++编程等级认证学习计划
C编程等级认证学习计划 计划目标 在30天内系统学习并掌握C编程等级认证(一至八级)的知识点,为参加认证考试做好充分准备。 前期准备 学习资料收集 准备涵盖C编程一至八级知识点的专业教材,如《C Primer》等。收集相关的在线教…...
c和c++中为什么要防止头文件被重复包含!
在编程中,头文件就像一本工具书,它包含了函数、类、宏、全局变量等的定义和声明,供其他代码文件引用。想象一下,如果你在写一篇文章时,反复引用同一本工具书的内容,会发生什么情况呢? 1. 避免重…...
安的厦小程序开发日志
目录 背景名字由来架构文件目录app.jsonapp.wxsspackage.jsonproject.config.jsindex.wxmlindex.wxssindex.jsindex.jsondetail.wxmldetail.wxssdetail.jsdetail.json参考资料背景 我们正在经历一场价值观的变迁,过去的丈母娘和女朋友总是要求男方要买房,那是因为房子是当下…...
深度评测uni-app x:开启跨平台开发新篇章
文章目录 一、引言1.1 跨平台开发的崛起1.2 uni-app x 初印象 二、uni-app x 核心特性评测2.1 uts 语言:跨平台编程新利器2.2 uvue 渲染引擎:原生渲染新体验2.3 强大的组件和 API 支持2.4 插件生态:拓展无限可能 三、与 uni-app 对比…...