文章目录
- 十、其他分区策略
- 10.1 大对象空间
- 10.1.1 转轮回收器
- 10.1.2 在操作系统支持下的对象移动
- 10.1.3 不包含指针的对象
- 10.2 基于对象拓扑结构的回收器
- 10.2.1 成熟对象空间的回收
- 10.2.2 基于对象相关性的回收
- 10.2.3 线程本地回收
- 10.2.4 栈上分配
- 10.2.5 区域推断
- 10.3 混合标记—清扫、复制式回收器
- 10.3.1 Garbage-First 回收
- 10.3.2 Immix回收以及其他回收
- 10.3.3 受限内存空间中的复制式回收
- 10.4 书签回收器
- 10.5 超引用计数回收器
- 附录
十、其他分区策略
10.1 大对象空间
可以将“大”的标准基于:
- 对象的绝对大小来定义(例如大于1024字节,或者相对于分配器所使用的内存块的大小)
- 对象的相对于堆的大小。
大对象满足我们在第8章所介绍的多个分区标准:
- 其分配成本通常较高,且更容易产生内存碎片(包括内部碎片以及外部碎片),因此值得为其使用一些特殊的管理策略(即使所选择的策略不太适合管理较小对象)。
如果将大对象分配在以复制方式进行回收的空间,则复制保留区的空间开销可能会非常大,同时复制大对象的开销也十分高昂(如果对象本身包含较大的指针数组,则复制的开销主要取决于更新指针域及其子节点的处理开销)。
正是由于这些原因,大对象空间通常使用那些不会在物理上移动对象的算法来管理。对于非移动式算法所带来的内存碎片问题,可以考虑偶尔对大对象空间进行整理。
大对象空间的实现及其管理方式存在多种解决方案。
- 最简单的方案是采用第7章所介绍的空闲链表分配器外加标记—清扫回收器进行回收
- 另外也可以将非移动式大对象空间与包括复制式算法在内的多种回收算法相结合。
- 有些方案将大对象拆分为(可能是固定大小的)头部和主体,主体部分保存在非移动的区域,而头部则可以与其他小对象采用相同的方式管理。
头部也可以使用分代垃圾回收器进行管理,但研究者们对于是否应当提升大对象头部仍存在分歧(如果不提升,则当大对象死亡后,回收器便可尽快回收其所占用的较大空间。
还有一些Java虚拟机,Oracle 的JRockit以及Microsoft的Marmot 并不针对大对象开辟独立的空间,而是直接将其分配在年老代,这是因为大对象通常都会存活一定的时间,而预分配策略可以节省提升过程的复制开销。
10.1.1 转轮回收器
对象的复制或者移动也可以是逻辑上的而非物理上的。
根据三色抽象法则,追踪式回收器可以将堆中对象划分为四个集合:
- 黑色(已完成扫描)
- 灰色(已访问到但尚未完成扫描)
- 白色(尚未访问到)
- 空闲内存
回收器在追踪过程中不断对灰色集合进行处理,直到该集合变空为止。不同的回收算法对不同集合的处理方式不同。
转轮回收器虽然是非移动式回收器,但它同时又兼具半区复制算法的一些优点。虽然该回收器原本是作为一种增量回收器来设计的,但它同时也可以较好地应用于万物静止式回收中的大对象管理。
转轮回收器以环状双向链表来组织对象(见图10.1),从逆时针方向来看,环的组成依次是黑色分段、灰色分段、白色分段以及空闲分段。
在整个堆中,黑色与灰色分段构成了目标空间,而白色分段相当于是来源空间。回收器的操作需要用到四个指针:
- 指针scan指向灰色分段的起始地址,同时也是灰色与黑色分段的分界点
- 指针B指向白色来源空间的尾部
- 指针T指向白色来源空间的首部
- 指针free是黑色分段与空闲分段的分界点
在执行万物静止式回收之前,所有对象均为黑色,且均位于目标空间中。
新对象的分配是通过顺时针方向移动指针free完成的,该操作相当于是从空闲分段中摘下一个内存单元并将其插人到黑色分段的首部。
当指针free与指针B (即来源空间的尾部)重合时,表示空闲内存耗尽,需要进行垃圾回收,此时整个转轮中最多只包含黑白两种颜色的对象。
回收器首先将来源空间与目标空间互换,然后将黑色对象重新着为白色,并将指针T与指针B互换。
接下来,回收器将采用类似半区复制的方式进行回收:
- 当完成某一灰色对象的扫描后,回收器逆时针移动指针scan,该操作相当于是将已完成扫描的对象插人到黑色分段的尾部。
- 当扫描到来源空间中的白色对象时,回收器将其从白色分段中摘除并插人灰色分段。
- 当指针scan与指针T重合时,表示灰色分段为空,即回收完成。
转轮回收器具有诸多优势。其分配与“复制”速度相当快,并发转轮回收器可以通过将对象插人到合适分段的方式简单地分配任意颜色的对象。由于摘除与插人操作并不会在物理上移动对象,因此分配与“复制”的操作可以在常数时间内完成,且与对象的大小无关。
将对象插人指定分段的操作简化了我们在第4章中所讨论的遍历顺序选择问题:
- 如果将对象插人到灰色分段的尾部(即在指针T之前),则遍历操作就会遵从广度优先的顺序
- 如果将灰色对象插人到灰色分段的首部(即指针scan的位置),则遍历操作会遵从深度优先的顺序,且无需额外的辅助栈。
不论使用哪种遍历顺序,转轮回收器的双向链表结构都已经自然地提供了遍历所需的数据结构(即栈或者队列)。
如果将转轮回收算法当作通用的垃圾回收器来使用,则其缺点之一在于每个对象都需要引人两个指针以实现双向链表。
但相对于复制式回收而言,转轮回收算法却无需任何复制保留区(因为它无需在物理上实现对象的复制),从而弥补了双向链表的空间开销。
转轮回收算法的另一个问题在于如何容纳不同大小的对象,一种解决方案是为每种空间大小使用不同的转轮。
但无论如何,这些不足之处对于大对象的管理都不会造成太大的问题。大对象转轮回收器通常会为每个对象分配专属的页(或者一组连续的页),如果将双向链表指针保存在该页中,则它们可以复用页中的碎片空间(这些碎片是由于将对象占用的空间向上圆整到页的整数倍造成的)。
另一方面,也可以将链表指针从其所属对象的页中移出并集中保存,其优势在于它不仅有助于降低用户代码破坏回收器元数据的风险,而且可以降低高速缓存不命中以及换页的开销。
10.1.2 在操作系统支持下的对象移动
如果操作系统支持,则回收器在“复制”或者“整理”对象时甚至有可能避免在物理上真正地移动它们。
要达到这一目的,分配器首先必须为每个大对象分配专属的页,当需要“复制”或者“整理”某一对象时,回收器可以对其所在页进行重映射来达到更新虚拟内存地址的目的,从而避免逐字节的内存复制。
基于操作系统的支持也可以实现大对象的增量初始化日,即当需要清空某一大对象的内存时, 不是将其一次性清零,而是修改其所在页的内存保护策略,任何尝试访问该对象未初始化部分的操作都会触发页保护陷阱,此时可以通过陷阱处理函数解除赋值器所访问地址的页保护并将该页清零。
10.1.3 不包含指针的对象
对大对象进行分区管理的思想也可以用于具有其他特征的对象。如果某一对 象内部不包含指针,则回收器无需对其进行扫描。
基于分区信息,回收器根据对象的地址便可以简单地判定其内部是否包含指针。如果对象的标记位保存在额外的位图中,则回收器根本无需访问对象本身。如果能将较大的位图与字符串保存在独立的区域,并使用特殊的扫描器对其进行管理,即使这些区域的空间不大,程序的性能也可以得到显著提升。
例如,Ungar 和Jackson 仅凭借一个330KB的独立空间便将回收停顿时间降低了3/4,而这一空间开销相对于现代标准几乎微不足道。
10.2 基于对象拓扑结构的回收器
10.2.1 成熟对象空间的回收
分代垃圾回收器的设计目的之一是降低回收停顿时间。
- 回收年轻分代所需的停顿时间可以通过控制年轻代整体大小的方式进行调整
- 但回收最老分代所需的工作量则取决于堆中存活对象的整体大小。
在基于年龄的划分策略之外,Hudson 和Moss 另辟蹊径,提出了 成熟对象空间(mature object space, MOS) 管理方案。
该方案依然将空间划分为多个固定大小的区域,每次回收只对一个区域进行处理,并将其中的存活对象复制到其他区域。
Hudson 和Moss将每个区域称为车厢(car), 然后使用多个先进先出链表对车厢进行组织,并称之为火车(train),因此该算法又俗称为火车回收器。对于每节正在处理的车厢,回收器会按照一定的规则将其中每个存活对象复制到特定的目标车厢中。
该策略可以确保环状垃圾最终都会被复制到一列单独的火车中,而该列火车可以被当作垃圾进行集体回收。算法的处理过程如下:
-
选择编号最小的火车t,并取其中编号最小的车厢c作为来源车厢。
-
如果没有任何赋值器根引用火车t中的对象,且t的记忆集为空,则该列火车中的所有对象均不可达,回收器可以将整列火车回收,回收结束。否则继续进行步骤3.。
-
将来源车厢c中所有被根集合引用的对象复制到火车 t’的目标车厢c’ 中,其中t’的编号比t大,且t’可能是一列新创建的火车。
-
递归地将车厢c中从目标车厢c’可达的对象复制到c’中,如果c’已满,则在火车t’中创建一节新的车厢,并将其作为目标车厢。
-
将年轻代存活对象提升到它们的引用来源所在的火车。
-
扫描来源车厢c的记忆集,如果其中的某一对象o从其他火车可达,则将对象复制到该列火车。
-
将来源车厢c中的剩余可达对象复制到其所在火车t的最后一节车厢中,如果该车厢已满,则增加新的车厢。
算法的第2步中,如果一列火车只包含垃圾,即使其中仍包含跨车厢的指针结构(例如环状垃圾),回收器也会将其整体回收。由于火车的记忆集为空,所以该列火车中的任何对象都不会被其他火车引用。
第3步和第4步会将来源车厢中所有直接从根集合可达或者经由本节车厢内部的指针链从根集合可达的对象移动到其他火车中,这些对象必然都是存活的,因此这两步会将它们从当前火车中其他可能是垃圾的对象中分离出来。
如在图10.2中,回收器将位于火车T1车厢C1的对象A和B复制到新创建的火车T3的第一节车厢中。算法最后两步的目的在于将链式垃圾结构与其他存活对象分离。
其中,第6步将来源车厢中从其他列车可达的对象移动到对应的火车中,如将对象P移动到火车T2的C2车厢中。
而第7步则是将来源车厢中其他潜在的存活对象(如对象X)移动到本列火车的末尾。算法的各个步骤以这种方式进行安排是十分必要的,因为某一对象可能会从多列火车可达。
第7步完成后,车厢c中的剩余对象必然无法从任何外部车厢可达,因而回收器可以像半区复制策略那样将整个来源车厢回收。
火车算法存在诸多优势。该算法的回收过程是增量式的,且每个回收周期所需复制的数据量不会超过一节车厢。
另外,该算法尝试将存活对象复制到其引用来源所在的车厢。由于存活对象的复制总是从编号较小的火车/车厢到编号较大的,所以记忆集只需要记录从编号较大的火车/车厢指向编号较低的火车/车厢的指针。
如果在成熟对象空间之外引入额外的年轻分代,且在每个回收周期都对其进行回收,则记忆集也无需记录任何来自年轻分代的指针。
不幸的是,火车回收器在对赋值器一般行为的适应方面存在一些问题,这表现在以下几个方面。
-
将环状垃圾隔离在单独的列车中可能需要经历多个回收周期,且所需的回收周期数与环状垃圾所分布车厢总数的平方成正比。
-
同时,火车算法在某些情况下可能会无法正常工作。
以图10.3a所示的情况为例,
-
假设单独一节车厢的空间不足以同时容纳A和B两个对象(或者指针结构),则当对第一节车厢进行回收时,对象A会被移动到同一列火车末尾新创建的那节车厢中。
-
假设在本例中各个指针都未被修改,则下一轮回收将会发现首节车厢存在一个外部引用,因而对象B会被移动到编号更高的火车中。
-
类似地,第三轮回收将会发现对象A引用了对象B,因而会将对象A移动到对象B所在的火车中。
-
此时,编号最低的一列火车将不再包含任何存活对象,因而可以整体回收。在这种情况下,火车回收器可以正常工作,但是,如果每次回收完成后赋值器都会将外部引用切换到第二节车厢中的对象(即图10.3b所示),则会出现问题:
- 首节车厢永远不会有来自本列火车之外的引用,因而无论在哪次回收中,火车回收器都会在编号最小的一列火车末尾创建一节新的车厢,并将首节车厢中的存活对象移动到其中,此时回收器将无法对其他火车进行处理。
Seligmann和Grarup 将这种情况称为“徒劳无功”的回收,他们的解决方案是进一步为每节车厢记录来自同一列火车中更靠后车厢的指针,并且在出现这一情况时据此将对象移动到其他火车上,从而最终实现此列火车的回收。
火车回收算法仅限制了每个回收周期所需复制的数据量,但它却无法进一步限制其他一些回收相关的工作,例如记忆集的扫描以及引用的更新。
如果某节车厢存在富引用对象(即该对象被引用的次数较多),则其记忆集通常较大,如果将其移动到其他车厢,回收器将不得不对大量对象的指针域进行更新。
Hudson和Moss建议将富引用对象移动到最新一列火车末尾专门为其创建的车厢中,后续回收过程中回收器便可对富引用车厢仅做逻辑上而非物理上的移动,进而避免大量的指针更新操作。
不幸的是,这一策略却无法保证回收器能够将环状垃圾隔离到单独一列火车中。即使允许在每节富引用车厢中容纳多个富引用对象,回收器也有必要将其中的对象彼此分离,除非其中的所有对象属于同一个指针结构。
Seligmann和Grarup 以及Printezis 和Garthwaite 都发现富引用对象在实践中十分普遍,后者的解决方案是允许记忆集增大到某一阈值(即4096个指针),并且在外部指针数量大于该值时使用哈希函数将其中的所有指针重新映射到相同大小的集合中,从而实现记忆集的扩大。
Seligmann和Grarup对可以回收到的垃圾进行动态评估,并尝试在此基础上降低回收频率(如果在评估时发现可以回收到的垃圾较少,则尝试降低回收频率),但Printezis和Garthwaite却发现,许多程序中通常都会存在少量包含长寿对象且长度很长的火车,这会导致Seligmann和Grarup的策略失效。
10.2.2 基于对象相关性的回收
对于基于分区策略的回收器而言,如果可以减少甚至彻底消除跨分区指针,回收器的性能将会得到提升。
Guyer 和McKinley 使用静态分析的方法直接将新创建的对象预分配到可能与其相关的对象所在的分代,Zee和Rinard 在分代回收器中使用静态分析的方法去除新创建对象初始化过程中的写屏障。
Hirzel等 对基于对象相关性的分配与回收策略做了进一步研究,他们发现,Java 对象的生命周期与它们之间的相关性有着十分密切的联系:
- 仅被栈槽引用的对象寿命通常较短,而从全局变量可达的对象则极有可能存活到程序运行结束时(他们同时指出,这一特性同时也在很大程度上取决于对“长寿”和“短命”的精确定义)。
- 以指针链的形式相互关联的对象通常会在同一时间死亡。
Hirzel等 基于这一观察结果开发出一种新的回收模型,即基于相关性的(垃圾)回收(connectivity-based (garbage) collection, CBGC),该模型包含四个基本组件。
保守式指针分析器用于将对象图划分为稳定的分区:
- 如果对象A可能指向对象B,则它们将位于同一个分区,或者由分区构成的 有向无环图(directed acyclic graph, DAG) 中会存在一条从A所在分区指向B所在分区的边。
尽管分区的数量可能会增加(例如有新的类系加载到程序中),但现有分区永远不会分裂。因此,一旦回收器完成对某一分区所有来源分区的回收,便可进一步对该分区进行回收。
回收器按照拓扑顺序来选择待回收分区,这存在两个好处:
- 一方面, 系统不再需要任何形式的写屏障或者记忆集
- 另一方面, 在使用拓扑顺序进行遍历时,一旦回收器完成对某一分区中对象的追踪,则该分区内部或者其来源分区中所有的白色对象将都变成垃圾,因此该策略具有较高的回收及时性。
- 该算法也可以忽略对富引用子分区的处理。
Hirzel 等人发现,对于基于对象相关性的垃圾回收器,其性能在很大程度上取决于保守式指针分析器的分区质量、对各分区存活对象的评估结果,以及待回收分区的选择方式。
它们使用模拟程序进行实验,其中回收器基于对象及其域的类型进行分区,基于分区中对象从全局变量或者栈的可达性来估算分区中存活对象的量(并使用一个基于分区年龄的衰减函数来调整估算的结果),使用贪婪算法来选择待回收分区,但不幸的是,实验结果令人失望,尽管其标记/构造率在某些情况下优于半区复制回收器,但却比Appel式分代回收器要差得多。
另外,该算法的最差停顿时间通常较小。与其他从分区策略中获取较高收益的回收器相比,该回收器显然在性能上与它们有较大差距,这一差距可能需要通过寻求更好的配置方式才能弥补。
基于对象分配位置(即进行对象分配的代码地址)的动态分区策略也可能提升回收器性能,但这又需要重新引人写屏障来实现分区的合并。
10.2.3 线程本地回收
降低垃圾回收停顿时间的方式:
- 将回收器线程与赋值器线程并发执行。
- 该策略的一个变种是增量式地执行回收工作,即回收工作在赋值器的执行间隙穿插进行。
这两种方案中,赋值器和回收器之间需要进行大量同步操作,因而增加了回收器的实现复杂度(我们将在后面的章节中描述增量回收器与并发回收器)。
如果我们可以确保某个对象集合只会被单个赋值器线程所访问,同时这些对象都存在于线程本地堆中,则对这些对象的操作可以免去同步操作的开销,从而可以将万物静止式的回收限制于单个线程之内。
需要注意的是,线程本地回收方法无法处理可能共享的对象,对它们进行处理时依然需要挂起所有的赋值器线程,或者使用更加复杂的并发回收/增量回收技术。
线程本地回收的关键在于如何将仅可能被单个线程访问的对象与潜在的共享对象隔离。
线程本地回收算法通常会将堆划分为一个共享空间以及一组线程本地堆,同时算法会对指针方向有着很严格的要求:
- 线程本地对象中的指针仅应当指向同一个线程本地堆中的对象或者共享对象,共享对象中不应当包含指向线程本地对象的指针,同时线程本地对象也不应当包含指向其他线程的本地对象的指针。
可以使用静态指针分析方法实现对象的静态分区,也可以使用动态分区,但这就需要赋值器在运行时检测出违背上述指针方向要求的操作。
注意
- 线程本地堆中对象的组织可以使用多种策略(如扁平式管理策略或者基于分代的管理策略)。
- 还可以基于对象自身来表示其是否属于共享对象(例如让对象头部中的一个标记位作为标识)。
Steensgaard 使用快速但保守的指针分析方法来判断哪些Java对象可能会从全局变量或多个线程可达,Ru 也使用过类似的方法,他使用 流不敏感(flow-insensitive) 但上下文敏感(context-sensitive) 的逃逸分析将创建对象的 函数特化(specialise) ,并据此决定是将对象分配在线程本地堆还是共享堆。每个堆都包含一个年轻分代以及一个年老分代。
流敏感,路径敏感和上下文敏感
什么是逃逸分析?
逃逸分析(Escape Analysis)技术
Steensgaard将所有静态域都作为线程本地堆的根,且每次回收都需要一个全局的线程汇聚(rendezvous),因而从严格意义上讲,该策略只能算是 主体线程本地(mostly thread-local) 回收器。回收开始时,回收器首先需要使用一个线程来完成所有从全局变量以及线程栈直接可达的对象的复制,然后再对共享堆进行Cheney扫描,最后再恢复各个线程,并由每个线程完成其本地堆的回收。当多个线程同时对共享堆中尚未复制的对象进行处理时可能发生冲突,在这种情况下就必须引人全局的锁。
要实现线程本地对象以及共享对象的静态分区,就需要对整个程序进行分析,这对于允许动态加载类的语言来说将会成为问题:
- 如果某个类在静态分析完成之后才加载到程序中,则程序很可能会调用该类中未经静态分析的多态方法来创建对象,并将其赋值给某一全局可达的域,从而造成引用“泄漏”。
Jones 和King解决了这一问题并设计出一种真正的线程本地回收器, 他们基于Steensgaard 的方法研究出了组合式逃逸分析技术——支持Java的动态类系加载,且可以确保在静态分析完成之后的类系加载操作依然安全。
对于在Solaris系统中运行在多处理器上的ExactVM Java虚拟机,该方案会在长期执行的Java程序中启动一个后台线程执行分析,且速度相当快。
他们为每个线程开辟了两块线程本地堆:
- 一个用于分配确定只会被当前线程访问的对象(不管未来是否会有新的类系加载到程序中。可以理解为当前线程的非共享的内容)
- 另一个用于分配 乐观本地对象(optimistically-local objects) ,此类对象在执行静态分析时只被一个线程访问,但在引入新的类系之后却有可能成为共享对象。
纯粹的线程本地对象通常较少,它们通常不会逃逸出创建它们的方法,但乐观本地对象则相当普遍。该方案对Steensgaard的指针方向要求做了适当的拓展:
- 纯线程本地对象可以引用乐观本地对象,但反之则不允许
- 同时乐观本地对象可以引用全局对象。
图10.4展示了该方案中允许出现的指针方向。Jones和King的方案允许对每个线程进行独立回收,从而无需引人全局的线程汇聚。
如果在静态分析完成之后没有新的类系引人,则对纯线程本地堆以及乐观本地堆可以同时进行回收。
一旦有新类系被动态引入,后台分析线程不但要对其方法进行特化,而且还要判断该类是否会影响现有的已经完成分析的类系,同时还要确定该类的方法是否会导致原有的乐观本地分配成为多线程共享分配。如果出现这样的情况(实际应用中这些“不合格”的方法通常极少出现),则所有可能调用该方法的线程都必须将其乐观本地堆标记为共享,同时不再允许对其进行线程本地回收(它们必须和共享堆一起进行回收)。
Steensgaard使用静态分析的方法来划分对象,但这个方法需要引入全局线程汇聚;Jones和King的方案也使用静态逃逸分析,但其回收过程却是纯线程本地的,该方案同时也会对动态加载的类系进行检查,并会针对可能导致线程本地对象变成共享对象的方法进行处理。除此之外,也可以在运行时动态检测某一对象是否会逃逸出创建该对象的线程。
Domani等 使用线程本地分配缓冲区来创建对象,但同时使用写屏障来精确捕获对象的逃逸行为。由于该方案不会将共享对象与线程本地对象分配在不同的空间,所以回收器需要一个独立的位图来记录对象的状态。当某一线程创建指向由其他线程所创建对象的引用之时,写屏障不仅要设置目标对象在位图中对应的标记位,还要将其递归闭包中的所有对象都设置为共享。
在Domani等人所设计的并行标记—清扫回收器中,各线程可以独立进行回收,只有当系统无法完成大对象的分配,或者需要分配新的缓冲区时,才需要挂起所有线程。他们同样也会将已知的、通常会全局可达的对象(如线程对象或者类对象,或者由离线分析器判定为全局的对象)分配在单独的共享区域。回收器应当确保在线程本地回收的执行过程中不会发起全局回收,这就需要在两者之间引入适当的同步机制。
如果所有对象都是不可修改的(immutable),则线程本地回收的实现将更加简单。
Erlang 是一种严格的动态类型的函数式编程语言,基于Erlang的应用程序通常会使用 大量轻量级进程(extremely light-weight processes) (此处的“进程”与操作系统的进程无任何关联,此处的各“进程”会共享包括地址空间在内的大量资源一译者注),且它们彼此之间通过消息传递来进行异步通信。最初的Erlang/OTP运行时系统是以 轻量级进程为核心的(process-centric) ,即每个轻量级进程拥有其本地内存区域。
由于Erlang不允许破坏性的赋值操作,因而消息传递必须使用复制语义,因此各线程可以独立地对其本地堆进行回收。这一设计方案的开销在于其消息传递操作是O(n)时间复杂度的(其中n是消息的大小),且消息数据会在多个轻量级进程之间出现冗余。
为减少消息传递过程中的复制开销,Sagonas 和Wilelmsson在上述架构中引入了两个共享区域:
- 一个用于保存消息
- 一个用于保存二进制数据
他们对线程本地空间以及共享消息空间之间合法的指针方向做了限制:
- 共享消息空间中不会包含任何环状引用数据
- 共享二进制数据空间则不会包含任何引用
它们使用一个静态消息分析器来引导分配过程:如果分析器推断待分配数据很可能会是消息的一部分,则将其分配到共享堆中,否则便将其分配到线程本地堆中。
所有的消息参数都会被封装到一个 按需复制(copy-on-demand) 的操作对象中,该对象会检测各消息参数是否已经存在于共享堆中,并且仅在不存在时才进行复制(编译器通常会将这-检测过程优化掉)。
基于Erlang语言的复制式消息传递语义,分析器既可以高估对象共享情况,也可以低估。线程本地堆使用分代式的、停止-复制式Cheney回收器进行管理,并使用分代式栈扫描。
由于共享二进制对象不会形成环,所以可以使用引用计数对其进行管理。每个轻量级进程需要维护一个簿记表曰(rememberedlist)来记录其所引用的二二进制对象,这样才能确保在轻量级进程死亡时其所引用的二进制对象都能正确地减少引用计数。
共享消息空间通过增量标记—清扫回收器进行管理,其回收需要使用一定的全局同步操作。
线程本地/共享区域(thread-local/shared region) 这一内存架构最早由Doligez和Leroy 提出。在他们的方案中,本地/共享区域同时也在回收器中扮演着年轻/年老分代的角色,该方案是针对Concurrent Caml Light (一种支持并发原语的ML实现)设计的。
与Erlang不同,ML语言中存在可修改对象,因此为确保每个线程可以独立回收其年轻分代,必须将可修改对象保存在共享的年老分代中。如果更新某一可修改对象的操作导致其引用了线程本地年轻分代中的对象,则写屏障必须将目标对象及其递归闭包中所有的年轻代对象提升到年老代。
与Erlang语言类似,该方案中年轻代对象都不可修改,因而允许其存在多个副本。在将年轻代对象复制到年老代的过程中,回收器(此时回收器的角色是由写屏障扮演的)会在对象的原始副本中记录转发地址(即其复制的共享副本的地址),该地址会在后续的线程本地年轻代对象回收中用到。需要注意的是,由于对象在年轻代中的副本仍在使用中,所以在记录转发地址时不能破坏性地覆盖对象数据,而必须使用对象头部中一个保留的域。
共享堆通过并发标记—清扫回收器进行管理,因而年老代对象可以省略这一保留域。 尽管这一额外的域给年轻分代带来了一定的空间开销,但这通常在可接受范围内,因为年轻代对象在整个堆中所占的比例通常会比年老代对象小得多。
10.2.4 栈上分配
一些研究者建议,任何情况下都应当尽可能在栈上而非堆中分配对象。
尽管研究者们提出了多种不同的方案,但真正实现的却很少,用于生产系统的则更是寥寥无几。栈上分配存在多种优势:
- 它可以潜在降低垃圾回收的频率
- 在栈上分配对象无需进行昂贵的扫描或者引用计数操作
- 栈上分配在理论上应当具有较高的高速缓存友好性
但其不足之处在于
- 在栈帧中分配的对象寿命很可能会被延长,进而长期占用栈空间
栈上分配技术的关键在于,如何才能确保栈上分配的对象不会被其他寿命更长的对象引用。
可以通过保守式逃逸分析来达到这一目的,也可以在运行时利用写屏障捕获逃逸对象。Baker 最早提出(但并非最早实现)以一个独立进行垃圾回收的堆作为 上下文(context) 来实现栈上分配。
如果栈沿着远离堆的方向增长,则我们可以使用一个高效的、基于地址比较的写屏障来判断哪些堆中的对象会比其所引用的栈上对象存活得更久。一旦发生这样的情况,便需将栈上对象复制(即“懒惰分配")到堆中。该方案还需引人一个读屏障来处理由这一复制过程所产生的转发地址。还有一些研究者建议将栈上对象分配在独立于 调用栈(callstack) 之外的栈帧中。
实际上,大多数栈上分配策略到目前为止都仍未实现,即使那些宣称已经实现的算法通常也缺乏相对系统的细节,或者并未取得显著的性能提升。
不可否认的是,在许多应用程序中很大一部分对象都可以使用栈上分配,且它们中的大多数通常都十分短命(Azul发现在大型Java应用程序中一半以上的对象可以进行栈上分配),但这却正是分代垃圾回收可以充分发挥优势的场景。栈上分配究竟是否可以降低内存管理的开销,目前尚无定论。
栈上分配的另一个问题在于,它会将整个对象都置于高速缓存中,从而减少内存带宽,即使高速缓存足够大,这一情况也不会得到优化。
一种有效的解决方案是 纯值替换(scalar replacement) 或者对象 内联(objectinling) ,即以局部变量来替代对象的域。
面向对象程序中迭代器的实现便是纯值替换的一种典型应用场景。
10.2.5 区域推断
栈上分配在更加通用的、基于区域划分的内存管理策略中属于一种受限的形式。
基于区域划分来管理内存的基本出发点在于,如果将对象分配在不同区域中,则一旦某一区域中的所有对象都不再被程序使用,回收器便可立即将整个区域回收。区域的回收通常可以在常数时间内完成。
何时需要创建一个区域、应当将对象分配到哪个区域、何时需要对区域进行回收,这些工作可以由开发者自己实现,也可以由编译器或者运行时系统来完成,也可以将三者相结合。
例如,开发者可能需要添加一些显式的指令或者注释来创建、回收区域,或者强制要求将对象分配到某一区域。
最知名的显式系统可能是 RTSJ (Real-Time Specification for Java,Java即时处理规范) 。
除了标准堆之外,RTSJ提供了一个永久性区域以及多个受限区域,该系统同时对合法的指针方向做出限制:外层受限区域中的对象不允许引用内层受限区域中的对象。
还有一些基于分区的系统放松了对指针方向的要求,也就是说,即使某一区 域中的对象仍被其他存活对象所引用,回收器也可以将其回收,但是为了确保安全,必须确保赋值器不会访问指向已回收区域的 悬挂指针(dangling pointer) 。
此类系统通常都需要在编译期推断出应当将对象分配到哪个区域,或者何时才能安全地回收区域,或者对开发者的注释进行检测(可能在非标准系统中)。其中最著名的当属为标准ML所设计的全原子化区域推断系统。
如果谨慎使用,该系统可以提升程序的性能并减少内存的使用量,但该系
统也严重依赖于开发者的编程风格,同时要求开发者对区域推断算法有着很深的理解(尽管无需理解其具体实现)。
在区域推断系统中,即使用户对代码进行很小的修改,也会引发推断结果的显著变化,这在无形中增加了开发者对算法的理解难度以及程序的维护难度。MLKit的推断算法在大型程序中开销巨大(例如,编译一个仅58 000行的程序就需要花费一个半小时)。
Tofte等人建议,最好的实践方案是仅将区域推断用于已经深入理解的编程模式上,而其他部分的管理则应当交由垃圾回收器。
10.3 混合标记—清扫、复制式回收器
在对内存块中的存活对象进行处理时,Spoonhower等 引人两个阈值来判断是应当将其复制,还是对其进行标记—清扫。当内存块中存活对象的比例小于 迁移阈值(evacuation threshold) 时,则将其复制到其他内存块,而当内存块中的空闲内存大于 分配阈值(allocation threshold) 时,该内存块才可以用于分配。
这两个阈值决定着何时以及如何减少内存碎片。
例如,标记—清扫回收器的迁移阈值为零(即任何情况下都不会复制存活对象),但分配阈值为100% (即内存块中的任何空闲内存都可以用于分配),而半区复制回收器的迁移阈值为100%,且其分配阈值为零(在下一次回收之前,来源空间将不会用于内存分配),如图10.5所示。
- 过于消极(即迁移阈值和分配阈值都较低)的内存管理器会受到内存碎片问题的影响
- 过于积极(即迁移阈值和分配阈值都较高)的管理器则会存在较大的性能开销,因为它需要对数据进行复制,或者需要更多的堆遍历过程。
大型或者长期运行的应用程序很容易受到内存碎片问题的影响,除非使用整理式回收器来管理堆内存。但与非移动式回收器相比,整理操作无论是在时间上还是空间上都存在较大开销。半区复制算法需要一个额外的复制保留区,而标记—整理算法则需要进行多次堆遍历才能完成对象的移动。
为解决这一问题,Lang 和Dupont 提出将标记—清扫回收与半区复制相结合,同时在堆中进行增量内存整理的方案(即一次只整理一个内存区域)。
该方案将整个堆空间划分为k + 1 个大小相等的窗口,其中包含一个空窗口。
回收开始时,回收器选定某个窗口作为来源空间,并以空窗口作为目标空间,而其他窗口则使用标记—清扫策略进行管理。
在追踪过程中,回收器会将来源窗口中的存活对象复制到目标窗口中,同时对其他窗口中的对象进行标记(见图10.6)。与此同时,回收器必须确保将所有窗口中指向来源窗口的引用更新到目标窗口中的对应副本里。
通过这种一次复制一个窗口的方式,Lang 和Dupont可以通过k次回收实现整个堆的整理,其空间开销仅为可用堆空间的1/k。
与标记—整理算法不同,该方案无需额外的堆遍厌过程或者其他额外数据结构。他们同时发现,即使目标空间使用Cheney算法进行管理,整体算法的追踪顺序也具有一定的柔性:
- 在追踪阶段的每一步,回收器都可以从标记—清扛和半区复制两个工作列表中选择一个来获取对象,不过Lang和Dupont建议优先处理标记清扫回收的工作列表,这样不仅有助于限制标记栈的大小,而且标记—清扫回收通常会比Cheney扫描具有更好的局部性。
10.3.1 Garbage-First 回收
Garbage-First 是一种精密且复杂的增量整理算法,其目的在于满足软实时性能要求,即在任意y毫秒的时间切片中,花费在垃圾回收上的时间均不超过x毫秒。
该算法在Sun微系统JDK 7 HotSpot VM中引人,并将以长期演进的方式逐渐替代原有的并发标记—清扫回收器,从而达到更加可预测的整理响应时间要求。本节我们仅关注该算法的分区策略。
与Lang和Dupont的回收器类似,Garbage-First也将堆空间划分为数个虚拟地址连续的、大小相等的窗口。该算法在整体上存在一个用于内存分配的、来自空窗口列表的当前窗口。
为减少多个赋值器线程之间的同步,每个线程都拥有本地顺序分配缓冲区,而这一缓冲区本身是使用CompareAndSwap原子操作从当前分配窗口中分配出来的。大对象也可简单地从当前分配窗口中直接分配,特别大的对象(即大于单个窗口3/4的对象)则会从其专属的窗口序列中进行分配。
与Lang和Dupont的方案不同,Garbage-First允许选择 任意窗口进行回收 ,这便要求赋值器写屏障记录所有跨窗口指针的创建。特别需要注意的是,此处需要 记录的是所有跨窗口的指针 ,而不是像火车回收器那样只需要记录单方向跨火车/车厢指针(因为火车回收器会以可预测的方式对车厢进行回收)。Garbage-First 使用过滤式写屏障,它使用卡表来记录回收相关指针。
基于Printezis和Detlefs 的位图标记技术,单个回收器线程可以在赋值器执行的同时进行并发堆标记(参见第16章)。一旦完成标记过程,Garbage-First 便通过位图来选择定罪窗口,然后挂起所有赋值器线程以实现定罪窗口的整理。定罪窗口通常是那些存活数据占比较低的窗口。
Garbage-First也可对窗口进行分代式的处理(参见第二章)。在纯粹的“全年轻”模式下,定罪窗口将是那些在上一次回收之后用于分配的窗口。而在“部分年轻”模式下,回收器可以额外地将一些窗口增加到定罪窗口集合。无论在哪种分代模式下,写屏障都可以过滤掉来自年轻代的指针。与其他策略类似,Garbage-First 试图识别出富引用对象并将其隔离在专属的窗口中,此类窗口永远不会被加入到定罪窗口集合中,因而也无需任何形式的记忆集。
10.3.2 Immix回收以及其他回收
通过付出一定的时间或空间开销用以解决标记—清扫回收的碎片化问题。
每种回收器都通过一种不同的方式来解决如下3个问题:
- 如何最好地利用堆空间
- 如何避免对去碎片化操作(复制或标记—整理)的依赖
- 如何降低回收器循环的时间开销。
Dimpsey等 为IBM服务器的Java虚拟机1.1.7版本设计了一种复杂的并行标记—清扫(偶尔执行整理操作)回收器。
与Sun的1.1.5版本回收器类似,该方案也使用线程本地分配缓冲区,小对象将直接在该缓冲区中进行顺序分配,缓冲区本身以及大对象(比缓冲区大小的1/4还大的对象)则使用空闲链表分配并需要一定的同步操作。
Dimpsey 等人发现,如果仅依赖这一架构,回收器的性能会非常差。大多数空闲链表的分配需求都是为线程申请新的本地分配缓冲区,但靠近链表头部的空闲内存单元通常无法满足这一分配需求,从而导致较长的查找时间。
为解决这一问题,他们额外引人了两个空闲链表:
- 一个仅用于分配线程本地缓冲区(1.5KB外加缓冲区头部)
- 一个则用于分配超过缓冲区大小且小于512KB的对象
一旦用于分配线程本地缓冲区的空闲链表为空,则分配器从另一个大对象链表中分配一个大块内存,并将其分割成多个缓冲区。这一优化大大提高了Java 应用程序在单处理器上的执行性能,对于多处理而言效果更佳。
Dimpsey等人使用额外的位图来标记对象,在清扫阶段对位图进行遍历时,回收器可以一次检测一个字节或者一个字。他们同时对清扫过程进行了一定的优化:
- 他们引人两个表以快速计算位图中任意一个字节所对应的前导与尾部为零位,清扫器会避免对较小的连续垃圾空间进行处理,并通过对象头部中的一个标记位来区分大对象与较小的连续垃圾空间。
在回收完成后,分配器只会从新的缓冲区中进行顺序分配,即使某一分配缓冲区中存在部分可用空间,分配器也不会从其中分配任何对象。这一策略不仅可以减少清扫时间,同时也缩短了空闲链表的长度,因为其中不再包含任何小块空闲内存。
这一策略的潜在开销在于,回收器并未将某些空闲内存归还给分配器。但由于对象通常“成簇创建,成批死亡”,因而Dimpsey等人可以尽量少地依赖整理过程。
根据Johnstone 的建议,他们使用基于地址顺序的首次适应分配策略以增加在可用堆中创建足够大的空洞的几率。
另外他们还允许在线程本地分配中使用可变大小的内存块:
- 如果空闲链表中第一个用于线程本地分配的缓冲区小于某一期望值T(即6KB),则线程将直接使用该缓冲区(注意该缓冲区不应小于空闲链表允许插人的最小空间大小)
- 如果其大小介于T和2T之间,则将其拆分为两个大小相等的缓冲区;否则将从该缓冲区中分裂出一个大小为T的缓冲区。
Dimpsey等人还在堆中预留5%的空间以用于拓展块保护,即仅在回收完成之后可用空间依然不足的情况下才动用这些内存。
与IBM服务器中的方案实现类似,Immix 回收器也通过这一方式避免内存碎片化。该回收器也属于主体标记—清扫回收器,但其在必要情况下消除碎片的方法是复制式而非整理式回收。Immix回收器与本节所介绍的其他回收器一样使用块结构堆。
回收器将堆空间划分为32KB的内存块,这不仅是线程为本地分配缓冲区申请空间的单元,也是执行碎片整理的操作单元。每次回收过程中,回收器会根据上次回收的结果来预测哪些内存块需要进行原地回收,哪些内存块需要进行复制式回收(与Spoonhower等人的方法类似,但与Detlefs等人的方法不同,后者基于并发标记来进行预测)。
IBM服务器中的回收器以及Immix回收器都使用速度较快的顺序分配策略,不同之处在于前者减少碎片的方式是从大小可变的缓冲区中进行分配,而后者允许在部分可用的缓冲区中以 行(line) 为单位的间隙里进行分配,行的大小通常为128字节,大致与高速缓存行的大小匹配。
Dimpsey等人对清扫过程的优化方法是忽略对较小连续垃圾空间的处理,而Blackburn和McKinley则是以行为单位来回收可复用内存块中的空间。
Immix回收器同时支持从完全为空以及部分为空(即可复用)的内存块中进行分配。图10.7展示了可复用内存块的结构。Immix 回收器将对象划分为大对象(它们将从大对象空间中分配)入、中等对象(即大小超过一个行的对象)以及小对象,大多数Java对象都是小对象。
算法10.1展示了Immix回收器分配小对象或者中等对象的方法。Immix回收器优先将对象分配到可复用内存块的空隙中,其所使用的分配算法为线性循环首次适应分配法。在算法的快速路径中,分配器尝试在当前连续空闲行序列中进行顺序分配(第2行),如果失败,则区分小对象和中等对象并执行不同的分配策略。
我们先考虑小对象的分配策略。分配器先在当前内存块中查找下一个空闲行序列(第11行),如果失败,则尝试从下一个可复用内存块(第13行)的空闲行或者下一个空内存块(第15行)中进行分配。
如果后续两个尝试均失败,则发起垃圾回收。需要注意的是,与首次适应分配不同,分配器永远不会在部分填充的行中分配对象。
在大多数应用程序中,少量Java对象的大小可能会超过一行, 但不会太大。Blackburn和McKinley发现,如果对这些对象使用与小对象相同的方式进行处理,则会浪费大量行。因此,为避免在可复用内存块中引入碎片,他们使用顺序分配策略直接从空闲内存块中分配中等大小的对象(用overlowAlloc方法)。
他们还发现,绝大多数对象都是从完全为空的内存块或者使用率不超过1/4的内存块中分配的。小对象和中等对象均是在线程本地缓冲区中进行分配,只有当获取一个新的内存块时,才需要使用同步操作(对于部分为空以及完全为空的内存块均是如此)。
Immix回收器需要同时对行(作者称之为标记区域)和对象进行标记(对后者进行标记是为了确保扫描过程的正常结束)。从定义上来看,小对象所占用的空间必然小于一行,但它仍有可能跨越两个行,因此Immix回收器会对小对象占据的第二个行进行隐式(且保守)标记,即所有位于某个已标记行之后的行都会被分配器忽略(见图10.7)。这样便造成在最差情况下,每个间隙内部都可能会有一行被浪费。
Blackburn 和McKinley发现,如果在扫描对象时(而不是标记对象同时将其添加到工作列表时)标记其所在的行进行有助于提升追踪过程的性能,因为开销更大的扫描操作可以掩盖对行进行标记的延迟。与小对象不同,回收器会对中等对象所在的行进行精确标记(根据对象头部的一个位来区分小对象和中等对象)。
Immix回收器只需偶尔执行整理操作,且整理过程可以在标记过程中进行。是否需要整理取决于某个描述堆碎片化程度的统计变量,清扫器在每次回收完成后都会设置该变量。
Immix回收器根据每个内存块中空隙和已标记行的数量来衡量碎片化程度,并在下一次回收过程中选择碎片程度最高的内存块作为备选整理对象。由于统计变量只是作为整理过程的一个参考,所以回收器可以在空间不足的情况下中止整理过程。在实际应用中,Immix回收器在大多数基准测试程序中都不太需要进行整理。
10.3.3 受限内存空间中的复制式回收
上述各种增量回收技术都仅需要一个内存块作为复制保留区,且需要经历多次回收才能完成整个堆的整理。Sachindran和Moss 将这一技术应用在内存空间受限环境下的分代回收器中。
他们所设计的Mark-Copy回收器将年老代划分为一组连续的内存块,但在进行整堆回收时,该回收器可以一次完成多个内存块中存活对象的迁移,而非一次只处理一个内存块。
与其他分代回收器类似,用于对象分配的新生区回收频率较高,其中的存活对象会被提升到年老代。如果堆中只剩一个空闲内存块,则会发起整堆回收。
如果回收器可独立回收每个内存块,则必须记录所有内存块之间的指针,这将使写屏障的设计更加复杂,因为原本只需记录跨代指针而现在还需要记录跨内存块的指针。因此Mark-Copy回收器会在标记阶段为每个内存块构造单向记忆集,并计算其中存活对象总量。
将记忆集的构造任务从赋值器转移到标记阶段有两个好处:
- 记忆集本身可以十分精确(因为记忆集可以仅包含在回收时刻从编号较高内存块指向编号较低内存块的指针),且不包含任何重复记录,因此回收器可以沿着内存块编号从小到大的方向(目的是避免在记忆集中记录双向指针),将一组连续内存块中的存活对象迁移到空闲块中
- 因为标记阶段已经完成了对每个内存块中存活对象总量的计算,所以回收器可以准确评估出当前回收过程能完成多少个内存块中存活对象的迁移,例如图10.8 中的第二次遍历过程可以完成连续3个内存块中存活对象的迁移。
回收完成后,回收器会将已完成迁移的内存块释放(解除其内存映射)。
与标准的半区复制回收器相比,Mark-Copy回收器显著增加了可用空间的比例,同时在空间大小相同的情况下其回收频率也会降低。
该回收器也可设计成增量式的,即将年老代内存块的回收穿插在年轻代回收之间。
该回收器同时也存在一些缺点:
- 每次整堆回收都需要两次扫描年老代对象,一次用于标记,另一次用于复制
- 回收器需要预留额外的空间来实现标记栈以及记忆集;每次复制过程可能都需要重新扫描线程栈以及全局变量
但不可否认的是,与其他需要更多保留空间的复制式分代回收器相比,Mark-Copy回收器在某些场景下表现更佳。
Mark-Copy回收器要求各内存块在地址空间上连续,而 M C 2 MC^2 MC2回收器则通过将内存块编号的方式放宽了这一限制,这带来几个好处:
- 已完成复制的内存块不必再通过解除内存映射的方式释放,进而避免了在32位环境下耗尽虛拟地址空间的风险
- 该方案允许通过改变内存块编号的方式实现逻辑上的复制,这对于存活对象比例较大的内存块将十分有用(此时逻辑复制所带来的收益会大于复制或者整理)
- 对内存块进行逻辑编号的方式也允许回收器在回收过程中改变内存块的回收顺序。
与Mark-Copy回收器不同,MC2回收器将复制年老代内存块所需的遍历过程分摊在年轻代回收中,它同时也使用Steele插人式屏障对年老代进行增量标记(我们将在第15章讨论增量标记)。
借助于增量标记技术,回收器可以在内存耗尽之前的某一时刻开始对年老代的回收,并且可以适应性地调整每个增量的工作量,进而避免内存耗尽时可能出现的较大停顿。
与本章所介绍的其他回收器类似,MC2回收器将富引用对象隔离在特殊的内存块中,同时省去对这些内存块记忆集的维护(因此富引用对象会被当作永久性对象来对待,但回收器仍可将其恢复为一般对象)。
另外,为了限制记忆集的大小,回收器还会将较大记忆集的实现方式从顺序存储缓冲区转化为卡表(我们将在第11章介绍这些技术),大数组也通过卡表的方式进行管理,其实现方式是将大数组的卡表紧邻其末尾保存。
通过对多种技术的精细化整合,MC2回收器最终成为一种空间利用率高、吞吐量大、停顿时间较为平衡的回收器。
10.4 书签回收器
上一节所述的各种增量整理技术均可以(最终)完成堆的整理,其回收过程不仅在时间开销方面低于传统的标记—整理回收,而且空间开销也远低于标准的半区复制回收。
但是,如果堆空间过大以致于赋值器操作或者回收器追踪会产生换页行为,则程序的性能依然可能受到严重影响。换页的开销可能会超过上百万个时钟周期,因而避免程序执行过程中的缺页异常也是十分重要的。
书签(bookmarking) 垃圾回收器不仅可以缓解赋值器执行过程中的缺页异常,而且能够避免回收过程中的缺页异常。
书签垃圾回收器可以通过操作系统的虚拟内存管理器来引导页淘汰策略。
如果没有回收器的引导,虚拟内存管理器通常在页淘汰方面没有太多的选择余地,例如,对于半区复制回收器与使用最近最少使用淘汰策略的内存管理器同时工作的情形,在回收时间之外,被淘汰的页通常是当前未使用但很快便会被目标空间所用的页,因此如果大部分对象都十分短命,则很可能最近最少使用的页就是分配器将要使用的下一个页,而这正是最糟的一种换页情形。
来源空间通常不会受换页问题的影响,不仅是因为赋值器在下一次回收过程之前不会访问该空间,而且是其中的数据也无需写回到外存中。
书签垃圾回收器可以在不引发缺页异常的前提下完成垃圾回收的追踪过程。在追踪过程中,回收器保守地假定 非驻留(non-resident) 页中所有对象都是存活的,但同时依然需要定位出所有从该页可达的对象。
为了达到这一目的,回收器会在某一存活页被换出时对其进行扫描,找出其中所有的对外引用并为其目标对象设置书签,同时如果该页重新装载到内存,则将其对应的书签删除。回收器可以使用书签来实现追踪过程的持续。
书签回收器需要对虚拟内存管理器进行修改,即在页淘汰发生时向应用程序发送一个信号。如果分配器无法获取新的空闲页,则唤起垃圾回收器并重新从刚刚清空的页中进行分配。
回收器可以通过某些特定的系统调用来影响虚拟内存管理器的行为。书签回收器会在回收完成后尝试收缩堆空间以避免缺页异常,而年轻分代或者回收器元数据所在的页则绝不会被换出。
如果无法找到一个空页(并将其换出),则回收器会寻找一个“牺牲品”(通常是事先预定的页)并扫描其对外引用,然后在其目标对象的头域中设置一个特殊的标记位。Herts等人在Linux内核中引入了一个系统调用,以允许用户进程显式地将一组页换出。
如果整个堆都未装入物理内存,则在整堆回收开始时回收器会先扫描存在书签的对象,并将其加人到回收器的工作列表中。尽管这一操作开销较大,但在堆空间较小的情况下该策略通常会比产生一次缺页异常的开销要低。
回收器偶尔需要对年老代进行整理,此时回收器会在标记阶段统计每个空间大小分级中存活对象的数量,并据此计算完成整理所需的最小的页集合。回收器使用Cheney扫描来将存活对象移动到选定的页上(除非对象已经在目标页上)。为了避免对非驻留页中的引用进行更新,回收器不会移动有书签的对象,进而规避由此产生的缺页异常。
10.5 超引用计数回收器
依据对象修改频率回收。
大量证据表明,在许多应用程序中,年轻对象的创建和死亡率都相当高,赋值器对这些对象的修改也十分频繁(例如将其初始化)。
对于此类对象而言,复制式回收是一种十分高效的回收策略,因为它允许顺序分配且仅需要对存活对象进行复制,而对象的存活率却往往很低。
现代应用程序的堆空间以及存活数据会越来越大,且长寿对象通常具有较低的死亡率和修改频率,这些因素都会给追踪式回收器带来一定的挑战:
- 追踪的开销正比于存活对象的总量,而频繁对长寿对象进行追踪通常会影响程序性能。
与追踪式回收器相比,引用计数策略能更好地适应这一场景,因为其开销通常仅与被修改对象的总量成正比。Blackburn和 McKinley 认为,在为年轻代和年老代选择各自的回收策略时,应当将分代大小、分代中对象的期望寿命以及对象的变更率这三者结合起来考虑。
因此,他们所设计的 超引用计数( ulterior reference counting) 回收器使用复制式回收来管理年轻代,同时使用引用计数来管理年老代。
年轻代的空间大小有限,且使用顺序分配策略。回收器会将所有在年轻代回收中存活的对象复制到由分区适应空闲链表管理的成熟空间。
赋值器写屏障的任务有二
- 正确管理成熟空间中对象的引用计数
- 记录从成熟空间指向年轻代对象的指针
赋值器会将涉及栈槽或者寄存器的引用计数操作延迟,同时回收器会将堆中对象的引用计数操作合并。一旦写屏障发现某一未被记录的对象发生变更,则会将其添加到日志中。日志中所记录的是对象的地址,并且会为其所有位于成熟空间的子节点缓冲一次引用计数减少操作。
在回收过程中,垃圾回收器会将年轻代存活对象移动到由引用计数管理的成熟空间中,并且回收两个空间中的所有不可达对象,其具体过程如下。
-
回收器首先将日志中每个子节点的引用计数加一,并且将其所有位于年轻代的目标对象标记为存活,然后将其添加到年轻代回收器的工作列表中。
-
当完成所有年轻代对象的提升后,回收器会增加这些对象所有子节点的引用计数。与其他延迟引用计数算法类似,回收过程中直接从根可达的对象的引用计数也会临时性地加一,且所有已缓冲的引用计数增加操作都会先于已缓冲的引用计数减少操作执行。
该回收器使用Recycler算法来处理环状引用,但它并不会在每次回收中都对所有存在引用计数减少操作的对象执行试验删除,这一过程仅在可用堆空间小于某一用户自定义阈值时才会触发。
图10.9展示了超引用计数的抽象示意,我们可以将其与第5章图5.1所示的标准延迟引用计数操作进行比较。
附录
[1]《垃圾回收算法手册 自动内存管理的艺术》
[英]理查德·琼斯(Richard Jones)[美] 安东尼·霍思金(Antony Hosking) 艾略特·莫斯(Eliot Moss)著
王雅光 薛迪 译