文章目录
十一、运行时接口
11.1 对象分配接口
阶段
-
分配一块大小合适的、符合字节对齐要求的内存单元,这一工作是由内存管理器的分配子系统完成的。
-
系统级初始化(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中的所有操作内联,并且在内存耗尽时唤起回收器。在创建新对象时,分配器使用顺序分配来获取内存,其实现简单,且初始化过程通常只需要使用已经计算好的值来填充对象的头部以及其他域。这是编译器与特定分配算法(以及回收算法)紧密关联的一个案例。
函数式初始化过程具有两个显著的优点:
- 它不仅可以确保完成对象的初始化,而且其初始化代码对于回收器而言属于原子操作
- 初始化过程中的写操作可以避免某些写屏障的引入,特别是在分代式回收器中,正在进行初始化的对象必然会比其所引用的其他对象都要年轻,因而初始化过程可以忽略分代间写屏障。但值得注意的是,这一结论在Java的构造函数中通常不成立。
语言级别的对象分配需求最终都会调用内存分配子过程,某些编译器会将这一过程内联,并完成阶段1的全部操作以及阶段2的部分或全部操作。
分配过程需要满足的一个关键要求是:
- 阶段1和阶段2中的所有操作对于其他线程以及回收器都应当是原子化的,只有这样才能确保系统的其他模块不会访问到未经系统初始化的对象。
如果我们对分配器接口(阶段1)进行深入思考便会发现,3个阶段之间的工作划分会存在多种可能的组合方式。在分配过程中需要考虑的参数如下。
-
待分配空间大小 ,通常以字节为单位,也可能以字或者其他粒度为单位。当需要分配数组时,分配接口可以将元素大小以及元素个数作为独立的输入参数。
-
字节对齐要求,分配器通常会以一种默认的方式进行字节对齐,但调用者也可以要求更严格的字节对齐方式。这些要求可能包括必须以2的整数次幂对齐(如按照字、双字、四字等进行对齐),或者在此基础上增加一个偏移量(例如在四字对齐的基础上偏移一个字)。
-
待分配对象的类别(kind) ,例如,诸如Java等托管运行时语言通常会将数组与非数组对象进行区分,某些系统会将不包含指针的对象与其他对象进行区分,还有一些系统会将包含可执行代码的对象与不包含可执行代码的对象区分对待。简而言之,任何需要分配器特殊对待的需求都要在分配接口中得到体现。
-
待分配对象的具体类型(type),即编程语言所关心的类型。与“类别”不同,分配器通常不需要关注对象的“类型”,但却会通过“类型”来初始化对象。将这一信息传递给分配子过程不仅可以简化阶段2的原子化实现(即将这一任务转移到阶段1),而且可以避免在每个分配位置上引入额外的指令,进而减少代码体积。
分配接口究竟需要支持上述各种参数中的哪些,这在一定程度上取决于其所服务的编程语言。我们还可以在分配接口中传递一些冗余参数以避免运行时的额外计算。
分配接口的一种实现策略是提供一个全功能型分配函数,该接口支持众多的参数并且可以对所有情况进行处理,而为了加速分配以及精简参数,我们也可以为不同类别的对象定制不同的分配接口。
以Java为例,定制化的分配接口可以分为以下几种:
- 纯对象(非数组)的分配
- bytel/boolen数组(元素为1字节)的分配
- short/char数组(元素为2字节)的分配
- int/float数组(元素为4字节)的分配
- 指针数组以及long/double数组(元素为8字节)的分配
除此之外还需考虑系统内部对象的分配接口,例如表示类型的对象、方法分派表、方法代码等,具体的分配方式取决于是否要将其置于可回收堆中,即使它们不从托管堆中分配,系统仍需为其提供特殊接口,以从显式释放的分配器中进行分配。
阶段1完成后,分配器可以通过如下几个 后置条件(post-condition) 来检测该阶段的执行是否成功。
-
已分配内存单元满足预定的大小以及字节对齐要求,但此时该内存单元还不能被赋值器访问。
-
已分配内存单元已完成清零,这可以确保程序不会将内存单元中原有的指针或者非指针数据误认为是有效的引用。零是非常好的一个值,对于指针而言,零值表示空指针,而对于大多数类型而言,零值都是平常的、合法的值。某些语言(如Java)需要通过清零或者其他类似的方式来确保安全类型的安全性。在调试系统中,将未分配的内存设置成特殊的非零值十分有用,例如 0xdeadbeef或者0xcafebabe,其字面意思就是表示其当前所处的状态。
-
内存单元已被赋予调用者所要求的类型。当然这一过程只有当调用者将类型信息传给分配器时才需考虑。与最小后置条件(即该条款中的第一条)相比,此处的区别在于分配器会填充对象的头部。
-
确保对象的完全类型安全性。这不仅涉及清零行为,而且还涉及填充对象头部的行为。这一步完成后,对象并未达到完整初始化的标准,因为此时对象中的每个域均只是安全的、平常的、默认的零值,而应用程序通常要求将至少一个域初始化到非默认的值。
-
确保对象完全初始化。这通常要求调用者在分配接口中传递所有的初值,因而这一要求并不普遍。一个较好的例子是Lisp语言中的cons 函数,该函数的调用相当普遍,因而有理由为其提供单独的分配函数,以加速并简化其分配接口。
究竟最合适的后置条件是哪一个?
某些后置条件(如清零)取决于编程语言的相关语义,同样还有一些后置条件取决于其所处环境的并发程度,以及对象可能会以何种方式从其诞生的线程中“逃逸”(从而成为其他线程或者回收器可达的对象)。一般来说,并发程度越高、逃逸情况越普遍,后置条件的要求就越高。
下面我们来考虑分配器无法立即满足分配要求时应当如何处理。
在大多数系统中,我们希望在分配子过程内部调用垃圾回收,并向调用者隐藏这一事实。此时调用者几乎不需要做任何事情,同时也可以避免在每个分配位置上引人重试。
然而,我们也可以将大多数情况下的快速路径(即分配成功的情况)进行内联,同时将回收—重试这一函数放在内联代码之外。如果我们将阶段1的代码内联,则阶段1和阶段2将不存在明显的分界线,但整个代码序列必须高效且原子化地实现。后续我将介绍赋值器和回收器之间的握手机制,其中便包括这一原子化要求的具体实现。在实现分配过程的原子化之后,我们便可将分配过程看作是仅有赋值器参与的行为。
11.1.1 分配过程的加速
关键技术之一是将一般情况下的代码(即“快速路径”)内联,同时将较少执行的、处理更复杂情况的“慢速路径”作为函数调用,而具体如何进行选择就需要在合适的负载下精心地进行比较测量。
快速路径(fast path): 是指在一个程序中比起一般路径,有更短 指令路径长(Instruction path length) 的路径。有效的快速路径会在处理最常出现的的情形上比一般路径更有效率,让一般路径处理特殊情形、边角情形、错误处理与其它反常状况
顺序分配显而易见的优点便是实现简单,其一般情况下的代码序列较短。
如果处理器的寄存器数量足够多,则系统甚至可以专门使用一个寄存器来保存 阶跃指针(bump pointer) ,同时再使用一个寄存器来保存堆地址上限,此时典型的代码序列可能会是:
- 将阶跃指针复制给结果寄存器、给阶跃指针增加待分配空间的大小、判断阶跃指针是否超出堆地址上限、在结果为真时调用慢速路径。
需要注意的是,只有当使用线程本地顺序分配时,才能将阶跃指针保存在寄存器中。某些ML 和Haskell 进一步将一段代码序列中的多个分配请求合并成一个较大的请求,使得只需要进行一次地址上限判断与分支。类似的技术也可以用于其他单入口多出口的代码序列,即一次性分配所有可能执行路径下最大的内存需求,或者仅在开始执行代码序列时使用该值来做基本的地址上限判断。
尽管顺序分配几乎必然会比空闲链表分配要快,但如果借助于部分内联以及优化,分区适应分配也可以十分高效。如果我们可以静态计算出对应的空间大小分级,并且使用寄存器来保存空闲链表数组的地址,此时的分配过程将是:
- 加载对应空闲链表的头指针,判断其是否为零,如果为零则调用慢速路径,加载下一个指针,将链表头指针设置为下一个指针。
在多线程系统中,最后一步操作可能需要原子化,即使用compareAndSwap操作并在失败时进行重试。另外也可以为每个线程提供专属的空闲链表序列,并独立对其进行回收。
11.1.2 清零
为确保安全,某些系统要求将其空闲内存设置为指定的值,该值通常是零,也可能是其他一些特殊的值(一般是为了调试)。仅提供最基本分配函数的系统(例如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所描述的预取技术能有多少效果,或者其至少需要通过很精细的调整才能确定合适的预取范围。如果是在调试环境下,将空闲内存清零或者向其中写入特殊值的操作应当在节点释放后立即执行,这样我们便可以在尽可能大的时间范围内捕获错误。
11.2 指针查找
回收器需要通过指针查找来确定对象的可达性。某些回收算法需要精确掌握程序中所有指针的信息。特别是对于移动式回收器而言,如果需要将某一对象从地址x移动到新地址x’,则必须将所有指向x的指针更新到x’。
安全回收某一对象的前提条件是程序不会再次访问该对象,但反之则不成立:将程序不再使用的对象保留并不存在安全问题,尽管这可能降低空间利用率(不可否认,如果程序无法获取可用堆内存,则可能崩溃)。
因此,回收器可以保守地认为所有引用均指向了不可移动的对象,但不应武断地移动其不能确定是否可以移动的对象。基本的引用计数算法便是保守式的。使用保守式回收的另一个原因在于回收器缺乏精确的指针信息,因此它可能会将某一非指针的值当作指针,特别是当该值看起来像是引用了某一对象时。
11.2.1 保守式指针查找
保守式指针查找的技术基础是将每个与指针大小相同的已对齐字节序列当作可能是指针的值,即 模糊指针(ambiguous pointer) 。
回收器可以掌握组成堆的内存区域集合,甚至知道这些区域中哪些部分已经分配出去,因而它可以快速排除掉必然不是指针的值。
为确保回收过程的性能,鉴别指针的工作必须十分高效。这一过程通常包含两个阶段。
-
回收器首先过滤掉未指向任何堆空间地址的值。如果堆空间本身就是一大块连续内存,则这一过程可以通过简单的地址判断来实现,另外也可以根据模糊指针的高位地址计算出其所对应的内存块编号,并通过一个堆内存块索引表进行查找。
-
回收器需要鉴别出模糊指针所指向的地址是否真正被分配出去,这一过程可以借助一个记录有已分配内存颗粒的位图来完成。
例如,Boehm-Demers-Weiser 保守式回收器使用块结构堆,且其每个内存块仅用于分配一种大小的内存单元。内存单元的大小保存在内存块所关联的元数据中,而其状态(已分配或空闲)则反映在位图中。
对于一个模糊指针,回收器首先使用堆边界来对其进行判定,然后再判断其所引用的内存块是否已被分配,如果判断成立,则进一步检测其所指向的内存单元是否已被分配。
只有最后一步的判断结果为真,回收器才可以对模糊指针的目标对象进行标记。图11.1展示了对模糊指针进行处理的全部过程,其每次判断大约需要30个RISC指令(精简指令集)。
某些编程语言要求指针所指向的地址是其引用对象的第一个字,或者在此基础上增加一些标准的偏移量(例如数个头部字之后,参见图7.2)。借助于这一规则,回收器便可忽略 内部指针(interior pointer) 而只需关注 正规指针(canonical pointer) 。不论是否需要支持内部指针,保守式回收器的设计均比较简单,Boehm-Demers-Weiser保守式回收器可以通过配置来选择是否需要支持内部指针。
如果在C语言中使用保守式回收器,存在一个细节问题需要关注:
- C语言允许内部指针指向某一数组范围之外的第一个元素,此时保守式回收器要么必须维护两个对象,要么必须为数组多分配一个字以避免出现歧义。
显式内存释放系统可以在对象之间插入额外的头部来解决这一问题。编译器的优化可能会“破坏”指针,从而引发回收器的误判。
某些非指针的值可能导致回收器错误地保留一个实际上并不可达的对象,因而Boehm 设计了 黑名单(black-listing) 机制来避免在堆中使用被这些非指针值所“指向”的虚拟地址空间。
特别地,如果回收器断定某个模糊指针指向了未分配的内存块,可以将该内存块加入到黑名单,但必须确保永远不在其中进行分配,否则后续的追踪过程便可能将伪指针误认为真正的指针。
回收器同时还支持在特定内存块中仅分配不包含指针的对象(如位图),这一区分策略不仅可以提升回收效率(因为无需扫描对象的内容),同时也可以避免昂贵的黑名单查询开销(即天然避免了将位图中的数据当作指针)。
回收器还可以进一步区分非法指针是否可能是内部指针,并据此改进黑名单(如果不允许使用内部指针,则堆空间中不可能存在内部指针)。
- 当允许使用内部指针时,黑名单中所记录的内存块在任何情况下都不得使用
- 而当不允许使用内部指针时,黑名单所记录的内存块可以分配不包含指针的小对象(这通常不会造成太多浪费)
在赋值器首次执行堆分配之前,回收器先发起一次回收以初始化黑名单。分配器通常也会避免使用地址末尾包含太多零的内存块,因为栈中的非指针数据通常可能会“引用”这些地址。
11.2.2 使用带标签值进行精确指针查找
某些系统(特别是基于动态类型的系统)支持为每个值附带一个特殊的 标签(tag) ,以表示其类型。标签的基本实现策略有二:
- 位窃取(bit stealing)
- 页簇(big bags of pages)
位窃取的方法需要在每个值中预留出一个或者多个位(通常是字的最高或最低几位),同时要求可能包含指针的对象必须以 面向字(word-oriented) 的方式进行布局。
例如,对于一台依照字节进行寻址且每个字包含四个字节的机器,如果我们要求每个对象都必须依照字来进行对齐,则指针的最低两位必然都是零,因而我们可以将这两位用作标签。我们亦可使用其他值来表示整数,例如可以要求所有用于表示整数的值最低位都必须是1,同时以高31位来表示整数的具体值(尽管这一方案确实减少了我们可以直接表达的整数范围)。
为确保堆的可解析性(参见第7.6节),我们可以要求堆中对象的第一个字必须以二进制 1 0 作为低两位。
表11.1介绍了一种标签编码方案,它与Smalltalk中真正使用的编码方案类似。
可能会有读者对带标签整数的处理效率提出挑战,但对于现代流水线处理器而言,这几乎不会成为问题,一次高速缓存不命中所造成的延迟便可轻易掩盖掉这一开销。
为支持使用带标签整数的动态类型语言,SPARC架构提供了专门的指令来对带标签整数直接进行加减操作,且这些指令均可以判断操作是否发生溢出。某些版本甚至还可以针对操作溢出或者被操作数低两位不为零的情况设置陷阱。
基于SPARC架构我们可以使用表11.2所示的标签编码方案。
该方案要求我们对指针所代表的引用进行调整,在大多数情况下,这一调整操作可以通过在加载和存储指令中引入一个偏移量来实现,但对数组的访问是一个例外:
- 在访问数组中的某个元素时,我们需要根据数组索引号以及这一额外的偏移量来计算其最终的访问地址。
真实硬件架构对带标签整数的支持进一步说明了位窃取方案的合理性:
- Motorola MC68000处理器曾经使用过这一编码方案,该处理器包含一条加载指令,该指令可以通过一个基址寄存器、一个其他寄存器外加一个立即数来构造有效地址,因此在MC68000处理器上使用该编码方案不存在太大的额外开销。
页簇方案是将标签/类型信息与对象所在的内存块相关联,因此其关联关系通常是动态的且需要额外的查表操作。
该方案的不足之处在于标签/类型信息的获取需要额外的内存加载操作,但其优势在于整数以及其他原生类型可以完全使用其原本所占据的空间。
该方案意味着系统中存在一组内存块专门用于保存整数,同时还有一组专门的内存块用于保存浮点数等。由于这些纯值不可能发生变化,因而在分配新对象时可能需要进行哈希查找以避免创建已经存在的对象。
11.2.3 对象中的精确指针查找
如果不使用带标签值,那么要找出对象中所包含的指针,必然需要知道对象的类型(至少需要知道对象中的哪些域是指针域)。
对于面向对象语言(确切地讲,是使用 动态方法分派(dynamic method dispatch) 机制的语言),指向对象的指针并不能完全反映运行时对象的类型,因而我们需要将对象的类型信息与对象本身关联,其实现方式通常是在对象头部增加一个指向其类型信息的指针域。
面向对象语言通常会为每一种类型生成方法分派向量,并在对象头部增加一个指向其方法分派向量的指针,因此编程语言便可将对象的类型信息保存在方法分派向量中,或者从方法分派向量可达的其他位置。
如此一来,回收器或者运行时系统中其他依赖对象类型信息的模块(如Java 的反射机制)便可快速获取对象的类型信息。
回收器需要的是一个能够反映对象内部指针域位置的表,该表的实现方式有二:
- 使用与标记位图相似的 位向量(bit vector)
- 使用一个向量来记录指针域在对象中的偏移量
Huang等人 通过调整偏移向量中元素的顺序来获取不同的追踪顺序,复制式回收器可以据此按照不同的顺序排列存活对象,进而提升高速缓存性能。
这一调整操作需在运行时谨慎执行(在万物静止式回收器中)。将包含指针的对象与不包含指针的对象进行 分区(partition) ,在某些方面来说是比查表更加简单的一种指针识别方法。
该策略在某些语言和系统的设计中可以直接使用,但在其他语言中则可能遇到问题。例如在ML中,对象可以具有 多态性(polymorphic) 。
假设某一对象在某些情况下将某个域当作指针,在另一些情况下又将该域当作非指针值,那么如果系统生成一段适用于所有多态情况的代码,则根本无法对两种情况进行区分。
对于允许派生类复用基类代码的面向对象系统,子类的域将位于基类所有域之后,这将必然导致指针域与非指针域的混合。
这一问题的一种解决方案是将两种不同的域沿着不同的方向排列:
- 指针域沿着偏移量为负的方向排列,而非指针域沿着偏移量为正的方向排列,该方案也称为 双向对象布局(bidirectional object layout) 。
在以字节方式寻址的机器上,对于按照字来对齐的对象,我们可以将对象头部第一个字的最低位设置为1,而按照字进行对齐又可以确保指针域的最低两位必然全为零,从而保证了堆的可解析性。在实际应用中,扁平排列方式通常不会成为问题。
某些系统会针对每种类型生成面向对象风格的代码,从而实现对象的追踪、复制等。我们可以将查表方式看作是类型解释器,而面向对象代码的方式则可以看作是对应的已编译代码。
Thomas在其设计中提出了一种十分有价值的思路,即当复制一个 闭包 (closure) 时,可以针对闭包的 环境(environment) 定制专门的复制函数,该函数会避免复制那些在特定函数中不会使用的环境变量。
该策略不仅可以在复制环境变量时节省空间,更重要的是可以避免复制环境中已经不再使用的部分。
在托管语言中,我们可以利用面向对象方法的间接调用过程实现特殊的回收相关操作。在Cheadle等 的复制式回收器中,他们通过动态改变对象的函数指针来实现读屏障的 自我删除(self-erase) ,这与Cheadle等 在 Glasgow Haskell编译器(GHC)中所使用的技术类似。
该系统使用相似的技术实现了多种版本的栈屏障,除此之外还基于该技术实现了一个在更新待计算 值(thunk) 时使用的分代间写屏障。能够更新闭包环境的系统存在一项优势,即它可以对现有对象进行收缩,而为确保堆可解析性,系统又需要在收缩完成之后在堆中插入一个伪对象。
相应的,系统也可能需要扩展某一对象,此时系统便会用一个中转对象覆盖原有对象,同时在中转对象中保存指向扩展对象的指针,后续的回收过程也可以将中转对象优化掉。回收器也可以额外为赋值器执行一些计算,例如对部分参数已经完成计算的“知名”函数提早执行计算,返回链表首个元素这一函数即为“知名”函数的一个例子。
从原则上讲,静态类型语言可以省略对象头部并节省空间。Appel 和 Goldberg 描述了如何在ML语言中实现这一要求。在他们的解决方案中,回收器只需要了解根的类型信息(因为回收的追踪过程必须有一个起点)。
11.2.4 全局根中的精确指针查找
全局根中的精确指针查找相对来说较为简单,在对象中查找指针的技术大多都可以在这里复用。
在全局根这一方面,不同语言之间的主要差别在于全局根集合是否可以动态增长。动态代码加载是导致全局根集增长的原因之一。
某些系统在启动时便包含一个基本的对象集合,某些Lisp以及某些Java系统在启动时(特别是在交互式环境中启动时)便会包含一个基本的系统“映像”,也称为 引导映像(boot image) ,其中包括众多的类系/函数及其对象实例。
程序在执行过程中可能会对引导映像进行局部修改,从而导致引导对象引用了运行时创建的新对象,此时回收器就必须把这些引导对象中的域也当作程序的根。
在程序的运行过程中,引导对象也可能成为垃圾,因此偶尔对引导映像进行追踪并找出其中的不可达对象也是一个不错的选择。是否需要关注引导映像通常取决于是否使用分代式回收策略,此时我们可以将引导映像看作是特殊的年老代对象。
11.2.5 栈与寄存器中的精确指针查找
在栈中精确查找指针的一种解决方案是将活动记录分配在堆中,正如Appel 所建议的那样,也使用同样的方案,且Miller和Rozas 再次论证了该方案的可行性。
某些语言实现使用与管理堆相同的方式来管理栈帧,从而达到了一箭双雕的效果,例如 Glasgow Haskell编译器 以及 Non-StopHaskell。语言的实现者也可以专门为回收器提供一些关于栈上内容的相关指引,例如Henderson 在 Mercury语言中便以这种方式来处理用户生成的C代码,Baker 等 在为Java进行实时性改造时也使用了类似的技术。
可参考-什么是栈帧
但是,出于多方面的效率因素,大多数语言都会对栈帧进行特殊处理以获取最佳的运行时性能,此时回收器的实现者便需要考虑以下3个问题:
-
如何在栈中查找帧(活动记录)
-
如何在帧中查找指针
-
如何处理以约定方式传递的参数、返回值,以及寄存器中值的保存与恢复
在大多数系统中,需要在栈中查找帧的不仅仅只有回收器,诸如异常处理与恢复等其他机制也需要对栈进行“解析”,更不用说调试环境下至关重要的栈检查功能了。同时,栈的可解析性 也是某些系统(如Smalltalk)自身的要求。
从开发者的角度来看,栈本身当然是十分简洁的,但在这个简洁的外表背后,真正的栈在实现上却是经过高度优化的,帧的布局通常也更加原始。
由于栈的可解析性通常十分有用,所以帧的布局管理通常需要支持这一点。
例如,在许多栈的设计实现中,每个帧中都会有一个域用于记录指向上一帧的动态链表指针,而其他各域均位于帧中固定偏移量的位置(此处的偏移量是相对于帧指针或者动态链表指针所指向的地址)。
许多系统中还会包含一个从函数返回地址到其所在函数的映射表,在非垃圾回收系统中该表通常只是调试信息表的一部分,但许多托管系统却需要在运行时访问该表,因此该表就必须成为程序代码的一部分(可以在启动时加载到程序,也可以在程序启动后生成),而不能仅作为辅助调试信息来使用。
为确保回收器可以精确地找出帧中的指针,系统可能需要为每个栈显式增加 栈映射( stack map) 信息。
这一元数据可以通过位图来实现,即通过位图中的位来记录帧中的哪些域包含指针。除此之外,系统也可以将帧划分为指针区和非指针区,此时元数据中所记录的便是两个区各自的大小。
需要注意的是
-
当栈帧已经存在但尚未完全初始化时,函数可能会需要插人额外的初始化指令,否则回收器在这一状态下进行栈扫描则可能遇到问题。
-
我们可能需要对帧初始化代码进行回收方面的仔细分析,同时也必须谨慎地使用push指令(如果机器支持的话)或者其他特殊的压栈方式。
当然,如果编译器可以将帧中的给定域固定当作指针或者非指针来使用,则帧扫描的实现便十分简单,此时所有的函数只需共享同一个映射表即可。
但是,单一栈映射方案通常不可行,如果使用该方案,则至少两种语言特性无法实现:
- 泛型/多态函数
- Java虚拟机的jsr指令
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) 要求:
- 某些寄存器遵守 调用者保存(caller-save) 协议:如果调用者想要在调用过程完成后继续使用某一寄存器中的值,其必须在发起调用之前将该寄存器的值保存下来
- 一些寄存器会遵守 被调用者保存(callee-save) 协议:被调用者在使用某一寄存器之前必须先将其中的值保存下来,并确保在使用完成后将其恢复。
- 对于寄存器中的指针查找,调用者保存寄存器(caller-save register) 并不存在太大困难,因为调用者必然知道该寄存器所保存数据的类别,但被调用者保存寄存器中的值究竟是何种类别,则只有上层调用者(如果存在的话)才会知道。因此,被调用者无法确定一个尚未保存的被调用者寄存器是否包含指针,即使被调用者将该寄存器的值保存到帧的某一域中,同样无法确定该域是否包含指针。
许多系统都通过 栈展开(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()函数所执行的操作依次是:
- 将r1保存到槽1中
- 将变量2设置为指向对象p的指针
- 将变量3赋值为75。
- 然后main()函数将调用函数f(),在执行调用之前r1的值为p,r2的值为784,函数返回地址为main() + 52。
- 函数f()首先保存返回地址
- 然后再将r2保存在槽1
- 将r1保存在槽2
- 将变量3赋值为-13
- 将变量4赋值为指向对象q的指针。
- 然后函数f()将调用函数g(),在执行调用之前r1的值是指向r的指针,r2的值为17,返回地址为f()+178。
- 函数g()保存返回地址
- 将r2保存在槽1
- 将变量2设置为对象r的引用
- 将变量3赋值为-7
- 将变量4设置为指向对象s的指针。
图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 变种算法、栈映射压缩 此处略。
11.2.6 代码中的精确指针查找
程序代码中可能会内嵌堆中对象的引用,特别是那些允许运行时加载代码或者动态生成代码的托管运行时系统。即使是对于事先编译好的代码,其所引用的静态/全局数据仍有可能在程序启动时从刚刚完成初始化的堆中分配。
代码中的精确指针查找存在以下几个难点:
-
从代码中分辨出嵌入其中的数据通常较为困难,甚至不可能。
-
对于“不合作”的编译器所生成的代码,几乎不可能将其中的非指针数据与可能指向堆中对象的指针进行区分。
-
当指针被嵌入到指令中时,指针本身可能会被割裂成为好几小段。MIPS处理器将32位静态指针值加载到寄存器中通常需要使用load-upper-immediate指令,该指令首先将一个16位的立即数加载到32位寄存器的高16位并将低16位清零,然后再使用or-immediate指令将另一个16位的立即数加载到寄存器的低16位。其他指令集也可能会出现类似的代码序列。此处的指针值算是一种特殊的派生指针(见11.2.8节)。
-
内嵌指针值可能并非直接指向其目标对象,具体可以参见我们对内部指针(见11.2.7节)以及派生指针(见11.2.8节)的讨论。
-
某些情况下我们可以通过代码反汇编来找出内嵌指针,但如果每次回收都需要反汇编全部代码并处理其中的根,则可能引入巨大的开销。当然,由于程序不会修改这些内嵌指针,因此回收器可以缓存其位置以提高效率。
-
更加通用的解决方案是由编译器生成一个额外的表来记录内嵌指针在代码中的位置。
-
某些系统简单地禁用内嵌指针,从而避免了这一问题。使用这一策略可能存在的问题是,在不同目标架构、不同的编译策略以及不同的访问特征下,代码的性能可能会有所不同。
目标对象可移动的情况。
如果内嵌指针的目标对象发生移动,则回收器必须更新内嵌指针。
-
更新内嵌指针的困难之一在于,出于安全性或者保密性原因,程序代码段可能是只读的,因此回收器可能不得不临时修改代码区的保护策略(如果可能的话),但这一操作可能会引发较大的系统调用开销。另一种策略则是禁止内嵌指针引用可移动对象。
-
更新内嵌指针的另一个困难之处在于,对内存中代码的修改通常并不会使代码在其他指令 高速缓存(instruction cache) 中的副本失效或者强制更新,为此可能要求所有处理器将受影响的指令高速缓存行失效。
在某些机器中,回收器在将指令高速缓存行失效之后可能还需要执行一个特殊的同步指令,目的是确保未来的指令加载操作发生在失效操作之后。
另外,在将指令高速缓存行失效之前,回收器可能还需要将被修改的数据高速缓存行强制刷新到内存中(其中所保存的是回收器所修改的代码),并且需要使用同步操作来确保这一操作执行完毕。此处的实现细节与具体的硬件架构相关。
代码可移动的情况。
一种特殊的情况是回收器可能会移动程序代码。
-
此时回收器不仅要考虑目标对象可移动情况下的所有问题,更要考虑对栈以及寄存器中所保存的返回地址的修正,因为回收器可能已经移动了返回地址所在代码。
-
回收器必须将所有与代码新地址相关的指令高速缓存行失效,并且小心地执行上文列出的所有相关操作。更深层次的问题在于,如果连回收器自己的代码都是可移动的,那么处理起来将更加复杂。
-
在并发回收器中进行代码移动将是一件极为困难的任务,此时回收器要么必须挂起所有线程,要么只能采用更加复杂的方式,即先确保新老代码都可以被线程使用,然后在一段时间内将所有线程都迁移到新代码,最后在确保所有线程都迁移完成的前提下将老代码所占用的空间回收。
11.2.7 内部指针的处理
所谓内部指针,即指向对象内部某一地址,但其所指向地址并非对象的标准引用的指针。更加准确地讲,我们可以把对象看作是一组与其他对象不重叠的内存地址集合,而内部指针所指向的正是该集合中的某一地址。
回顾图7.2 我们可以发现,标准的对象可能并不会与其任何一个内部指针相等。另外,对象真正占据的空间也可能会比开发者可见数据所需的空间要大。例如,C语言允许指针指向数组末尾之外的数据,但对于数组而言这依然是一个合法的内部引用。
在某些系统中,语言级别的对象可能是由数个不连续的内存片段组成,但在描述内部指针(以及派生指针)时,我们这里的“对象”仅仅是指位于一块连续内存之上的(语言级别)对象。
回收器在处理内部指针时遇到的主要问题是判定其究竟指向了哪个对象,即如何通过内部指针的值来反推出其目标对象的标准引用。可行的方案有以下几种:
-
使用一张表来记录每个对象的起始地址。
- 系统可以通过一个数组来维护对象的起始地址,数组可以使用两级映射的方式进行组织,即类似于Tarditi 记录代码中回收点时所使用的策略(参见11.2节)。
- 另一种策略是使用位图,位图中每一位均对应堆中一个内存颗粒(即内存分配单位),同时将对象首地址所在内存颗粒对应的位设置为1。该方案可能适用于所有的分配器以及回收器。
-
如果系统支持堆的可解析性(参见7.6节),则回收器可以通过堆扫描来确定内部指针所指向的地址究竟落在哪个对象内部。
- 如果每次都从堆的起始地址开始查找未免开销过大,因此系统通常会为堆中每个k字节的内存块记录其内部首个(或者最后一个)对象的起始地址,为了方便和确保计算效率,k通常是2的整数次幂。回收器便可根据这一信息在内部指针所指向的内存块中进行查找,必要情况下可能需要从上一个内存块开始查找。使用额外的表会引人空间开销,而堆解析又会引入时间开销,回收器需要在这两者之间进行适当的取舍。(11.8节所介绍的跨越映射)。
-
如果使用页簇分配策略,则回收器可以通过内部指针所指向的内存块的元数据来获取对象的大小,同时也可计算出目标地址在内存块中的偏移量(将目标地址与合适的掩码进行与操作,获取该地址的低位),根据对象的大小将偏移量向下圆整,便可得到对象的首地址。
我们假设对于任意一个内部指针,回收器都能计算出其目标对象的标准引用。当某一内部指针的目标对象发生移动时(例如在复制式回收器中),回收器必须同时更新该内部指针,并且确保其目标地址在新对象中的相对位置与移动之前完全一致。另外,系统也可能会将对象 钉住(pin) 。
如果系统允许使用内部指针,则由此带来的主要问题是:对内部指针的处理需要花费额外的时间和空间。如果内部指针数量相对较少,且可以与 正规指针(tidy pointer) (即指向对象标准引用位置的指针)进行区分,则处理内部指针的时间开销可能不会太大。
但是,如果要彻底支持内部指针,则可能需要引入额外的表(尽管具体的回收器通常会包含一些必要的表或者元数据),进而增大了系统的空间开销,同时维护该表也会引入额外的时间开销。
代码中的返回地址是一种特殊的内部指针,尽管它们并没有什么特殊的处理难度,但基于多种原因,回收器在查找某个返回地址所对应的函数时,所用的表通常会不同于其他对象。
11.2.8 派生指针的处理
Diwan等 将派生指针定义为:
- 对一个或者多个指针进行算数运算所得到的指针。
内部指针是派生指针的一个特例,它可以表示成 p + i 或者 p + c 这种简单形式,其中p为指针,i为动态计算出的整数偏移量,c为静态常量。
由于内部指针所指向的地址必然位于对象p所覆盖的内存地址中的一个,所以其处理起来相对简单,但派生指针的形式则可以更加一般化,例如:
- u p p e r k ( p ) upper_k(p) upperk(p)或者 l o w e r k ( p ) lower_k(p) lowerk(p),即指针p的高k位或者低k位。
- p ± c p \pm c p±c,但计算出的地址位于对象p之外。
- p − q p - q p−q,即两个对象之间的距离。
某些情况下,我们可以根据派生指针来反推正规指针(即指向标准引用地址的指针),例如派生指针 p + c 且 c 为编译期确定的常量。
我们通常都必须知道生成派生指针的基本表达式,尽管该表达式本身可能也是一个派生指针,但追根溯源,必然可以找到产生派生指针的正规指针。
在非移动式回收器中,回收器可以简单地将正规指针当作根进行处理。但需要注意的是,在垃圾回收时刻,即使派生指针依然存活,其目标对象的正规指针仍有可能被编译器的存活变量分析判定为死亡,因此编译器必须为每个派生指针保留至少一个正规指针,但 p ± c p \pm c p±c 这一情况属于例外,因为回收器通过一个编译期常量对派生指针进行调整,便可计算出其所对应的正规指针,该过程不需要依赖其他运行时数据。
在移动式回收器中,派生指针的处理则需要编译器的进一步支持:
- 为了记录每个派生指针是从哪个地址计算得出的,以及如何重建派生指针,编译器需要对栈映射进行扩展。
Diwan等 给出了处理形如 ∑ i p i − ∑ j q j + E \sum _i p_i - \sum _j q_j+E ∑ipi−∑jqj+E 的派生指针的通用解决方案,其中 p i p_i pi和 q j q_j qj是正规指针或者派生指针,E是一个与指针无关的表达式(即使 p i p_i pi或 q j q_j qj发生移动,该表达式也不会受到任何影响)。
其处理流程是:
- 先在派生指针的基础上减去 p i p_i pi然后加上 q j q_j qj,进而计算出E的值,然后执行移动,最后再根据移动之后的 p i ′ p'_i pi′和 q j ′ q'_j qj′,以及E计算出新的派生指针值。
Diwan等 指出,编译器的优化可能会给派生指针的处理带来一些额外的问题
- 死亡基准变量(dead base variables)
- 多派生指针指向相同的代码位置(导致回收器在处理某一派生指针时需要涉及更多的变量)
- 间接引用(变量的值被记录在引用链的某个中间位置)
- 等
为支持派生指针,编译器有时需要减少对代码的优化,但其影响通常较小。
11.3 对象表
基于赋值器性能以及空间开销的考虑,许多系统都使用直接指向对象的指针来表示引用。一种更加通用的方案是为每个对象赋予一个唯一标识,并通过某种映射机制来定位其具体数据的地址。
对于对象所占空间较大且可能较为持久,但底层硬件地址空间却相对较小的场景,这一技术具有一定的吸引力。本节我们关注的正是堆如何适应地址空间。
在上述场景中,对象表(object table) 是一种十分有用的解决方案,除此之外,对象表在许多其他系统中同样十分有用。
对象表通常是一个较为密集的数组,其中的每个条目引用一个对象。对象表可以仅包含指向对象数据的指针,也可以包含其他额外的状态信息。
为确保执行速度,对象的引用通常是其在对象表中的直接索引,或者指向其在对象表中对应条目的指针。如果使用直接索引,则回收器迁移对象表的工作便十分简单,但系统在访问具体对象时却必须先获取对象表的基址,然后再执行偏移,如果系统可以提供一个专门的寄存器来保存对象表的基址,则这一操作并不需要额外的指令。
显著优点:
- 可以简化堆的整理,即当需要移动某一对象时,回收器可以简单地移动对象并更新其在对象表中的对应条目。
为简化这一过程,对象内部应当隐含一个自引用域(或者指向其在对象表中对应条目的指针),据此,回收器便可通过对象的数据快速找到其在对象表中的对应条目。
在此基础上,标记—整理回收器可以采用传统的方式完成标记(需要通过对象表间接实现),然后简单地“挤出”垃圾对象,从而实现对象数据的滑动整理。回收器可以将对象表中的空闲条目以空闲链表的方式组织。
需要注意的是,将对象的标记位置于其在对象表的对应条目中的效率更高,这可以在检测或者设置标记位时节省一次内存访问操作。额外的标记位图也具有类似的优点。还可以将对象的其他元数据置于对象表中,例如指向其类型及大小信息的引用。
对象表本身也可以进行整理,例如使用3.1节所描述的双指针算法。也可以在整理对象数据的同时整理对象表,此时只需要进行一次对象数据遍历便可同时实现对象数据和对象表的整理。
如果编程语言允许使用内部指针或者派生指针,则对象表策略可能会存在问题,甚至会成为障碍。类似地,对象表也很难处理从外部代码指向堆中对象的引用,这一问题我们将在11.4节详述。
如果编程语言禁止内部指针,则不论是否使用对象表,语言的具体实现都不会因此受到任何语义上的影响,但是有一种语言特征或多或少都需要依赖对象表来保证其实现效率,即Smalltalk的 become:原语。该原语的作用是将两个对象的身份互换,如果使用对象表,则其实现起来相当简单,赋值器只需要将它们在对象表中的对应条目互换即可。如果没有对象表的支持,become:操作就可能需要对整个堆进行扫描。但即使不使用对象表,谨慎地使用become:操作也是可以接受的(Smalltalk通常使用become:操作来设置对象的新版本),毕竟直接引用的方式在大多数情况下都会比对象表更加高效。
11.4 来自外部代码的引用
某些语言或者系统允许托管环境之外的代码使用堆中分配的对象,一个典型的例子便是 Java原生接口(Java Native Interface) ,它允许C、C++或者其他语言所开发的代码访问Java堆中的对象。更加一般化地讲,几乎每种系统都需要支持输入/输出,这一过程几乎必然需要在操作系统和堆之间进行一定的数据交换。
如果系统需要支持外部代码和数据引用托管堆中的对象,那么将存在两个难点。
- 如果某一对象从外部代码可达,那么回收器如何才能正确地将其当作存活对象,并确保在外部代码的访问结束之前不会将该对象回收。
我们通常只需要在调用外部代码期间满足这一要求,因而可以在发起外部调用线程的栈中保留指向该对象的存活引用。但是,某些托管对象也可能会被外部代码长期使用,其可达范围也可能超出最初发起外部调用的函数。
基于这一原因,回收器通常会维护一个已注册对象表来记录此类对象。如果外部代码需要在当前调用完成之后继续使用某一对象,则其必须对该对象进行注册,同时当外部代码不再需要且未来也不会再使用该对象时,必须显式将其注销。回收器可以简单地将已注册对象表中的引用当作额外的根。
- 外部代码如何才能确定对象的地址(该问题只会在移动式回收器中出现)
某些实现接口会将具体对象与外部代码相隔离,后者只有借助于回收器所提供的渠道才能访问堆中对象。此类接口对移动式回收器的支持较好。回收器通常会将指针转化为句柄之后再交由外部代码使用,句柄中会包含堆中对象的真正引用,也可能包含其他一些托管数据。此处的句柄相当于是已注册对象表中的条目,同时也是回收的根。Java原生接口即采用这种方式实现外部调用。需要注意的是,句柄与对象表中的条目十分类似。
句柄不仅可以作为托管堆和非托管世界之间的一道桥梁,而且可以更好地适应移动式回收器,但并非所有的外部访问都可以遵从这一访问协议,特别是操作系统调用。
此时回收器就必须避免移动被外部代码所引用的对象。为此,回收器可能需要提供一个钉住接口,并提供 钉住(pin) 和 解钉(unpin) 操作。
当某一对象被钉住时,回收器将不会移动该对象,同时也意味着该 对象可达且不会被回收。
如果我们在分配对象时便知道该对象可能需要钉住,则可以直接将其分配到非移动空间中。文件流IO缓冲区便是以这种方式进行分配的。但程序通常很难事先判断哪个对象未来需要钉住,因此某些语言支持 pin 和 unpin 函数 以便开发者自主进行任何对象的钉住与解钉操作。
钉住操作在非移动式回收器中不会成为问题,但却会给移动式回收器造成一定不便,针对这一问题存在多种解决方案,每种方案各有优劣。
延迟回收,或者至少对包含被钉住对象的区域延迟回收。该方案实现简单,但却有可能在解钉之前耗尽内存。
如果应用程序需要钉住某一对象,且对象当前位于可移动区域中,则我们可以立即回收该对象所在的区域(以及其他必须同时回收的区域)并将其移动到非移动区域中。
该策略适用于钉住操作不频繁的场景,同时也适用于将新生代存活对象提升到非移动式成熟空间的回收器(例如分代回收器)。
对回收器进行扩展以便在回收时不移动被钉住的对象,但这会增加回收器的复杂度并可能引入新的效率问题。
我们下面将以基本的非分代复制式回收器为例来考虑如何对移动式回收器进行扩展,从而支持钉住对象。
为达到这一目的,回收器首先要能将已钉住对象与未钉住对象区分。
-
回收器依然可以复制并转发未钉住对象
-
对于被钉住对象,回收器只能追踪并更新其中指向被移动对象的指针,却不能移动该对象,回收器同时还必须记录其所发现的已钉住的对象。
-
当完成所有存活对象的复制之后,回收器不能简单地释放整个来源空间,而是只能释放已钉住对象之间的空隙。
此时回收所获得的不再是一块单独的、连续的空闲内存,而可能是数个较小的、不连续的空间集合,分配器可以将每段空间当作单独的顺序分配缓冲区来使用。
已钉住对象不可避免地会造成内存碎片,但在未来的回收过程中,一旦被钉住的对象得到解钉,由此造成的碎片便可消除。正如我们在10.3节看到的,某些主体非移动式回收器
也会采用类似的方案,即在存活对象间隙进行顺序分配。
钉住对象给移动式回收器引入的另一个难点在于:
- 即使对象已被钉住,回收器仍需对其扫描和更新,但在执行该操作的同时,外部代码可能也在访问该对象,进而导致竞争的出现。
为此,回收器不仅要将直接被外部代码引用的对象钉住,同时还可能需要钉住其所引用的其他对象。同样地,如果外部代码从某一对象开始遍历其他对象,或者仅判断/复制对象的引用而不关心其内部数据,回收器仍需将其钉住。
编程语言自身的特性或其具体实现也可能会依赖对象的钉住机制。
例如,如果编程语言允许将对象的域当作引用来传递,则栈中可能会出现指向对象内部域的引用。此时我们可以使用11.2.7节所描述的内部指针相关技术来移动包含被引用域的对象,但该技术的实现通常较为复杂,且正确处理内部指针的代码可能会难以维护。
因此某些语言实现通常会简单地将此类对象钉住,这便要求回收器能够简单高效地判定出哪些对象包含直接被其他对象(或者根)引用的域。
该方案可以轻易解决内部指针的处理问题,但却无法进一步拓展到更一般化的派生指针问题(参见11.2.8节)。
11.5 栈屏障
回收器可以使用增量式栈扫描策略,但也可以使用 栈屏障(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.6 安全回收点以及赋值器的挂起
我们在第11.2节提到,回收器需要知道哪些栈槽以及哪些寄存器包含指针;我们同时还提到,如果垃圾回收发生在同一函数的不同位置(即IP,也就是指令指针),这一信息通常会发生变化。
对于哪些位置可以进行垃圾回收,有两个问题需要关注:
- 回收器是否可以在某一IP处安全执行垃圾回收
- 如何控制栈映射表的大小(参见11.2节关于栈映射压缩的细节),如果允许在更多的位置执行垃圾回收,那么通常需要更大的栈映射表。
下面我们来考虑哪些原因可能导致回收器无法在某一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节,届时我们将介绍相关线程如何从回收的一个阶段迁移到另一个阶段,以及赋值器线程在回收的开始和结束阶段应当执行哪些工作。
11.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) 技术,即系统使用新版本代码实例来替换其正在执行的老版本实例。该方案不仅可以提升正在运行的方法调用的性能,而且有助于回收老版本的代码,因而其使用越来越广泛。
栈上替换技术的直接目的通常是优化代码或其他一些应用,例如需要对代码进行逆优化的调试需求,而在另一方面,回收器也可以利用该技术来回收老版本代码。
11.8 读写屏障
许多垃圾回收算法需要赋值器在运行时探测并记录 回收相关指针(interesting pointer) 。如果回收器仅回收堆中一部分区域,则任何从该区域之外指向该区域的指针都属于回收相关指针,且回收器必须在后续处理过程中将它们当作根。
例如,分代垃圾回收器必须捕获所有将年轻代对象的引用写入年老代对象的写操作。
当赋值器和回收器交替执行时(不论回收器是否运行在单独的回收线程之上),将很有可能出现赋值器操作导致回收器无法追踪到某些可达对象的情况,如果这些引用没有被正确地探测到并传递给回收器,则存活对象可能会被过早地回收。这些场景都要求赋值器即时地将回收相关指针添加到回收器的工作列表中,而这一任务的完成就需要借助于读写屏障。
本节我们将把各种特定回收算法(例如分代回收器或并发回收器)中的读写屏障进行抽象,并将注意力集中在回收相关指针的探测与记录上。
- 探测(detection) 即确定某一指针是否属于回收相关指针
- 记录(record) 则是对回收相关指针进行登记,以便回收器后续使用
探测和记录在某种程度上是正交的,但某些探测方法的使用可能会加强特定记录方法的优势,例如,如果写屏障通过页保护违例来进行探测,则对被修改位置进行记录会更加合理。
11.8.1 读写屏障的设计工程学
除了要执行真正的读/写操作之外,典型的屏障通常还会包括一些额外的检查与操作。典型的检查包括判断被写入的指针是否为空、被引用对象与其引用者所处分代之间的关系等,而典型的操作则是将对象记录到记忆集中。
完整的检查以及记录操作可能太大,以至于无法整体内联,但这取决于屏障的具体实现。即使得到内联的指令序列相对短小,仍可能导致编译器生成的代码剧烈膨胀并进一步影响指令高速缓存的性能。
由于屏障内部的大部分代码通常很少执行,所以设计者可以将指令序列划分为“快速路径”和“慢速路径”:
- 快速路径通常会进行内联以确保性能
- 慢速路径则只有在必要时才会调用,也就是说,为节省空间以及提升指令高速缓存的性能,慢速路径通常只会存在一份代码实例。
快速路径应当包含最一般的情况,而慢速路径则仅应当在部分情况下执行,这一点十分重要。某些情况下,这一规则同样也适用于慢速路径的设计:
- 如果屏障会经常进行多重检查,则设计者有必要对检查逻辑进行合理排序,并确保第一重检查会过滤掉大多数情况,第二重检查可以过滤掉次多的情况,以此类推,从而最大限度地降低检查开销。
为达到这一要求,设计者通常需要对检查逻辑以多种方式进行排序并分别测量其性能,因为现代硬件环境中存在非常多的影响因素,以至于用简单的分析模型通常无法给出足够好的指引。
提升读写屏障性能的另一个因素是加速所有必需数据结构的访问速度,例如卡表。系统甚至可以付出一个寄存器的代价来保存某一数据结构的指针,例如卡表的基地址等,但是否值得如此取决于机器以及算法的类型。
设计者还需要对软件工程学有所关注,包括如何对垃圾回收算法的各个方面(即读写屏障、回收检查、分配顺序等)进行整合,它们都会被构建到系统的编译器中。
如果有可能,设计者最好能为编译器指明哪些子过程需要内联,这些子过程内部应当是快速路径所对应的代码序列。这样一来,编译器便无需知道具体细节,而设计者则可以自由替换这些内联子过程。但正如我们前面所提到的,这些代码序列可能会存在一些限制,例如在其执行过程中不允许发生垃圾回收,这便需要设计者小心对待。
编译器可能也需要避免对这些代码序列进行优化,例如保留一些显而易见的无用写入(它们所写入的数据对回收器有用)、禁止对屏障代码进行指令重排序或者与周围代码进行穿插。最后,编译器可能需要支持一些特殊的 编译指示(pragma) ,或者允许设计者使用特殊的编译属性,例如不可中断的代码序列。
11.8.2 写屏障的精度
回收相关指针的记录存在多种不同的实现策略与机制,具体的实现策略决定了记忆集记录回收相关指针位置的精度。在选择回收相关指针的记录策略时,我们需要对赋值器与回收器各自的开销进行平衡。
实践中我们通常倾向于增加相对不频繁的回收过程(例如查找根集合)的开销,同时降低更为频繁的赋值器行为(例如堆的写操作)的开销。
在引入写屏障之后,指针写操作所需的指令数可能会增大两倍或者更多,但如果写屏障的局部性比赋值器自身的局部性要好,则这一开销很可能会被掩盖(例如,写屏障在记录回收相关指针时通常不会导致用户代码的延迟)。
一般来说,记忆集中回收相关指针的记录精度越高,回收器查找操作的开销就会越低,而赋值器过滤并记录指针的开销则会越高。
作为一种极端情况,分代式回收器中的赋值器可以不记录任何指针写操作,从而将所有回收相关开销转移给回收器,此时后者就只能扫描整个堆空间并找出所有指向定罪分代的引用。
虽然这并非一种通用的较为成功的分代策略,但是对于无法借助于编译器或者操作系统的支持来捕获指针写操作的场景,这可能是唯一可选的分代策略,此时回收器可以使用局部性更好的线性扫描而非追踪策略来查找回收相关指针。
记忆集的设计策略需要从三个维度进行考虑。
- 回收相关指针的记录应达到何种精度
尽管并非所有的指针都是回收相关指针,但对于赋值器而言,无条件记录的开销显然会低于对回收无关指针执行过滤之后再记录。记忆集的具体实现是决定过滤开销的关键:
-
如果记忆集可以使用非常廉价的机制来增加条目,例如简单地在某一固定大小的表中写人一个字节,则该策略非常适合无条件记录,特别是在添加操作本身满足幂等要求的情况下
-
如果向记忆集添加条目的开销较高,或者记忆集的大小也需要控制,则写屏障过滤掉回收无关指针则显得十分必要。对于并发回收器或者增量回收器而言,过滤操作是必不可少的,只有这样才能确保回收器的工作列表可以最终为空。
每种过滤策略都需要考虑过滤逻辑应当内联到何种程度,何时应当通过外部调用来执行过滤或将指针添加到记忆集。内联的指令越多,则需要执行的指令越少,但这可能导致代码体积的膨胀并增大指令高速缓存不命中的几率,进而影响程序的性能。因此,开发者需要对过滤检查的顺序以及需要内联的过滤操作进行精细化调节。
- 对指针位置的记录应当达到何种粒度
- 最精确的方案是记录指针所写入的域的地址,但如果某一对象中的指针域较多(例如对数组进行更新时),则可能会增大记忆集的大小。
- 此外可以记录被修改指针域所在的对象,其优势在于可以根据对象进行去重,对指针域进行记录通常无法做到这一点(因为在指针域中通常不会有额外的空间来标记该域是否已被记录)。
记录对象的方法要求回收器在追踪阶段扫描对象内部的每个指针域,进而才能找到它们所引用的尚未得到追踪的对象。
一种混合式解决方案是以对象为粒度来记录数组,而以指针域为粒度来记录纯对象,因为当数组中的一个域得到更新时,其他域通常也会得到更新。也可使用完全相反的策略,即以指针域为粒度来记录数组(以避免对整个数组进行扫描),而以对象为粒度来记录纯对象(纯对象通常比较小)。
对于数组而言,还可以只记录数组的一部分,这一策略与 卡标记(card mark) 策略十分类似,不同之处在于其依照数组索引而非数组域在虚拟内存中的地址进行对齐。究竟应当记录对象还是记录域,还取决于赋值器可以获取到哪些信息:
-
如果写操作既可以获取对象的地址又可以获取指针域的地址,则其可以任意选择一种
-
如果写屏障只能获取被写入域的地址,则计算其所属对象的地址可能会引入额外的开销。
Hosking 等 在某一解释型Smalltalk系统中解决了这一难题,它们的策略是在顺序存储缓冲区中同时记录对象以及域的地址。
卡表(card table) 技术将堆在逻辑上划分为较小且固定大小的卡。
该方案以卡为粒度来记录指针的修改操作,其记录方式通常是在卡表中设置一个标记字节。卡标记不仅可以对应被修改的域,也可以对应被修改的对象(两类信息可以对应不同的卡)。在回收阶段,回收器必须先找到所有与待回收分代相关的脏卡,然后找出其中记录的所有回收相关指针。卡表的记录方式(记录对象还是记录域)会影响查找过程的性能。
比卡表的粒度更粗的记录方式是以虚拟内存页为单元,其优点在于可以借助于硬件与操作系统的支持来实现写屏障,从而不会给赋值器带来任何直接负担,但与卡表类似,回收器的工作负担则会加重。与卡表的不同之处在于,由于操作系统不可能获取对象的布局信息,因而页标记方案通常只能对应被修改的指针域,而无法获取其所属的对象。
- 是否允许记忆集包含重复条目
允许重复条目的好处在于可以降低赋值器的去重检测开销,但代价是增大了记忆集的大小以及回收器处理重复条目的开销。
-
卡表和页标记技术是通过在表中设置标记位或者标记字节的方式来进行标记的,因而其可以天然实现去重。
-
如果使用记录对象的方式,也可以通过标记对象的方式实现去重,例如通过对象头部中的一个标记位来记录其是否已经添加到日志中,但如果以指针域为记录粒度则无法通过这一方式进行简单去重。
尽管该策略可以降低记忆集的空间大小,但其需要赋值器执行一次额外的判断逻辑以及一次额外的写操作。
如果不允许记忆集中出现重复对象,则记忆集的实现必须是真正的 集合(set) 而非 多集合(multiset) 。
综上所述,如果使用卡表或者基于页的记录策略,则回收器的扫描开销取决于脏卡或者脏页的数量。
如果允许记忆集中出现重复条目,则回收器的开销将取决于指针写操作的数量,而如果不允许重复,则回收器的开销取决于被修改的指针域的数量。不论对于哪种情况,过滤掉回收无关指针都会减少回收器扫描根集合的开销。记忆集的实现方式包括哈希表、顺序存储缓冲区、卡表、虚拟内存机制与硬件支持,我们将逐一进行介绍。
11.8.3 哈希表
如果对象头部中没有足够的空间来记录其是否已经添加到记忆集,需要通过集合来记录对象。我们进一步希望向记忆集中增加条目的操作可以很快完成,最好是在常数时间内。哈希表即是满足这些条件的实现方案之一。
在Hosking 等 的多分代内存管理工具包中,他们给出了一种基于线性 散列环状哈希表(circular hash table) 的记忆集实现方案,并将其应用在一种Smalltalk解释器中,该解释器将栈帧保存在堆的第0分代的第0阶中。
具体而言,每个分代都会对应一个独立的记忆集,且记忆集中既可以记录对象,也可以记录域。其哈希表基于一个包含 2 i + k 2^i+k 2i+k个元素的数组实现(k = 2),它们将地址映射为一个i位的哈希值(从对象的中间几位中获取),并以此作为该地址在数组中的索引。
如果该索引对应的位置为空,则将该对象的地址或域保存在该索引位置,否则将在后续的k个位置中查找可用位置(此时并非环状查找,正因如此,数组的大小才是2+k)。如果依然查找失败,则对数组进行环状查找。
为减轻记录指针的工作量,写屏障首先过滤掉所有针对第0代对象的写操作以及所有新—新指针(即从新生对象指向新生对象的指针)的创建。另外,写屏障会将所有回收相关指针添加到一个单独的“草稿”记忆集中,而非直接将其添加到目标分代所对应的记忆集。
该策略不会占用赋值器的时间来判断回收相关指针究竟属于那个记忆集,因而其可能更加适合多线程环境,除此之外,为每个处理器维护“草稿”记忆集也可以避免潜在的冲突问题,因为线程安全哈希表在运行时可能会引入较大开销。
Hosking 等使用17条内联MIPS指令来实现写屏障的快速路径,其中包括更新记忆集的相关调用。
即使对于MIPS这种寄存器较多的架构,这一方案的开销也相对较高。
在回收阶段,来自某一分代的根要么位于该分代对应的记忆集中,要么位于“草稿”记忆集中。回收器可以将分代所对应的记忆集中的回收相关指针重新散列到“草稿”记忆集中,从而完成去重,然后再将“草稿”记忆集中的所有回收相关指针添加到合适的记忆集中。
Garthwaite在其火车回收算法的实现中也使用了哈希表。
其哈希表的操作一般是插入以及迭代,因而其使用 开放定址法(open addressing) 来解决冲突问题。由于哈希表中经常会记录相邻地址,所以其舍弃了会将相邻地址映射到哈希表中相邻槽的线性定址法(即简单的地址模N,N为哈希表的大小),取而代之的是通用的哈希函数。
Garthwaite选用了一个58位的质数p,并为每个哈希表绑定两个参数α和b,这两个参数是通过重复调用一个伪随机函数生成的,且0<a, b<p。某一地址r在哈希表中对应的索引是 ( ( a r + b ) m o d p ) m o d N ((ar + b) mod p) mod N ((ar+b)modp)modN。
当冲突发生时,开放定址法需要一定的手段来进行再次探测。线性探测与平方探测(下一个探测位置的索引值为当前索引值加 d,且每次探测都对d增加一个常量i)可能会导致一组插入请求产生相同的探测序列,因而Garthwaite使用再散列方法,即把平方探测中的增量i替换为一个基于地址的函数。
对于大小为2的整数次幂的哈希表,如果探测增量i为奇数,则可以确保整个哈希表都可以探测到。Garthwaite的策略是:
- 在每次探测时判断d是否为奇数,如果是,则将i设置为零(线性探测),否则则将d和i都设置为d +1。如此一来,可用探测序列的集合便可翻倍。
最后,如果哈希表的负载过高,则需要进行扩展,一种可选方案是通过修改插人过程来重新平衡哈希表。当发生碰撞时,我们需要判断是要将当前正在插入的地址进行再次探测,还是将当前槽中原有的对象进行冲突探测(并将其插人到新的位置)。
Garthwaite等人使用robin hood哈希,其每个槽中存储的条目都会记录其插入过程中的探测次数,由于哈希表所记录的地址中会存在很多为零的位(例如卡的地址),所以可以复用这些位来记录探测次数。
当插入一个新地址时,如果探测所得的槽已被占用,我们会选择槽中的现有地址以及待插入地址中探测次数较多的一个留在槽中,而对另一个地址继续进行探测。
11.8.4 顺序存储缓冲区
使用简单的 顺序存储缓冲区(sequential store buffer,SSB) (例如内存块链表)可以加快指针的记录速度。
每个线程可以针对所有分代维护统一的本地顺序存储缓冲区
- 可以避免写屏障选择适当缓冲区的开销
- 可以消除多线程之间的竞争
一般情况下,向顺序存储缓冲区中添加条目只需要很少的指令:
- 简单地判断next指针是否达到上限、将引用存入缓冲区中下一个位置、向前递增next指针。
MMTk 使用内存块链表来实现顺序存储缓冲区,每个内存块的大小为2的整数次幂,同时也依照2的整数次幂对齐,其填充方向是从高地址到低地址。此时写屏障通过判定next指针的低位是否为零(该操作通常很快),便可简单地完成溢出检测。
有多种方法可以消除显式溢出检测的开销,如此一来,向顺序缓冲区中追加条目所需的指令可以降低到一至两条,如算法11.4所示。在 PowerPC 上,如果使用专用寄存器,则该操作可以通过一条指令完成:stwu fld , 4 (next)。
Appel ,Hudson和 Diwan 以及Hosking 等 使用写保护 哨兵页(guard page) 来消除显式溢出检测。
当写屏障尝试在哨兵页上添加一个条目时,陷阱处理函数会执行适当的溢出操作。触发以及处理页保护异常的开销很大,其通常会花费数百甚至上千个指令,因此只有当陷阱很少被触发时,该策略才会体现出效率上的优势,即陷阱执行开销应当小于(大量)软件检测所花费的开销:
Appel将顺序存储缓冲区保存在年轻分代中,并使用链表来组织内存块,据此可以确保在每个回收周期中页保护陷阱只会被精确地触发一次。
Appel将哨兵页布置在年轻分代末尾的保留空间中,因此任何分配操作(不论是分配对象还是记忆集内存块)都可能触发陷阱并呼起垃圾回收。
该技术要求年轻代的空间必须是连续的。某些系统可能会将堆布置在数据区的末尾,并使用brk系统调用来扩大(或者收缩)堆。
但正如Reppy 所提到的,为堆末端边界之外的页设置特殊的保护策略会干扰malloc函数对brk的调用,因此更好的解决方案是使用更高的地址空间,并使用mmap来管理堆的扩展与收缩。
某些体系架构所支持的特殊机制也可以用于消除溢出检测。例如Solaris系统的UTRAP异常,该异常用于处理 非对齐(misaligned) 数据访问,且速度要比Unix信号处理机制快上百倍。
Detlefs等使用由 2 n 2^n 2n字节内存块组成的链表来实现顺序分配缓冲区,每个内存块以 2 n + 1 2^{n+1} 2n+1字节对齐但不满足 2 n + 2 2^{n+2} 2n+2字节的对齐要求,这可能造成一定的空间浪费。
算法11.5描述了其插入流程:
- next寄存器通常指向下一个条目之后4字节的位置,当缓冲区被填满时(即next寄存器指向 2 n + 2 2^{n+2} 2n+2对齐边界之前的槽)便会触发UTRAP陷阱,正如示例中的第5行。
上面的示例可能有错误,笔者笔记+修改如下,不一定正确。
n = 4,认为内存块是 2 4 2^4 24 = 16,以 2 5 2^5 25 = 32位对齐,对齐边界 2 6 2^6 26 = 64位。
64的前一个槽:64 - 4 = 60,即当到达60时,触发陷阱。
1.位置于32时,next位于40,插入点是36
插入过后,位置于36,插入点是40,next位于44。
…
3.位置于40时,next位于48,插入点是44
插入过后,位置于44,插入点是48,next>>(n-1) = 0B 110 000 >> 3 = 6(此处可能示例错了),tmp = 6,next为54
4.位置于44时,next位于54,插入点是50
插入过后,位置于48,插入点是50,next = 60
5.此时再插入,就会触发陷阱。
顺序存储缓冲区可以直接用于记忆集的实现,也可以当作哈希表的快速记录前端。对于简单的两分代且使用集体提升策略的回收器,次级回收完成后年轻代将被清空,因而其可以简单地将记忆集抛弃,因此在这一场景下,回收器不需要更加复杂的记忆集结构(假设顺序存储缓冲区在执行回收之前不会溢出)。
但是,其他更加复杂的回收器则需要在两次回收之间保留记忆集,如果使用多分代,即使定罪分代使用集体提升策略,更高级别分代之间的分代间指针依然需要保留。如果定罪分代内部包含阶,或者使用了其他延迟提升策略(参见9.4节),则记忆集依然需要保留从更老分代指向未提升对象的引用。
一种解决方案是简单地将顺序存储缓冲区中不再需要的条目移除,可以将该位置的指针清空,也可以将其指向只有在整堆回收时才会处理的对象(或者永远不会回收的对象)。另外,如果某一对象不包含任何回收相关指针,则可以将其对应条目移除。但是,这些解决方
案均无法控制记忆集的增长,且可能导致回收器不断重复处理相同的长寿条目。更好的解决方案是将需要保留的条目移动到各分代对应的记忆集中,这些目标记忆集可以使用顺序存储缓冲来实现,也可将其转换成更加精确的哈希表来记录。
11.8.5 溢出处理
哈希表以及顺序存储缓冲区均可能会溢出,有多种方案可以解决这一问题。当顺序存储缓冲区溢出时,MMTk 的解决方案是分配一个新的内存块并将其链接到顺序存储缓冲区中,Hosking等的策略是无论是否会溢出,都将顺序存储缓冲区中的数据转移到哈希表中,并将前者清空。
为保持哈希表相对稀疏,如果在插入一个指针时出现冲突,或者经历k次线性探测之后依然冲突,或者哈希表的使用率超过某一阈值(例如60%),则需将哈希表扩大。扩大的方式是增加键的长度并简单地将哈希表的大小翻倍,但如此一来键的长度便不能是编译常量,这将增加哈希表的大小以及写屏障的执行开销。
Appel 将其顺序存储缓冲区保存在堆中,一旦其发生溢出,则立即唤起垃圾回收,MMTk 也会在回收器自身元数据(例如顺序存储缓冲区)过大时发起回收。
11.8.6 卡表
卡表(卡标记) 策略将堆在逻辑上划分为固定大小的连续区域,每个区域称之为卡。
卡通常较小,介于128~512字节之间。卡表最简单的实现方案是使用字节数组,并以卡的编号作为索引。当某个卡内部发生指针写操作时,写屏障将该卡在卡表中对应的字节设置为脏(如图11.3所示)。
- 卡的索引号可以通过对指针域的地址进行移位获得。
- 卡表的设计初衷在于尽量简化写屏障的实现并提高其性能,从而将其内联到赋值器代码中。
- 与哈希表或者顺序存储缓冲区不同,卡表不存在溢出问题。
但这些收益总是要付出一定代价的:
- 回收器的工作负荷会加重,因为回收器必须对脏卡中的域进行逐个扫描,并找出其中已被修改的、可能包含回收相关指针的域,此时回收器的工作量将正比于已标记卡的数量(以及卡的大小),而非产生回收相关指针的写操作的发生次数。
使用卡表的目的在于尽可能减轻赋值器的负担,因而其通常应用在无条件写屏障中,这便意味着卡表必须能够将所有可能被write操作修改的地址映射到卡表中的某个槽。
如果我们可以确保堆中的某些区域永远不可能写入回收相关指针,同时引人条件检测来过滤掉这些区域的指针写操作,则可以减少卡表的大小。例如,如果将堆中高于某一固定虚拟地址边界的空间用作新生区(回收器在每次回收过程中都处理该区域),则卡表只需要对低于该边界地址的空间创建对应的槽。
最紧凑的卡表实现方式应当是位数组,但多种因素决定了位数组并非最佳实现方案。现代处理器的指令集并不会针对单个位的写入设置单独的指令,因而位操作比原始操作需要更多的指令:
- 读取一个字节、通过逻辑运算设置或清除一个位、写回该字节。
- 更糟糕的是,这些操作序列并不是原子化的,多线程同时更新同一个卡表条目可能会导致某些信息丢失,即使它们所修改的是堆中不同的域或者对象。
正因如此,卡表才通常使用字节数组。由于处理器清空内存的指令的执行速度更快,所以通常使用0来表示“脏”标记。在使用字节数组的场景下,在卡表中设置脏标记只需要两条SPARC指令(其他架构所需的指令可能会稍多一些),如算法11.6所示。
为方便表述,我们使用zERo来代表SPARD寄存器 %g0 ,该寄存器的值通常为0。BASE寄存器的值需要初始化为CT1-(H>>L0G_CARD_SIZE),其中CT1为卡表的起始地址,H为堆的起始地址,两者均依照卡的大小(即512字节)对齐。
Detlefs等 使用一个SPARC本地寄存器来作为BASE寄存器,并在程序进入到某一可能执行写操作的函数时设置其值,该寄存器的值在函数调用时的保存则依赖寄存器窗口机制。
算法11.7,降低了大多数情况下写屏障的开销,代价是牺牲了记录的精度。该算法中,对卡表中第i个字节进行标记意味着从第i到第i+L个卡都可能被修改过。
如果某一对象中被修改的域在其内部的偏移量小于L个卡,则可以在卡表中设置对象首地址所对应的字节。令L为1通常可以涵盖大多数指针写操作场景,但数组则是一个例外,写屏障必须采用传统的方式对其进行标记。
如果使用128字节的卡,则对于大小不超过32个字的对象,对其内部任意一个域的修改均可以确保能将其首地址记录到卡表中。
只有当某个卡内部的最后一个对象所占用的空间会延伸到下一个卡时,才可能发生歧义,此时回收器可能还需要扫描该对象(或者该对象必要的起始部分)。
即使卡的大小比较小,卡表所占用的空间通常也可以接受。例如在32位体系架构下,128字节的卡所对应的卡表仅会占用堆中不到1%的空间。在确定卡的大小时需要在空间开销与回收器扫描根的时间开销两方面做出权衡:
- 增大卡的大小,尽管会减少卡表的空间开销,但其精度也会降低,反之,相反。
在回收阶段,回收器必须在所有脏卡中查找回收相关指针,因而其必须先对卡表进行扫描并找出脏卡。由于赋值器的更新操作通常具有较高的局部性,所以干净的卡与脏卡通常会出现聚集效应,回收器可以根据这一特性来加速查找过程。如果卡表使用字节数组来实现,则回收器可以一次性对卡表中由4个或者8个槽所组成的字进行检测。
如果分代回收器不使用集体提升的策略,则在次级回收之后,某些年轻代存活对象会留在年轻代里,其他的则会得到提升。
如果得到提升的对象引用了尚未提升的对象,则由此产生的老—新指针不可避免地会将某个卡打上脏标记。但是这些被已提升对象所引用的未提升对象终究都会得到提升,因此我们应当尽可能不去标记已提升的对象所在的卡,否则在下一轮回收中将会出现一些不必要的卡扫描。
在将对象提升到某一干净的卡时,Hosking 等 使用过滤复制屏障来扫描得到提升的对象,因此其仅会在必要时才将卡标记为脏。尽管如此,如果堆空间过大,回收器依然可能需要花费大量时间来跳过干净的卡。
Detlefs等 观察发现,绝大多数卡都是干净的,且单个卡中很少会包含超过16个分代间指针。因此可以使用两级卡表来加速回收器查找脏卡的过程,尽管这一策略会付出额外的空间开销。
第二级卡占用的空间更小,其中的每个槽对应 2 n 2^n 2n个粒度更细的卡,因而其能够将干净卡的扫描速度提升n倍。写屏障可以使用与算法11.6类似的技术实现(只需要额外增加两条指令),但其需要确保第二级卡表的起点与第一级对齐,即CT1-(H>>L0G_CARD_SIZE)=CT2-(H>>L0G_SUPERCARD_S1zE),如算法11.8所示。这一要求可能会造成一定的空间浪费。
11.8.7 跨越映射
在回收阶段,回收器必须对其在卡表中找到的脏卡进行处理,这一过程需要确定卡中被修改的对象及对象内部被修改的槽。扫描对象中的域通常只能从对象的起始地址开始,但卡的起始地址却不一定会与对象的起始地址重合,因而扫描过程并不能直接进行。
更加糟糕的是,导致卡被标记为脏的指针域可能属于某一大对象,而该对象的头部则可能位于该卡之前的某个卡中(这也是需要对大对象进行分离存储的原因之一)。为确保回收器可以从对象头部开始扫描,我们必须借助于跨越映射来描述对象在卡内部或者卡之间的布局。
跨越映射中的每个条目与卡表中的卡是一一对应的关系,其每个条目所记录的是对应卡中第一个起始地址落入该卡的对象在卡中的偏移量。回收器会在提升对象时设置年老代卡所对应的跨越映射条目,如果分配器直接将对象预分配在年老代则也需设置这一信息。新生区的对象不可能指向更年轻的对象(它们已经是最年轻的对象),因而无需为其维护卡表。卡表的记录方式(记录对象或是记录被修改的域)决定了跨越映射的设计方式。
如果写屏障使用卡表来记录被修改的指针域,则跨越映射必须记录每个卡中最后一个(起始地址落入该卡的)对象的偏移量,如果任何一个对象的起始地址都不在该卡中,则跨越映射必须记录一个负数偏移量。
由于对象可能会跨越多个卡,所以被修改槽所属对象的起始地址可能会位于脏卡之前的另一个卡中。例如在图11.3中,堆中的白色方框表示对象,假设图中所描述的场景是在32位环境下,且卡的大小为512字节。
第一个卡中最后一个对象的偏移量为408字节(102个字),该值将会记录到其在跨越映射的对应的条目中。该对象跨越了4个卡,因而跨越映射中后面两个条目的值均为负数。当堆中第5个对象的某个域被修改之后(灰色所示区域),其所对应的卡(第4个卡)将被标记为脏(黑色区域)。为找到被修改对象的起始地址,回收器必须从跨越映射中进行后退查找,直到发现某一偏移量非负的条目为止(见算法11.9)。
需要注意的是,负数表示需要后退的距离,当对象较大时,回收器可以根据该值快速找到对象首地址所在的条目。当然,系统也可以在这些条目中填入特定的值来表示倒退,例如-1,但这将减缓回收器在大对象中的查找速度。
年老代通常使用非移动式回收算法进行管理,此时空闲内存块与已分配内存块便会在堆中混杂分布。在并行回收器中,为避免回收过程中可能存在的冲突,不同的回收线程往往会拥有不同的目标提升区域,因此得到提升的对象很容易形成多个孤岛,每个孤岛之间都是较大的空闲区域。
为了更好地支持堆的可解析性,每个空闲区域可以使用一个自描述的伪对象来填充。但是,基于槽的跨越映射算法却更适用于堆中对象排布比较密集的情况:
- 如果在两个脏卡之间存在一块很大的空闲内存块(例如10MB),则算法11.9中 search方法的第一个循环可能需要迭代数万次,才能找到用于描述空闲内存块的伪对象的头部。
降低这一查找开销的方法之一是在跨越映射中存储后退距离的对数值,即如果某个条目所记录的值为 − k -k −k,则意味着回收器需要后退 2 k − 1 2^{k-1} 2k−1个卡,然后根据新位置中所记录的值继续执行查找(与线性后退策略相似)。
如果需要在大块空闲内存的起始地址分配对象,回收器只需要更新log(n)个跨越映射条目,即可修正跨越映射的状态,其中n为此内存块所占据的卡的数量。
Garthwaite等 设计出一种巧妙的跨越映射编码方式,该策略可以消除查找过程中的循环。该策略中,我们可以简单地将跨越映射中的每个条目v看作是16位无符号整数(两个字节)。表11.3描述了其编码策略。如果v的值为零,意味着其所对应的卡中任何对象都不包含引用。如果v的值不大于128,则该值表示卡中第一个对象与卡的末端之间的距离(单位为字)。
需要注意的是,此处的记录方式与图11.3中所描述的记录方式有所不同,记录第一个对象而非最后一个对象的偏移量可以确保回收器在大多数情况下无须后退到前一个卡。
诸如数组之类的大对象可能会跨越多个卡,针对这一情况,大于256且小于等于384的编码值表示对象跨越了两个或者更多个卡,当前卡中前v - 256个字属于该对象的末尾,且此空间内所有的域均为指针域。
引入这一范围的编码值的好处在于,回收器无需访问对象的类型信息,便可直接判断出对此段空间中的域都是指针域。
但如果对象落入该卡中的域混杂着指针域和非指针域,则这一编码方式将会失效,此时v值将大于384,意味着回收器应当在跨越映射中后退v-384个条目并继续进行查找。另外,如果对象横跨了两个完整的跨越映射槽,则可以在由这两个槽组成的4字节空间中记录该对象的地址,该方案假定跨越映射中的每个条目占据两个字节,但如果使用512字节的卡,并使用64位的对齐方式,则仅用一个字节也可达到同样的编码效果。
11.8.8 汇总卡
某些分代回收器并不采用集体提升策略,因此如果回收器通过对脏卡的扫描发现了回收相关指针但并未将其目标对象提升,则回收器需要保留该卡的脏标记,以便后续过程再次进行扫描。
如果后续回收过程可以直接获取此类脏卡中的回收相关指针而不用再对卡表进行扫描,则可以提升回收效率。幸运的是,绝大多数脏卡中都只包含数量很少的回收相关指针,因此Hosking 和Hudson 建议在完成某个卡的扫描之后将其中的回收相关指针添加到哈希表中,同时清除该卡的脏标记。Hosking 等 也采用相同的策略,不同之处在于其使用的是顺序存储缓冲区。
Sun的Java 虚拟机中,清扫器会对清扫完成后依然包含回收相关指针的卡进行 汇总( summarise) ,并据此优化卡的再扫描过程。
由于卡表的实现方式是字节数组而非位数组,所以可以将卡的状态进一步划分为“干净”、“已修改”、“已汇总”。
-
如果回收器在“已修改”的卡中发现不多于k个回收相关指针,则将该卡标记为“已汇总”,并将这些指针域的偏移量记录在 “汇总表”(summary table) 的对应条目中。
-
如果卡中回收相关指针的数量大于k(例如k = 2),则该卡将依然保持“已修改“状态,同时其在汇总表中对应的条目将被标记为“已溢出”。
因此在下次回收过程中,回收器无需用跨映射对卡进行扫描便可直接找到其中的回收相关指针(除非该卡重新被写屏障标记为脏)。另外,由于卡表的实现方式是字节数组,所以如果卡相对较小,也可直接在卡表中记录少量的偏移量信息。
Reppy 在卡表的编码中加入额外的分代信息以降低扫描开销。当完成某个卡的清理后,其多分代回收器会获取该卡内部所有指针域引用的对象所处的分代,并将其中最年轻分代的编号(0代表新生代)记入汇总卡。因此在后续对第n个分代的回收过程中,如果某个卡在汇总卡中的对应条目的值大于n,则回收器可以快速将其跳过。
11.8.9 硬件与虚拟内存技术
某些早期的分代垃圾回收器需要依赖操作系统以及硬件的支持。支持 带标签值(tagged value) 的硬件架构可以轻易区分出指针与非指针,某些硬件写屏障还可以在页表中进行置位操作 。
在没有特殊硬件支持的条件下,也可以借助于操作系统来实现对写操作的追踪。例如Shaw 对HP-UX操作系统进行修改,并利用其换页系统来达到这一目的。虚拟内存管理器通常需要对脏页进行记录,并以此判定在某一页被换出时是否需要将其写回到 交换文件(swap file) 。
Shaw的修改会拦截虚拟内存管理器的换页操作,并记录被换出的页的脏标记状态,他同时添加了数个系统调用来清空一组页的脏标记,或者返回自从上次回收以来被修改的页集合。该策略的优势在于其不会给赋值器引入任何常规开销,但其缺点也十分明显:
- 操作系统将某一页标记为脏时不可能区分写入的值是否为指针,因此其记忆集的精度较低,同时换页陷阱与系统调用的开销也不容忽视。
为避免对操作系统进行修改,Boehm等 在一轮回收之后会修改已回收内存页的写保护策略。
在该页发生的第一个写操作会触发写保护异常,陷阱处理函数会设置该页的脏标记,并解除该页的写保护策略,以避免在下一轮回收之前在该页重新触发陷阱。
在回收过程中,回收器显然需要解除对象将被提升到的目标页的写保护策略以避免触发陷阱。页保护策略不会给赋值器带来开销,且与卡表类似,写屏障的开销将正比于被修改的页的数量,而与写操作的数量无关。
但是,该策略却引入了其他更加昂贵的开销:
- 从操作系统读取脏页信息的开销通常较大
- 页保护机制有可能引发所谓的 “陷阱风暴”( trap storms) 问题,即在回收完成之后赋值器会触发大量写保护异常来解除程序工作集的写保护
- 页保护异常本身的开销就不容忽视,如果其处理函数在用户空间执行则开销更大
- 操作系统页通常会比卡大得多,因而页扫描算法需要更加高效(或许可以使用类似于汇总卡的技术来提升扫描性能)。
11.9 地址空间管理
某些算法要求使用大块连续地址空间,或者在这一场景下实现更为简单。在32位地址空间下,如果使用静态布局方式,系统通常很难保证各个空间的大小能够满足所有应用程序的要求。
更加糟糕的是,操作系统有可能将动态链接库(也称共享对象文件)加载到地址空间的任意位置,造成空间的割裂,从而进一步增大了大块连续地址空间的获取难度。
另外,出于安全目的,操作系统可能会将动态库加载在地址空间的随机位置,因此程序每次运行时,动态库的位置便会有所不同。64位的大地址空间是这一问题的解决方案之一,但更大的指针同时也会增大应用程序所占用的物理内存。
使用大块连续地址空间布局策略的主要原因之一是确保基于地址比较的写屏障的执行效率,即写屏障可以将指针与一个固定地址或者另一个指针直接进行比较,而无需进行额外的查表操作。例如,如果将分代系统中的新生区布置在堆空间的一端,则写屏障只需一次简单的地址比较,便可判断出写入堆的指针是否引用了位于新生区的对象。
在设计一个新系统时,应当尽量避免把堆设计成大块连续地址空间的形式,而应当设计成基于 帧(frame) 的形式,或者至少允许在连续地址空间中存在“空洞”。但不幸的是,这一要求可能会导致写屏障不得不借助于查表操作。
假设查表操作的开销可以接受,则系统可以将逻辑地址空间映射到可用虚拟地址空间,从而能够管理更大的逻辑地址空间。尽管该策略并不能增加堆空间的大小,但其确实可以避免系统对地址空间连续性的依赖,因此给系统的设计提供了一定的柔性。
该策略将可用内存划分成大小为2的整数次幂且依照2的整数次幂对齐的帧,每一帧的大小通常会大于一个虚拟内存页。系统使用一张表来维护所有的帧,并以帧的编号(通常是帧首地址的高位)作为索引以记录其逻辑地址,各种面向地址的写屏障便可基于该表进行地址比较。
对于分代间写屏障,系统还可以将每个帧所处分代的编号记录到表中。算法11.11给出了此类写屏障的伪代码,代码中的每一行在一般的处理器上都对应一条指令,如果帧表中的每个条目都对应一个字节,则可以简化数组的索引操作。
需要注意的是,即使ref 为空,该算法也能正常工作,为达到这一目的,我们可以简单地为地址为零的帧赋予最高的分代编号,由此代码在执行过程中便可避免对地址为零的帧调用remember方法。
我们甚至可以将较大地址空间内的多块可用内存“合并”成较小的连续空间——操作系统正是以这种方式来为进程提供虚拟内存的。
一种实现策略是使用宽地址并检查每个地址空间访问操作,这相当于是使用软件来模拟虚拟内存管理硬件的工作,其中可能会包括软件层面实现的转译后备缓冲区等。该方案的性能惩罚可能相当高,当然也可以通过对虚拟内存硬件施加影响来避免惩罚,第11.10节将介绍这方面的更多细节。
在构建系统时最好能确保堆在系统启动时便具备迁移能力。许多系统都存在一个 起始堆(starting heap) 或者 系统映像(system image) ,系统在启动时便会加载它们。该映像通常假定其自身会常驻在某一特定的地址空间中,但如果该地址已被动态链接库所占据,则其加载过程便会遇到问题。
因此,如果系统映像内部用一张表来记录当自身被移动时哪些字需要做出调整(其实现方案与许多代码段的加载十分类似),映像加载器便可相对直接地将其移动到地址空间中的其他位置。同理,如果整个堆或者部分堆空间具有迁移能力,也能提升系统的柔性。
在实际应用中,在进行虚拟内存管理时,我们可以仅为托管系统保留特定的地址空间,但并不要求操作系统为其分配真正的内存页,从而可以避免操作系统在运行时将动态链接库映射到保留地址空间。这些页通常都是请求二进制零页。这一操作的开销相对较低,但可能会影响操作系统的资源保留(例如交换空间),并且所有虚拟内存映射操作的开销通常都比较大。
当堆空间较大时,程序也可以通过提前分配页来判定系统中的剩余资源是否足够,但操作系统在请求二进制零页真正被访问之前通常不会为其分配资源,因此简单地进行页分配可能会得出错误的预判。
11.10 虚拟内存页保护策略的应用
垃圾回收系统可以借助于虚拟内存页保护检查机制实现多种检测,这一检测方式在常规情况下的开销很低甚至没有开销,同时也不需要在检测过程中增加显式的条件分支。
但是,使用该方案时必须考虑陷入页保护陷阱的开销,陷阱处理函数需要先陷入操作系统再返回用户态,因此其开销可能相当大。
另外,修改页保护策略也会具有一定开销,特别是在多处理器环境下,因为系统可能需要挂起所有正在运行的处理器并更新它们的内存页映射信息。因此在某些情况下,即使可以使用页保护陷阱,使用显式判断的开销也通常更低。陷阱在处理“不合作”的代码时依然有用,因为除此之外,系统无法通过其他方法来实现屏障或者检测。
另外一种需要考虑的情况是,出于硬件性能方面的原因,内存页的大小在未来可能会进一步增大,同时开发者所使用的内存总量也越来越多,系统的可映射内存也越来越多。
但是,基于速度和能耗方面的考虑,转译后备缓冲区的大小却不太可能进一步增大。由于转译后备缓冲区的大小或多或少都是固定的,因此如果使用较小的页,转译后备缓冲区查找不命中的概率就会增大,但如果使用更大的页,某些虚拟内存相关技巧可能不再适用。
我们假设在某一体系架构中,页保护策略包括:
- 读写访问
- 只读访问
- 禁止访问
此处我们不关注可执行权限,因为我们尚未发现在垃圾回收技术中使用不可执行保护的案例,另外,某些平台可能不支持对可执行权限的控制。
11.10.1 二次映射
二次映射(double mapping) 技术:系统可以通过该技术将相同的页以不同的保护策略映射到不同的虚拟地址。
我们以遵守目标空间不变式的增量复制回收器为例(参见第17章)。为阻止赋值器访问尚未完成处理的内存页中的来源空间指针,回收器可以将这些页的保护策略设置为禁止访问,相当于是借助于硬件支持高效地创建了一个读屏障。
在并发系统中,如果回收器解除这些内存页的禁止访问保护,则赋值器可能会在回收器处理完成之前访问到页中的内容。为解决这一问题,回收器可以将待处理页以读写访问权限二次映射到其他地址,此时回收器便可基于其第二个映射来进行内容处理,处理完成后,回收器便可解除该页的禁止访问保护,并恢复所有等待访问该页的赋值器线程的执行。
当地址空间较小时(即便是32位在当前也可以算作小地址空间),进行二次映射可能存在困难。一种解决方案是fork出一个子进程进行处理,子进程将以不同的页保护策略来将待处理页映射到自身的地址空间,回收器可以通过与父子进程之间的通信来引导子进程完成页的处理。
需要注意的是,二次映射技术在某些系统中可能遇到问题。如果高速缓存依照虚拟地址进行索引,则可能出现潜在的高速缓存不一致问题,因为此时进行二次映射的地址很可能出现高速缓存不一致问题。为避免这一问题,硬件系统通常不允许 别名条目(aliased entries) 同时驻留在高速缓存中,但这可能导致额外的高速缓存不命中问题。
不过在我们的应用场景中,赋值器和回收器一般是在相邻时间访问同一内存页的两个映射,且运行在同一个处理器上(因此高速缓存不命中问题就不那么重要——译者注)。
另外,如果系统使用倒排页表,则每个物理内存页在任意时刻只能映射到一个虚拟地址上,此时系统便无法支持二次映射。这种情况下,操作系统可以快速将某一物理内存页的虚拟地址失效并将其与另一个虚拟地址关联,但这可能引发高速缓存刷新操作。
11.10.2 禁止访问页的应用
在对二次映射的描述中,我们已经看到了禁止访问保护策略的一个应用,即无条件读屏障。该策略至少还有两种常见的应用场景。
- 探测程序对空指针(即目标地址为0的指针)的解引用操作。
系统将第0页(以及其后的几个页)设置为不可访问页,如果赋值器尝试访问空指针所指向的域,则其必然会对不可访问的页执行读或者写操作。
由于对空指针解引用异常的处理通常不需要很快,所以在这一场景下使用禁止访问页保护策略较为合理。极少情况下程序会访问距0地址偏移量较大的地址,编译器可以针对这一情况增加显式检测。
如果将对象的头部或者其他域布置在对象指针地址负数偏移量的位置,则系统也可对地址最高的几个页做禁止访问保护(0地址的负数偏移量将绕回到最高地址——译者注)。但在大多数系统中,高地址空间通常会保留给操作系统使用。
- 哨兵页(guard page) 。
例如,以顺序存储缓冲区作为实现的记忆集在插人新元素时需要经历三个步骤:
-
判断缓冲区的剩余空间是否足够
-
将新元素写入缓冲区
-
增加缓冲区指针
如果在缓冲区的末端布置一个禁止访问的哨兵页,则写屏障可以省去检测剩余空间以及调用缓冲区溢出处理子过程的操作。由于写屏障的调用频率通常较高,且其代码可能会被嵌入在很多位置,因而哨兵页技术可以加速赋值器的执行速度并减小代码体积。
某些系统使用相同的策略来检测栈或堆的溢出,即在栈(堆)的末尾布置一个哨兵页。
检测栈溢出的最佳方式是在子过程开始执行时立即尝试访问其将建立的新栈帧的最远处。此时一旦触发哨兵页陷阱,指令指针将位于一个事先预定的位置,因而陷阱处理函数可以通过重新分配的方式增大栈空间,或者增加一个新的栈分段并调整栈帧指针,然后再恢复赋值器的执行。
类似地,当使用顺序分配缓冲区时,分配器可以在执行分配之前访问新对象的最后一个字,一旦该字落入缓冲区末尾的哨兵页中,分配器将触发一个陷阱。
不论在哪种情况下,如果新的栈帧或者对象过大以至于最远的一个字可能会越过哨兵页,则系统仍需使用显式的边界检查。但如此巨大的栈帧和对象在许多系统中都十分罕见,且大对象通常会花费更多的时间初始化使用,这一开销通常足以掩盖显式边界检查的开销。
我们还可以利用禁止访问页保护策略在较小虚拟地址空间中获取较大的逻辑地址空间,Texas持久对象存储,即是一个案例。
尽管该策略是针对数据持久化而设计的(程序下次执行时,堆中的数据依然保持着上一次运行结束时的状态),但其所用到的技术也同样适用于垃圾回收等非持久化场景。
该系统基于内存页进行工作,每个页的大小与虚拟内存页相同,或者是后者的 2 n 2^n 2n倍。系统通过一张表来记录每个逻辑页的状态,每个页不仅有其在(虚拟)内存中的地址,系统还会为其在磁盘上维护一个明确的托管交换文件。每一页都可以有以下四种状态:
- 未分配(unallocated) :页为空,尚未得到使用。
- 驻留(resident) :页中的数据已经加载到内存,并且可以访问。但其在磁盘上对应的交换文件不一定存在。
- 非驻留(non-resident) :页中的数据在磁盘上,无法直接访问。
- 保留(reserved) :页中的数据在硬盘上,无法直接访问,但已为其保留虚拟地址。
新创建的页的初始状态为“驻留”,且系统会为其分配新的逻辑地址(与虚拟内存地址无关)。随着虚拟内存的不断使用,某些页可能需要换出到磁盘。保存过程需要基于页的逻辑地址,系统需要将页中所有的指针转换成更长的逻辑地址,因而其在硬盘中的存在形式一般会比其在内存中的要大。
这一过程在文献中被称为 逆转换(unswizzling) ,它要求系统必须能够准确地找出每个页中的指针。
在“驻留”页被换出后,其状态将变成“保留”,系统进一步将其对应的虚拟地址空间设置为禁止访问,此时一旦程序访问被换出的页便会触发页保护陷阱,陷阱处理函数会将该页重新载入内存。
如果系统需要复用“保留”页的虚拟地址空间,则其必须确保该“保留”页不被任何“驻留”页引用。为达到这一目的,系统可以将所有引用该页的“保留”页换出,然后将该页的状态修改为“非驻留”,此时系统便可复用其地址空间。
需要注意的是,“驻留”页只能引用“驻留”页或者“保留”页,但不能直接引用“非驻留”页中的数据。
系统通过查表获取该页的逻辑地址,并将其从磁盘加载到内存。然后系统需要遍历其中的逻辑地址,并将其转换成较短的虚拟地址(该过程称为 指针转换(pointer swizzling) )。对于该页中指向“驻留”页或者“保留”页中的引用,转换操作均可直接通过查表完成
但是,对于其中指向“非驻留”页的引用,系统必须先为目标页保留虚拟地址(此时目标页的状态将从“非驻留”转变为“保留”),然后再将这些引用从逻辑地址转换为虚拟地址。为这些新的“保留”页分配虚拟地址可能需要将其他页换出,该操作可能进一步将被换出页的状态修改为“非驻留”并回收其虚拟地址空间。
11.11 堆大小的选择
在其他条件相同的情况下,堆空间越大,则赋值器的吞吐量越高,垃圾回收的开销越小。
但在某些情况下,较小的堆可能会提升赋值器的局部性、减少转译后备缓冲区不命中的几率,进而提升赋值器吞吐量。
另外,如果堆空间过大以至于物理内存无法将其容纳,则程序的执行很容易出现性能上的颠簸,特别是在垃圾回收过程中。
“足够小”的标准通常会因为运行时系统以及操作系统而产生差异,因此有自动内存管理器调整堆大小的策略。(笔记此处省略)
除了调整堆空间大小之外,减少程序所占用物理内存的策略还包括将某些页换出到磁盘(如书签回收器)、将一些很少访问的对象保存到磁盘中。
附录
[1]《垃圾回收算法手册 自动内存管理的艺术》
[英]理查德·琼斯(Richard Jones)[美] 安东尼·霍思金(Antony Hosking) 艾略特·莫斯(Eliot Moss)著
王雅光 薛迪 译
[2]《Java虚拟机:JVM高级特性与最佳实践》周志明 著