文章目录
- 五、引用计数
- 5.1 引用计数算法的优缺点
- 5.2 提升效率
- 5.3 延迟引用计数
- 5.4 合并引用计数
- 5.5 环状引用计数
- **步骤**
- 5.6受限域引用计数
- 六、垃圾回收器的比较
- 6.1 吞吐量
- 6.2 停顿时间
- 6.3 内存空间
- 6.4 回收器的实现
- 6.5 自适应系统
- 6.6 统一垃圾回收理论
- 6.6.1 垃圾回收的抽象
- 6.6.2 追踪式垃圾回收
- 6.6.3 引用计数垃圾回收
- 附录
五、引用计数
在引用计数算法中,对象的存活性可以通过引用关系的创建或删除直接判定,从而无须像追踪式回收器那样先通过堆遍历找出所有的存活对象,然后再反向确定出未遍历到的垃圾对象。
引用计数算法所依赖的是一个十分简单的不变式:
- 当且仅当指向某个对象的引用数量大于零时,该对象才有可能是存活的。
在引用计数算法中,每个对象都需要与一个引用计数相关联,这一计数通常保存在对象头部的某个槽中。
算法5.1展示了最简单的引用计数实现,即当创建或者删除某一对象的引用时增加或者减少该对象的引用计数。
-
Write 方法用于增加新目标对象的引用计数,同时减少旧目标对象的引用计数,即使对于局部变量的更新也需如此。我们同时假设,在一个方法返回之前,赋值器会将所有局部变量中的引用设置为空。
-
addReference 方法实现对象引用计数的增加,相应地,deleteReference 实现引用计数的减少。
需要注意的是,对引用计数的修改要遵循先增后减的顺序(算法5.1中的第9~10行),否则当新对象和老对象相同,也就是src[i]=ref时,可能导致对象被过早回收。一旦某一对象的引用计数降至零(算法5.1中的第20行),便可以将其回收,同时减少其所有子节点的引用计数,这可能引发子节点递归式的回收。
算法5.1中的Write方法是写屏障的一个例子,此处编译器在真正的指针写操作之外增加了一些简短的代码序列。
此类回收器可能会与赋值器并发执行:
- 要么在赋值器的引用计数操作中立即执行,要么在另一个线程中异步地执行。
回收器也可能会以不同的频率来处理堆中不同区域的对象,例如分代式回收器。在这些情况下,赋值器必须引入一些额外的屏障操作来确保回收算法的正确性。
5.1 引用计数算法的优缺点
优点
-
引用计数算法的内存管理开销分摊在程序运行过程中,同时一旦某一对象成为垃圾便可立即得到回收(但在后文我们将看到,这一特性并非对所有场合都有益),因此引用计数算法可以持续操作即将填满的堆,而不必像追踪式回收器那样需要一定的保留空间。
-
引用计数算法直接操作指针的来源与目标,因此其局部性不会比它所服务的应用程序差。当应用程序确定某一对象并非共享对象时,可以直接对其进行破坏性的操作而无须事先创建副本。
-
引用计数算法的实现无须运行时系统的支持,特别是无须确定程序的根。即使当系统部分不可用时,引用计数算法也能回收部分内存,这一特性在分布式系统中将是十分有用的。
缺点
-
引用计数给赋值器带来了额外的时间开销。
-
为避免多线程竞争可能导致的对象释放过早,引用计数的增减操作以及加载和存储指针的操作都必须是原子化的,而仅对引用计数的增减操作进行保护是不够的。某些提供引用计数的智能指针库需要调用者小心使用以避免竞争。开发者必须避免在更新指针槽过程中可能出现的竞争问题,否则将可能产生未定义的行为。
-
在简单的引用计数算法中,即使是只读操作也需要引发一次内存写请求(用以更新引用计数)。类似地,对某一指针域进行修改时也需要对该域原本指向的对象进行读写操作各一次。这里的写操作会“污染”高速缓存,同时可能引发额外的内存冲突。
-
引用计数算法无法回收环状引用数据结构(即包含自引用的数据结构)。即使此类数据结构在对象图中成为孤岛(即整体不可达时),其各个组成对象的引用计数也不会降至零。但是,自引用数据结构十分普遍(例如双向链表、存在从子节点指向根节点的指针的树等),尽管在不同程序中它们的出现频率相差较大。
-
在最坏情况下,某一对象的引用计数可能等于堆中对象的总数,这意味着引用计数所占用的域必须与一个指针域的大小相同,即一个完整的槽。鉴于面向对象语言中对象的平均大小通常较小(例如Java程序中对象的大小通常是20 ~ 64字节), 这一空间开销便显得十分昂贵。
-
引用计数算法仍有可能导致停顿的出现。当删除某一大型指针结构根节点的最后一个引用时,引用计数算法会递归地删除根节点的每一个子孙节点。
5.2 提升效率
引用计数算法的效率可以从两方面进行提升:
- 减少屏障操作的次数
- 用更加廉价的非同步操作来替代昂贵的同步操作。
-
延迟(deferral):延迟引用计数(deferred reference counting) 以牺牲少量细粒度回收增量的时效性(即当对象成为垃圾时立即将其回收)来换取效率的提升。该方案将某些垃圾对象的鉴别推迟到某一时 段结束时的回收阶段中,从而避免了某些屏障操作。
-
合并(coalescing): 许多引用计数操作都是临时性的、“不必要”的,开发者可以手动去掉这些无用操作,在某些特殊场景下编译器也可以完成这一工作,但更加通用的方法可能是在程序运行时仅跟踪对象在某一时段开始和结束时的状态。在单个时段内,合并引用计数(coalescing reference counting) 只关注对象是否被第一次修改,针对同一对象的再次修改则会被忽略。
-
缓冲(buffering):缓冲引用计数(buffered reference counting) 同样会延迟垃圾对象的鉴别。但与延迟引用计数或合并引用计数不同,该方案将所有的引用计数增减操作缓冲起来以便后续处理,同时只有回收线程可以执行引用计数变更操作。缓冲引用计数关注的是在“何时”执行引用计数变更操作,而不是“是否”需要进行变更。
5.3 延迟引用计数
-
与简单的追踪式回收算法相比,引用计数操作给赋值器带来的开销相对较高。
-
引用计数的变更必须是原子化的且必须与指针的变更保持一致。
-
写操作对新老对象都要进行修改,从而很可能导致高速缓存被一些无法立即复用的数据污染。
-
手工删除无用的引用计数操作很容易出错,而事实证明,编译器优化是解决这一问题的有效方案。
因此,大多数高性能引用计数系统都使用延迟引用计数策略。
绝大多数指针加载操作都是将其加载到局部变量或者临时变量,即寄存器或者栈槽中。只有当赋值器将指针写人堆中对象时才调整其目标对象的引用计数。
图5.1展示了延迟引用计数的抽象视图,只有当赋值器操作堆中对象时产生的引用计数变更才会立即执行,而操作栈或寄存器所产生的引用计数变更则会被延迟执行。
这当然是要付出一定代价的:
-
如果忽略局部变量的引用计数操作,则引用计数便不再准确,因此立即回收引用计数为零的对象便不再安全。
-
为了确保所有的垃圾都能够得到回收,延迟引用计数必须引入万物静止式的停顿来定期修正引用计数,但幸运的是,这种停顿时间通常会比追踪式回收器用的时间短,例如标记—清扫回收器。
延迟计数会让计数不准确。因为局部变量的引用不会立即计入对象中的统计。(因为局部变量一般引用和删除引用很频繁,所以不立即改变计数)
因此当需要回收时,我们需要通过根可达的方式,对引用为0的对象(在零表中,计数为零会被放入)进行确认。
-
在算法5.2中,赋值器加载对象所使用的读操作是第1章中所介绍的简单的、无屏障的实现方案,将引用写入根的操作也是无屏障的(见算法5.2中的第14行),但将引用写人堆中对象时却必须使用屏障,在这种情况下必须立即增加新对象的引用计数(见算法5.2中的第17行)。
-
当某一对象的引用计数变为零时,写屏障需要将其添加到 零引用表( zero count table, ZCT) 中,而不能立即将其释放(见算法5.2中的第26行),因为程序栈中仍可能存在该对象的引用,但该引用并未计入到对象的引用计数中。
零引用表可以通过多种方式实现,例如位图或者哈希表。 从概念上讲,零引用表中包含的对象都是引用计数为零但可能依旧存活的对象。当赋值器把某一零引用对象的引用写入堆中对象时可以将其从零引用表中移除,因为此时该对象的引用计数必然为一个正数(见算法5.2中的第19行),这一策略有助于控制零引用表的大小。
- 当堆可用内存耗尽时(例如分配器无法分配内存)就必须进行垃圾回收。回收器需要挂起所有赋值器线程并检查零引用表中对象的引用计数是否真正为零。对于零引用表中的对象,只有当其被一个或者多个根引用时,该对象才可以被确定是存活的。
确定零引用表中存活对象的最简单方法是对根所指向的对象进行扫描,并增加其引用计数(见算法5.2中的第29行),这一步完成后,所有被根引用的对象的引用计数必然都为正数,而引用计数为零的对象则是垃圾。
-
为实现垃圾对象的回收,我们可以采用与标记—清扫类似的方法(例如算法2.3)扫描整个堆,即找到且回收所有引用计数为零的“未标记"对象,但仅扫描零引用表也能达到相同的效果,即采用与算法5.1类似的方法来处理并释放零引用表中的对象。
-
最后,必须还原“标记”操作,即再次对根进行扫描,并将其目标对象的引用计数减1 (即将引用计数恢复到其原有的值)。此时如果某个对象的引用计数再次归零,则需重新将其加入零引用表。
延迟引用计数消除了赋值器操作局部变量时的引用计数变更开销。
一些较早的研究表明,延迟引用计数可以将指针操作的开销减少80%甚至更多,如果再考虑其对局部性的提升,那么在现代硬件条件下,其在性能提升方面应该更具优势。
然而,对象指针域的引用计数操作却无法延迟,而必须立即执行,且必须为原子操作。
5.4 合并引用计数
延迟引用计数解决了赋值器操作局部变量时的引用计数变更开销,但是当赋值器将某一对象的引用存人堆时,引用计数的变更开销依然无法避免。
Levanoni 和Petrank注意到,对于任意时段内的任意对象域,回收器只需关注其在该时段开始和结束时的状态,而时段内的引用计数操作则可以忽略,因此可以将对象的多个状态合并成两个。
例如,假设初始状态下对象X的指针域f引用了对象
O
0
O_0
O0,该域在某个时段内依次被修改为
O
1
,
O
2
,
.
.
O
n
O_1,O_2,.. O_n
O1,O2,..On,此时引用计数的更新操作如下所示:
中间状态的一对操作(见实线框内)相互抵消了,因而可以省略。
Levanoni和Petrank的方法是
-
在每个时段内,写屏障会在对象首次得到修改之前将其复制到本地日志中。
-
对于在当前时段尚未修改过的对象,当赋值器更新其某一指针域时,算法5.3会捕获这一操作并将对象的地址及其每个指针域的值都记录到本地更新缓冲区中(算法5.3中的第5行),同时将被修改的对象标记为脏。
-
在log方法中,为避免将对象重复加入线程本地日志,算法先将对象指针域的初始值添加到日志中(算法5.3中的第11行),同时只有当src不为脏时才将其添加到日志中( appendAndCommit方法),然后再增加日志的内部游标(算法5.3中的第13行),此时算法需要将对象打上脏标记以便与指针域进行区分。将对象标记为脏的方法是将其在日志中对应条目的地址写人其头域。
需要注意的是,即使竞争导致在多个线程本地缓冲区中出现同一对象的条目,算法也能保证各个条目包含相同的信息,因此也无须关心对象头域中所记录的日志条目究竟位于哪个线程的本地缓冲区中。基于处理器的内存一致性模型, 写屏障很可能不需要任何同步操作。
在回收周期的开始阶段,算法5.4先将每个线程挂起,然后将每个线程的更新缓冲区合并到回收器的日志中,最后再为每个线程分配新的更新缓冲区。
-
上文提到,竞争关系可能导致多个线程的更新缓冲区中包含同一对象的条目,这就要求回收器确保对每个脏对象只会进行一次处理,因此processReferenceCounts方法在更新引用计数之前会先判断对象是否为脏。
-
对于标记为脏的对象,回收器先清空其脏标记以确保不会对它进行重复处理,然后将回收时刻其所有子节点的引用计数加1,最后再将当前时段内该对象首次得到修改之前的子节点的引用计数减1。
-
在该时段内,对象的最初子节点可以直接从日志中获得,而对象当前的子节点则可以从对象自身直接获取(日志中包含了该对象的引用)。另外,在增加和减少引用计数的循环中,算法都可以进行对象或者引用计数域的预取。
我们以图5.2为例来演示合并引用计数的处理过程。
假设对象A的某个指针域在某一时段内从对象C修改为对象D,则在该时段内从对象C修改为对象D,则在该时段结束时,对象A的两个指针域原有的值(B和C)则已经记录在回收器日志中(图5.2的左边)了,因此回收器会增加对象B和对象D的引用计数,同时减少对象B和C的引用计数。
由于对象A中指向对象B的指针域并未修改,因此对象B的引用计数不变。将延迟引用计数与合并引用计数相结合可以降低赋值器上大部分引用计数操作的开销。
特别需要指出的是,我们通过这两种方法消除了赋值器线程对昂贵的同步操作的依赖,但这些收益也是要付出一定代价的。为了进行垃圾回收,我们要再次引入了停顿,尽管这一停顿的时间可能会比追踪式回收器短。我们降低了回收的时效性(垃圾对象只有在时段结束时刻才能得到回收),同时日志缓冲区与零引用表也带来了额外的空间开销。在合并引用计数中,对于一个从未得到修改的指针槽,它所指向的对象仍有可能需要回收器各增删引用计数一次。
5.5 环状引用计数
对于环状数据结构而言,其内部对象的引用计数至少为1,因此仅靠引用计数本身无法回收环状垃圾。
不论是在应用程序还是在运行时系统中,环状数据结构都十分普遍,如双向链表或者环状缓冲区。对象—关系映射(object-relationsmapping)系统可能要求数据库和其中的表互相引用对方的一些信息。
真实世界中的某些结构天然就是环状的,例如地理信息系统中的道路。 懒惰函数式语言( lazy functional language) 通常使用环来表示递归。研究者们提出了多种解决环状引用计数问题的策略,我们介绍其中的几种。
最简单的策略是在引用计数之外偶尔使用追踪式回收作为补充。
该方法假定大多数对象不会被环状数据结构所引用,因此可以通过引用计数方法实现快速回收,而追踪式回收则负责处理剩余的环状数据结构。这一方案简单地减少了追踪式回收的发起频率。
许多学者建议将导致闭环出现的指针与其他指针进行区分。
他们将普通引用称为强引用(strong reference),将导致闭环出现的引用称为 弱引用(weak reference)。
如果不允许强引用组成环,则强引用图可以使用标准引用计数算法处理。Brownbridge 的算法得到了广泛应用,简而言之,每个对象需要包含一个强引用计数以及一个弱引用计数,在进行写操作时,写屏障会检测指针以及目标对象的强弱,并将所有可能产生环的引用设置为弱引用。为维护“所有可达对象均为强可达,且强引用不产生环”这一不变式,赋值器在删除引用时可能需要改变指针的强弱属性。
但是,这一算法并不安全,且可能导致对象提前被回收,具体可以参见Salkild的引用计数示例。
在所有能够处理环状数据结构的引用计数算法中,得到了最广泛认可的是 试验删除(trialdeletion) 算法。
该算法无须使用后备的追踪式回收器来进行整个存活对象图的扫描,相反,它将注意力集中在可能会因删除引用而产生环状垃圾的局部对象图上。在引用计数算法中:
- 在环状垃圾指针结构内部,所有对象的引用计数均由其内部对象之间的指针产生。
- 只有在删除某一对象的某个引用后该对象的引用计数仍大于零时,才有可能出现环状垃圾。
部分追踪(partial tracing) 算法充分利用上述两个结论,该算法从一个可能是垃圾的对象开始进行子图追踪。
对于遍历到的每个引用,算法将对其目标对象进行试验删除,即临时性地减少目标对象的引用计数,从而移除由内部指针产生的引用计数。追踪完成后,如果某个对象的引用计数仍然不是零,则必然是因为子图之外的其他对象引用了该对象,进而可以判定该对象及其传递闭包都不是垃圾。
试验删除:也就是先把自引用减去(-1),再去计算是否有引用(去计算是不是0)。如果是0就会把它放入candidate中,认为它可能是自引用。
反向试验删除:就是把-1加回去。
Recycler算法支持环状引用计数的并发回收。算法5.5仅演示了该算法较为简单的同步版本,异步回收的版本则在第15 章进行讨论。
环状数据结构的回收分为3个阶段:
-
回收器从某个可能是环状垃圾成员的对象出发进行子图追踪,同时减少由内部指针产生的引用计数(markCandidates 方法)。算法将遍历到的对象着为灰色。
-
对子图中的所有对象进行检测,如果某一对象的引用计数不是零,则该对象必然被子图外的其他对象引用。此时需要对第一阶段的试验删除操作(scan方法)进行修正,算法将存活的灰色对象重新着为黑色,同时将其他灰色对象着为白色。
-
子图中所有依然为白色的对象必然是垃圾,算法可以将其回收(collectCandidates方法)。
同步模式下的Recycler算法使用五种颜色来区分对象。
- 黑色代表存活
- 白色代表垃圾
- 灰色代表对象可能是环状垃圾中的一个成员
- 紫色表示对象可能是环状垃圾的一个备选根(备选垃圾)
实验性删除后的可能:
-
如果删除指向某一对象的一个引用之后其引用计数依然不是零,则可能导致环状垃圾的产生,因此算法5.5将其标记为紫色,同时将其添加到环状垃圾备选成员集合中(算法5.5中的第22行)。
-
如果删除指向某一对象的一个引用后其引用计数降至零,则该对象必然是垃圾,release方法会将其修改为黑色,并递归地处理其子节点。此时如果该对象不是备选垃圾,release方法会直接将其释放,而如果对象包含在candidates集合中,对该对象的回收将会推迟到markCandidates阶段。如图5.3a中,当删除某一指向对象A的引用后,对象A的引用计数仍然不是零,此时会将该对象添加到candidates集合中。
步骤
- markCandidates 方法首先限定可能是环状垃圾的对象的范围,并消除内部引用对引用计数的影响。
该阶段会检测candidates集合中的每个对象。如果对象依然是紫色(即在该对象被添加到candidates集合之后,再没有新的引用指向该对象),则将其递归闭包都标记为灰色,否则便将其从集合中移除。
如果对象为黑色且引用计数为零,则立即将其回收。markGrey在追踪过程中会将其所遍历到的每个对象的引用计数减1。
因此在图5.3b中,从A开始的子图已被着为灰色,且由子图内部引用所产生的引用计数都已经得到消除。
- 算法会对备选垃圾及其灰色传递闭包进行扫描,同时找出哪些对象存在外部引用。
- 如果某一对象的引用计数不是零, 则其必然受到灰色子图之外的某个对象的引用,在这种情况下,scanBlack会对由markGrey造成的引用计数减少操作进行补偿,即增加对象的引用计数并将其着为黑色;
- 如果对象的引用计数为零,则将其着为白色,并继续扫描其子节点。
需要注意的是,这里不能将白色对象与垃圾等价,因为如果scanBlack方法从另一个节点开始进行子图遍历,则有可能再次访问到白色对象。如图5.3b中,尽管对象Y和Z的引用计数均为零,但它们依旧通过外部对象X可达。当scan方法遍历到对象X时会发现它的引用计数不是零,此时算法会调用scanBlack方法来修正其灰色传递闭包中对象的引用计数。
子图最终的状态如图5.3c所示。
- collectwhite 方法将回收白色(垃圾)对象。
该方法将清空candidates集合,同时将遍历到的每个白色对象释放(并将其重置为黑色),然后再递归处理其子节点。
需要注意的是,collectwhite方法不会处理已经存在于candidates集合中的子对象,它们将在collectCandidates方法内的后续循环中得到处理。
对某些类型的对象进行特殊处理可以进一步提升回收性能。此类对象包括不包含指针的对象、永远不可能是环状数据结构成员的对象等。
Recycler算法在分配这些对象时会将其着为绿色而非黑色,同时决不会将其加入备选垃圾集合中,更不会对其进行追踪。
Bacon 和Rajan发现这一方法可以将备选垃圾集合的大小降低一个数量级。图5.4描述了同步Recycler算法完整的对象状态转化,包括绿色节点在内。
5.6受限域引用计数
对象的引用计数在其头部所占用的空间也是值得注意的。从理论上讲,某一对象可能会被堆中所有的对象引用,因此引用计数域的大小应当与指针域的大小相同,但对于小对象而言,这一级别的空间开销显得太过昂贵。
在实际应用中,大部分对象的引用计数通常较小,除非是有意为之才会变得很大。另外,大部分对象都不是共享对象,一旦指向它们的指针被删除,这些对象便可立即得到复用, 这一特性允许函数式语言对诸如数组等对象进行原地更新,而不必基于其新的副本进行修改。
如果事先知道引用计数可能达到的上限,则可以使用较小的域来记录引用计数,但许多程序中通常都会存在一些被广泛引用的对象(popular object)。
在面对引用计数偶尔超出上限的问题时,如果能够引入后备处理机制,则仍有可能限制引用计数域的大小。
一旦某个对象的引用计数达到允许的最大值,可以将其转变成粘性引用计数(sticky reference count) ,即之后的任何指针操作都不再改变该对象的引用计数值。
最极端的选择是仅使用一个位来表示引用计数,从而将引用计数的能力集中在非共享对象上。该位可以保存在对象中, 也可以记录在指针上。
受限域引用计数的必然结果是:
- 一旦对象的引用计数超出上限,则不能再通过引用计数来回收对象,此时就需要后备的追踪式回收器来处理这种对象。追踪式回收器在遍历每个指针时可以将对象的引用计数修复到正确的值(不论该对象的引用计数是否超限)。
Wise 表示,标记—整理和复制式回收器经过适当改造也能恢复对象的唯一性信息。后备的追踪式回收器应当在任何情况下都能回收环状垃圾。
六、垃圾回收器的比较
6.1 吞吐量
对于许多用户而言,他们关注的首要问题可能是程序的整体吞吐量,这同时也可能是批处理程序或者网络服务器的主要评价指标。
对于前者而言,短暂的停顿可以接受,而对于后者,这种停顿往往会被系统或者网络的延迟所掩盖。尽量快地执行垃圾回收固然重要,但更快的回收速度并不意味着程序整体执行速度也会更快。
在一个配置良好的系统中,垃圾回收应当只占用整体执行时间的一小部分,如果更快的回收器会给赋值器操作带来更多的额外开销,则很有可能导致应用程序的整体执行时间变长。
赋值器的开销可以是显式的,例如引用计数算法中的读写屏障,但某些隐式因素也可能影响赋值器的性能
-
如果复制式回收器重排列对象的方式不恰当,则可能降低赋值器的高速缓存友好性
-
减少引用计数的操作很可能需要访问一个较“冷”的对象。
在任何情况下,避免进行同步操作都十分重要,但引用计数的变更必须使用同步操作以避免更新操作的“丢失”,延迟引用计数和合并引用计数则可以消除这些同步操作的开销。
有人会使用算法复杂度来比较不同的回收算法。
- 标记—清扫回收需要考虑追踪(标记)和清扫两个阶段的开销,清扫过程则需要访问每个对象(包括存活对象以及死亡对象)
- 复制式回收器的复杂度则仅取决于追踪阶段,追踪过程只需要访问所有存活对象
如果仅据此进行比较,很容易得出标记—清扫回收的开销大于复制式回收这一错误结论。
同样是进行追踪,标记—清扫算法访问一个对象所需的指令远比复制式回收算法要少。局部性也对回收性能有很大影响。
我们在2.6节看到,使用预取技术可以弥补高速缓存不命中问题,但对于复制式回收器而言,是否可以在保留深度优先复制所带来的好处的前提下使用预取技术,却没有一个完美的答案。
在所有的追踪式回收器中,指针追踪的开销通常起决定性作用。另外,当堆中存活对象的比例较小时,复制式回收器表现最佳,但如果在标记—清扫算法中使用懒惰清扫,也可以在这一场景下达到最佳性能。
6.2 停顿时间
许多用户关注的另一个问题是,垃圾回收会给程序的执行造成停顿。尽量缩短停顿时间不仅对交互式程序十分重要,而且是事务型服务处理程序的一个关键要求,否则将导致事务的积压。
-
目前为止我们介绍过的追踪式回收器都会引人万物静止式的停顿,即回收器需要将赋值器线程挂起直到回收完成。
-
引用计数算法的优势在于它可以将回收开销分摊在程序的执行过程中,从而避免万物静止式的停顿,但正如上一章所提到的,在高性能引用计数系统中,这一优势也并非绝对:
- 当删除指向较大数据结构的最后一个引用时,可能会引发递归性的引用计数修改以及对象释放操作。
- 所幸的是,对垃圾对象进行引用计数变更操作并不会存在多线程竞争问题,尽管这仍有可能造成对象所在高速缓存行的冲突。
- 更致命的问题是,延迟引用计数和合并引用计数这两种最能有效提升引用计数性能的策略都需要通过万物静止式的停顿来回收零引用表中的对象。
6.3 内存空间
如果物理内存较小,或者应用程序非常庞大,再或者应用程序需要较好的扩展能力,则内存的使用量便显得十分重要。所有的垃圾回收算法都会引人空间上的开销,这通常是由多方面因素决定的。
某些算法可能需要在每个对象上占用一定的空间,例如引用计数域。
半区复制式回收器需要额外的堆空间来作为复制保留区,且为了确保回收的安全性,复制保留区应当能够容纳当前所有已分配的对象,除非存在后备的处理机制(例如标记-整理算法)。
非移动式回收器会面临内存碎片问题,这将导致堆的可用率降低。回收所需的元数据空间虽然不属于堆,但是也不能忽略。
追踪式回收器可能会需要标记栈、标记位图或者其他更加复杂的数据结构。包括显式管理器在内的所有非整理式回收器都需要一定的空间来维持其自身所需的数据结构,例如分区空闲链表等。
最后,对于追踪式回收或者延迟引用计数回收而言,如果要避免因频繁回收而导致的性能颠簸,则必须在堆中为垃圾对象保留一定的空间。
在垃圾回收系统中,保留空间的大小通常会达到应用程序所需最小内存量的30%~200%,甚至是300%。
许多系统在必要时可以进行堆扩展,以达到避免性能颠簸等目的。使用垃圾回收器的应用程序时如果要达到与显式管理堆相同的性能,其所需的内存空间通常是后者的3~ 6倍。
当某一对象与存活对象图不再关联时,简单的引用计数算法可以立即将其回收。除了可以避免堆中垃圾的堆积,这一特性还具有其他一些潜在优势:
- 被释放的空间通常会在很短时间内重新得到分配,从而有助于高速缓存性能的提升
- 在某些场景下,编译器能够探测出某一对象成为垃圾的时刻,然后可以立即将其复用,从而无需再将其交给内存管理器去回收。
理想的垃圾回收器不仅应当满足完整性要求(即所有死亡对象最终都会被回),还应当达到回收的及时性(即在每个回收周期内都可以将所有死亡对象回收)。
前面几章介绍的基本的追踪式回收器都可以达到这一要求,但其代价是每次回收过程都需要扫描所有存活对象。
然而,基于性能方面的考虑,现代高性能回收器通常都会放弃回收的及时性,即允许部分垃圾从当前回收周期“漂浮”到下一个回收周期。
此外,引用计数算法还面临着回收完整性问题,即如果不借助于追踪方法,环状垃圾便无法得到回收。
6.4 回收器的实现
正确地实现垃圾回收算法并非易事,而正确地实现并发回收算法则更是难上加难。
回收器和编译器之间的接口十分关键。回收器所产生的错误很可能在很久之后才会表现出来(或许在多个回收周期之后),而其后果则通常是赋值器尝试去访问一个非法引用,因此回收器的 鲁棒性(robustness) 与其速度同样重要。
回收器是关乎性能的关键系统组件,可借助于模块化和组件化等优秀软件工程实践来指导回收器的实现,从而确保代码具有较高的可维护性。
简单的追踪式回收器的优点之一:
- 回收器和赋值器之间的接口比较简单,即只有当分配器耗尽内存时才会唤起回收器。
实现这一接口的主要复杂点在于如何判断回收的根,包括全局变量、寄存器和栈槽中包含的引用等。
需要强调的是,复制式与整理式回收器的设计要比非移动式回收器复杂得多。
- 移动式回收器需要精确地找出每一个根并更新指向某一对象的所有引用
- 非移动式回收器则只需要找到指向存活对象的至少一个引用,也不需要改变指针的值。
所谓的保守式回收器(conservative collctor) 可以在没有精确的赋值器栈以及对象布局信息的条件下进行垃圾回收,它们使用智能(但安全、保守)的猜测来确定一个值是否为指针。
由于非移动式回收器不会更新引用,因此即使回收器错误地将某个值识别为引用,也不会对该值进行修改,唯一的风险在于可能导致空间的泄漏。关于保守式垃圾回收器的更详细讨论可以参考Jones。
引用计数算法需要与赋值器紧耦合,这既是其优点也是其缺点。
-
优点在于,引用计数能够以库的形式实现,因而开发者可以自行决定哪些对象需要由引用计数进行管理,而哪些对象需要手动管理。
-
缺点在于,这种耦合关系给赋值器带来了处理上的开销,而为了确保引用计数操作的正确性,这些操作又至关重要。
对于任何一个使用动态内存分配的现代编程语言,内存管理器都对性能有着至关重要的影响。其关键操作通常包括内存分配、赋值器更新操作(包括读写屏障)、垃圾回收器的内部循环等。
这些关键操作的实现代码应当 内联(inline) ,但同时也要小心地避免代码过度膨胀。
内联
以内联函数为例:
内联函数也称为内嵌函数,它主要是解决程序的运行效率。函数调用需要建立栈内存环境,进行参数传递,并产生程序执行转移,这些工作都需要一些时间开销。有些函数使用频率高,但代码却很短。
于是我们就通过内联函数,将该部分在编译时加入到调用的位置,就类似于我们直接将方法的步骤写到了调用的位置然后编译。
C++中支持函数内联,其目的是为了提高函数的执行效率(速度)。如果一个函数是内联的,那么在编译时,编译器会把该函数的代码副本放置在每个调用该函数的地方。对内联函数进行任何修改,都需要重新编译函数的所有客户端,因为编译器需要重新更换一次所有的代码,否则将会继续使用旧的函数。
以宏为例:
在C程序中,可以用宏代码提高执行效率。预处理器用复制宏代码的方式代替函数调用,省去了参数压栈、生成汇编语言的CALL调用、返回参数、执行 return等过程,从而提高了速度。
使用宏代码最大的缺点是容易出错,预处理器在复制宏代码时常常产生意想不到的边际效应。对于C++而言,使用宏代码 还有另一种缺点:无法操作类的私有数据成员。
也正是这种方式,导致了编译生成的文件大小的不确定,即膨胀。
如果处理器的指令高速缓存足够大,且代码膨胀得足够小(对于高速缓存较小的老式系统,Steenkiste建议小于30%),则代码膨胀对性能的影响可以忽略不计。
多数情况下所执行的代码序列(即“快速路径”)应当尽量短小以便于内联,而对于少数情况下才执行的“慢速路径”,则可以用过程调用的方式实现[Blackburn and McKinley, 2002]。 另外,编译器的输出结果也至关重要,因此有必要对其汇编代码进行检查。高速缓存相关行为对性能也有重要影响。
6.5 自适应系统
商业系统通常允许用户自主选择垃圾回收器,且每种回收器都具有一系列可调参数,但如何进行选择和调整则通常让用户困惑。更加复杂的问题在于,每种回收器的各个参数之间并非相互独立。
一些研究人员建议,系统应当能够根据所服务的应用程序所在的环境进行自适应调整。Soman等所开发的Java运行时系统能够在运行时根据可用堆的大小动态地切换回收器的类型,切换的依据有二:
- 使用离线分析的方法确定在程序当前堆大小情况下最佳的分配器
- 根据程序当前所用空间占最大可用空间的比例来切换回收器。
Singer等[2007a]使用机器学习技术对程序的某些静态特征进行分析,并据此预测最适用于该程序的回收器类型(仅需要一次实验运行)。
Sun 的Ergonomic调节系统。可以通过对HotSpot回收器性能的调整来控制堆中可用空间的大小,进而达到用户所需的吞吐量以及最大停顿时间要求。
对于开发者而言,我们能够提供的最好的或许也是唯一的建议是, 掌控自己所开发应用程序的行为以及所用对象的空间大小、生命周期分布特征,然后据此使用不同的回收器进行实验,并最终选用最合适的一种。
但是,实验通常需要基于真实的数据集才能保证结果的准确性,人造的、“玩具”般的基准测试程序都可能会起反向误导作用。
6.6 统一垃圾回收理论
前面的章节介绍了两种不同类型的垃圾回收策略:
- 直接回收,即引用计数
- 间接回收,即追踪式回收。
Bacon等 发现这两种回收策略之间存在深层次的相似性,并提出了统一垃圾回收理论这一抽象框架。我们可以据此精确地展示不同回收器之间的相同与差异。
6.6.1 垃圾回收的抽象
在接下来的抽象框架中,我们仅使用简单的抽象数据结构,具体的实现方式可以有所不同。垃圾回收可以表示为一种定点计算(fixed-point computation),即计算某一节点n的引用计数p(n)。
对象的有效引用来源包括根集合以及其他引用计数非零的节点,即:
从引用计数角度考虑,引用计数非零的对象应当保留,而剩余的对象则应被回收。引用计数的值不必十分精确,但至少应当是其真实值的一个安全近似。
在抽象的垃圾回收算法中,对引用计数的计算需要用到一个待处理对象工作列表W,当W为空时算法结束。在接下来的描述中,W是一个多集合(multiset),因为同一个引用可能会在不同的操作中多次被添加到其中。
6.6.2 追踪式垃圾回收
统一垃圾回收理论将追踪式垃圾回收算法抽象成一种引用计数形式。
算法6.1展示了追踪式垃圾回收过程的抽象:
- 追踪过程从所有引用计数非零的节点出发
- 每个回收周期结束后,sweepTracing方法会将所有节点的引用计数清零,New 方法也将新创建对象的引用计数置为零
- 在collectTracing方法中,算法先调用rootsTracing方法构建初始的工作列表W,然后将其传递给scanTracing方法。
正如我们所预期的那样,回收器会对整个对象图进行追踪,以发现所有从根节点可达的对象:
- scanTracing 方法对工作列表中的每个节点进行追踪,并在追踪过程中将发现的每个节点的引用计数加1,这样最终便可完成所有节点引用计数的重建(回想我们在5.6节所描述过的,追踪式回收器可以用于修正粘性引用计数)。
- 如果一可达节点src首次被发现(即从0变为1时,见算法6.1 中的第10行),回收器将对其各指针域进行扫描,同时将它们所指向的子节点加入到工作列表w中,从而实现对sre所有出边的递归扫描。
- while循环的结束意味着扫描过程的完成,此时所有存活节点都已经被发现,每个存活对象的非零引用计数等于该节点的人边的数量。
- 接下来,sweepTracing 方法释放所有的无用节点,同时将所有对象的引用计数清零,以便进行下一次回收。需要注意的是,实际应用中的追踪式回收器可以仅用一个位来表示对象的引用计数,即通过标记位来记录对象是曾否被访问过,此时的标记位就相当于是其引用计数的粗略近似。
追踪式回收器的计算结果是式(6.1)的最小定点解(least fixed-point solution),即每个对象的引用计数值都是可以满足等式的最小值。
我们可以使用2.2节介绍的三色抽象来对垃圾回收算法进行诠释。在算法6.1 中,引用计数为零的对象为白色,而引用计数非零的对象则为黑色。对象颜色从白色到灰色再到黑色的变化过程代表着对象从初次被发现到完成扫描的整个过程。因此,抽象追踪算法也可以看作是将全部节点划分为两个集合的过程,黑色对象集合代表可达对象,白色对象集合代表垃圾。
6.6.3 引用计数垃圾回收
为展示引用计数垃圾回收与追踪式回收的相似之处,在算法6.2中所展示的抽象引用计数垃圾回收算法中,由赋值器执行的inc和dec方法会将引用计数操作写人缓冲区,而不是立即执行。对于多线程应用程序而言,对引用计数变更操作进行缓冲的策略十分有用。
此处的缓冲操作与5.4节所介绍的合并引用计数算法存在相似之处。在算法6.2中,具体的垃圾回收工作是在collectCounting方法中完成的,其中
applyIncrements方法执行被延迟的引用计数的增加操作(I)(字母i), scanCounting 方法执行被延迟的引用计数的减少操作(D)。
当赋值器使用Write方法执行赋值操作时,即将新的目标引用dst写人域src[i]中时,对新目标对象引用计数的增加(即inc(dst))以及对原目标对象引用计数的减少(即dec(src[i1))都会写人缓冲区。
-
在回收开始阶段,回收器首先执行缓冲区中所有的引用计数增加操作,此时对象的引用计数可能比其真实值要大。
-
接下来,scanCounting方法将会对工作列表进行遍历,并将其遇到的每个对象的引用计数减1日。如果某个对象的引用计数在这个阶段降为零,说明该对象是垃圾,同时其子节点也会被加人到工作列表。
-
最后,sweepCounting 方法会释放所有的垃圾节点。
追踪式算法与引用计数算法整体相同,它们之间仅存在一些细微的差别。两种算法均包含扫描过程:追踪式回收的scanTciing方法使用引用计数增加操作,而引用计数算法的scanCounting方法则使用引用计数减少操作。两种算法都需要对零引用对象进行递归扫描,都需要通过清扫过程来释放垃圾节点所占用的空间。算法6.1和算法6.2中前31行的大致框架基本类似。另外,延迟引用计数策略(将根对象引用计数操作延迟的策略)也符合这一抽象框架(见算法6.3)。
前面的章节曾经提到,当对象图中存在环时,引用计数的计算会遇到问题。图6.1 展示了一个简单的对象图,其中的两个对象构成一个独立的环。如果对象A的引用计数为零,则对象B的引用计数也为零(因为只有引用计数非零的对象才会对其所引用对象的引用计数造成影响)。但由于对象A和对象B的引用计数相互依赖,因而这里便产生了一个鸡生蛋还是蛋生鸡的问题:我们也可以将对象A的引用计数预设为1,从而其所引用的对象B的引用计数也为1。
由于可能出现多种不同的情况,所以通用的定点计算表达式可能存在多个不同的解。对于图6.1所示的情况,Nodes = {A, B}, Roots = {}, 则定点运算(见式6.1)存在两个解:
最小定点解ρ(A)= ρ(B)=0 和最大定点解ρ(A)= ρ(B)=1。 追踪式回收器的计算结果是最小定点解,而引用计数回收器的计算结果则是最大定点解,因而其无法(仅依靠引用计数本身)回收环状垃圾。仅从环状垃圾可达的对象集合是这两个解的唯一不同之处。 5.5节曾介绍过,在引用计数算法中可以利用局部追踪来回收环状垃圾,这一过程本质上就是从最大定点解出发,不断缩小待回收对象的集合,最终得到最小定点解的过程。
附录
[1]《垃圾回收算法手册 自动内存管理的艺术》
[英]理查德·琼斯(Richard Jones)[美] 安东尼·霍思金(Antony Hosking) 艾略特·莫斯(Eliot Moss)著
王雅光 薛迪 译