当压缩发生后,GC如何更新引用?

21

.NET垃圾回收器负责回收对象(释放它们的内存),并执行内存压缩(以最小化内存碎片)。

我想知道,由于一个应用程序可能会有许多对对象的引用,当GC进行内存压缩时,GC(或CLR)如何管理这些对象的引用,因为对象的地址发生变化。

3个回答

14

这个概念很简单,垃圾回收器只需更新任何对象引用,并将其重新指向移动的对象。

实现起来有点棘手,本地代码和托管代码之间没有真正的区别,它们都是机器代码。对象引用也没有什么特殊之处,它只是在运行时的指针。需要的是一种可靠的方式,让垃圾收集器能够找到这些指针并将其识别为引用托管对象的指针类型。不仅要在紧缩时更新指向的对象,还要识别确保对象不会过早被回收的活动引用。

对于存储在GC堆上的类对象中存储的任何对象引用,这很简单,CLR知道对象的布局以及哪些字段存储了指针。但对于存储在堆栈或CPU寄存器中的对象引用(例如局部变量和方法参数),情况就不那么简单了。

执行托管代码与本地代码的关键属性之一是CLR可以可靠地迭代由托管代码拥有的堆栈帧。这是通过限制用于设置堆栈帧的代码类型来完成的。这通常在本地代码中是不可能的,"跨帧优化选项"尤其让人头痛。

堆栈帧遍历首先可以找到存储在堆栈上的对象引用。还可知道线程当前正在执行托管代码,因此应检查CPU寄存器中的引用。从托管代码到本地代码的转换涉及在堆栈上写入特殊的“cookie”,以便收集器识别。因此,它知道任何后续堆栈帧都不应被检查,因为它们将包含不随时引用托管对象的随机指针值。

当您启用未管理代码调试时,可以在调试器中看到这种情况。查看调用堆栈窗口并注意[Native to Managed Transition]和[Managed to Native Transition]注释。那是调试器认识到这些cookie。这对它也很重要,因为它需要知道Locals窗口是否可以显示任何有意义的内容。堆栈遍历也暴露在框架中,请注意StackTrace和StackFrame类。对于沙箱环境非常重要,代码访问安全性(CAS)执行堆栈遍历。


因此,它只需遍历所有引用并将其更新为指向新对象地址即可。我还以为有其他一些“魔法”在发生。谢谢! - lysergic-acid
3
另一种实现方式可能会决定使用双重间接指针,并且只在压缩时更新重定向全局表(由GC管理)中的第二个指针。但这会对CPU预取性能造成很大的影响。因此,选择通过GC遍历并原地更新直接指针来进行折衷处理,这是一种更快的全局解决方案。 - v.oddou
使用句柄(顺便提一下,这是1984年Macintosh中包含的一个功能)的优点在于,如果只允许句柄直接引用内存,并且使用句柄的代码在创建/获取/固定/取消固定/释放/销毁它们时需要使用适当的协议,则GC只需要维护已创建但未销毁的句柄列表,而不需要知道可能存在哪些引用到这些句柄。 - supercat

8
为简单起见,我将假设停止世界垃圾收集(GC),在这种情况下没有对象被固定,每个对象在每个GC周期都会被扫描和重新定位,并且目标不重叠。实际上,.NET GC要复杂一些,但这应该可以让您对工作原理有一个很好的了解。
每次检查引用时,有三种可能性:
1. 它是null。在这种情况下,不需要采取任何措施。 2. 它标识了一个对象,其标题显示它不是重新定位标记(下面描述的特殊类型的对象)。在这种情况下,将对象移动到新位置,并用包含新位置、包含刚刚观察到的引用的对象的旧位置以及对象内的偏移量的三个字的重新定位标记替换原始对象。然后开始扫描新对象(因为系统只是记录了其地址,所以可以暂时忘记正在扫描的对象)。 3. 它标识了一个头部显示为重新定位标记的对象。在这种情况下,更新正在扫描的引用以反映新地址。
一旦系统完成扫描当前对象,它就可以查看其旧位置,以找出在开始扫描当前对象之前它正在做什么。
一旦对象已被重新定位,其前三个字的旧内容将在其新位置处可用,并且在旧位置不再需要。由于对象偏移量始终是四的倍数,并且单个对象限制为每个2GB,因此只需要一小部分所有可能的32位值即可容纳所有可能的偏移量。只要对象头中的至少一个字有至少2^29个值它永远不能为除了对象重新定位标记以外的任何东西所用,并且每个对象都分配了至少12个字节,就可以处理任何深度的树形而不需要任何深度相关存储超出不再需要其内容的对象的旧副本所占用的空间。

这个答案似乎是最技术性的(这很好),但不幸的是它并不是非常清晰。例如,什么是重定位标记以及何时设置它?这三个单词是什么?当你说“每次检查引用”时 - 在哪个阶段?什么是“该偏移量内的偏移量”?等等。 - Arnon Axelrod
在编写了上一个命令之后,我又读了几遍并自己思考了一下,我相信我终于明白了 :-)。不过,我仍然认为你应该改进一下解释。 - Arnon Axelrod
@ArnonAxelrod:这样好一些吗? - supercat

4

垃圾回收

每个应用程序都有一组根。根标识存储位置,这些位置引用托管堆上的对象或设置为 null 的对象。例如,应用程序中所有全局和静态对象指针都被认为是应用程序的根。此外,线程堆栈上的任何局部变量 / 参数对象指针也被认为是应用程序的根。最后,包含指向托管堆中对象的指针的任何 CPU 寄存器也被认为是应用程序的根。活动根列表由即时 (JIT) 编译器和公共语言运行时维护,并被提供给垃圾回收算法。

当垃圾回收器开始运行时,它假设堆中的所有对象都是垃圾。换句话说,它假设应用程序的任何根都未引用堆中的任何对象。现在,垃圾回收器开始遍历根并构建从根可达到的所有对象的图形。例如,垃圾回收器可能会找到一个指向堆中对象的全局变量。

完成这部分图形后,垃圾回收器检查下一个根并再次遍历对象。随着垃圾回收器从对象到对象的遍历,如果它尝试将先前添加的对象添加到图形中,则垃圾回收器可以停止沿该路径向下遍历。这有两个目的。首先,它可以显著提高性能,因为它不会重复遍历一组对象。其次,它可以防止无限循环,如果您有任何循环对象列表。

一旦检查完所有根,垃圾回收器的图形包含从应用程序根的某种方式可达的所有对象集;不在图形中的任何对象都不可由应用程序访问,因此被视为垃圾。垃圾回收器现在线性地遍历堆,查找连续的垃圾对象块(现在被认为是空闲空间)。垃圾回收器然后将非垃圾对象向内存下移(使用您多年来所知道的标准 memcpy 函数),消除了堆中的所有间隙。当然,移动内存中的对象会使指向对象的所有指针失效。因此,垃圾回收器必须修改应用程序的根,以便指针指向对象的新位置。此外,如果任何对象包含指向另一个对象的指针,则垃圾回收器负责纠正这些指针。

C# fixed 语句

fixed 语句将指针设置为托管变量并在语句执行期间 "固定" 该变量。没有 fixed,对可移动的托管变量的指针几乎没什么用处,因为垃圾回收可能会不可预测地重定位这些变量。C#编译器只允许您在 fixed 语句中分配指针给托管变量。

垃圾回收: Microsoft .NET Framework 中的自动内存管理

fixed 语句 (C#


实际上我非常明白为什么这完全没有回答问题。虽然它是对GC工作的美妙解释,但它没有提及任何解决指针问题的方案。Hans Passant的回答实际上增加了混乱程度,而Ruben的回答则是零熵,纯粹冷静!因为原帖(还有像我一样的谷歌用户)已经知道了所有这些。我们想知道的是Hans说的答案。我不会点踩,但它应该受到谴责。 - v.oddou
@v.oddou:我添加了一个答案,其中包含了一个用于处理引用更新的停止-全球垃圾收集器机制。 - supercat

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