阶段
分配一块大小合适的、符合字节对齐要求的内存单元,这一工作是由内存管理器的分配子系统完成的。
系统级初始化(system initialisation) ,即在对象被用户程序访问之前,其所有的域都必须初始化到适当的值。例如在面向对象语言中,设定新分配对象的方法 分派向量( method dispatch vector) 即是该阶段的任务之一。该阶段通常也需要在对象头部设置编程语言或内存管理器所需的头域,对Java对象而言,则包括哈希值以及同步相关信息,而Java数组则需要明确记录其长度。
次级初始化(secondary initialisation) ,即在对象已经“脱离”分配子空间,并且可以潜在被程序的其他部分、线程访问时,进一步设置(或更新)其某些域。
下面是C和Java语言中对象分配及其初始化过程:
C:
所有分配工作均在阶段1完成,编程语言无需提供任何形式的系统级初始化或次级初始化,所有这些任务均由开发者完成(或者初始化失败)。
需要注意的是,分配器仍需对已分配内存单元的头部进行修改,以确保能够在未来将其释放,但这一头部存在于返回给调用者的内存单元之外。
Java:
阶段1和阶段2共同完成新对象的方法分派向量、哈希值、同步信息的初始化,同时将所有其他域设置为某一默认值(通常全为零)。
数组的长度域也在这两个阶段完成初始化。字节码new所返回的对象便处于这一状态,此时尽管对象满足类型安全要求,但其依然是完全“空白”的对象。
阶段3在Java语言中对应的表现形式是对象构造函数或者静态初始化程序中的代码,或者在对象创建完成后将某些域设置为非零值的代码段。final域的初始化也是在阶段3中完成的,因此一旦过早地将新创建的对象暴露给其他线程,同时又要避免其他线程感知到对象域的变化,实现起来将十分复杂。
如果编程语言要求完整的对象初始化语义,则对象分配接口的定义会存在一些细小的问题:
Modula-3允许开发者提供函数式的初始化方法(并非一定要求如此),可以将 初始化闭包(initialising closure) 传递给分配子过程,后者会分配适当的空间并执行初始化闭包来填充对象的域,从而解决了这一问题。
初始化闭包中包含了需要设置的初始值以及将其设置到对象特定域的代码。Modula-3使用 静态作用域(static scope) ,且闭包本身并不需要从堆中进行分配,其本身只是一个静态链指针(指向包含它的环境 (enclosing environment,封闭环境) 中的变量),因此它可以避免分配过程中的无限循环递归。但是,如果编译器可以自动生成初始化代码,则无论初始化过程是在分配过程内部还是外部便都无关紧要了。
Glasgow Haskell编译器采用另一种不同的策略来解决这一问题:
它将阶段1和阶段2中的所有操作内联,并且在内存耗尽时唤起回收器。在创建新对象时,分配器使用顺序分配来获取内存,其实现简单,且初始化过程通常只需要使用已经计算好的值来填充对象的头部以及其他域。这是编译器与特定分配算法(以及回收算法)紧密关联的一个案例。
函数式初始化过程具有两个显著的优点:
语言级别的对象分配需求最终都会调用内存分配子过程,某些编译器会将这一过程内联,并完成阶段1的全部操作以及阶段2的部分或全部操作。
分配过程需要满足的一个关键要求是:
如果我们对分配器接口(阶段1)进行深入思考便会发现,3个阶段之间的工作划分会存在多种可能的组合方式。在分配过程中需要考虑的参数如下。
待分配空间大小 ,通常以字节为单位,也可能以字或者其他粒度为单位。当需要分配数组时,分配接口可以将元素大小以及元素个数作为独立的输入参数。
字节对齐要求,分配器通常会以一种默认的方式进行字节对齐,但调用者也可以要求更严格的字节对齐方式。这些要求可能包括必须以2的整数次幂对齐(如按照字、双字、四字等进行对齐),或者在此基础上增加一个偏移量(例如在四字对齐的基础上偏移一个字)。
待分配对象的类别(kind) ,例如,诸如Java等托管运行时语言通常会将数组与非数组对象进行区分,某些系统会将不包含指针的对象与其他对象进行区分,还有一些系统会将包含可执行代码的对象与不包含可执行代码的对象区分对待。简而言之,任何需要分配器特殊对待的需求都要在分配接口中得到体现。
待分配对象的具体类型(type),即编程语言所关心的类型。与“类别”不同,分配器通常不需要关注对象的“类型”,但却会通过“类型”来初始化对象。将这一信息传递给分配子过程不仅可以简化阶段2的原子化实现(即将这一任务转移到阶段1),而且可以避免在每个分配位置上引入额外的指令,进而减少代码体积。
分配接口究竟需要支持上述各种参数中的哪些,这在一定程度上取决于其所服务的编程语言。我们还可以在分配接口中传递一些冗余参数以避免运行时的额外计算。
分配接口的一种实现策略是提供一个全功能型分配函数,该接口支持众多的参数并且可以对所有情况进行处理,而为了加速分配以及精简参数,我们也可以为不同类别的对象定制不同的分配接口。
以Java为例,定制化的分配接口可以分为以下几种:
除此之外还需考虑系统内部对象的分配接口,例如表示类型的对象、方法分派表、方法代码等,具体的分配方式取决于是否要将其置于可回收堆中,即使它们不从托管堆中分配,系统仍需为其提供特殊接口,以从显式释放的分配器中进行分配。
阶段1完成后,分配器可以通过如下几个 后置条件(post-condition) 来检测该阶段的执行是否成功。
已分配内存单元满足预定的大小以及字节对齐要求,但此时该内存单元还不能被赋值器访问。
已分配内存单元已完成清零,这可以确保程序不会将内存单元中原有的指针或者非指针数据误认为是有效的引用。零是非常好的一个值,对于指针而言,零值表示空指针,而对于大多数类型而言,零值都是平常的、合法的值。某些语言(如Java)需要通过清零或者其他类似的方式来确保安全类型的安全性。在调试系统中,将未分配的内存设置成特殊的非零值十分有用,例如 0xdeadbeef或者0xcafebabe,其字面意思就是表示其当前所处的状态。
内存单元已被赋予调用者所要求的类型。当然这一过程只有当调用者将类型信息传给分配器时才需考虑。与最小后置条件(即该条款中的第一条)相比,此处的区别在于分配器会填充对象的头部。
确保对象的完全类型安全性。这不仅涉及清零行为,而且还涉及填充对象头部的行为。这一步完成后,对象并未达到完整初始化的标准,因为此时对象中的每个域均只是安全的、平常的、默认的零值,而应用程序通常要求将至少一个域初始化到非默认的值。
确保对象完全初始化。这通常要求调用者在分配接口中传递所有的初值,因而这一要求并不普遍。一个较好的例子是Lisp语言中的cons 函数,该函数的调用相当普遍,因而有理由为其提供单独的分配函数,以加速并简化其分配接口。
究竟最合适的后置条件是哪一个?
某些后置条件(如清零)取决于编程语言的相关语义,同样还有一些后置条件取决于其所处环境的并发程度,以及对象可能会以何种方式从其诞生的线程中“逃逸”(从而成为其他线程或者回收器可达的对象)。一般来说,并发程度越高、逃逸情况越普遍,后置条件的要求就越高。
下面我们来考虑分配器无法立即满足分配要求时应当如何处理。
在大多数系统中,我们希望在分配子过程内部调用垃圾回收,并向调用者隐藏这一事实。此时调用者几乎不需要做任何事情,同时也可以避免在每个分配位置上引人重试。
然而,我们也可以将大多数情况下的快速路径(即分配成功的情况)进行内联,同时将回收—重试这一函数放在内联代码之外。如果我们将阶段1的代码内联,则阶段1和阶段2将不存在明显的分界线,但整个代码序列必须高效且原子化地实现。后续我将介绍赋值器和回收器之间的握手机制,其中便包括这一原子化要求的具体实现。在实现分配过程的原子化之后,我们便可将分配过程看作是仅有赋值器参与的行为。
关键技术之一是将一般情况下的代码(即“快速路径”)内联,同时将较少执行的、处理更复杂情况的“慢速路径”作为函数调用,而具体如何进行选择就需要在合适的负载下精心地进行比较测量。
快速路径(fast path): 是指在一个程序中比起一般路径,有更短 指令路径长(Instruction path length) 的路径。有效的快速路径会在处理最常出现的的情形上比一般路径更有效率,让一般路径处理特殊情形、边角情形、错误处理与其它反常状况
顺序分配显而易见的优点便是实现简单,其一般情况下的代码序列较短。
如果处理器的寄存器数量足够多,则系统甚至可以专门使用一个寄存器来保存 阶跃指针(bump pointer) ,同时再使用一个寄存器来保存堆地址上限,此时典型的代码序列可能会是:
需要注意的是,只有当使用线程本地顺序分配时,才能将阶跃指针保存在寄存器中。某些ML 和Haskell 进一步将一段代码序列中的多个分配请求合并成一个较大的请求,使得只需要进行一次地址上限判断与分支。类似的技术也可以用于其他单入口多出口的代码序列,即一次性分配所有可能执行路径下最大的内存需求,或者仅在开始执行代码序列时使用该值来做基本的地址上限判断。
尽管顺序分配几乎必然会比空闲链表分配要快,但如果借助于部分内联以及优化,分区适应分配也可以十分高效。如果我们可以静态计算出对应的空间大小分级,并且使用寄存器来保存空闲链表数组的地址,此时的分配过程将是:
在多线程系统中,最后一步操作可能需要原子化,即使用compareAndSwap操作并在失败时进行重试。另外也可以为每个线程提供专属的空闲链表序列,并独立对其进行回收。
为确保安全,某些系统要求将其空闲内存设置为指定的值,该值通常是零,也可能是其他一些特殊的值(一般是为了调试)。仅提供最基本分配函数的系统(例如C)通常都不会如此,或者仅在调试状态下才会执行这一操作。
分配保障较强的系统(例如具有完全初始化能力的函数式语言)通常无需对空闲内存清零。
尽管如此,将空闲内存设置为特定的值仍会有助于系统调试。Java便是需要将空闲内存清零的一个典型案例。
系统应当在何时执行清零操作?如何进行清零?
我们可以在每次分配对象时将其清零,但经验告诉我们,一次性对较大空间进行清零将更加高效。
使用显式的内存写操作进行清零可能会引发大量的高速缓存不命中,同时在某些硬件架构上执行大量清零操作也可能会影响读操作,因为读操作必须阻塞到硬件写缓冲区中的清零操作全部执行完毕为止。
某些ML的实现以及Sun的 HotSpot Java虚拟机会(在顺序分配中)对位于阶跃指针之前的数据进行精确地预取,并以此掩盖新分配数据从内存加载到高速缓存时的延迟,但现代处理器通常可以探测到这一访问模式并实现硬件预取。
Diwan等人 发现,使用支持 以字为单位(per-word basis) 进行分配的 写分配高速缓存(write-allocate cache) 可以获得最佳性能,但在实践中这一结论并非永远成立。
从分配器的实现角度来看,将整个内存块清零的最佳方式通常是调用运行时库提供的清零函数,例如bzero。
extern void bzero(void *s, int n);
参数说明:s 要置零的数据的起始地址; n 要置零的数据字节个数。(C++)
这些函数通常会针对特定系统进行高度优化,甚至可能使用特殊的指令直接清零高速缓存而不将其写入内存,例如 PowerPC上的 dcbz指令(Data Cache Block Zero) 。开发者直接使用这些指令可能较难,因为高速缓存行的大小是与处理器架构密切相关的一个参数。任何情况下,系统在对以2的整数次幂对齐的大内存块清零时通常会达到最佳性能。
另一种清零技术是使用虚拟内存的 请求二进制零页(demand-zero page) 。
该技术通常更适合程序启动时的场景,如果在运行时使用该技术,则开发者需要手工将待清零页 重新映射(remap) ,操作系统会对该页设置陷阱,并在应用程序访问该页时将其清零。由于相关操作的开销相对较大,因而其性能可能还比不上开发者自行调用库函数清零。只有当需要清零的页面数量较多且地址连续时,该技术的执行开销才可能得到有效掩盖,其性能优势才能得到凸显。
陷阱(trap):可以打断cpu的正常运行,并迫使其去运行一些特殊的代码来处理的事件,我们称之为陷阱。
- 系统调用(system call)
- 异常(exception)
- 中断(interrupt)
我们可以在垃圾回收完成之后立即进行清零,但其显而易见的缺点便是延长了回收停顿时间,同时还可能使大量内存被修改,而这些内存很可能在很久之后才会用到。被清零的数据很可能需要从高速缓存写回到内存,并在分配阶段重新加载到高速缓存。
我们可能会根据直观经验武断地认为,对内存的最佳清零时机应当是在其将要被分配出去之前的某一时刻,这样处理器便可在分配器访问这块内存之前将其预取到高速缓存中,但问题在于,即使被清零的内存距离阶跃指针不远,其依然很容易被刷新到内存中。
对于现代硬件处理器而言,很难说Appel所描述的预取技术能有多少效果,或者其至少需要通过很精细的调整才能确定合适的预取范围。如果是在调试环境下,将空闲内存清零或者向其中写入特殊值的操作应当在节点释放后立即执行,这样我们便可以在尽可能大的时间范围内捕获错误。
回收器需要通过指针查找来确定对象的可达性。某些回收算法需要精确掌握程序中所有指针的信息。特别是对于移动式回收器而言,如果需要将某一对象从地址x移动到新地址x’,则必须将所有指向x的指针更新到x’。
安全回收某一对象的前提条件是程序不会再次访问该对象,但反之则不成立:将程序不再使用的对象保留并不存在安全问题,尽管这可能降低空间利用率(不可否认,如果程序无法获取可用堆内存,则可能崩溃)。
因此,回收器可以保守地认为所有引用均指向了不可移动的对象,但不应武断地移动其不能确定是否可以移动的对象。基本的引用计数算法便是保守式的。使用保守式回收的另一个原因在于回收器缺乏精确的指针信息,因此它可能会将某一非指针的值当作指针,特别是当该值看起来像是引用了某一对象时。
保守式指针查找的技术基础是将每个与指针大小相同的已对齐字节序列当作可能是指针的值,即 模糊指针(ambiguous pointer) 。
回收器可以掌握组成堆的内存区域集合,甚至知道这些区域中哪些部分已经分配出去,因而它可以快速排除掉必然不是指针的值。
为确保回收过程的性能,鉴别指针的工作必须十分高效。这一过程通常包含两个阶段。
回收器首先过滤掉未指向任何堆空间地址的值。如果堆空间本身就是一大块连续内存,则这一过程可以通过简单的地址判断来实现,另外也可以根据模糊指针的高位地址计算出其所对应的内存块编号,并通过一个堆内存块索引表进行查找。
回收器需要鉴别出模糊指针所指向的地址是否真正被分配出去,这一过程可以借助一个记录有已分配内存颗粒的位图来完成。
例如,Boehm-Demers-Weiser 保守式回收器使用块结构堆,且其每个内存块仅用于分配一种大小的内存单元。内存单元的大小保存在内存块所关联的元数据中,而其状态(已分配或空闲)则反映在位图中。
对于一个模糊指针,回收器首先使用堆边界来对其进行判定,然后再判断其所引用的内存块是否已被分配,如果判断成立,则进一步检测其所指向的内存单元是否已被分配。
只有最后一步的判断结果为真,回收器才可以对模糊指针的目标对象进行标记。图11.1展示了对模糊指针进行处理的全部过程,其每次判断大约需要30个RISC指令(精简指令集)。
某些编程语言要求指针所指向的地址是其引用对象的第一个字,或者在此基础上增加一些标准的偏移量(例如数个头部字之后,参见图7.2)。借助于这一规则,回收器便可忽略 内部指针(interior pointer) 而只需关注 正规指针(canonical pointer) 。不论是否需要支持内部指针,保守式回收器的设计均比较简单,Boehm-Demers-Weiser保守式回收器可以通过配置来选择是否需要支持内部指针。
如果在C语言中使用保守式回收器,存在一个细节问题需要关注:
显式内存释放系统可以在对象之间插入额外的头部来解决这一问题。编译器的优化可能会“破坏”指针,从而引发回收器的误判。
某些非指针的值可能导致回收器错误地保留一个实际上并不可达的对象,因而Boehm 设计了 黑名单(black-listing) 机制来避免在堆中使用被这些非指针值所“指向”的虚拟地址空间。
特别地,如果回收器断定某个模糊指针指向了未分配的内存块,可以将该内存块加入到黑名单,但必须确保永远不在其中进行分配,否则后续的追踪过程便可能将伪指针误认为真正的指针。
回收器同时还支持在特定内存块中仅分配不包含指针的对象(如位图),这一区分策略不仅可以提升回收效率(因为无需扫描对象的内容),同时也可以避免昂贵的黑名单查询开销(即天然避免了将位图中的数据当作指针)。
回收器还可以进一步区分非法指针是否可能是内部指针,并据此改进黑名单(如果不允许使用内部指针,则堆空间中不可能存在内部指针)。
在赋值器首次执行堆分配之前,回收器先发起一次回收以初始化黑名单。分配器通常也会避免使用地址末尾包含太多零的内存块,因为栈中的非指针数据通常可能会“引用”这些地址。
某些系统(特别是基于动态类型的系统)支持为每个值附带一个特殊的 标签(tag) ,以表示其类型。标签的基本实现策略有二:
位窃取的方法需要在每个值中预留出一个或者多个位(通常是字的最高或最低几位),同时要求可能包含指针的对象必须以 面向字(word-oriented) 的方式进行布局。
例如,对于一台依照字节进行寻址且每个字包含四个字节的机器,如果我们要求每个对象都必须依照字来进行对齐,则指针的最低两位必然都是零,因而我们可以将这两位用作标签。我们亦可使用其他值来表示整数,例如可以要求所有用于表示整数的值最低位都必须是1,同时以高31位来表示整数的具体值(尽管这一方案确实减少了我们可以直接表达的整数范围)。
为确保堆的可解析性(参见第7.6节),我们可以要求堆中对象的第一个字必须以二进制 1 0 作为低两位。
表11.1介绍了一种标签编码方案,它与Smalltalk中真正使用的编码方案类似。
可能会有读者对带标签整数的处理效率提出挑战,但对于现代流水线处理器而言,这几乎不会成为问题,一次高速缓存不命中所造成的延迟便可轻易掩盖掉这一开销。
为支持使用带标签整数的动态类型语言,SPARC架构提供了专门的指令来对带标签整数直接进行加减操作,且这些指令均可以判断操作是否发生溢出。某些版本甚至还可以针对操作溢出或者被操作数低两位不为零的情况设置陷阱。
基于SPARC架构我们可以使用表11.2所示的标签编码方案。
该方案要求我们对指针所代表的引用进行调整,在大多数情况下,这一调整操作可以通过在加载和存储指令中引入一个偏移量来实现,但对数组的访问是一个例外:
真实硬件架构对带标签整数的支持进一步说明了位窃取方案的合理性:
页簇方案是将标签/类型信息与对象所在的内存块相关联,因此其关联关系通常是动态的且需要额外的查表操作。
该方案的不足之处在于标签/类型信息的获取需要额外的内存加载操作,但其优势在于整数以及其他原生类型可以完全使用其原本所占据的空间。
该方案意味着系统中存在一组内存块专门用于保存整数,同时还有一组专门的内存块用于保存浮点数等。由于这些纯值不可能发生变化,因而在分配新对象时可能需要进行哈希查找以避免创建已经存在的对象。
如果不使用带标签值,那么要找出对象中所包含的指针,必然需要知道对象的类型(至少需要知道对象中的哪些域是指针域)。
对于面向对象语言(确切地讲,是使用 动态方法分派(dynamic method dispatch) 机制的语言),指向对象的指针并不能完全反映运行时对象的类型,因而我们需要将对象的类型信息与对象本身关联,其实现方式通常是在对象头部增加一个指向其类型信息的指针域。
面向对象语言通常会为每一种类型生成方法分派向量,并在对象头部增加一个指向其方法分派向量的指针,因此编程语言便可将对象的类型信息保存在方法分派向量中,或者从方法分派向量可达的其他位置。
如此一来,回收器或者运行时系统中其他依赖对象类型信息的模块(如Java 的反射机制)便可快速获取对象的类型信息。
回收器需要的是一个能够反映对象内部指针域位置的表,该表的实现方式有二:
Huang等人 通过调整偏移向量中元素的顺序来获取不同的追踪顺序,复制式回收器可以据此按照不同的顺序排列存活对象,进而提升高速缓存性能。
这一调整操作需在运行时谨慎执行(在万物静止式回收器中)。将包含指针的对象与不包含指针的对象进行 分区(partition) ,在某些方面来说是比查表更加简单的一种指针识别方法。
该策略在某些语言和系统的设计中可以直接使用,但在其他语言中则可能遇到问题。例如在ML中,对象可以具有 多态性(polymorphic) 。
假设某一对象在某些情况下将某个域当作指针,在另一些情况下又将该域当作非指针值,那么如果系统生成一段适用于所有多态情况的代码,则根本无法对两种情况进行区分。
对于允许派生类复用基类代码的面向对象系统,子类的域将位于基类所有域之后,这将必然导致指针域与非指针域的混合。
这一问题的一种解决方案是将两种不同的域沿着不同的方向排列:
在以字节方式寻址的机器上,对于按照字来对齐的对象,我们可以将对象头部第一个字的最低位设置为1,而按照字进行对齐又可以确保指针域的最低两位必然全为零,从而保证了堆的可解析性。在实际应用中,扁平排列方式通常不会成为问题。
某些系统会针对每种类型生成面向对象风格的代码,从而实现对象的追踪、复制等。我们可以将查表方式看作是类型解释器,而面向对象代码的方式则可以看作是对应的已编译代码。
Thomas在其设计中提出了一种十分有价值的思路,即当复制一个 闭包 (closure) 时,可以针对闭包的 环境(environment) 定制专门的复制函数,该函数会避免复制那些在特定函数中不会使用的环境变量。
该策略不仅可以在复制环境变量时节省空间,更重要的是可以避免复制环境中已经不再使用的部分。
在托管语言中,我们可以利用面向对象方法的间接调用过程实现特殊的回收相关操作。在Cheadle等 的复制式回收器中,他们通过动态改变对象的函数指针来实现读屏障的 自我删除(self-erase) ,这与Cheadle等 在 Glasgow Haskell编译器(GHC)中所使用的技术类似。
该系统使用相似的技术实现了多种版本的栈屏障,除此之外还基于该技术实现了一个在更新待计算 值(thunk) 时使用的分代间写屏障。能够更新闭包环境的系统存在一项优势,即它可以对现有对象进行收缩,而为确保堆可解析性,系统又需要在收缩完成之后在堆中插入一个伪对象。
相应的,系统也可能需要扩展某一对象,此时系统便会用一个中转对象覆盖原有对象,同时在中转对象中保存指向扩展对象的指针,后续的回收过程也可以将中转对象优化掉。回收器也可以额外为赋值器执行一些计算,例如对部分参数已经完成计算的“知名”函数提早执行计算,返回链表首个元素这一函数即为“知名”函数的一个例子。
从原则上讲,静态类型语言可以省略对象头部并节省空间。Appel 和 Goldberg 描述了如何在ML语言中实现这一要求。在他们的解决方案中,回收器只需要了解根的类型信息(因为回收的追踪过程必须有一个起点)。
全局根中的精确指针查找相对来说较为简单,在对象中查找指针的技术大多都可以在这里复用。
在全局根这一方面,不同语言之间的主要差别在于全局根集合是否可以动态增长。动态代码加载是导致全局根集增长的原因之一。
某些系统在启动时便包含一个基本的对象集合,某些Lisp以及某些Java系统在启动时(特别是在交互式环境中启动时)便会包含一个基本的系统“映像”,也称为 引导映像(boot image) ,其中包括众多的类系/函数及其对象实例。
程序在执行过程中可能会对引导映像进行局部修改,从而导致引导对象引用了运行时创建的新对象,此时回收器就必须把这些引导对象中的域也当作程序的根。
在程序的运行过程中,引导对象也可能成为垃圾,因此偶尔对引导映像进行追踪并找出其中的不可达对象也是一个不错的选择。是否需要关注引导映像通常取决于是否使用分代式回收策略,此时我们可以将引导映像看作是特殊的年老代对象。
在栈中精确查找指针的一种解决方案是将活动记录分配在堆中,正如Appel 所建议的那样,也使用同样的方案,且Miller和Rozas 再次论证了该方案的可行性。
某些语言实现使用与管理堆相同的方式来管理栈帧,从而达到了一箭双雕的效果,例如 Glasgow Haskell编译器 以及 Non-StopHaskell。语言的实现者也可以专门为回收器提供一些关于栈上内容的相关指引,例如Henderson 在 Mercury语言中便以这种方式来处理用户生成的C代码,Baker 等 在为Java进行实时性改造时也使用了类似的技术。
可参考-什么是栈帧
但是,出于多方面的效率因素,大多数语言都会对栈帧进行特殊处理以获取最佳的运行时性能,此时回收器的实现者便需要考虑以下3个问题:
如何在栈中查找帧(活动记录)
如何在帧中查找指针
如何处理以约定方式传递的参数、返回值,以及寄存器中值的保存与恢复
在大多数系统中,需要在栈中查找帧的不仅仅只有回收器,诸如异常处理与恢复等其他机制也需要对栈进行“解析”,更不用说调试环境下至关重要的栈检查功能了。同时,栈的可解析性 也是某些系统(如Smalltalk)自身的要求。
从开发者的角度来看,栈本身当然是十分简洁的,但在这个简洁的外表背后,真正的栈在实现上却是经过高度优化的,帧的布局通常也更加原始。
由于栈的可解析性通常十分有用,所以帧的布局管理通常需要支持这一点。
例如,在许多栈的设计实现中,每个帧中都会有一个域用于记录指向上一帧的动态链表指针,而其他各域均位于帧中固定偏移量的位置(此处的偏移量是相对于帧指针或者动态链表指针所指向的地址)。
许多系统中还会包含一个从函数返回地址到其所在函数的映射表,在非垃圾回收系统中该表通常只是调试信息表的一部分,但许多托管系统却需要在运行时访问该表,因此该表就必须成为程序代码的一部分(可以在启动时加载到程序,也可以在程序启动后生成),而不能仅作为辅助调试信息来使用。
为确保回收器可以精确地找出帧中的指针,系统可能需要为每个栈显式增加 栈映射( stack map) 信息。
这一元数据可以通过位图来实现,即通过位图中的位来记录帧中的哪些域包含指针。除此之外,系统也可以将帧划分为指针区和非指针区,此时元数据中所记录的便是两个区各自的大小。
需要注意的是
当栈帧已经存在但尚未完全初始化时,函数可能会需要插人额外的初始化指令,否则回收器在这一状态下进行栈扫描则可能遇到问题。
我们可能需要对帧初始化代码进行回收方面的仔细分析,同时也必须谨慎地使用push指令(如果机器支持的话)或者其他特殊的压栈方式。
当然,如果编译器可以将帧中的给定域固定当作指针或者非指针来使用,则帧扫描的实现便十分简单,此时所有的函数只需共享同一个映射表即可。
但是,单一栈映射方案通常不可行,如果使用该方案,则至少两种语言特性无法实现:
jsr: 跳转至指定16位offset位置,并将jsr下一条指令地址压入栈顶
我们曾经提到,多态函数可能会使用同一段代码来处理指针和非指针值,由于单映射表无法对两种情况进行区分,所以系统需要一些额外的信息。
尽管多态函数的调用者可能“知道”具体的调用类型,但调用者本身也可能是一个多态函数,因而调用者需要将这一信息传递给更上层的调用者。因此在最差情况下,可能需要从main()函数开始逐级传递类型信息。这将与从根开始识别对象类型的策略十分类似。
Java虚拟机通过jsr指令来实现 局部调用(local call) ,该指令不会创建新的帧,但它所调用的代码却能够以调用者的角色来访问当前帧中的局部变量。
Java通过该指令来实现try-finally特性,在正常以及异常逻辑下,finally块中的代码都会通过jsr指令来调用。这里的问题在于,当虚拟机调用jsr指令时,某些局部变量的类型可能会出现歧义,此时局部变量的类型可能会取决于通过jsr指令调用finally块的调用者。
对于某一未在finally块中使用但在未来会用到的变量,在正常调用逻辑下,它可能会包含指针,而在异常调用逻辑下,它又可能不包含指针。
有两种策略可以解决这一问题。
是依赖jsr指令的调用者来消除歧义,此时栈映射中域的栈槽类别就不能简单地划分为指针和非指针两种(即可以通过一个位来表示),还需要包含“询问jsr调用者”这第三种类别。此时我们需要找到jsr调用的返回地址,为达到这一目的,程序需要通过对Java字节码进行一定的分析。
是简单地将finally块复制一份,虽然这可能改变字节码或者动态编译代码,但该方案在现代系统中应用得更加广泛。尽管该方案在最差情况下可能会造成代码体积指数级别的膨胀,但它确实简化了系统中 finally块的设计。据说有证据表明,为动态编译代码生成栈映射是某些隐蔽错误的重要来源,因而在这里控制系统的复杂度可能更加重要。某些系统会将栈映射的生成延迟到回收器真正需要它的时候,尽管这样可以节约正常执行逻辑下的时间和空间,但可能会增加回收停顿时间。
系统选用单一栈映射的另一个问题是:
因此这一因素便首先决定了单一栈映射方案不适用于寄存器数量较少的机器。
需要注意的是,不论我们是为每个函数创建一个栈映射,还是为一个函数的不同部分创建不同的栈映射,编译器都必须确保调用层次最深的函数也能获取栈槽的类型信息。如果我们在开发编译器之前就能意识到这一需求的重要性,则实现起来并不会特别困难,但是如果要对现有的编译器进行修改,则难度相当大。
寄存器中的指针查找。
到目前为止,我们都忽略了寄存器中的指针。寄存器中的指针查找会比栈中的指针查找复杂得多,这是由以下几个原因决定的:
我们曾经提到,对于某个具体的函数,编译器可以固定地将其栈帧中的某个域用作指针域或者非指针域,但这一方案通常不能简单地套用在寄存器上,或者存在较大的局限性:
即使我们可以确保所有全局根、堆中对象、局部变量都不包含内部指针和派生指针,但经过高度优化的本地代码序列仍有可能导致寄存器持有这样的“非正规”指针。
函数 调用约定(call convention) 要求:
许多系统都通过 栈展开(stack unwinding) 机制来实现栈帧以及调用链的 重建(reconstruct) ,特别是对于没有提供专用的“上一帧”寄存器的系统。
栈展开:如果在一个函数内部抛出异常,而此异常并未在该函数内部被捕捉,就将导致该函数的运行在抛出异常处结束,所有已经分配在栈上的局部变量都要被释放
解决被调用者保存寄存器中的指针查找问题,给出一个策略。该策略首先要求为每个函数增加一个元数据,其中记录的信息包括该函数保存了哪些被调用者保存寄存器,以及每个寄存器的值保存在帧的哪个域中。
我们假定系统所使用的是最常见的一种函数调用方案,即函数一开始便将其可能用到的被调用者保存寄存器保存到帧中。如果编译器较为复杂,以至于同一函数内的不同代码段都可能以不同的方式使用寄存器,则其需要为函数内不同的代码段分别插入被调用者保存寄存器的相关信息。
从顶层帧开始,我们重建寄存器时首先应当恢复被调用者保存寄存器,并获取这些寄存器在调用者执行调用时的状态。
为确保栈回溯顺利,我们需要记录哪些寄存器得到恢复,以及恢复操作后所获取到的值。
到达调用栈底部的函数之后,所有的被调用者保存寄存器均可忽略(因为它不存在任何调用者),此时我们便可确定所有寄存器中的指针,回收器可以使用该信息并在必要时更新其中的值。
栈回溯过程需要恢复被调用者保存寄存器。需要注意的是,如果回收器更新了某一指针,则它同时也需要更新已保存的寄存器值。一旦函数使用了被调用者寄存器,我们便需要从额外的表中获取该寄存器原有的值,必要时,回收器还需要对该值进行更新。
后续处理调用者的过程中,我们应当避免对已经处理过的被调用者保存寄存器进行二次处理。在某些回收器中,对根进行二次处理不会存在副作用(例如标记—清扫回收器),但在复制式回收器中,我们会很自然地认为所有尚未转发的引用都位于来源空间中,因此如果回收器两次处理相同的根(不是两个引用了同一对象的根),则可能会在目标空间中生成一份额外的副本。
算法11.1详细描述了上述处理流程,图11.2中展示了一个具体的处理实例。
算法11.1中,func是回收器用于扫描帧和寄存器的函数,它可以是算法2.2(标记—清扫回收、标记—整理回收)中markFromRoots函数for each循环中的代码段,也可以是算法4.2(复制式回收)中 collect 函数扫描根的循环中的代码段。
我们首先来考虑图11.2a所示的调用栈(右侧带阴影的方框),其调用过程如下:
程序从main()函数开始执行,初始状态下寄存器r1的值为155,r2为784。
为确保效率,main()函数的调用者应当位于整个垃圾回收体系之外,因而其所对应的帧不得引用任何堆中对象,其寄存器的值也不能是指针。类似地,我们也无需关注main()函数的返回地址oldIP。
main()函数所执行的操作依次是:
图11.2a中,每个粗方框代表一个函数的帧,每个方框之上的寄存器值表示函数开始执行时寄存器的状态,位于方框之下的寄存器值表示其发起函数调用时寄存器值的状态。这些寄存器的值都应当在后续的栈展开过程中得到恢复。
假设函数g()在执行过程中触发了垃圾回收。
垃圾回收过程发生在函数g()中的g()+ 36位置,此时r1的值为指向r的指针,r2的值为指向t的指针。我们假定此时指令指针(IP)以及各寄存器的值都已经保存在被挂起线程的某个数据结构中,或者保存在垃圾回收过程的某一帧中。
在某一时刻,回收器会在线程栈上调用processStack函数,参数func即为回收器扫描帧和寄存器的函数。对于复制式回收器而言,func即 copy函数,此时由于目标对象会发生移动,因而回收器需要更新栈以及寄存器中的引用。
图11.2a左侧的方框展示了处理过程中变量Regs 和Restore的变化,回收器将依照g()、f()、main()的顺序进行处理。我们对Regs和Restore的快照在左侧进行编号,编号的顺序与我们下面所描述的执行步骤保持一致。
processstack函数将线程状态中的当前寄存器值写入Regs中,并将Restore初始化为空。
此时函数执行到算法11.1的第15行,其所处理的帧为函数g()的帧。
算法执行到第19行,处理的帧依然为函数g()所对应的帧。此时我们已经完成了Regs的更新,并且已经将函数g()在触发垃圾回收之前对寄存器的修改保存在了Restore中。
由于函数g()一开始便将r2的值保存在槽1,所以我们可以推断出在函数f()调用函数g()的时刻,r2的值应当为17。在函数g()发起垃圾回收的时刻,r2的值为t,我们将这一信息保存在Restore中9。
在进一步对函数g()进行处理之前,我们先递归调用processstack函数对其调用者进行处理。图11.2a中,我们把calleeSavedRegs 函数所返回的对组以及指令指针记录在函数g()所对应帧的左侧。
算法再次执行到第19行,此时所处理是函数f()所对应的帧,我们从槽2和槽1中分别恢复r1和r2的值。
算法再次执行到第19行来处理函数main()的帧。由于函数main()“不存在”调用者,所以我们无需恢复任何的被调用者保存寄存器。
更加确切地讲,应该是 main()函数的调用者位于整个垃圾回收体系之外,其任何寄存器都不会包含与垃圾回收相关的指针。
完成函数main()在调用函数f()之前的寄存器数据重建之后,我们便可以对main()函数的帧以及寄存器进行处理,函数f()和g()也使用完全相同的方法来处理。
接下来我们通过图11.2b来介绍每个帧将会到达的两种状态,一种状态对应算法11.1的第35行,另一种对应第38行之后。
图11.2b所反映的是各个帧在算法第35行的状态,其中加粗的值表示已更新的值(尽管该值可能并不需要更新),灰色表示未更新的值。
Regs所记录的是函数main()调用函数f()之前的寄存器状态,此时集合Done依然为空。
函数 func对寄存器r1进行更新(因为r1属于main() + 52处的集合pointerRegs),并将其加入集合Done中,目的是记录rl已经更新到其所引用对象的新地址(如果存在的话)。
Regs所记录的是函数f()调用函数g()之前的寄存器状态。注意,rl和r2的值需要重新保存到槽1和槽2中,同时它们在Regs 中对应的值需要从Restore中恢复。
函数func更新r1并将其添加到集合Done 中
Regs所记录的寄存器值是函数g()发起垃圾回收之前的寄存器状态。
与第11步类似,回收器需要将r2的值重新保存到槽1中,同时其在Regs 中对应的值需要从Restore中恢复。由于r1并未从Restore中恢复,所以r1依然存在于集合Done 中。
函数func将跳过寄存器r1(因为它已经存在于集合Done中),但它会更新r2并将其加入集合Done 中。
最后,第15步,函数processstack将Regs中寄存器的值恢复到线程状态中。
算法11.1 变种算法、栈映射压缩 此处略。
程序代码中可能会内嵌堆中对象的引用,特别是那些允许运行时加载代码或者动态生成代码的托管运行时系统。即使是对于事先编译好的代码,其所引用的静态/全局数据仍有可能在程序启动时从刚刚完成初始化的堆中分配。
代码中的精确指针查找存在以下几个难点:
从代码中分辨出嵌入其中的数据通常较为困难,甚至不可能。
对于“不合作”的编译器所生成的代码,几乎不可能将其中的非指针数据与可能指向堆中对象的指针进行区分。
当指针被嵌入到指令中时,指针本身可能会被割裂成为好几小段。MIPS处理器将32位静态指针值加载到寄存器中通常需要使用load-upper-immediate指令,该指令首先将一个16位的立即数加载到32位寄存器的高16位并将低16位清零,然后再使用or-immediate指令将另一个16位的立即数加载到寄存器的低16位。其他指令集也可能会出现类似的代码序列。此处的指针值算是一种特殊的派生指针(见11.2.8节)。
内嵌指针值可能并非直接指向其目标对象,具体可以参见我们对内部指针(见11.2.7节)以及派生指针(见11.2.8节)的讨论。
某些情况下我们可以通过代码反汇编来找出内嵌指针,但如果每次回收都需要反汇编全部代码并处理其中的根,则可能引入巨大的开销。当然,由于程序不会修改这些内嵌指针,因此回收器可以缓存其位置以提高效率。
更加通用的解决方案是由编译器生成一个额外的表来记录内嵌指针在代码中的位置。
某些系统简单地禁用内嵌指针,从而避免了这一问题。使用这一策略可能存在的问题是,在不同目标架构、不同的编译策略以及不同的访问特征下,代码的性能可能会有所不同。
目标对象可移动的情况。
如果内嵌指针的目标对象发生移动,则回收器必须更新内嵌指针。
更新内嵌指针的困难之一在于,出于安全性或者保密性原因,程序代码段可能是只读的,因此回收器可能不得不临时修改代码区的保护策略(如果可能的话),但这一操作可能会引发较大的系统调用开销。另一种策略则是禁止内嵌指针引用可移动对象。
更新内嵌指针的另一个困难之处在于,对内存中代码的修改通常并不会使代码在其他指令 高速缓存(instruction cache) 中的副本失效或者强制更新,为此可能要求所有处理器将受影响的指令高速缓存行失效。
在某些机器中,回收器在将指令高速缓存行失效之后可能还需要执行一个特殊的同步指令,目的是确保未来的指令加载操作发生在失效操作之后。
另外,在将指令高速缓存行失效之前,回收器可能还需要将被修改的数据高速缓存行强制刷新到内存中(其中所保存的是回收器所修改的代码),并且需要使用同步操作来确保这一操作执行完毕。此处的实现细节与具体的硬件架构相关。
代码可移动的情况。
一种特殊的情况是回收器可能会移动程序代码。
此时回收器不仅要考虑目标对象可移动情况下的所有问题,更要考虑对栈以及寄存器中所保存的返回地址的修正,因为回收器可能已经移动了返回地址所在代码。
回收器必须将所有与代码新地址相关的指令高速缓存行失效,并且小心地执行上文列出的所有相关操作。更深层次的问题在于,如果连回收器自己的代码都是可移动的,那么处理起来将更加复杂。
在并发回收器中进行代码移动将是一件极为困难的任务,此时回收器要么必须挂起所有线程,要么只能采用更加复杂的方式,即先确保新老代码都可以被线程使用,然后在一段时间内将所有线程都迁移到新代码,最后在确保所有线程都迁移完成的前提下将老代码所占用的空间回收。
所谓内部指针,即指向对象内部某一地址,但其所指向地址并非对象的标准引用的指针。更加准确地讲,我们可以把对象看作是一组与其他对象不重叠的内存地址集合,而内部指针所指向的正是该集合中的某一地址。
回顾图7.2 我们可以发现,标准的对象可能并不会与其任何一个内部指针相等。另外,对象真正占据的空间也可能会比开发者可见数据所需的空间要大。例如,C语言允许指针指向数组末尾之外的数据,但对于数组而言这依然是一个合法的内部引用。
在某些系统中,语言级别的对象可能是由数个不连续的内存片段组成,但在描述内部指针(以及派生指针)时,我们这里的“对象”仅仅是指位于一块连续内存之上的(语言级别)对象。
回收器在处理内部指针时遇到的主要问题是判定其究竟指向了哪个对象,即如何通过内部指针的值来反推出其目标对象的标准引用。可行的方案有以下几种:
使用一张表来记录每个对象的起始地址。
如果系统支持堆的可解析性(参见7.6节),则回收器可以通过堆扫描来确定内部指针所指向的地址究竟落在哪个对象内部。
如果使用页簇分配策略,则回收器可以通过内部指针所指向的内存块的元数据来获取对象的大小,同时也可计算出目标地址在内存块中的偏移量(将目标地址与合适的掩码进行与操作,获取该地址的低位),根据对象的大小将偏移量向下圆整,便可得到对象的首地址。
我们假设对于任意一个内部指针,回收器都能计算出其目标对象的标准引用。当某一内部指针的目标对象发生移动时(例如在复制式回收器中),回收器必须同时更新该内部指针,并且确保其目标地址在新对象中的相对位置与移动之前完全一致。另外,系统也可能会将对象 钉住(pin) 。
如果系统允许使用内部指针,则由此带来的主要问题是:对内部指针的处理需要花费额外的时间和空间。如果内部指针数量相对较少,且可以与 正规指针(tidy pointer) (即指向对象标准引用位置的指针)进行区分,则处理内部指针的时间开销可能不会太大。
但是,如果要彻底支持内部指针,则可能需要引入额外的表(尽管具体的回收器通常会包含一些必要的表或者元数据),进而增大了系统的空间开销,同时维护该表也会引入额外的时间开销。
代码中的返回地址是一种特殊的内部指针,尽管它们并没有什么特殊的处理难度,但基于多种原因,回收器在查找某个返回地址所对应的函数时,所用的表通常会不同于其他对象。
Diwan等 将派生指针定义为:
内部指针是派生指针的一个特例,它可以表示成 p + i 或者 p + c 这种简单形式,其中p为指针,i为动态计算出的整数偏移量,c为静态常量。
由于内部指针所指向的地址必然位于对象p所覆盖的内存地址中的一个,所以其处理起来相对简单,但派生指针的形式则可以更加一般化,例如:
某些情况下,我们可以根据派生指针来反推正规指针(即指向标准引用地址的指针),例如派生指针 p + c 且 c 为编译期确定的常量。
我们通常都必须知道生成派生指针的基本表达式,尽管该表达式本身可能也是一个派生指针,但追根溯源,必然可以找到产生派生指针的正规指针。
在非移动式回收器中,回收器可以简单地将正规指针当作根进行处理。但需要注意的是,在垃圾回收时刻,即使派生指针依然存活,其目标对象的正规指针仍有可能被编译器的存活变量分析判定为死亡,因此编译器必须为每个派生指针保留至少一个正规指针,但 p±cp \pm cp±c 这一情况属于例外,因为回收器通过一个编译期常量对派生指针进行调整,便可计算出其所对应的正规指针,该过程不需要依赖其他运行时数据。
在移动式回收器中,派生指针的处理则需要编译器的进一步支持:
Diwan等 给出了处理形如 ∑ipi−∑jqj+E\sum _i p_i - \sum _j q_j+E∑ipi−∑jqj+E 的派生指针的通用解决方案,其中pip_ipi和qjq_jqj是正规指针或者派生指针,E是一个与指针无关的表达式(即使pip_ipi或qjq_jqj发生移动,该表达式也不会受到任何影响)。
其处理流程是:
Diwan等 指出,编译器的优化可能会给派生指针的处理带来一些额外的问题
为支持派生指针,编译器有时需要减少对代码的优化,但其影响通常较小。
基于赋值器性能以及空间开销的考虑,许多系统都使用直接指向对象的指针来表示引用。一种更加通用的方案是为每个对象赋予一个唯一标识,并通过某种映射机制来定位其具体数据的地址。
对于对象所占空间较大且可能较为持久,但底层硬件地址空间却相对较小的场景,这一技术具有一定的吸引力。本节我们关注的正是堆如何适应地址空间。
在上述场景中,对象表(object table) 是一种十分有用的解决方案,除此之外,对象表在许多其他系统中同样十分有用。
对象表通常是一个较为密集的数组,其中的每个条目引用一个对象。对象表可以仅包含指向对象数据的指针,也可以包含其他额外的状态信息。
为确保执行速度,对象的引用通常是其在对象表中的直接索引,或者指向其在对象表中对应条目的指针。如果使用直接索引,则回收器迁移对象表的工作便十分简单,但系统在访问具体对象时却必须先获取对象表的基址,然后再执行偏移,如果系统可以提供一个专门的寄存器来保存对象表的基址,则这一操作并不需要额外的指令。
显著优点:
为简化这一过程,对象内部应当隐含一个自引用域(或者指向其在对象表中对应条目的指针),据此,回收器便可通过对象的数据快速找到其在对象表中的对应条目。
在此基础上,标记—整理回收器可以采用传统的方式完成标记(需要通过对象表间接实现),然后简单地“挤出”垃圾对象,从而实现对象数据的滑动整理。回收器可以将对象表中的空闲条目以空闲链表的方式组织。
需要注意的是,将对象的标记位置于其在对象表的对应条目中的效率更高,这可以在检测或者设置标记位时节省一次内存访问操作。额外的标记位图也具有类似的优点。还可以将对象的其他元数据置于对象表中,例如指向其类型及大小信息的引用。
对象表本身也可以进行整理,例如使用3.1节所描述的双指针算法。也可以在整理对象数据的同时整理对象表,此时只需要进行一次对象数据遍历便可同时实现对象数据和对象表的整理。
如果编程语言允许使用内部指针或者派生指针,则对象表策略可能会存在问题,甚至会成为障碍。类似地,对象表也很难处理从外部代码指向堆中对象的引用,这一问题我们将在11.4节详述。
如果编程语言禁止内部指针,则不论是否使用对象表,语言的具体实现都不会因此受到任何语义上的影响,但是有一种语言特征或多或少都需要依赖对象表来保证其实现效率,即Smalltalk的 become:原语。该原语的作用是将两个对象的身份互换,如果使用对象表,则其实现起来相当简单,赋值器只需要将它们在对象表中的对应条目互换即可。如果没有对象表的支持,become:操作就可能需要对整个堆进行扫描。但即使不使用对象表,谨慎地使用become:操作也是可以接受的(Smalltalk通常使用become:操作来设置对象的新版本),毕竟直接引用的方式在大多数情况下都会比对象表更加高效。
某些语言或者系统允许托管环境之外的代码使用堆中分配的对象,一个典型的例子便是 Java原生接口(Java Native Interface) ,它允许C、C++或者其他语言所开发的代码访问Java堆中的对象。更加一般化地讲,几乎每种系统都需要支持输入/输出,这一过程几乎必然需要在操作系统和堆之间进行一定的数据交换。
如果系统需要支持外部代码和数据引用托管堆中的对象,那么将存在两个难点。
我们通常只需要在调用外部代码期间满足这一要求,因而可以在发起外部调用线程的栈中保留指向该对象的存活引用。但是,某些托管对象也可能会被外部代码长期使用,其可达范围也可能超出最初发起外部调用的函数。
基于这一原因,回收器通常会维护一个已注册对象表来记录此类对象。如果外部代码需要在当前调用完成之后继续使用某一对象,则其必须对该对象进行注册,同时当外部代码不再需要且未来也不会再使用该对象时,必须显式将其注销。回收器可以简单地将已注册对象表中的引用当作额外的根。
某些实现接口会将具体对象与外部代码相隔离,后者只有借助于回收器所提供的渠道才能访问堆中对象。此类接口对移动式回收器的支持较好。回收器通常会将指针转化为句柄之后再交由外部代码使用,句柄中会包含堆中对象的真正引用,也可能包含其他一些托管数据。此处的句柄相当于是已注册对象表中的条目,同时也是回收的根。Java原生接口即采用这种方式实现外部调用。需要注意的是,句柄与对象表中的条目十分类似。
句柄不仅可以作为托管堆和非托管世界之间的一道桥梁,而且可以更好地适应移动式回收器,但并非所有的外部访问都可以遵从这一访问协议,特别是操作系统调用。
此时回收器就必须避免移动被外部代码所引用的对象。为此,回收器可能需要提供一个钉住接口,并提供 钉住(pin) 和 解钉(unpin) 操作。
当某一对象被钉住时,回收器将不会移动该对象,同时也意味着该 对象可达且不会被回收。
如果我们在分配对象时便知道该对象可能需要钉住,则可以直接将其分配到非移动空间中。文件流IO缓冲区便是以这种方式进行分配的。但程序通常很难事先判断哪个对象未来需要钉住,因此某些语言支持 pin 和 unpin 函数 以便开发者自主进行任何对象的钉住与解钉操作。
钉住操作在非移动式回收器中不会成为问题,但却会给移动式回收器造成一定不便,针对这一问题存在多种解决方案,每种方案各有优劣。
延迟回收,或者至少对包含被钉住对象的区域延迟回收。该方案实现简单,但却有可能在解钉之前耗尽内存。
如果应用程序需要钉住某一对象,且对象当前位于可移动区域中,则我们可以立即回收该对象所在的区域(以及其他必须同时回收的区域)并将其移动到非移动区域中。
该策略适用于钉住操作不频繁的场景,同时也适用于将新生代存活对象提升到非移动式成熟空间的回收器(例如分代回收器)。
对回收器进行扩展以便在回收时不移动被钉住的对象,但这会增加回收器的复杂度并可能引入新的效率问题。
我们下面将以基本的非分代复制式回收器为例来考虑如何对移动式回收器进行扩展,从而支持钉住对象。
为达到这一目的,回收器首先要能将已钉住对象与未钉住对象区分。
回收器依然可以复制并转发未钉住对象
对于被钉住对象,回收器只能追踪并更新其中指向被移动对象的指针,却不能移动该对象,回收器同时还必须记录其所发现的已钉住的对象。
当完成所有存活对象的复制之后,回收器不能简单地释放整个来源空间,而是只能释放已钉住对象之间的空隙。
此时回收所获得的不再是一块单独的、连续的空闲内存,而可能是数个较小的、不连续的空间集合,分配器可以将每段空间当作单独的顺序分配缓冲区来使用。
已钉住对象不可避免地会造成内存碎片,但在未来的回收过程中,一旦被钉住的对象得到解钉,由此造成的碎片便可消除。正如我们在10.3节看到的,某些主体非移动式回收器
也会采用类似的方案,即在存活对象间隙进行顺序分配。
钉住对象给移动式回收器引入的另一个难点在于:
为此,回收器不仅要将直接被外部代码引用的对象钉住,同时还可能需要钉住其所引用的其他对象。同样地,如果外部代码从某一对象开始遍历其他对象,或者仅判断/复制对象的引用而不关心其内部数据,回收器仍需将其钉住。
编程语言自身的特性或其具体实现也可能会依赖对象的钉住机制。
例如,如果编程语言允许将对象的域当作引用来传递,则栈中可能会出现指向对象内部域的引用。此时我们可以使用11.2.7节所描述的内部指针相关技术来移动包含被引用域的对象,但该技术的实现通常较为复杂,且正确处理内部指针的代码可能会难以维护。
因此某些语言实现通常会简单地将此类对象钉住,这便要求回收器能够简单高效地判定出哪些对象包含直接被其他对象(或者根)引用的域。
该方案可以轻易解决内部指针的处理问题,但却无法进一步拓展到更一般化的派生指针问题(参见11.2.8节)。
回收器可以使用增量式栈扫描策略,但也可以使用 栈屏障(stack barrier) 技术进行主体并发扫描。该方案的基本原理是在线程返回(或者因抛出异常而展开)到某一帧时对线程进行劫持。
假设我们在栈帧F上放置了屏障,然后回收器便可异步地处理F的调用者及其更高层次的调用者等,同时我们可以确保在异步扫描的过程中,线程不会将调用栈退回到栈帧F中。
引入栈屏障的关键步骤在于劫持帧的返回地址,即将帧上保存的返回地址改写为栈屏障处理函数的入口地址,同时将原有的返回地址保存在栈屏障处理函数可以访问到的标准地址,例如线程本地存储中。栈屏障处理函数可以在合适的时候移除栈屏障,同时还应当小心确保不会对上层调用者的寄存器造成任何影响。
同步(synchronous) 增量扫描: 当赋值器线程陷入栈屏障处理函数时,其会向上扫描数个栈帧,并在扫描结束的位置设置新的栈屏障(除非处理函数已经完成整个栈的扫描)。
异步(asynchronous) 增量扫描: 是由回收线程执行的,此时栈屏障的目的是在被扫描线程触达被扫描栈帧之前将其挂起。扫描线程在完成数个帧的扫描之后可以沿着调用栈的回退方向移动栈屏障,因此被扫描线程可能永远都不会触达栈屏障,一旦触达,则被扫描线程必须等待扫描线程执行完毕并解除栈屏障,然后才能继续执行。
Cheng 和 Blelloch 使用栈屏障技术来限制一个回收增量内的工作量,并借助该技术来实现异步栈扫描。他们将线程栈划分为固定大小的 子栈(stacklet) ,每个子栈都可以一次性完成扫描,从一个子栈返回另一个子栈的位置即为栈屏障的备选位置。该方案并不要求各子栈连续布局,同时也不需要事先确定哪些帧上可以放置栈屏障。
回收器也能以另一种完全不同的方式来使用栈屏障,即利用栈屏障来记录栈中的哪些部分未改变过,因此回收器便不用每次都在这些位置中寻找新的指针。在主体并发回收器中,该技术可以减少回收周期结束时的 翻转(flip) 时间。
栈屏障的另一种用途是处理代码的动态变更,特别是经过优化的代码。例如,假设在某一场景下子过程A调用了B,B又调用了C,我们进一步假定系统将A和B内联,即A+B共用一帧。如果用户修改了B,则后续对B的调用应当执行到其新版本的代码中。
因此,当线程从C中返回时,系统需要对 A+B进行 逆优化(deoptimise) ,同时分别为A和B的未优化版本创建新的帧,只有当线程从B返回到A之后,子过程A才能访问新版的子过程B.系统甚至有可能重新进行优化并构建出新版的A+B。此处我们关注的是,从C返回到A+B的过程将触发逆优化,而栈屏障正是触发机制的一种实现方案。
我们在第11.2节提到,回收器需要知道哪些栈槽以及哪些寄存器包含指针;我们同时还提到,如果垃圾回收发生在同一函数的不同位置(即IP,也就是指令指针),这一信息通常会发生变化。
对于哪些位置可以进行垃圾回收,有两个问题需要关注:
下面我们来考虑哪些原因可能导致回收器无法在某一IP处安全地进行垃圾回收。
大多数系统通常都会存在一些必须作为整体来执行的短小代码序列,其目的在于确保垃圾回收需要依赖的一些不变式得到满足。例如,典型的写屏障不仅要执行底层写操作,还要记录一些额外的信息。
如果垃圾回收过程发生在这两个阶段之间,则可能导致某些对象发生遗漏,或者某些指针被错误地更新。
系统通常都会包含许多此类短代码序列,在垃圾回收器看来它们均应当是原子化的(尽管在严格的并发意义上讲它们并非真正的原子化操作)。更多的例子还包括新栈帧的创建、新对象的初始化等。
系统可以简单地允许回收器在任意IP位置发起垃圾回收,此时回收器将无需关心赋值器线程是否已经挂起在可以安全进行垃圾回收的位置,即 安全回收点(GC-safe point) 或者 简称回收点(GC-point) ,但此类系统在实现上通常更加复杂,因为系统必须为每个IP提供对应的栈映射,或者只能使用不需要栈映射的技术(例如面向“不合作”的C和C++编译器的相关技术)。
假定系统允许回收器在绝大多数IP位置发起垃圾回收,那么如果某一线程在回收发起时挂起在不安全的IP位置,则回收器可以对线程挂起位置之后、下一个安全回收点之前的指令进行解析,或者将线程唤起一小段时间,以便其(在一定概率上可以)运行到安全回收点。指令解析会增加出错的风险,而将线程向前驱动一小段则只能在一定概率上保证其到达安全回收点。除此之外,此类系统所需的栈映射空间也可能会很大。
许多系统使用另一种完全不同的策略,即只允许垃圾回收发生在特定的、已注册的安全回收点,同时也只为这些回收点生成栈映射。出于回收正确性的考虑,安全回收点的最小集合应当包括每个内存分配位置(因为垃圾回收通常会在此处发生)、所有可能发生对象分配的子过程调用、所有可能导致线程挂起的子过程调用(因为在某一线程被挂起的同时,其他线程有可能引发垃圾回收)。
为确保线程能够在有限时间内到达安全回收点,系统可以在安全回收点最小集合之外的更多位置增加回收点。
为此系统可能需要在每个循环中增加安全回收点:
由于这些额外的回收点并不会真正触发垃圾回收,所以在线程这些位置只需要检查是否有其他线程发起垃圾回收,因此我们可以称其为 回收检查点(GC-checkpoints) 。
尽管回收检查点会给赋值器带来一定开销,但这一开销通常不大,编译器也可以通过一些简单的方法来减轻这一开销。例如当函数十分短小,或者其内部不包含循环或进一步函数调用时将回收检查点优化掉。
为避免在循环的每次迭代中都执行回收检查,编译器也可以额外引人一层循环,即在每n轮迭代之后才执行回收检查。
当然,如果回收检查的开销很小,这些优化手段便不再必要。总之,系统必须在回收检查的频率和回收发起时延之间做出平衡。
Agesen 对两种将线程挂起在安全回收点的策略进行了比较。
一种策略是 轮询(poll) ,即我们刚刚介绍的方案,该方案要求线程在每个回收检查点都要对一个旗标进行检查,该旗标被设置则意味着其他线程已经发起了垃圾回收。
另一种方案是使用 补丁(patching) 技术,即当某一线程处于挂起状态时修改其执行路径上的下一个(或者多个)回收点的代码,线程恢复执行后便可在下一个回收点停顿下来。
这与调试器在程序中放置临时断点的技术类似。Agescn发现,补丁技术的开销要比轮询技术低得多,但其实现起来也更加复杂,在并发系统中也更容易出现问题。
在引出回收检查点这一思想时,我们曾经提到过回收器和赋值器之间的 握手(handshake) 机制。
即使对于多个赋值器线程执行在相同处理器上的这种并非真正“并发”的情况,握手机制也是十分必要的,在回收启动之前,回收器必须将所有已经挂起、但挂起位置并非安全回收点的线程唤醒,并使其运行到安全回收点。为避免这一额外的复杂度,某些系统能够保证线程仅会在安全回收点挂起,但基于其他原因,系统可能无法控制线程调度的所有方面,因而仍需借助于握手机制。
每个线程可以维护一个线程本地变量,该变量用于反映系统中的其他线程是否需要该线程在安全回收点关注某一事件。这一机制可以用于包括发起垃圾回收信号在内的多种场景。线程会在回收检查点检查这一本地变量,如果该变量非零,则线程会根据该变量的值执行具体的系统子过程。
某个特殊的值将意味着“是时候进行垃圾回收了",当线程发现这一请求之后,会设置另一个本地局部变量,该变量表示该线程已经准备就绪,除此之外线程也可以对某一回收器正在监听的全局变量执行自减操作来达到这一目的。系统通常会尽量降低线程本地变量的访问开销,因而该策略可能是一个不错的握手机制实现方案。
另一种方案是在被挂起线程已保存的线程状态中设置 处理器条件码(processor conditioncode) ,因此线程在回收检查点便可通过一个十分廉价的条件分支来调用该条件码对应的系统子过程。
该方案仅适用于包含多条件码集合的处理器(如 PowerPC),同时还必须确保线程在被唤醒之后不会处于外部代码的上下文中。如果处理器的寄存器足够多,则可以使用一个寄存器来表示信号,而寄存器的使用开销几乎与条件码一样小。如果线程正在执行外部代码,则系统便需要通过某种方式来关注线程何时从外部代码返回(除非线程恰好被挂起在与安全回收点等价的位置),对返回地址进行劫持(也可参见第11.5节)是捕获线程从外部代码返回的策略之一。
系统还可以使用操作系统级别的线程间信号来实现握手,例如 POSIX线程中的某些实现。该策略可能不具有广泛的可移植性,其执行效率也可能成为问题。影响效率的原因之一是信号传递的到用户级别处理函数需要通过操作系统内核级别的通道,而这一通道的处理路径相对较长。除此之外,这一机制不仅需要借助于底层处理器中断,还会影响高速缓存以及转译后备缓冲区,这也是影响其执行效率的重要原因之一。
综上所述,回收器与赋值器线程之间的握手机制主要有两种实现方式:
我们需要进一步指出的是,如果各线程直接完成其栈的扫描,必须还要考虑硬件和软件层面的并发情况,此处可能会涉及第13章的相关内容。其中与握手机制相关性最大的内容可能是第13.7节,届时我们将介绍相关线程如何从回收的一个阶段迁移到另一个阶段,以及赋值器线程在回收的开始和结束阶段应当执行哪些工作。
许多系统已经能够动态加载或者构建代码,并在运行时进行优化。由于系统可以动态加载或者生成代码,所以我们自然会希望当这些代码不再使用时,其所占用的空间能够得到回收。面对这一问题,直接的追踪式或引用计数算法通常无法满足该要求,因为许多从全局变量或者符号表可达的函数代码将永远无法清空。某些语言只能靠开发者显式卸载这些代码实例,但语言本身甚至可能根本不支持这一操作。
另外,还有两个特殊场景值得进一步关注。
由一个函数和一组环境变量绑定而成的闭包。我们假设某一简单的闭包是由内嵌在函数f中的函数g,以及函数f的完整环境变量构成的,它们之间可能会共享某一环境对象。Thomas 和 Jones[1994]描述了一种系统,该系统可以在进行垃圾回收时将闭包的环境变量特化为仅由函数g使用的变量。该策略可以确保某些其他闭包最终不可达并得到回收。
在基于类的系统中。此类系统中的对象实例通常会引用其所属类型的信息。系统通常会将类型信息及其方法所对应的代码保存在非移动的、不会进行垃圾回收的区域,因此回收器便可忽略掉所有对象中指向类型信息的指针。但是如果要回收类型信息,回收器就必须要对所有对象中指向类型信息的指针进行追踪,在正常情况下这一操作可能会显著增大回收开销。回收器可以仅在特殊模式下才对指向类型信息的指针进行追踪。
对于Java而言,运行时类是由其类代码以及 类加载器(class loader) 共同决定的。
类的生命周期:
对于何时加载,Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
当虚拟机启动时,用户需要指定一个要执行的主类(包含main() 方法的那个类),虚拟机会先初始化这个主类。
当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
由于系统在加载类时通常会存在一些副作用(例如初始化静态变量),所以类的卸载会变得不透明(即存在副作用——译者注),这是因为该类可能会被同一个类加载器重新加载。
唯一可以确保该类不被某个类加载器加载的方法是使类加载器本身也能得到回收。类加载器中包含一个已加载类表(以避免重复加载或者重复初始化等),运行时类也需要引用其类加载器(作为自身标识的一部分)。
因此,如果要回收一个类,则必须确保其类加载器、该类加载器所加载的其他类、所有由该类加载器所加载的类的实例都不被现有的线程以及全局变量所引用(此处的全局变量应当是由其他类加载器加载的类的实例)。
另外,由于 引导类加载器(bootstrapclass loader) 永远不会被回收,所以其所加载的任何类都无法得到回收。由于Java类卸载是一种特殊的情况,所以某些依赖这一特性的程序或者服务器可能会因此耗尽空间。
即使对于用户可见的代码元素(例如方法、函数、闭包等),系统也可能为其生成多份实例以用于解析或者在本地执行,例如经过优化的和未经优化的版本、函数的特化版本等。
为函数生成新版本实例可能会导致其老版本实例在未来的调用中不可达,但这些老版本实例可能仍在当前的执行过程中得到调用,它们在栈槽或者闭包中的返回地址会保持其可达性。
因此在任何情况下,系统都不能立即回收老版本代码实例,而只能通过追踪或者引用计数的方法来将其回收。
此处的相关技术是 栈上替换(on-stack replacement) 技术,即系统使用新版本代码实例来替换其正在执行的老版本实例。该方案不仅可以提升正在运行的方法调用的性能,而且有助于回收老版本的代码,因而其使用越来越广泛。
栈上替换技术的直接目的通常是优化代码或其他一些应用,例如需要对代码进行逆优化的调试需求,而在另一方面,回收器也可以利用该技术来回收老版本代码。
许多垃圾回收算法需要赋值器在运行时探测并记录 回收相关指针(interesting pointer) 。如果回收器仅回收堆中一部分区域,则任何从该区域之外指向该区域的指针都属于回收相关指针,且回收器必须在后续处理过程中将它们当作根。
例如,分代垃圾回收器必须捕获所有将年轻代对象的引用写入年老代对象的写操作。
当赋值器和回收器交替执行时(不论回收器是否运行在单独的回收线程之上),将很有可能出现赋值器操作导致回收器无法追踪到某些可达对象的情况,如果这些引用没有被正确地探测到并传递给回收器,则存活对象可能会被过早地回收。这些场景都要求赋值器即时地将回收相关指针添加到回收器的工作列表中,而这一任务的完成就需要借助于读写屏障。
本节我们将把各种特定回收算法(例如分代回收器或并发回收器)中的读写屏障进行抽象,并将注意力集中在回收相关指针的探测与记录上。
探测和记录在某种程度上是正交的,但某些探测方法的使用可能会加强特定记录方法的优势,例如,如果写屏障通过页保护违例来进行探测,则对被修改位置进行记录会更加合理。
除了要执行真正的读/写操作之外,典型的屏障通常还会包括一些额外的检查与操作。典型的检查包括判断被写入的指针是否为空、被引用对象与其引用者所处分代之间的关系等,而典型的操作则是将对象记录到记忆集中。
完整的检查以及记录操作可能太大,以至于无法整体内联,但这取决于屏障的具体实现。即使得到内联的指令序列相对短小,仍可能导致编译器生成的代码剧烈膨胀并进一步影响指令高速缓存的性能。
由于屏障内部的大部分代码通常很少执行,所以设计者可以将指令序列划分为“快速路径”和“慢速路径”:
快速路径应当包含最一般的情况,而慢速路径则仅应当在部分情况下执行,这一点十分重要。某些情况下,这一规则同样也适用于慢速路径的设计:
为达到这一要求,设计者通常需要对检查逻辑以多种方式进行排序并分别测量其性能,因为现代硬件环境中存在非常多的影响因素,以至于用简单的分析模型通常无法给出足够好的指引。
提升读写屏障性能的另一个因素是加速所有必需数据结构的访问速度,例如卡表。系统甚至可以付出一个寄存器的代价来保存某一数据结构的指针,例如卡表的基地址等,但是否值得如此取决于机器以及算法的类型。
设计者还需要对软件工程学有所关注,包括如何对垃圾回收算法的各个方面(即读写屏障、回收检查、分配顺序等)进行整合,它们都会被构建到系统的编译器中。
如果有可能,设计者最好能为编译器指明哪些子过程需要内联,这些子过程内部应当是快速路径所对应的代码序列。这样一来,编译器便无需知道具体细节,而设计者则可以自由替换这些内联子过程。但正如我们前面所提到的,这些代码序列可能会存在一些限制,例如在其执行过程中不允许发生垃圾回收,这便需要设计者小心对待。
编译器可能也需要避免对这些代码序列进行优化,例如保留一些显而易见的无用写入(它们所写入的数据对回收器有用)、禁止对屏障代码进行指令重排序或者与周围代码进行穿插。最后,编译器可能需要支持一些特殊的 编译指示(pragma) ,或者允许设计者使用特殊的编译属性,例如不可中断的代码序列。
回收相关指针的记录存在多种不同的实现策略与机制,具体的实现策略决定了记忆集记录回收相关指针位置的精度。在选择回收相关指针的记录策略时,我们需要对赋值器与回收器各自的开销进行平衡。
实践中我们通常倾向于增加相对不频繁的回收过程(例如查找根集合)的开销,同时降低更为频繁的赋值器行为(例如堆的写操作)的开销。
在引入写屏障之后,指针写操作所需的指令数可能会增大两倍或者更多,但如果写屏障的局部性比赋值器自身的局部性要好,则这一开销很可能会被掩盖(例如,写屏障在记录回收相关指针时通常不会导致用户代码的延迟)。
一般来说,记忆集中回收相关指针的记录精度越高,回收器查找操作的开销就会越低,而赋值器过滤并记录指针的开销则会越高。
作为一种极端情况,分代式回收器中的赋值器可以不记录任何指针写操作,从而将所有回收相关开销转移给回收器,此时后者就只能扫描整个堆空间并找出所有指向定罪分代的引用。
虽然这并非一种通用的较为成功的分代策略,但是对于无法借助于编译器或者操作系统的支持来捕获指针写操作的场景,这可能是唯一可选的分代策略,此时回收器可以使用局部性更好的线性扫描而非追踪策略来查找回收相关指针。
记忆集的设计策略需要从三个维度进行考虑。
尽管并非所有的指针都是回收相关指针,但对于赋值器而言,无条件记录的开销显然会低于对回收无关指针执行过滤之后再记录。记忆集的具体实现是决定过滤开销的关键:
如果记忆集可以使用非常廉价的机制来增加条目,例如简单地在某一固定大小的表中写人一个字节,则该策略非常适合无条件记录,特别是在添加操作本身满足幂等要求的情况下
如果向记忆集添加条目的开销较高,或者记忆集的大小也需要控制,则写屏障过滤掉回收无关指针则显得十分必要。对于并发回收器或者增量回收器而言,过滤操作是必不可少的,只有这样才能确保回收器的工作列表可以最终为空。
每种过滤策略都需要考虑过滤逻辑应当内联到何种程度,何时应当通过外部调用来执行过滤或将指针添加到记忆集。内联的指令越多,则需要执行的指令越少,但这可能导致代码体积的膨胀并增大指令高速缓存不命中的几率,进而影响程序的性能。因此,开发者需要对过滤检查的顺序以及需要内联的过滤操作进行精细化调节。
记录对象的方法要求回收器在追踪阶段扫描对象内部的每个指针域,进而才能找到它们所引用的尚未得到追踪的对象。
一种混合式解决方案是以对象为粒度来记录数组,而以指针域为粒度来记录纯对象,因为当数组中的一个域得到更新时,其他域通常也会得到更新。也可使用完全相反的策略,即以指针域为粒度来记录数组(以避免对整个数组进行扫描),而以对象为粒度来记录纯对象(纯对象通常比较小)。
对于数组而言,还可以只记录数组的一部分,这一策略与 卡标记(card mark) 策略十分类似,不同之处在于其依照数组索引而非数组域在虚拟内存中的地址进行对齐。究竟应当记录对象还是记录域,还取决于赋值器可以获取到哪些信息:
如果写操作既可以获取对象的地址又可以获取指针域的地址,则其可以任意选择一种
如果写屏障只能获取被写入域的地址,则计算其所属对象的地址可能会引入额外的开销。
Hosking 等 在某一解释型Smalltalk系统中解决了这一难题,它们的策略是在顺序存储缓冲区中同时记录对象以及域的地址。
卡表(card table) 技术将堆在逻辑上划分为较小且固定大小的卡。
该方案以卡为粒度来记录指针的修改操作,其记录方式通常是在卡表中设置一个标记字节。卡标记不仅可以对应被修改的域,也可以对应被修改的对象(两类信息可以对应不同的卡)。在回收阶段,回收器必须先找到所有与待回收分代相关的脏卡,然后找出其中记录的所有回收相关指针。卡表的记录方式(记录对象还是记录域)会影响查找过程的性能。
比卡表的粒度更粗的记录方式是以虚拟内存页为单元,其优点在于可以借助于硬件与操作系统的支持来实现写屏障,从而不会给赋值器带来任何直接负担,但与卡表类似,回收器的工作负担则会加重。与卡表的不同之处在于,由于操作系统不可能获取对象的布局信息,因而页标记方案通常只能对应被修改的指针域,而无法获取其所属的对象。
允许重复条目的好处在于可以降低赋值器的去重检测开销,但代价是增大了记忆集的大小以及回收器处理重复条目的开销。
卡表和页标记技术是通过在表中设置标记位或者标记字节的方式来进行标记的,因而其可以天然实现去重。
如果使用记录对象的方式,也可以通过标记对象的方式实现去重,例如通过对象头部中的一个标记位来记录其是否已经添加到日志中,但如果以指针域为记录粒度则无法通过这一方式进行简单去重。
尽管该策略可以降低记忆集的空间大小,但其需要赋值器执行一次额外的判断逻辑以及一次额外的写操作。
如果不允许记忆集中出现重复对象,则记忆集的实现必须是真正的 集合(set) 而非 多集合(multiset) 。
综上所述,如果使用卡表或者基于页的记录策略,则回收器的扫描开销取决于脏卡或者脏页的数量。
如果允许记忆集中出现重复条目,则回收器的开销将取决于指针写操作的数量,而如果不允许重复,则回收器的开销取决于被修改的指针域的数量。不论对于哪种情况,过滤掉回收无关指针都会减少回收器扫描根集合的开销。记忆集的实现方式包括哈希表、顺序存储缓冲区、卡表、虚拟内存机制与硬件支持,我们将逐一进行介绍。
如果对象头部中没有足够的空间来记录其是否已经添加到记忆集,需要通过集合来记录对象。我们进一步希望向记忆集中增加条目的操作可以很快完成,最好是在常数时间内。哈希表即是满足这些条件的实现方案之一。
在Hosking 等 的多分代内存管理工具包中,他们给出了一种基于线性 散列环状哈希表(circular hash table) 的记忆集实现方案,并将其应用在一种Smalltalk解释器中,该解释器将栈帧保存在堆的第0分代的第0阶中。
具体而言,每个分代都会对应一个独立的记忆集,且记忆集中既可以记录对象,也可以记录域。其哈希表基于一个包含2i+k2^i+k2i+k个元素的数组实现(k = 2),它们将地址映射为一个i位的哈希值(从对象的中间几位中获取),并以此作为该地址在数组中的索引。
如果该索引对应的位置为空,则将该对象的地址或域保存在该索引位置,否则将在后续的k个位置中查找可用位置(此时并非环状查找,正因如此,数组的大小才是2+k)。如果依然查找失败,则对数组进行环状查找。
为减轻记录指针的工作量,写屏障首先过滤掉所有针对第0代对象的写操作以及所有新—新指针(即从新生对象指向新生对象的指针)的创建。另外,写屏障会将所有回收相关指针添加到一个单独的“草稿”记忆集中,而非直接将其添加到目标分代所对应的记忆集。
该策略不会占用赋值器的时间来判断回收相关指针究竟属于那个记忆集,因而其可能更加适合多线程环境,除此之外,为每个处理器维护“草稿”记忆集也可以避免潜在的冲突问题,因为线程安全哈希表在运行时可能会引入较大开销。
Hosking 等使用17条内联MIPS指令来实现写屏障的快速路径,其中包括更新记忆集的相关调用。
即使对于MIPS这种寄存器较多的架构,这一方案的开销也相对较高。
在回收阶段,来自某一分代的根要么位于该分代对应的记忆集中,要么位于“草稿”记忆集中。回收器可以将分代所对应的记忆集中的回收相关指针重新散列到“草稿”记忆集中,从而完成去重,然后再将“草稿”记忆集中的所有回收相关指针添加到合适的记忆集中。
Garthwaite在其火车回收算法的实现中也使用了哈希表。
其哈希表的操作一般是插入以及迭代,因而其使用 开放定址法(open addressing) 来解决冲突问题。由于哈希表中经常会记录相邻地址,所以其舍弃了会将相邻地址映射到哈希表中相邻槽的线性定址法(即简单的地址模N,N为哈希表的大小),取而代之的是通用的哈希函数。
上一篇:ip 地址分类说明