垃圾收集器在堆中移动数据时引用会更新吗?

Posted

技术标签:

【中文标题】垃圾收集器在堆中移动数据时引用会更新吗?【英文标题】:Do references get updated when Garbage Collectors move data in heap? 【发布时间】:2015-02-21 09:24:09 【问题描述】:

我读到 GC(垃圾收集器)出于性能原因在堆中移动数据,我不太明白为什么,因为它是随机存取内存,也许是为了更好的顺序访问,但我想知道堆栈中的引用是否会在这种情况下更新一个移动发生在堆中。但也许偏移地址保持不变,但数据的其他部分被垃圾收集器移动,我不确定。

我认为这个问题与实现细节有关,因为并非所有垃圾收集器都可以执行此类优化,或者他们可能会这样做但不会更新引用(如果这是垃圾收集器实现中的常见做法)。但我想得到一些特定于 CLR(公共语言运行时)垃圾收集器的整体答案。

我还在阅读 Eric Lippert 的“引用不是地址”文章here,下面的段落让我有点困惑:

如果您认为引用实际上是一个不透明的 GC 句柄,那么 很明显,要找到与句柄关联的地址 你必须以某种方式“修复”对象。你必须告诉 GC “直到 进一步注意,不得将带有此句柄的对象移入 内存,因为有人可能有一个指向它的内部指针”。(那里 有多种方法可以做超出本文范围的事情 熨平板。)

听起来对于引用类型,我们不希望移动数据。那么我们在堆中还存储了什么,我们可以移动它们以进行性能优化?也许我们存储在那里的类型信息?顺便说一句,如果您想知道那篇文章是关于什么的,那么 Eric Lippert 正在比较引用和指针,并尝试解释说引用只是地址可能是错误的,即使它是 C# 实现它的方式。

另外,如果我上面的任何假设是错误的,请纠正我。

【问题讨论】:

如果我没记错的话,是的。 GC 中有一个“重定位”阶段,它移动所有对象以删除/减少内存碎片,在这个阶段,对移动对象的引用得到更新。我会尝试从 Channel9(或者可能来自 MSDN 文章)中找到链接,并将更新此评论。编辑:这是链接:msdn.microsoft.com/en-us/library/…(看看搬迁阶段)。 @AdamHouldsworth:但我的问题是了解这是如何发生的:当整个对象移动到其他一些内存地址时,它是否通过更新它们来维护引用值,或者只是不移动初始地址对象数组,以便它不需要更改参考值。 @kha:您的链接将不胜感激!谢谢。 @Tarik 确实,这就是我没有发布答案的原因。我也在等答案。 另一个更详细的链接(我认为您无法获得比这更多的详细信息):informit.com/articles/article.aspx?p=1409801&seqNum=2这是您可能会觉得有趣的一点:When the garbage collection occurs, the memory occupied by objects B and D is reclaimed,which leads to gaps on the managed heap. To remove these gaps, the garbage collector compacts the remaining live objects (Obj A, C, and E) and coalesces the two free blocks (used to hold Obj B and D) into one free block. Lastly, the current allocation pointer is updated as a result of the compacting and coalescing 【参考方案1】:

是的,在垃圾回收期间引用会更新。必然地,当堆被压缩时,对象会被移动。压缩有两个主要目的:

它通过更有效地使用处理器的数据缓存使程序更高效。这对现代处理器来说是一个非常非常大的问题,与执行引擎相比,RAM 非常慢,快了两个数量级。当处理器必须等待 RAM 提供变量值时,它可能会因 数百 条指令而停止。 它解决了堆遭受的碎片问题。当一个被活体包围的小物体被释放时,就会发生碎片。不能用于其他任何东西的孔,只能用于相同或更小的物体。不利于内存使用效率处理器效率。请注意 LOH(.NET 中的大对象堆)如何没有被压缩,因此会遇到这种碎片问题。 SO 有很多关于这方面的问题。

尽管有 Eric 的说教,对象引用实际上只是一个地址。指针,与您在 C 或 C++ 程序中使用的指针完全相同。非常有效,必然如此。移动对象后,GC 所要做的就是更新存储在该指针中的地址,指向被移动的对象。 CLR 还允许为对象分配句柄,额外 引用。在 .NET 中作为 GCHandle 类型公开,但仅在 GC 需要帮助确定对象是否应该保持活动状态或不应该被移动时才需要。仅当您与非托管代码互操作时才相关。

不那么简单的是找到那个指针。 CLR 投入巨资以确保能够可靠和高效地完成。这样的指针可以存储在许多不同的地方。更容易找到的是存储在对象字段、静态变量或 GCHandle 中的对象引用。硬指针是存储在处理器堆栈或 CPU 寄存器上的指针。例如,发生在方法参数和局部变量上。

为了实现这一点,CLR 需要提供的一个保证是 GC 始终可以可靠地遍历线程的堆栈。因此它可以找到存储在堆栈帧中的局部变量。然后它需要知道 在哪里 在这样的堆栈帧中查找,这就是 JIT 编译器的工作。当它编译一个方法时,它不仅为该方法生成机器代码,它还构建一个表来描述这些指针的存储位置。您可以在 this post 中找到更多详细信息。

【讨论】:

想象一个世界很有趣,在这个世界中,引用是通过句柄间接寻址的地址,句柄是地址表的索引。当然,每次访问都会稍微慢一些,但我们为虚函数调用付出了完全相同的代价,而不必为此感到压力。当实现这样的方案时,您很快就会意识到,当一个对象被释放时,您会在地址表中找到一个洞,而现在您又回到了与以前相同的问题:如何消除这些洞以保持表的小。这里没有免费的午餐!我想问一个关于这个作为面试问题的变体。 @EricLippert,最初用于 68K 型号的 Apple 操作系统(Lisa/MacIntosh)就是这样工作的,即内存访问的双重间接。 @adrianm:即使在 C++ 中,也不难想象一种方案可能具有优势的情况。我一直在尝试使用该原理编写嵌入式 ARM 字符串库,目标是 RAM 低于 128K(在许多情况下低于 16K)的机器。让每个 C++ 对象拥有一个 16 位句柄 [有 128K 的 RAM,可以肯定的是,字符串的数量将足够小以适应] 然后引用 16 位字符串池缩放偏移量,并且只需要一个 C++对象识别任何句柄(必须构造/销毁)但允许多个句柄识别字符串,应该... ...工作得很好。我设想每个引用的成本为四个字节(池中两个,外部两个);最多 64 个字节的字符串实例将花费一个字节加上字符串内容。更长的字符串将涉及时间/空间权衡(我可能有一个“平面数组”类型,它表现为一个字符串,但存储为要连接的字符串数组,除了第一个和最后一个之外的所有字符串都被分组到32-63 个字符的块)。因此,连接和子字符串提取通常需要为新字符串的开始/结束创建新的短字符串实例,或者... ...连接两个字符串,但现有字符串的大部分可以重复使用。如果句柄每个占用四个字节,那么尝试支持与引用计数共享将没有任何意义。【参考方案2】:

查看C++\CLI In Action,有一节是关于内部指针与固定指针:

C++/CLI 提供了两种指针来解决这个问题。 第一种称为内部指针,由 运行时以反映所指向对象的新位置 每次重新定位对象时。指向的物理地址 内部指针永远不会保持不变,但它总是指向 同一个对象。另一种称为固定指针,它 防止 GC 重新定位对象;换句话说,它固定 对象到 CLR 堆中的特定物理位置。和一些 限制,内部,固定和之间的转换是可能的 本机指针。

由此,您可以得出结论,引用类型确实在堆中移动,并且它们的地址确实发生了变化。在标记和扫描阶段之后,对象在堆内被压缩,因此实际上移动到新地址。 CLR 负责跟踪实际存储位置并使用内部表更新这些内部指针,确保在访问时仍指向对象的有效位置。

这里有一个例子:

ref struct CData

    int age;
;

int main()

    for(int i=0; i<100000; i++) // ((1))
        gcnew CData();

    CData^ d = gcnew CData();
    d->age = 100;

    interior_ptr<int> pint = &d->age; // ((2))

    printf("%p %d\r\n",pint,*pint);

    for(int i=0; i<100000; i++) // ((3))
        gcnew CData();

    printf("%p %d\r\n",pint,*pint); // ((4))
    return 0;

解释如下:

在示例代码中,您创建了 100,000 个孤立的 CData 对象 ((1)) 所以 你可以填满 CLR 堆的很大一部分。然后你创建一个 存储在变量中的 CData 对象和 ((2)) 内部指针 到此 CData 对象的 int 成员年龄。然后你打印出 指针地址以及指向的 int 值。现在, ((3)) 你又创建了 100,000 个孤立的 CData 对象;沿着某处 行,垃圾收集循环发生(孤儿对象 之前创建的 ((1)) 被收集,因为它们没有被引用 任何地方)。请注意,您不使用 GC::Collect 调用,因为那是 不保证强制垃圾收集周期。正如你已经 在垃圾收集算法的讨论中看到 上一章,GC 通过删除孤立对象来释放空间 以便它可以进行进一步的分配。 在代码的末尾(通过 垃圾回收发生的时间),你再次((4))打印 取出指针地址和年龄值。这是我得到的输出 在我的机器上(请注意,地址因机器而异 机器,所以你的输出值不会一样):

012CB4C8 100
012A13D0 100

【讨论】:

这就是我们拥有内部的方式吗?:var person = new Person(); var name = person.Name;,那么name 是否会成为 C# 中的内部指针类型的引用? 在 C# 中,“内部指针”的概念并不真正存在,只存在于不安全的代码中。您可以从逻辑上看它是一个“内部指针”,它将在整个分配过程中始终引用正确的对象地址。执行不安全代码时,您检索的是内部指针,而不是本机指针。

以上是关于垃圾收集器在堆中移动数据时引用会更新吗?的主要内容,如果未能解决你的问题,请参考以下文章

67.Java垃圾收集机制对象引用垃圾对象的判定垃圾收集算法标记—清除算法标记—整理算法分代收集垃圾收集器性能调优

GC时如何处理对象引用

深入Java虚拟机之二:Java垃圾收集机制

深入Java虚拟机之八:Java垃圾收集机制

Java垃圾回收机制

CLR垃圾收集器