文章目录
- 七、内存分配
- 7.1 顺序分配
- 7.2 空闲链表分配
- 7.2.1 首次适应分配
- 7.2.2 循环首次适应分配
- 7.2.3 最佳适应分配
- 7.2.4空闲链表分配的加速
- 快速适应分配(fast-fit allocation)
- 位图适应分配(bitmapped-fits allocation)
- 7.3 内存碎片化
- 7.4 分区适应分配
- 7.4.1 内存碎片
- 7.4.2 空间大小分级的填充
- 7.5 分区适应分配与简单空闲链表分配的结合
- 7.6其他需要考虑的问题
- 7.6.1 字节对齐
- 7.6.2 空间大小限制
- 7.6.3 边界标签
- 7.6.4 堆可解析性
- 7.6.5 局部性
- 7.6.6 拓展块保护
- 7.6.7 跨越映射
- 7.7 并发系统中的内存分配
- 附录
七、内存分配
内存管理器需要处理的问题包括3个方面:
- 如何分配内存
- 如何确定存活数据
- 如何回收死亡对象所占用的空间,以便在程序的后续执行过程中重新将其分配出去
对于这3个问题,垃圾回收系统和显式内存管理器有着不同的处理策略,且不同回收器所使用的算法也各不相同。但不论如何,内存的分配和回收过程都是紧密相关的,使用任何一种分配策略都必须要考虑如何回收其所分配的内存。
垃圾回收系统一次性完成所有死亡对象的回收,而显式内存管理却通常一次只回收一个对象。更进一步,某些垃圾回收算法(如复制式或整理式回收)可以一次性回收大块连续空间。
- 许多拥有垃圾回收能力的系统在分配对象时可以获取更多的信息,例如待分配对象的大小及其类型。
- 相对于显式内存管理,垃圾回收可以将开发者从内存管理的任务中解脱出来,从而其编程模式会更倾向于频繁地使用堆内存分配。
7.1 顺序分配
顺序分配使用一个较大的空闲内存块。对于n字节的内存分配请求,顺序分配将从空闲块的一端开始进行分配,其所需的数据结构十分简单,只需要一个空闲指针(free pointer)和一个界限指针(limit pointer)。
算法7.1展示了顺序分配的伪代码,其内存分配方向是从低地址到高地址,图7.1对其分配过程进行了描述。
由于顺序分配策略总是简单地移动空闲指针,所以俗称为阶跃指针分配(bump pointer allocation)。
在某些场景下,顺序分配也被称为线性分配(linear allocation), 因为对于指定内存块而言,其所分配地址的顺序总是线性的。
关于分配过程中的字节对齐以及填充( padding)要求,参见7.6节和7.8节。顺序分配的特征如下:
-
简单
-
高效
-
相对于空闲链表分配,顺序分配可以给赋值器带来更好的高速缓存局部性,特别是对于移动式回收器中对象的初次分配。
-
与空闲链表分配相比,顺序分配不适用于非移动式回收器。如果未被回收的对象将较大的空闲内存块分割成许多较小的内存块,则空闲内存将会呈现出碎片化趋势,即可用空间散布在众多可以用于顺序分配的小内存块中,而不是少数几个大内存块里。
Java中的内存分配
假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为 “指针碰撞”(Bump the Pointer) 。(翻译过来名字不同)
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为 “空闲列表”(Free List) 。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
7.2 空闲链表分配
空闲链表分配是与顺序分配截然不同的一种内存分配策略,它使用某种数据结构来记录 空闲内存单元(free cell) 的位置和大小,该数据结构即空闲内存单元的集合。
严格意义上讲,空闲内存单元的组织方式并非一定是链表,也可以采用其他形式,但尽管如此,我们仍使用“空闲链表”这一传统名称。我们可以将顺序分配看作是空闲链表分配退化后的一种特例,但在实际应用中,顺序分配的性质更加特殊,且实现更为简单。
我们将首先考虑以单链表方式组织空闲内存单元的策略,即在需要内存分配时,分配器顺序检测每个空闲内存单元,依照某种策略选择一个并从中进行分配。其算法实现通常是顺序扫描所有空闲内存单元,直到发现第一个符合条件的内存单元为止,因而被称为 顺序适应分配(sequential-fits allocation) 。
典型的顺序适应策略包括首次适应(first-fit)、 循环首次适应(next-fit)、 最佳适应(best-fit)。
7.2.1 首次适应分配
对于一次内存分配请求,首次适应分配器将在所发现的第一个满足分配要求的内存单元中进行分配。
如果该内存单元的空间大于所需的分配空间,则分配器将 分裂(split) 该内存单元,并将剩余空间归还到空闲链表中。
-
如果分裂后的剩余空间太小以至于无法再用于分配(分配算法及数据结构通常限制了最小可分配内存单元的大小),则分配器会避免分裂。
-
如果分裂后的剩余空间小于某一固定阈值或者某一百分比,分配器也可避免分裂。
算法7.2给出了首次适应分配的代码。需要注意的是,该算法假定每个空闲内存单元内部都记录了自身大小以及下一个空闲内存单元的地址,因此只需要一个全局变量head来记录链表中第一个空闲内存单元的地址。
算法7.3是首次适应分配的一个变种,即当节点分裂之后将后半个内存单元分配出去,从而简化了代码实现。该方案可能的不足之处在于其对象对齐方式有所不同,但无论如何这也是分割内存单元的一种方式。首次适应分配的特征如下:
-
较小的空闲内存单元会在靠近链表头的位置聚集,从而导致分配速度变慢。
-
在空间使用率方面,在首次适应分配算法的空闲链表中,内存单元会大致以从小到大的顺序排列,因此其行为模式与最佳适应分配比较相似。
在首次适应分配算法中,空闲节点在链表中的排列顺序是一个值得关注的问题。
-
在显式内存管理的场景下,分配器可以将被释放的内存单元插入空闲链表的不同位置,例如链表头部、链表末尾,也可以按照空闲内存单元的地址或者大小进行排序。
-
在垃圾回收场景中,对空闲链表中的内存单元按照地址进行排序通常更加自然,标记—清扫回收算法使用的便是这一排序策略。
7.2.2 循环首次适应分配
循环首次适应分配(next-fit allocation)是首次适应分配算法的一个变种。
在分配内存时,该算法不是每次都从空闲链表头部开始查找,而是从上次成功分配的位置开始,即算法7.4中的prev变量。当查找过程到达链表末尾时,指针curr将绕回到表头继续进行查找。
与首次适应分配相比,该算法可以有效避免对空闲链表前端较小空闲内存单元的遍历。
虽然循环首次适应分配看起来很有吸引力,但在实践中它通常表现较差:
-
在空间上相邻的存活对象可能并不是在同一时段分配,因此它们被回收(或者显式释放)的时间也通常不同,从而加剧了内存碎片化(见7.3节)。
-
在分配与释放的过程中,空闲指针的位置会不断向前迭代,导致新分配出去的空间并不是刚刚被释放的内存单元,因此其空间局部性较差。
-
在同一时段内分配的对象会散落在堆中不连续的位置上,且穿插在其他时段分配的对象之间,从而降低了赋值器的局部性。
7.2.3 最佳适应分配
所谓最佳适应分配是指在空闲链表中找到满足分配要求且空间最小的空闲内存单元,其目的在于减少空间浪费,同时避免不必要的内存单元分裂。算法7.5演示了最佳适应分配的具体实现。
在实际应用中,最佳使用分配似乎在大多数应用程序中都表现良好,其空间浪费率相对较低,但在最差情况下性能较差。 尽管这一测试结果是在显式内存释放实验中得出的,但可以预测,其内存浪费率较低的特征在垃圾回收系统中仍然成立。
7.2.4空闲链表分配的加速
如果堆空间较大,则仅使用单个顺序链表对空闲内存单元进行组织便显得力不从心,因此研究者们开发出了多种更加复杂的空闲内存单元组织方式来加快它的分配速度。
快速适应分配(fast-fit allocation)
使用平衡二叉树来组织空闲内存单元,从而可以按照空间大小(针对最佳适应分配)或者地址顺序(针对首次适应或者循环首次适应分配)进行排序。
当按照节点大小进行排序时,可以将大小相同的空闲节点组织成一条链表,然后使用平衡二叉树对各条链表进行管理。此时不仅查询效率较高,而且由于节点的分配和归还通常都在对应的链表中完成,因而树的变更操作较少,整体分配效率可以得到提升。
笛卡儿树是适用于首次适应分配或者循环首次适应分配的平衡树,它同时使用节点的地址(主键)和大小(次键)来组织索引。笛卡儿树依照节点地址进行排序,同时也将节点按照空间大小组织成“堆”,从而允许在首次适应或循环首次适应分配中快速找到满足要求的节点。
这一策略同时也被称为快速适应分配(fast-fit allocation)。
对于笛卡儿树中的某一节点,其所记录的内容包括:该节点所对应空闲内存单元的地址和大小、左右子节点的指针、节点自身及其子树中最大空闲内存单元的大小(该值的计算方法是先获取节点自身所对应空闲内存大小、左右子节点的最大空闲内存大小,然后取三者的最大值),因此笛卡儿树的节点大小会比基于链表的简单方案要大。
算法7.6展示了基于笛卡儿树实现首次适应分配的查找过程,其中忽略了树中节点的插人和删除操作。全局变量root代表笛卡儿树的根节点,max(n) 表示节点n的子树(包括节点n自身)中最大空闲内存单元的大小。循环首次适应分配的代码仅比首次适应分配稍为复杂一些。
平衡二叉树的引入将分配算法在最坏情况下的时间复杂度从0 (N)降低到O (log (N)),其中N为空闲内存单元的数量。 自调整树(self-adjusting tree) (splay tree) 也具有类似的优点(即平均分配速度较快)。
位图适应分配(bitmapped-fits allocation)
另一种有效策略是 位图适应分配(bitmapped-fits allocation) 。
该算法使用额外的位图来记录堆中每个可分配内存颗粒的状态,因此在进行内存分配时,分配器可以基于位图而非堆本身进行搜索。
借助一张预先计算好的映射表,分配器仅需要对位图中一个字节进行计算,便可得知其所对应的8个连续内存颗粒所能组成的最长连续可用空间。
也可以使用额外的长度信息记录较大的空闲内存单元或者已分配内存单元,从而快速将其跳过以提升分配性能。位图适应分配具有如下一些优点:
-
位图本身与对象相互隔离,因此不容易遭到破坏。这一特性不仅对于诸如C和C++等安全性稍低的语言十分重要,而且对于安全性更高的语言也十分有用,因为这可以提升回收器的可靠性以及可调试性。
-
引入位图之后,无论是对于空闲内存单元还是已分配内存单元,回收器都不需要占据其中的任何空间来记录回收相关信息,从而最大限度地降低了对内存单元大小的要求。如果以一个32位的字作为最小内存分配单元,该策略会引入大约3%的空间开销,但其所带来收益却远大于这一开销。不过,基于其他一些方面的考虑,对象可能依然需要一个头部,因而这一优点并非始终能够得到体现。
-
相对于堆中的内存单元,位图更加紧凑,因此基于位图进行扫描可以提升高速缓存命中率,从而提升分配器的局部性。
7.3 内存碎片化
对于支持动态内存分配的系统,在程序执行的初始阶段,堆中通常仅包含一个或者少数几个大块连续空闲内存。随着程序执行过程中不断的内存分配与释放,堆中逐渐会出现许多较小的空闲内存单元。我们将这种大块可用内存空间被拆分成大量小块可用内存的现象称为内存碎片化(fragmentation)。
对于动态内存分配系统而言,碎片化至少带来两种负面影响:
-
导致内存分配失败。对于一次内存分配请求,尽管堆的整体空闲内存足够,但可能所有的空闲内存单元都无法满足分配需求。对于非垃圾回收系统,这一情况通常会导致程序崩溃。而对于垃圾回收系统而言,这可能加快垃圾回收的频率。
-
即使堆中的空闲内存可以满足分配需求,碎片化问题仍可能导致程序消耗更多的地址空间、更多的常驻内存页以及更多的高速缓存行。想要完全避免内存碎片是不切实际的。
- 一方面,分配器通常无法预测程序未来会以何种序列进行内存分配
- 另一方面,即使可以精确地预测内存分配序列,找出一种最优的内
存分配策略(即使用最小的空间来满足一组内存分配与释放序列)也是NP困难的。
尽管我们并不能根除内存碎片化,但仍可以找到一些较好的方法来对其进行控制。一般来说,我们应当在分配速度和碎片化之间进行一个粗略的平衡,同时我们也发现,在任何情况下对内存碎片化进行预测都是十分困难的。
-
最佳适应分配的内存碎片会导致堆中散布大量很小的内存碎片。
-
首次适应分配也会产生大量小块内存碎片,它们通常集中在靠近空闲链表头的位置。
-
循环首次适应分配趋向于将小块碎片均匀地分散在堆中,但并不是说这样就更好。
-
唯一可以解决内存碎片化问题的方案是使用整理式或者复制式垃圾回收。
7.4 分区适应分配
基本的空闲链表分配器会将大多数时间花费在合适的空闲内存单元的查找上,因此如果使用多个空闲链表对不同大小的空闲内存单元进行管理,则会加快分配速度。
在本节所述的“分区适应”概念中,多个空闲链表所服务的仅仅是相同的逻辑空间,而在第9章我们将看到,回收器也可以将堆划分为“多个内存空间”,且每个内存空间拥有其专属的内存分配器,我们必须对这两个概念进行区分。
例如,许多回收器会对大对象或者不包含任何引用的大对象(例如图像或者其他二进制数据)进行单独管理,这不仅是基于性能的考虑,而且是因为这些对象通常都具有与众不同的生命周期特征。这些大对象可能会位于不同的空间,且回收器会对它们进行特殊处理。
另外,每个内存空间内部都可能存在独立的大对象集合,且它们通常使用分区适应算法进行分配,但是内存空间中的小对象却通常使用顺序分配而非分区适应分配。有许多方法可以将分区适应分配与多内存空间策略相结合。
分区适应分配的基本思想
- 将可分配内存单元的大小限制为k种
其中, S 0 < S 1 < . . . < S k − 1 S_0<S_1<...<S_{k-1} S0<S1<...<Sk−1 k值的选择可以有多种,但其通常是一个固定值。
- 空闲链表通常有k+1个
其中, f 0 , . . . , f k f_0,...,f_k f0,...,fk 。在空闲链表 f i f_i fi中,空闲内存单元的大小b必须满足 S i − 1 < b ≤ S i S_{i-1}<b \le S_i Si−1<b≤Si。其中 S − 1 = 0 , S k = ∞ S_{-1} = 0,S_k=\infty S−1=0,Sk=∞ 。
由于分区的目的在于避免内存分配时的空闲内存单元查找过程,所以我们可以进一步将空闲链表f中内存单元的大小精确地限制为 S i S_i Si。
但空闲链表 f k f_k fk是一个例外,它将用于保存所有大于 S k − 1 S_{k-1} Sk−1的空闲内存单元。
当需要分配不大于 S k − 1 S_{k-1} Sk−1的内存单元时,分配器会将所需的空间大小b向上圆整到不小于b的最小的 S i S_i Si我们将不同大小的 S i S_i Si称为空间大小分級(size class),因此针对b的空间大小分级即为满足 S i − 1 < b ≤ S i S_{i-1}<b \le S_i Si−1<b≤Si的一个。
空闲链表 f k f_k fk。中所管理的是所有大于 S k − 1 S_{k-1} Sk−1的空闲内存单元,我们可以使用7.2节所描述的某种单链表算法进行管理,因此笛卡儿树或者其他具有较好的期望时间性能的数据结构都是不错的备选方案。
- 大对象的分配通常不太频繁
- 即使抛开分配频率这一因素,仅对较大对象进行初始化就会付出较大开销。
因此,即使大对象的分配开销比在单一空间大小的链表中进行分配稍高,其对程序整体执行时间的影响也不会超过1%。
对于大小为b的内存分配需求,有多种方式可以加速对应的空间大小分级的计算。假设 S 0 S_0 S0到 S k − 1 S_{k-1} Sk−1之间的空间大小等级均匀分布,即 S i = S 0 + c ∗ i S_i = S_0+c*i Si=S0+c∗i且 c > 0 c>0 c>0,如果 b > s k − 1 b>s_{k-1} b>sk−1,则对应的空间大小分级为 S k S_k Sk,否则将为 S j S_j Sj,其中 j = [ ( b − S 0 + c − 1 ) / c ] j=[(b-S_0+c-1)/c] j=[(b−S0+c−1)/c] (即线性适配,表达式中加上c- 1的目的是为了向上圆整)。
- 对于以 字节(byte) 为单位进行寻址的计算机,分配的单位通常为字节
- 对于以 字(word) 为单位进行寻址的计算机,分配的单位通常是字
即使分配单位是字节,内存颗粒的大小通常也会是一个字或者更大。
如果c是2的整数次幂,则表达式中的除法操作可以用移位的方式实现,这将比通用的除法操作快得多。
较小的空间大小分级可以分布得较为密集。除此之外,为避免对通用空闲链表分配算法的调用,分配器也可以提供多个较大的、分布较为稀疏的空间大小分级。
例如,在Boehm-Demers-Weiser回收器中,不大于8字节的对象均会在8字节的空闲链表中分配,然后在16~32字节中,每个能被4整除的数字都会对应一级空闲链表。
对于大于32字节的分配需求,则需动态确定其对应的空闲链表,即:
- 使用一个数组将所需内存大小(以字节为单位)映射到对应的分配大小(以字为单位),然后在空闲链表数组中直接以分配大小为索引找到对应的空闲链表,这些空间链表均使用懒惰填充策略。
如果将空间大小分级的集合固化在运行时系统中(即在系统编译时就已经确定),则理论上对于任何在编译期可以确定大小的分配需求,编译器都可以预先确定其所对应的空闲链表,这通常可以提高大多数分配操作的性能。
基于单个空闲链表的顺序分配(如首次适应、最佳适应等算法)通常会花费较长的时间以寻找合适的空闲内存单元,而如果引人某种形式的平衡树,则在最差情况下的时间复杂度会降低到对数级别。
相比之下,分区适应分配的主要优势在于,任何在非 S k S_k Sk的空间大小分级中执行的分配都会在常数时间内完成,如算法7.7所示,也可参见算法2.5中的懒惰清扫变体。
7.4.1 内存碎片
7.2节所述的简单空闲链表分配器只会面临一种内存碎片情况,即空闲内存单元太小以至于无法满足任何分配需求。由于不可用空间分布在已分配空间之外,因此这种碎片被称为外部碎片( external fragmentation)。
而在引入空间大小分级之后,分配器必须将分配需求向上圆整到某一特定的空间大小分级,因此在已分配的内存单元内部就可能存在空间上的浪费,由此造成的碎片被称为 内部碎片(internalfragmentation) 。
分区适应分配需要在内部碎片和空间大小分级数量方面进行平衡。特定的字节对齐要求也会以类似的方式引人碎片,但由于不可用空间位于已分配内存单元之外,所以从严格意义上讲这一情况属于外部碎片。
7.4.2 空间大小分级的填充
在分区适应分配算法中,各级空闲链表的填充也是需要考虑的一个重要方面。
- 一种策略是单个内存块仅用于填充特定大小的空闲链表,即页簇分配
- 另一种策略则是基于内存块分裂的策略
基于内存块的 页簇分配(big bag of pages block-based allocation) 该方案需要一个块分配器来分配大小为B的内存块,且B为2的整数次幂。对于大于B的内存分配需求,将直接从块分配器中获取一组连续内存块。
对于 s < B s < B s<B 的空间大小分级,如果分配器需要分配更多这一大小的内存单元,首先需要从块分配器中获取一个内存块,然后再将该内存块分割成大小为s的内存单元,并填充到空闲链表中。
分配器通常需要记录每个内存块填充了哪个空间大小分级,并将该信息与其他元数据(例如该内存块中内存单元的标记位) 一起记录在内存块内部,但Boehm和Weiser认为更好的方案是将其记录在独立的空间中,这样一来,如果仅需要对内存块的元数据进行查找和更新,该方案可以减少转译 后备缓冲区(translation lookaside buffer) 不命中以及 缺页异常(page fault) 的几率,同时也无需刻意将每个内存块中的元数据以不同的方式对齐(以避免不同内存块的元数据之间竞争相同的高速缓存集合)。
在2.5节对懒惰清扫的描述过程中,我们介绍了基本的基于 内存块的分配(block-basedallocation)策略 ,该策略的优势在于系统能够以内存块而非单个内存单元为单位来记录元数据,但这样却会使内存碎片问题更加复杂:如果每个内存块仅用于填充一种大小的空闲链表,则每个内存块中(平均)一半的空间会被浪费,而对于特定的空间大小分级,最差情况下内存块的浪费率将达到 ( B − s ) / B (B - s)/B (B−s)/B (此时每个内存块中仅有一个内存单元被分配出去)。
如果B不能被s整除,则在内存块的末尾会存在一块小于 s的空间无法使用,对于内存块而言,它属于内部碎片,而对于内存单元而言,它属于外部碎片。
在某些系统中,内存单元所关联的元数据不仅包括其空间大小,还包括该其内所承载的对象的类型。如果单个内存块仅用于分配一种类型的对象,可能会造成较大的内存碎片(因为大小相同但类型不同的两种对象必须从不同的内存块中分配,且由不同的空闲链表进行管理),但对于空间较小且数量较多的类型,如果将其元数据记录在内存块而非内存单元中,则在空间上的节省效果还是相当明显的,例如Lisp语言中的cons单元。
如果将较小内存单元的元数据与其所在的内存块进行关联,则空闲内存单元的合并将极为简单高效:
只有当内存块中所有的内存单元都被释放时,才需要合并空闲内存单元,然后再将整个内存块归还给块分配器。对于一般的内存分配需求,分配器只需简单地从对应的空闲链表中分配一个内存单元,如果空闲链表为空,则直接分配一个内存块并用其填充空闲链表。这一分配过程十分高效,但其主要缺陷在于,最差情况下的内存碎片问题较为严重。
内存块 分裂(splitting) 在 7.2节对几种简单空闲链表分配算法的介绍中,我们已经提到了空闲内存单元的分裂策略,即从较大的空闲内存单元中拆出一块以满足较小的内存分配需求。如果空间大小分级的分布较为密集,则在拆分–个空闲内存单元时,很可能会有一个合适的空闲链表恰好能够接纳剩余的内存单元。如果不希望空间大小分级过密,也可以使用一些特殊的方法来组织空闲链表并达到相同效果。 伙伴系统(buddysystem) 即是满足这一条件的方案之一,其空间大小分级均为2的整数次幂。
我们可以将一个大小为 2 t + 1 2^{t+1} 2t+1的空闲内存单元分裂为两个大小为 2 t 2^t 2t的空闲内存单元,同时也可以将两个相邻的大小为 2 t 2^t 2t的空闲内存单元合并成大小为 2 t + 1 2^{t+1} 2t+1的一个,但进行合并的前提是两个相邻空闲内存单元原本就是由同-一个较大的空闲内存单元分裂得到的。
在该算法中,大小为 2 t 2^t 2t的空闲内存单元两两成对,因而称之为伙伴。由于伙伴系统的内部碎片通常较为严重(对于任意的内存分配需求,其平均空间浪费率会达到25%),因此该算法基本已经成为历史,在实践中较少使用。
斐波那契伙伴系统(Fibonacci buddy system) 是伙伴系统的一个变种,其空间大小分级符合斐波那契序列,即 S i + 2 = S i + 1 + S i S_{i+2} = S_{i+1} + S_i Si+2=Si+1+Si同时需要选定合适的 S 0 S_0 S0和 S 1 S_1 S1。与传统的伙伴系统相比,该算法相邻空闲内存单元的大小比值更小,因而在一定程度上缓解了内部碎片问题。但该算法的问题在于,在回收完成后将相邻空闲内存单元合并的操作会更加复杂,因为回收器需要判定某一空闲内存单元究竟应当与相邻两个空闲内存单元中的哪一个进行合并。
7.5 分区适应分配与简单空闲链表分配的结合
我们可以将分区适应分配当作单–空闲链表分配的前端加速器,并将回收所得的内存单元置于其空间大小分级所对应的空闲链表中。
在进行一次内存分配时,如果发现请求所对应的空闲链表为空,则可以使用最佳适应策略(即沿着空间大小分级增大的方向)在更大的空闲链表中进行查找,当然也可以使用首次适应或循环首次适应策略,其不同之处仅在于发现空闲链表为空之后如何进行处理。
不论使用哪种策略,分配器都可以确保查找过程能够在空闲链表 f k f_k fk中结束,该链表中所有的空闲内存单元都比 S k − 1 S_{k-1} Sk−1要大,此时便需要使用基于空闲链表的分配策略(首次适应、最佳适应或循环首次适应)来完成分配。
该策略的另一种描述方式是:如何在已有的分区适应分配策略上实现对空闲链表 f k f_k fk的管理,其方案通常包含以下几种:
- 将其作为单个空闲链表,从而使用首次适应、最佳适应、循环首次适应或者它们的变种,例如笛卡儿树或者其他可以加速空闲内存单元查找的数据结构。
- 使用基于内存块的分配。
- 使用伙伴系统。
7.6其他需要考虑的问题
7.6.1 字节对齐
将对象按照特定的边界要求进行对齐
- 底层硬件或者机器指令集的要求
- 这样做有助于提升各层次存储器的性能(包括高速缓存、转译后备缓冲区、内存页)
以Java语言的double数组为例,某些机器可能要求double这一双字浮点数必须以双字为边界进行对齐,即其地址必须是8的整数倍(地址的后三位为零)。
一种简单但稍显浪费的解决方案是将双字作为内存分配的颗粒,即所有已分配或未分配内存单元的大小均为8的整数倍,且均按照8字节边界对齐。
但即便如此,当分配一个double类型的数组时,分配器仍需要进行一些额外工作。
假设Java语言中纯对象(即非数组对象)头部都必须保留两个字,一个指向对象的类型信息(用于虚函数调用、类型判定等),另一个用于记录对象的哈希值以及同步操作所需的锁(这也是一种典型的设计方式)。
数组对象则需要第三个字来记录其中元素的个数。如果将这三个头部字保存在已分配内存单元的起始位置,则数组元素就不得不以奇数字为单位进行对齐。
如果使用双字作为内存颗粒,则可以简单地用四个字(即两个双字)来保存这三个头部字,然后浪费掉一个。
但如果内存颗粒是一个字,我们则希望尽量减少上述的内存浪费。此时,如果某个空闲内存单元按照奇数字对齐(即其地址模8余4),则我们可以简单地将三个头部字放在内存单元的起始位置,后续的数组元素自然会满足双字的对齐要求。
如果某个空闲内存单元按照双字对齐,则我们必须浪费一个字以满足对齐要求。这一方案增加了分配过程的复杂度,因为某一空闲内存单元是否满足分配需求不仅取决于所需空间的大小,还取决于字节对齐要求,正如算法7.8所示。
7.6.2 空间大小限制
某些回收器要求对象(内存单元)的大小必须大于某一下界。
例如,基本的整理式回收要求对象内部至少可以容纳一个指针,还有一些回收器可能需要用两个字来保存锁或状态以及转发指针,这就意味着即使开发者仅需要分配一个字, 分配器也必须多分配两个字。
如果开发者需要分配不包含任何数据、仅用作唯一标识的对象, 原则上编译器无需分配任何空间,但在实际情况下这通常不可行:对象必须要有唯一的地址,因此对象的大小至少应为一个字节。
7.6.3 边界标签
为了确保在释放内存时可以将相邻空闲内存单元合并,许多内存分配系统为每个内存单元增加了额外的头部或者边界标签,它们通常不属于可用内存的范畴。
边界标签保存了内存单元的大小及其状态(即空闲或已分配),还可以在其中记录上一个内存单元的大小,从而可以快速读取上一个内存单元的状态并判断其是否为空。当内存单元空闲时,边界标签也可用于保存构建空闲链表的指针。
基于这些原因,边界标签可能达到两个字或者更大,但如果使用一些额外的方法,并允许在分配和释放的过程中引入一定的额外开销,则仍有可能将边界标签压缩到一个字。
如果使用额外的位图来标记堆中每个内存颗粒的状态,则不仅无需使用边界标签,而且可以增加程序的鲁棒性。这一方法是否会减少空间开销,取决于对象的平均大小以及内存颗粒的大小。
我们进一步注意到,垃圾回收通常会一次性释放大量对象,因此某些特定的算法可能不再需要边界标签,或者其边界标签中需要包含的信息较少。另外,托管语言中对象的大小通常可以通过其类型得出,因而无需使用额外的边界标签来单独记录相关信息。
7.6.4 堆可解析性
在标记—清扫回收的清扫阶段,回收器必须能够顺次逐个遍历堆中的每个内存单元,我们将这一能力称为堆可解析性。
尽管对于其他种类的回收器而言,堆可解析性并非不可或缺,但它对于回收器的调试将是十分有用的,因此如果条件允许,支持堆可解析性还是很有必要的。
通常我们只需要支持单方向的堆解析,即沿着地址增大的方向。编程语言通常会在对象内部使用一到两个字来记录对象的类型以及其他信息,我们称之为对象的头部。
例如,在许多Java语言的实现中,对象头部通常会占据两个字,一个用于记录对象的类型(指向类型信息的指针,类型信息中会包含该类的方法分派向量,另一个用于记录哈希值、同步信息、垃圾回收标记位等。
对于数组而言,如果数组的引用与其首个元素的引用相同,且后继元素在高地址方向顺次连续排列,则通过索引快速获取数组元素这一操作在大多数机器上都可以高效地完成。
由于运行时系统以及垃圾回收器通常需要以某种统一的方式来获取对象的类型,所以我们将对象的头部置于它的数据之前,但这样一来,对象的引用便不再是其所占用内存单元的首地址,而是数据区某个域的地址。将对象的头部置于数据之前有助于堆的前向解析。
同样以Java系统为例,每个数组实例均需单独记录自身长度。
将length域置于一般对象会用到的两个域之后可以简化堆的解析。此时数组的首个元素将位于内存单元的第三个字,而length域的索引号是-1,其他两个头域的索引分别为-2和-3。
为确保对象类型的获取方式一致,非数组的纯对象同样也需要将其两个头域置于索引号为-2和-3位置,这可能导致索引号为-1的位置出现空洞,但将对象内部的数据整体前移一个字便可以解决这一问题(此处假定硬件允许依照一个较小的负数常量进行索引,大多数硬件满足这一要求)。
另外,即使对象不包含额外的域,对于这一布局策略,依然不会存在任何内存浪费的问题:
- 该对象的引用可以是其后继对象某个头域的地址。
图7.2对这三种情况都进行了描述。
某些系统会存在使用较小对象覆盖较大对象的情况(例如许多函数式语言会将某个闭包替换为其计算后的值),此时可能出现一些特殊的问题。
如果覆盖时不采取额外操作,则回收器在扫描堆的过程中可能会遇到覆盖操作的中间状态,进而引发一些不可预知的错误。
Non-Stop Haskell通过插入一个 填充对象(filler object) 的方式解决了这一问题。赋值器在创建闭包时通常会预留1 ~ 8个字的元数据,正常情况下的覆盖操作仅需要在元数据中插入一个适当大小的无指针对象,而很少会出现较大的填充对象,一旦出现则需要动态创建元数据。
闭包(closure):是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。
换而言之,闭包让函数外部可访问函数内部变量。
由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,小心内存溢出
可参考学习Javascript闭包(Closure)
最后需要考虑的是字节对齐可能引发的问题。
如果出于字节对齐的目的而对某个对象进行一个或者数个字的移位,那么我们需要在由此造成的空隙中记录一 些数据,以便回收器在进行堆解析时将其跳过。如果可以确保对象头部以非零字开始,那么可以将空隙填充为零,
并在堆解析时简单地跳过这些为零的字。而另–种简单的方法是将特定范围的值写人空隙的起始位置,该值不仅可以表示接下来的一段内存是空隙,而且可以反映空隙的长度。
例如,Sun公司长期以来都使用一种所谓的“自解析堆”:
- 当(在不可移动的空间中)释放一个对象时,回收器使用一个填充对象覆盖其空间,而填充对象内部会包含一个表示自身大小的域(认为它是一个字数组),清扫器可以据此快速跳过空闲内存单元并找到下一一个真正的对象。
如果使用位图来记录每个对象的起始位置,不仅可以简化堆的解析过程,而且降低了对象头部的设计要求,其不足之处在于位图会占用额外的空间,在分配过程中对位图进行操作也会花费额外的时间。但对于许多回收器而言,基于位图的分配依然十分有用,特别是对于并行回收器与并发回收器。
本节介绍了Java语言中一种可以确保堆的可解析性的设计实现,其设计思想同样适用于其他语言。
另外,基于内存块的分配不仅会简化小块内存单元的解析,而且也可以简化大块内存的处理。
为提升高速缓存性能,我们可以将大对象置于连续内存块中的随机位置9,这样一来,该对象之前或者之后浪费的空间也是一个随机值。为确保堆的可解析性,可以将大对象的地址简单地记录在内存块的起始位置。
7.6.5 局部性
在内存分配过程中,局部性的影响表现在多个方面。分配和释放过程本身就会受到局部性作用的影响。
同等条件下,基于地址顺序的空闲链表分配可能提升分配器访问内存的局部性,顺序分配天然的线性访问模式也具有较高的高速缓存友好性,同时也可以在分配时进行一定的软件预取, 但对于某些硬件而言,软件预取并不是必要的一。
局部性这一概念还会以另一种完全不同的方式影响内存的分配和释放:
如果一批对象同时成为垃圾,且它们在堆中集中排列,则当回收完成后,它们所占的空间将会合并成一个单独的空闲块,从而最大限度地减少了内存碎片。
事实证明,同一时刻分配的对象通常也会在同一时刻成为垃圾,因此非移动式回收器所面临的内存碎片问题比人们预想的要小, 这同时也说明,将连
续两次分配的对象连续排列或者尽可能靠近排列的启发式方法是有价值的。
如果某次内存分配是通过拆分一个大内存块实现的,则下一次内存分配应当尽可能使用同一个内存块。
7.6.6 拓展块保护
一般情况下,堆通常是由一大块连续的地址空间所组成的,其低地址边界通常与程序的代码段或者静态数据区相邻,高地址边界之外的地址空间则通常会保留下来以备后续扩展。
在UNIX系统中,这一边界通常被称为“break",并可以通过sbrk系统调用进行扩展或者收缩。系统调用与内存管理(sbrk、brk、mmap、munmap)
该边界之外的地址空间通常不会映射到虚拟内存中,因此堆中最后一个空闲内存块便具有了可扩展性,我们称其为**“未使用空间”(unoccupied territory), 或者拓展块(wilderness)**。
Korn 和Vo 发现,如果将拓展块作为内存分配的最后备选内存块,则有助于降低内存碎片,这一策略被称为拓展块保护。这一策略同时也有助于延缓堆的增长,进而减少整个系统的资源消耗。
7.6.7 跨越映射
某些回收策略或者赋值器写屏障需要分配器对跨越映射进行额外的操作。跨越映射反映了堆中每个已对齐的 2 k 2^k 2k片段内最后一个起始于该片段的对象的地址。与堆的可解析性相结合,回收器或赋值器写屏障便可以根据对象内部的某一地址快速找到该对象的起始地址,然后进一步访问该对象头部。
7.7 并发系统中的内存分配
在多线程环境下,分配过程的许多操作都需要原子化以确保分配数据结构的完整性,这些操作都必须使用原子操作或者锁,但这样一来,内存分配就可能成为性能瓶颈。
最基本的解决方案是为每个线程开辟独立的内存分配空间,如果某个线程的可分配空间耗尽,则从全局内存池中为其分配一个新的空闲块,此时只有与全局内存池的交互才需要原子化。
不同线程的内存分配频度可能不同,因此如果在为线程分配内存块时使用自适应算法(即:为分配速度较慢的线程分配较小的内存块,而为分配速度较快的线程分配较大的内存块),则程序的时间和空间性能均可获得提升。
Dimpsey 等 声称,在多处理器Java系统中,为每个线程配备一个合适的 本地分配缓冲区(local allocation buffer, LAB) 可以大幅提升性能。
他们进一步指出,由于几乎所有的小对象都是从本地分配缓冲区分配的,因而我们有理由对全局(基于空闲链表的)分配器进行调整,以使其能够更加高效地分配用于线程本地分配缓冲区的内存块。
Garthwaite等 讨论了如何对本地分配缓冲区的大小进行自适应调整,他们同时发现,将本地分配缓冲区与处理器而非线程相关联效果更佳。
该算法通过如下方式对本地分配缓冲区的大小进行调整:
- 线程初次申请本地分配缓冲区时将获得24个字(94字节)的内存块,之后每次新申请的内存块均为上一次的1.5倍,同时每经历一次垃圾回收过程,回收器都会将线程的本地分配缓冲区的大小折半。
该算法同时也会根据不同线程的分配次数调整年轻代的空间大小。
每处理器(per-processor) 本地分配缓冲区的实现依赖于多处理器的 可重启临界区(restartable critical section) , Garthwaite 等人对此做了介绍。
- 其基本原理是,线程可以判断自身是否被 抢占(preermpt) 或者被 重新调度(reschedule) ,然后可以据此判断自身是否被切换到其他处理器上运行。
当线程抢占发生时,处理器会对某个本地寄存器进行修改,该操作会为抢占完成后的写入操作设置一个 陷阱 ,而陷阱处理函数则会重启被中断的分配过程。尽管每处理器本地分配缓冲区需要更多的指令支持,但与每线程本地分配缓冲区相比,其分配时延相同,且不需要复杂的缓冲区调整机制。
Garthwaite 同时发现
- 当线程数量较少时(特别是当线程数量小于处理器数量时),每线程(per-thread) 本地分配缓冲区的性能较好
- 在线程数量较多的情况下,每处理器本地分配缓冲区的表现更佳
因此他们将系统设计成可在两种方案之间进行动态切换。
本地分配缓冲区通常使用顺序分配策略。每个线程(或处理器)也可以独立维护自身对应的分区适应空闲链表,同时使用增量清扫策略。
线程在内存分配过程中会执行增量清扫,并将清扫所得的空闲内存单元添加到自身空闲链表中,但Berger等 指出,如果将该算法用于显式内存管理会存在一些问题。
例如,在某一使用 生产者—消费者模型 的程序中,消息对象通常由生产者创建并由消费者释放,因此两个线程之间将会产生单方向的内存转移。
在垃圾回收环境下通常不会存在这一问题,因为回收器可以将空闲内存释放到全局内存池中。如果使用增量清扫,空闲内存单元将被执行清扫的线程所获取,从而自然地将回收所得的内存返还给分配最频繁的线程。
附录
[1]《垃圾回收算法手册 自动内存管理的艺术》
[英]理查德·琼斯(Richard Jones)[美] 安东尼·霍思金(Antony Hosking) 艾略特·莫斯(Eliot Moss)著
王雅光 薛迪 译
[2]《Java虚拟机:JVM高级特性与最佳实践》周志明 著