原文来自互联网,由长沙DotNET技术社区编译。尽管这是一篇来自2009年的古老的文章,但或许能够对你理解GC产生一些作用。
了解.NET中的垃圾回收
一旦了解了.NET的垃圾收集器是如何工作的,那么可能会触及.NET应用程序的一些更为神秘的问题时,进行原因分析就会变得更加清楚。NET已经不在提供显式内存管理的方式,但在开发.NET应用程序时,仍然有必要分析内存的使用情况,以便避免与内存相关的错误和某些性能问题。
.NET的垃圾收集器已在Windows应用程序中作为显式内存管理和内存泄漏的结束而开放给我们:这个想法是,在后台运行垃圾收集器的情况下,开发人员不再需要担心管理它们创建的对象的生命周期–应用程序完成处理后,垃圾收集器将对其进行处理。
但是,实际情况要复杂得多。垃圾收集器无疑解决了非托管程序中最常见的泄漏-由开发人员在完成使用后忘记释放内存而引起的泄漏。它还解决了内存释放过早的相关问题,但是当垃圾收集器对开发人员对对象是否仍然处于“活动状态”并且能够进行开发时有不同的看法时,解决该问题的方式可能导致内存泄漏。要使用的。解决这些问题之前,您需要对收集器的工作方式有所了解。
垃圾收集器如何工作
那么,垃圾收集器如何实现其魔力?基本思想非常简单:它检查对象在内存中的布局方式,并通过遵循一系列引用来标识正在运行的程序可以“访问”的所有那些对象。
当垃圾回收开始时,它将查看一组称为“ GC根”的引用。这些是由于某种原因总是可以访问的内存位置,并且包含对程序创建的对象的引用。它将这些对象标记为“活动”,然后查看它们引用的所有对象。它也将这些标记为“实时”。它以这种方式继续,遍历它知道是“活动”的所有对象。它将它们引用的所有内容都标记为也被使用,直到找不到其他对象为止。
如果某个对象或其超类之一的字段包含另一个对象,则该对象由垃圾收集器标识为引用另一个对象。
一旦知道了所有这些活动对象,就可以丢弃所有剩余的对象,并将空间重新用于新对象。.NET压缩内存,以确保没有间隙(有效地压缩丢弃的对象不存在)–这意味着空闲内存始终位于堆的末尾,并可以非常快速地分配新对象。
GC根本身不是对象,而是对对象的引用。GC根引用的任何对象将自动在下一个垃圾回收中保留下来。.NET中有四种主要的根:
当前正在运行的方法中的局部变量被视为GC根。这些变量引用的对象始终可以通过声明它们的方法立即访问,因此必须保留它们。这些根的生命周期可以取决于程序的构建方式。在调试版本中,局部变量的持续时间与方法在堆栈上的时间一样长。在发行版本中,JIT能够查看程序结构以找出执行过程中该方法可以使用变量的最后一点,并在不再需要该变量时将其丢弃。这种策略并不总是使用,可以通过例如在调试器中运行程序来关闭。
静态变量也始终被视为GC根。声明它们的类可以随时访问它们引用的对象(如果是公共的,则可以访问程序的其余部分),因此.NET将始终保持它们不变。声明为“线程静态”的变量仅会在该线程运行时持续存在。
如果通过互操作将托管对象传递给非托管COM +库,则该对象也将成为具有引用计数的GC根。这是因为COM +不进行垃圾收集:它使用引用计数系统;通过将引用计数设置为0,一旦COM +库完成了该对象,它将不再是GC根目录,并且可以再次收集。
如果对象具有终结器,则在垃圾回收器确定该对象不再“处于活动状态”时,不会立即将其删除。相反,它成为一种特殊的根,直到.NET调用了finalizer方法。这意味着这些对象通常需要从内存中删除一个以上的垃圾回收,因为它们在第一次发现未使用时仍将生存。
对象图
总体而言,.NET中的内存形成了一个复杂的,打结的引用和交叉引用图。这可能使得很难确定特定对象使用的内存量。例如,List对象使用的内存非常小,因为List 类只有几个字段。但是,其中之一是列表中的对象数组:如果列表中有许多条目,则这可能会很大。这几乎总是由列表“独占”,因此关系非常简单:列表的总大小是小的初始对象和它引用的大数组的大小。但是,数组中的对象可能完全是另一回事:很可能存在通过内存的其他路径来访问它们。在这种情况下,
当循环引用开始起作用时,事情变得更加混乱。
在开发代码时,通常将内存视为组织为更容易理解的结构:从各个根开始的树:
确实,以这种方式进行思考确实使(更确实可能)思考对象在内存中的布局方式。这也是编写程序或使用调试器时表示数据的方式,但这很容易忘记一个对象可以附加到多个根。这通常是.NET中内存泄漏的来源:开发人员忘记或从未意识到,一个对象锚定到多个根。考虑一下此处所示的情况:将GC root 2设置为null实际上不会允许垃圾收集器删除任何对象,这可以从查看完整图形中看到,而不能从树中看到。
内存剖析器可以从另一个角度查看图形,就像树根植于单个对象并向后跟随引用以将GC根放在叶子上一样。对于根2引用的ClassC对象,我们可以向后跟随引用以获取下图:
通过这种方式的思考表明,ClassC对象具有两个最终的“所有者”,在垃圾收集器将其删除之前,这两个对象都必须放弃它。一旦将GC根目录2设置为null,就可以断开GC根目录3与该对象之间的任何链接,以便将其删除。
在实际的.NET应用程序中,这种情况很容易出现。最常见的是,数据对象被用户界面中的元素引用,但在数据处理完毕后不会被删除。这种情况并不是很泄漏:当用新数据更新UI控件时,将回收内存,但是这可能意味着应用程序使用的内存比预期的要多得多。事件处理程序是另一个常见原因:很容易忘记一个对象的寿命至少与它从中接收事件的对象一样长,对于某些全局事件处理程序(如Application类中的事件),这种情况永远存在。
实际的应用程序,尤其是那些具有用户界面组件的应用程序,具有比这复杂得多的图形。甚至可以从大量不同的地方引用对话框中的标签之类的简单内容…
很容易看到偶然的物体如何在迷宫中丢失。
垃圾收集器的局限性
仍在引用的未使用对象
.NET中垃圾收集器的最大局限性是一个细微的限制:虽然它可以检测和删除未使用的对象,但实际上它会找到未引用的对象。这是一个重要的区别:程序可能永远不会再引用对象。但是,尽管有一些路径导致它可能仍被使用,但它永远不会从内存中释放出来。这导致内存泄漏;在.NET中,当将不再使用的对象保持引用状态时,会发生这些情况。
尽管内存使用率上升的症状很明显,但这些泄漏的来源可能很难发现。有必要确定哪些未使用的对象保留在内存中,然后跟踪引用以找出为什么不收集它们。内存分析器对于此任务至关重要:通过比较发生泄漏时的内存状态,可以找到麻烦的未使用对象,但是没有调试器可以向后跟踪对象引用。
垃圾收集器旨在处理大量资源,也就是说,释放对象的位置无关紧要。在现代系统上,内存属于这一类(何时回收内存无关紧要,只要及时完成以防止新分配失败)。仍然有一些资源不属于此类:例如,需要快速关闭文件句柄以避免引起应用程序之间的共享冲突。这些资源不能由垃圾收集器完全管理,因此.NET为管理这些资源的对象提供Dispose
()方法以及using
()构造。在这些情况下,对象的稀缺资源可通过实施Dispose
方法,但是紧要的内存要少得多,然后由垃圾回收器释放。
Dispose
意味着.NET没有什么特别的,因此仍必须取消引用已处置的对象。这使已处置但尚未回收的对象成为内存泄漏源的良好候选对象。
堆的碎片
.NET中一个鲜为人知的限制是大对象堆的限制。成为该堆一部分的对象不会在运行时移动,这可能导致程序过早地耗尽内存。当某些对象的寿命比其他对象长时,这将导致堆在对象过去所在的位置形成孔-这称为碎片。当程序要求一个大的内存块,但堆变得非常分散,以至于没有单个内存区域足以容纳它时,就会发生问题。内存分析器可以估计程序可以分配的最大对象:如果该对象正在下降,则很可能是原因。一个OutOfMemoryException
当程序显然具有大量可用内存时,通常会发生由碎片引起的错误–在32位系统上,进程应至少能够使用1.5Gb,但是由于碎片导致的故障通常会在使用该碎片之前开始发生很多内存。
碎片化的另一个征兆是.NET通常必须保留分配给应用程序的空洞所使用的内存。这显然导致它使用比在任务管理器中查看所需的内存更多的内存。这种效果通常相对来说是无害的:Windows非常擅长于意识到未被占用的孔所占用的内存并将其分页,并且如果碎片没有恶化,则程序将不会耗尽内存。但是,对于用户而言,这看起来并不好,他们可能会认为该应用程序浪费且“ blo肿”。当探查器显示程序分配的对象仅使用少量内存,而任务管理器显示该进程占用大量空间时,通常会发生这种情况。
垃圾收集器的性能
在性能方面,垃圾收集系统的最重要特征是垃圾收集器可以随时开始执行。这使它们不适用于定时至关重要的情况,因为任何操作的定时都可能被收集器的操作所抛弃。
.NET收集器有两种主要的操作模式:并发和同步(有时称为工作站和服务器)。默认情况下,并发垃圾收集用于桌面应用程序,同步用于服务器应用程序(例如ASP.NET)。
在并发模式下,.NET将尝试避免在进行收集时停止正在运行的程序。这意味着在给定的时间内应用程序可以完成的总次数较少,但应用程序不会暂停。这对交互式应用程序很有用,在交互应用程序中,给用户留下印象,即应用程序应立即做出响应,这一点很重要。
在同步模式下,.NET将在垃圾收集器运行时挂起正在运行的应用程序。实际上,这总体上比并发模式更有效–垃圾回收花费相同的时间,但是不必与程序继续运行进行竞争–但是,这意味着必须执行完整的回收时会有明显的暂停。。
如果默认设置不合适,则可以在应用程序的配置文件中设置垃圾收集器的类型。当更重要的是应用程序具有高吞吐量而不是显示响应时,选择同步收集器可能很有用。
在大型应用程序中,垃圾收集器需要处理的对象数量会变得非常大,这意味着访问和重新排列所有对象都将花费很长时间。为了解决这个问题,.NET使用了“分代”垃圾收集器,该垃圾收集器试图将优先级赋予较小的一组对象。这个想法是,最近创建的对象更有可能被快速释放,因此,当试图释放内存时,分代垃圾收集器会优先处理它们,因此.NET首先查看自上一次垃圾收集以来已分配的对象,并且只会开始如果无法通过这种方式释放足够的空间,请考虑使用较旧的对象。
如果.NET可以自行选择收集时间,则此系统效果最佳,并且如果GC.Collect
调用()会中断该系统,因为这通常会导致新对象过早地变旧,这增加了在不久的将来再次进行昂贵的完整收集的可能性。
具有终结器的类也会破坏垃圾收集器的平稳运行。这些类的对象不能立即删除:相反,它们进入终结器队列,并在运行终结器后从内存中删除。这意味着它们所引用的任何对象(以及那些对象所引用的任何对象,依此类推)至少也必须在此之前保留在内存中,并且在内存再次可用之前需要两次垃圾回收。如果该图包含带有终结器的许多对象,则这可能意味着垃圾收集器需要多次通过才能完全释放所有未引用的对象。
有一个避免此问题的简单方法:IDisposable
在可终结类上实现,将完成对象所需的操作移到Dispose()
方法中并GC.SuppressFinalize()
在最后调用。然后可以修改终结器以调用该Dispose()
方法。GC.SuppressFinalize()
告诉垃圾回收器,该对象不再需要终结,可以立即被垃圾回收,这可以导致更快地回收内存。
结论
如果您花一些时间了解垃圾收集器的工作方式,则更容易理解应用程序中的内存和性能问题。它表明,尽管.NET减轻了内存管理的负担,但并不能完全消除跟踪和管理资源的需求。但是,使用内存分析器来诊断和修复.NET中的问题更加容易。考虑到.NET在开发中尽早管理内存的方式可以帮助减少问题,但是即使那样,由于框架或第三方库的复杂性,此类问题仍然可能出现。