Java理论与实践: JVM 1.4.1中的垃圾收集2010-12-21 IBM Brian Goetz上个月,我们分析了引用计数、复制、标记-清除和标记-整理这些经典的垃 圾收集技术。其中每一种方法在特定条件下都有其优点和缺点。例如,当有很多 对象成为垃圾时,复制可以做得很好,但是有许多长寿对象时它就变得很糟(要 反复复制它们)。相反,标记-整理对于长寿对象可以做得很好(只复制一次) ,但是当有许多短寿对象时就没有那么好了。JVM 1.2 及以后版本使用的技术称 为 分代垃圾收集(generational garbage collection),它结合了这两种技术 以结合二者的长处,结果就是对象分配开销非常小。老对象和年轻对象在任何一个应用程序堆中,一些对象在创建后很快就成为垃圾,另一些 则在程序的整个运行期间一直保持生存。经验分析表明,对于大多数面向对象的 语言,包括 Java 语言,绝大多数对象――可以多达 98%(这取决于您对年轻对 象的衡量标准)是在年轻的时候死亡的。可以用时钟秒数、对象分配以后�h内存管理子系统分配的总字节或者对象分配后经历的垃圾收集的次数 来计算对象的寿命。但是不管您如何计量,分析表明了同一件事――大多数对象 是在年轻的时候死亡的。大多数对象在年轻时死亡这一事实对于收集器的选择很 有意义。特别是,当大多数对象在年轻时死亡时,复制收集器可以执行得相当好 ,因为复制收集器完全不访问死亡的对象,它们只是将活的对象复制到另一个堆 区域中,然后一次性收回所有的剩余空间。那些经历过第一次垃圾收集 后仍能生存的对象,很大部分会成为长寿的或者永久的对象。根据短寿对象和长 寿对象的混合比例,不同垃圾收集策略的性能会有非常大的差别。当大多数对象 在年轻时死亡时,复制收集器可以工作得很好,因为年轻时死亡的对象永远不需 要复制。不过,复制收集器处理长寿对象却很糟糕,它要从一个半空间向另一个 半空间反复来回复制这些对象。相反,标记-整理收集器对于长寿对象可以工作 得很好,因为长寿对象趋向于沉在堆的底部,从而不用再复制。不过,标记-清 除和标记-理整收集器要做很多额外的分析死亡对象的工作,因为在清除阶段它 们必须分析堆中的每一个对象。分代收集分代收集器(generializational collector)将堆分为多个代。在年轻的代中 创建对象,满足某些提升标准的对象,如经历了特定次数垃圾收集的对象,将被 提升到下一更老的代。分代收集器对不同的代可以自由使用不同的收集策略,对 各代分别进行垃圾收集。小的收集分代收集的一个优点是它不同时收集所有的代,因此可以使垃圾收集暂停更 短。当分配器不能满足分配请求时,它首先触发一个 小的收集(minor collection),它只收集最年轻的代。因为年轻代中的许多对象已经死亡,复制 收集器完全不用分析死亡的对象,所以小的收集的暂停可以相当短并通常可以回 收大量的堆空间。如果小的收集释放了足够的堆空间,那么用户程序就可以立即 恢复。如果它不能释放足够的堆空间,那么它就继续收集上一代,直到回收了足 够的内存。(在垃圾收集器进行了全部收集以后仍不能回收足够的内存时,它将 扩展堆或者抛出 OutOfMemoryError )。代间引用跟踪垃圾收集器,如复制、标记-清除和标记-整理等垃圾收集器,都是从根集 (root set)开始扫描,遍历对象间的引用,直到访问了所有活的对象。分代跟踪收集器从根集开始,但是并不遍历指向更老一代中对象的引用,这 减少了要跟踪的对象图的大小。但是这也带来一个问题――如果更老一代中的对 象引用一个不能通过从根开始的所有其他引用链到达的更年轻的对象该怎么办?为了解决这个问题,分代收集器必须显式地跟踪从老对象到年轻对象的引用 并将这些老到年轻的引用加入到小的收集的根集中。有两种创建从老对象到年轻 对象的引用的方法。要么是将老对象中包含的引用修改为指向年轻对象,要么是 将引用其他年轻对象的年轻对象提升为更老的一代。跟踪代间引用不管一个老到年轻的引用是通过提升还是指针修改创建的,垃圾收集器在进 行小的收集时需要有全部老到年轻的引用。做到这一点的一种方法是跟踪老的代 ,但是这显然有很大的开销。更好的一种方法是线性扫描老的代以查找对年轻对 象的引用。这种方法比跟踪更快并有更好的区域性(locality),但是仍然有很 大的工作量。赋值函数(mutator)和垃圾收集器可以共同工作以在创建老到年轻的引用时 维护它们的完整列表。当对象提升为更老一代时,垃圾收集器可以记录所有由于 这种提升而创建的老到年轻的引用,这样就只需要跟踪由指针修改所创建的代间 引用。垃圾收集器可以有几种方法跟踪由于修改现有对象中的引用而产生的老到年 轻的引用。它可以使用在引用计数收集器中维护引用计数的同样方法(编译器可 以生成围绕指针赋值的附加指令)跟踪它们,也可以在老一代堆上使用虚拟内存 保护以捕获向老对象的写入。另一种可能更有效的虚拟内存方法是在老一代堆中 使用页修改脏位(page modification dirty bit),以确定为找到包含老到年 轻指针的对象时要扫描的块。用一点小技巧,就可以避免跟踪每一个指针修改并检查它是否跨越代边界的 开销。例如,不需要跟踪针对本地或者静态变量的存储,因为它们已经是根集的 一部分了。也可以避免跟踪存储在某些构造函数中的指针,这些构造函数只用于 初始化新建对象的字段(即所谓 初始化存储(initializing stores)),因为 (几乎)所有对象都是分配到年轻代中。不管是什么情况,运行库都必须维护一 个老对象到年轻对象的引用集并在收集年轻代时将这些引用添加到根集中。在图 1 中,箭头表示堆中对象间的引用。红色箭头表示必须添加到根集中供 小的收集使用的老到年轻的引用。蓝色箭头表示从根集或者年轻代到老对象的引 用,在只收集年轻代时不需要跟踪它们。图 1. 代间引用