为什么并发GC需要标记阶段?

8
并发垃圾回收需要“标记阶段”。 “标记阶段”的作用是在“并发标记阶段”期间标记修改的对象。但我认为,如果我们只在“并发标记阶段”中标记新创建的对象,则没有必要执行“标记阶段”。
“标记阶段”是由于修改的对象而需要的。修改可以是两种类型。一种是新对象的创建,另一种是指向另一个对象的已修改指针。如果我们标记新创建的对象,则可以轻松解决新对象问题。实际上,指向另一个对象的修改指针并不是问题。因为“死亡对象无法复活”。死亡对象意味着没有人能指向该对象。它们如何复活?因此,修改的指针应指向已标记的对象。这意味着没有必要执行“标记”。
有人可能会说:“在创建时标记新对象太昂贵了。因此,在‘并发标记阶段’中不能标记它们,这就是需要‘标记阶段’的原因”。这似乎是合理的。但这可能会引起另一个问题。如何在不遍历根对象的情况下进行“标记阶段”?如果“标记阶段”应遍历从根对象开始的所有对象,则“并发标记阶段”完成的工作将是无用的。或者,如果“标记阶段”仅遍历修改的对象,则应将哪个对象已修改的信息保存在某个地方。我认为这可能比仅进行标记要昂贵得多。
我错了吗?这应该是错误的。但我不知道哪一点是错误的。

你会如何在创建时标记新对象?你不知道它们在清理阶段启动时是否还存活。或者我有什么误解吗? - biziclop
我的建议是在GC状态为并发标记时标记新对象。可能很快就可以删除新对象,但这些对象将在下一个GC周期后被删除。这可能有点不太有效,但仍然可行。实际上...通常使用标记-清除GC进行分代GC的老一代,新对象实际上是来自新空间的对象,并且看起来具有较长的生命周期。 - Joffrey
它用于旧一代,然而在标记和重新标记之间可能会有几个年轻一代的收集,这可能会保留很多东西。 (事实上,可中止的预清理阶段正好起到与年轻一代收集协调的目的:它等待直到您大约在年轻一代收集之间的一半。)由于CMS不进行压缩,因此将新任命的对象保留到下一轮旧一代收集可能是一个坏主意。 - biziclop
2个回答

3
“实际上,修改指向另一个对象的指针并不是问题。因为死亡对象无法复活。”
“确实如此,但你知道哪些对象已经死亡了吗?不知道!为什么呢?”
“在初始标记阶段之后,您只查看线程堆栈而不跟随引用,所以您不知道。”
“在并发标记阶段之后,您也不知道,因为以下情况可能发生:”
- 一个线程读取字段 a.x 并将其值存储在其寄存器中(或者存储在其堆栈或其他地方)。 - 然后该线程设置 a.x = null(或其他值)。 - 垃圾回收器出现并在那里看到 null。 - 然后线程将 a.x 恢复到其先前的值。
“现在,GC错过了 a.x 所指向的对象。虽然上述情况不太常见,但它可能会发生,并且有更现实(也更复杂)的情况。”
所以需要再次查看修改后的内存,即备注阶段。幸运的是,并不需要再次扫描整个内存,因为会使用卡表
我担心这个(本来不错的)解释在这一点上有些误导:

垃圾回收标记阶段是一个全停顿。如果应用程序正在并发运行并继续更改活动对象,则CMS无法正确确定哪些对象处于活动状态(将它们标记为活动)。

线程确实会改变活动对象,但它们也会改变你能看到的活动对象,这就是问题所在。 这篇文章表述得相当清楚:

标记阶段的一部分工作涉及重新扫描应用程序线程更改的对象(即查看对象A是否已被应用程序线程更改,以便A现在引用另一个对象B,并且B以前未被标记为活动)。

我想说:当你一个房间一个房间地搜寻时,如果孩子们把眼镜移来移去,你可能会错过它们。

关于场景的说明

我相信上述情景是可能的,只是不是程序通常执行的方式。举一个相当现实的例子,考虑以下内容:
void swap(Object[] a, int i, int j) {
    Object tmp = a[i];
    a[i] = a[j];
    // Now the original reference a[i] is in a register only.
    a[j] = tmp;
}

谢谢你的回答。我不确定这种情况是否可能发生,但让我们假设它是可能的。那么我有另一个问题。如果这种情况是可能的,那么在标记阶段,即使停止世界,如何确定哪个对象是活动的,即如何遍历寄存器状态?这是不可能的。我认为解决方案应该是在执行标记之前刷新寄存器状态。然后就没有问题了。对吗? - Joffrey
我一遍又一遍地思考那种情况。在我看来,那种情况是不可能的。一个线程读取字段 a.x 意味着一个寄存器加载了地址为 a.x 的值。假设该寄存器为 $r1,线程设置 a.x=Null 可以被翻译为 add $r1, 0, 0,这意味着将 $r1 设为零。然后 store $r1, a.x 的地址。在这一点上,先前加载到 $r1 上的值就没有了。 - Joffrey
@Joffrey 关于寄存器,我会说备注阶段会停止整个程序并重新检查所有的根节点,就像最初的标记阶段一样。它还必须像并发标记阶段那样遵循所有链接,但速度要快得多,因为它不需要从已标记为活动对象(现在几乎所有可达对象都是如此)的对象中前进。 - maaartinus
@Joffrey 你的寄存器示例是错误的,因为你错误地翻译了这个Java代码片段 Object o = a.x; a.x = null; a.x = o;。将一个内存位置置为空实际上不能用置空一个寄存器代替(即你的 r1,即 o)。 - maaartinus
我猜测注释阶段(实际上在任何收集器中,所有的停止-全球收集阶段)可能构成了一个内存屏障,强制线程刷新其状态。 - biziclop
@biziclop 我敢打赌,在此之前,所有线程都必须到达safepoint。在safepoint,它们很可能会推送它们的寄存器,并执行更复杂的操作以帮助GC。例如,必须有一种方法来区分引用和其他变量。 - maaartinus

1
GC必须始终查看自当前周期开始以来已被修改的每个存储引用,因为有可能在GC尚未查看的地方保存某个引用,并将其从原始位置中删除。并发GC可以在不停止整个系统的情况下重新访问已修改的引用,但在此过程中,其他线程可能会继续修改更多的引用。如果在进行全面扫描时有10%的对象被修改,那么访问该10%而不停止整个系统可能是值得的,但在访问该10%时,其他线程可能会干扰3%。同时访问该3%可能是值得的,但其他线程可能会干扰2%。进一步的遍历可能会减少需要在停止整个系统模式下完成的工作量,但在其他线程仍在干扰引用的过程中,GC不太可能完全完成。除非所有线程都突然停止使用引用,否则GC永远无法100%完成而不停止整个系统。
请注意,可能有一种GC设计可以承诺永远不会停止超过某个时间的全局垃圾回收,但代价是可能会使新的分配请求无法得到满足。GC无法确定完成一个循环需要多长时间,因为在循环完成之前,它无法知道是否存在一个尚未发现的引用指向了一个大型的、尚未发现的对象集合的根节点。另一方面,如果GC达到一个点,在没有这样的发现的情况下,它将期望在1ms内完成,那么它可以停止全局垃圾回收,但如果新的发现导致它需要花费超过2ms的时间,则可以让全局垃圾回收重新开始。让全局垃圾回收重新开始将使必须再次停止全局垃圾回收才能释放任何内存,而且很难保证没有事件组合会导致无限循环的终止清理尝试,但如果允许的“停止全局垃圾回收时间”相对于代码使用引用的“翻转”量是合理的,那么失败的最终清理应该很少出现,重复的最终清理更是例外。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接