清空C#中的列表后再将其置为null

12

今天我看到了一段代码,刚开始看起来有些奇怪,但后来让我重新考虑了。这是代码的缩短版本:

if(list != null){
    list.Clear();
    list = null;
}

我的想法是,为什么不简单地用以下内容替换它:

list = null;

我阅读了一些内容并理解,清空列表将删除对象的引用,使GC可以处理,但不会“调整大小”。该列表的分配内存保持不变。

另一方面,设置为null也会删除对列表的引用(因此对其项目的引用),从而允许GC启动清理。

因此,我一直在尝试找出像第一个块那样做的原因。我想到的一个场景是如果您有两个对列表的引用。第一个块将清除列表中的项目,因此即使第二个引用仍然存在,GC仍然可以清除为项目分配的内存。

尽管如此,我觉得这里有些奇怪,所以我想知道我提到的情况是否有意义?

此外,还有其他情况我们需要在将引用设置为null之前清除列表吗?

最后,如果我提到的情况有意义,最好确保我们不同时持有多个对该列表的引用,我们该如何做到这一点(明确)?

编辑:我了解清空和置空列表之间的区别。我主要想知道,在GC内部是否有任何原因要先清除再置空。


通常而言,将其设置为 null 是您通常应该执行的操作。一旦 GC 意识到 List 是不可达的 - 它将意识到它的内容(即底层数组及其所指向的内容)也是不可达的。有些场景中清除可能是值得的 - 例如,如果底层 List 有多个引用,并且您不仅想摆脱对此 List 的引用,还想确保所有 其他 引用都指向一个空列表(如您在问题中确定的那样)。公正地说,这很少见。 - mjwills
除了通过作用域(即将列表作为函数作用域或“私有”并且从不在外部公开)之外,没有简单的方法来做到这一点。那么我们该如何明确地确保我们不同时持有对此列表的多个引用呢? - mjwills
在这种情况下,它是私有的并且仅在类内部使用。 - DereckM
在这种情况下,它们是等价的。 - mjwills
1
如果这是对该列表唯一的引用,那么在将保存列表的变量置空之前立即调用List.Clear()是无用的。假设是这种情况,那么作者可能既不知道这一点,也可能混淆了List.Clear与其他在其类中是Dispose同义词的Clear方法(例如Stream.Dispose),并且因此认为List.Clear是一种可以单独处理列表元素的“深度Dispose”。 - davidbak
显示剩余2条评论
5个回答

8
在您的情况下(其中列表是私有的且仅在类内部使用),list.Clear()不必要。
关于可达性/活动对象的一个好的入门级链接是http://levibotelho.com/development/how-does-the-garbage-collector-work
引用类型变量指向它,垃圾收集器如何确定对象是否为垃圾。在垃圾收集器的上下文中,引用类型变量被称为“根”。根的示例包括:
- 栈上的引用 - 静态变量中的引用 - 在托管堆上的另一个对象中的引用,该对象不符合垃圾收集条件 - 方法中的局部变量形式的引用
在此背景下的关键点是“在托管堆上的另一个对象中的引用,该对象不符合垃圾收集条件”。因此,如果List可以被回收(并且列表中的对象没有在其他地方引用),则列表中的这些对象也可以被回收。
换句话说,在同一步骤中,GC将意识到list及其内容是不可达的。
因此,是否存在list.Clear()会有用的情况? 是的。如果您对单个列表有两个引用(例如作为两个不同对象中的两个字段),则其中一个引用可能希望以影响另一个引用的方式清除列表,此时list.Clear()就很完美。

4

这个答案最初是为Mick撰写的评论而起草的,他声称:

这取决于你使用的.NET版本。在像Xamarin或mono这样的移动平台上,您可能会发现垃圾收集器需要这种帮助才能完成其工作。

这个说法需要事实核查。那么,让我们看看...


.NET

.NET使用分代标记和扫描垃圾回收器。您可以在垃圾回收期间发生了什么中看到算法的摘要。总体而言,它会遍历对象图,如果无法访问对象,则该对象可被擦除。

因此,垃圾回收器将在同一次迭代中正确地识别列表项为可回收项,无论您是否清空列表。没有必要预先解耦对象。

这意味着在.NET的常规实现中清空列表不会帮助垃圾回收器。

注意:如果列表有另一个引用,则清空列表的事实将是可见的。


Mono 和 Xamarin

Mono

事实证明,Mono 也是如此。

Xamarin.Android

同样适用于Xamarin.Android

Xamarin.iOS

然而,Xamarin.iOS需要额外考虑。特别是,MonoTouch将使用包装的Objective-C对象,这些对象超出了垃圾回收器的范围。请参见iOS性能下避免强循环引用。这些对象需要不同的语义。
Xamarin.iOS通过保持缓存来最小化使用Objetive-C对象:
C# NSObjects也是按需创建的,当您调用返回NSObject的方法或属性时。此时,运行时将查看对象缓存,并确定是否已经将给定的Objective-C NSObject暴露给托管世界。如果该对象已经暴露,则返回现有对象,否则将调用以IntPtr作为参数的构造函数来构造该对象。
即使从托管代码中没有引用,系统也会保持这些对象的活动状态:
NSObjects的用户子类通常包含C#状态,因此每当Objective-C运行时对这些对象执行“retain”操作时,运行时都会创建一个GCHandle,即使没有C#可见引用该对象,也会保持托管对象的生命。这样做可以简化很多簿记工作,因为状态将自动保留。因此,在Xamarin.iOS下,如果列表可能包含包装的Objective-C对象,此代码将有助于垃圾回收器。请参见问题在Xamarin.IOS上如何管理内存Miguel de Icaza在他的回答中解释语义是在引用时“保留”对象,在引用为空时“释放”它。
在 Objective-C 方面,“release” 并不意味着销毁对象。Objective-C 使用引用计数垃圾回收器。当我们“retain”对象时,计数器会增加,而当我们“release”对象时,计数器会减少。当计数器达到零时,系统会销毁对象。请参见:关于内存管理
因此,在 Xamarin.iOS 中应避免使用循环引用(如果 A 引用 B,B 引用 A,则它们的引用计数不为零,即使无法访问它们)。事实上,忘记解除引用会导致 Xamarin.iOS 中的泄漏... 请参见:Xamarin iOS memory leaks everywhere

其他

dotGNU同样使用分代标记和扫描垃圾回收器。

我也看了一下CrossNet(将IL编译为C++的工具),他们似乎也尝试实现了这个功能。我不知道它的表现如何。


很好的分析。我并没有打算暗示任何特定的CLI实现更容易出现内存泄漏,这也是我经常使用“可能”一词的原因。只是垃圾回收器的健壮性不能从一个实现保证到另一个实现。当然,在这些平台上从一个版本到另一个版本也是另一回事了。Xamarin和Mono只是可能更容易出现内存泄漏的平台示例。我敢打赌,在树莓派上的.NET也可能是另一个候选者。 - Mick
@Mick,.NET Compact Framework(ARM)和.NET Micro Framework(Netduino)都使用标记和清除。它们实际上更容易,因为它们不允许您处理指针,因此不需要固定对象。但是性能是一个问题。我不知道树莓是否曾经运行过其中之一,但它可以运行Mono和.NET Core,这些都是标记和清除的。如果您忘记处理本机包装器,则可能会发生泄漏,而与GC无关。至于版本,所有这些GC都基于Boehm GC,其原始版本早于.NET,大多数平台已转向并发变体。 - Theraot
是的,我不声称对GC的任何实现有详细的了解。但是我有使用它们的经验。距离我上次使用.NET Compact Framework已经有一段时间了,十年前我确实遇到了在.NET Compact Framework上运行的应用程序的内存问题,这些问题肯定通过解耦得到了解决。我想紧凑框架GC自那时以来已经进行了重大升级。运行紧凑框架的设备的处理能力肯定已经大幅提高,这将允许GC在CPU使用方面更加积极。 - Mick
@Mick 十年前大多数人使用那个东西 - 为它构建是一种痛苦,但在除了 Visual Studio 2008 以外的任何其他工具中构建它 - 据我所知 - 是不可能的。我肯定不会安装它来检查它有多糟糕。如果我没记错的话,垃圾回收器很慢,启动时间过长,垃圾会慢慢积累。当它运行时,它会停止你的程序。我认为在预定义位置使用 GC.Collect 是常见做法,既可以减少垃圾,也可以控制停顿。不... 让我们假装没有人使用它,并忘记它。 - Theraot

2
这取决于你使用的.NET版本。在移动平台上,如Xamarin或mono,您可能会发现垃圾回收器需要这种帮助才能完成其工作。而在桌面平台上,垃圾回收器的实现可能更加复杂。每个CLI的实现都将有其自己的垃圾回收器实现,并且从一个实现到另一个实现的行为可能不同。
我记得10年前我在开发一个Windows Mobile应用程序时遇到过内存问题,这种代码就是解决方案。这可能是因为移动平台需要一种比桌面更节省处理能力的垃圾回收器。
解耦对象有助于简化垃圾回收器需要执行的分析,并有助于避免垃圾回收器无法识别一个大型对象图已经与应用程序中的所有线程断开连接的情况。这会导致内存泄漏。
任何认为在.NET中不可能存在内存泄漏的人都是经验不足的.NET开发人员。在桌面平台上,仅确保调用实现Dispose方法的对象可能已经足够了,但在其他实现中,您可能会发现这并不够。
List.Clear()将使列表中的对象与列表及彼此解耦。
编辑:需要明确的是,我并不声称目前存在的任何特定实现容易出现内存泄漏。而且,根据阅读此答案的时间,当前CLI的任何实现的垃圾回收器的稳健性可能已经发生了变化。
本质上,我建议如果您知道您的代码需要跨平台使用,并在许多.NET框架实现中使用,特别是在移动设备的.NET框架实现中使用,那么将对象解耦合当它们不再需要时可能值得投资时间。在这种情况下,我会首先向已经实现Dispose的类添加解耦合,然后如果需要,考虑在不实现IDisposable的类上实现IDisposable并确保调用Dispose。
如何确保是否需要?您需要在每个平台上仪器化和监控应用程序的内存使用情况。我认为最好的方法不是编写大量多余的代码,而是等待监控工具指示您存在内存泄漏。

List.Clear() 不会将列表中的对象相互解耦。它不是某种“深度清除”。它只会将元素与列表结构本身解耦。 - davidbak
@davidbak,就垃圾回收器而言,列表中的对象在同一图形中,因此通过列表间接耦合在一起,这是我最初陈述的意思。如果列表成员彼此引用,则无论如何进行List.Clear()操作都不会解除这些引用。 - Mick

1

文档中所述:

List.Clear 方法(): 计数器被设置为0,集合元素对其他对象的引用也被释放。

在你的第一段代码中:

if(list != null){
    list.Clear();
    list = null;
}

如果您只是将list设置为null,则意味着您释放了对内存中实际对象的list引用(因此list本身仍然在内存中),并等待垃圾收集器来释放其分配的内存。
但问题是,您的列表可能包含持有对其他对象的引用的元素,例如:
list → objectA, objectB, objectC
objectB → objectB1, objectB2

所以,在将list设置为null后,现在list没有引用,稍后应该被垃圾回收器收集,但是objectB1objectB2仍然有一个引用来自于objectB(仍然在内存中),因此垃圾回收器需要分析对象引用链。为了使它不那么混乱,此代码片段使用.Clear()函数来消除这种混淆。


所以如果我理解正确,这是为了防止在对象持有对象的引用的情况下出现缓慢的垃圾收集(例如 objectB1 -> objectB3 -> objectB4),因为垃圾收集器需要进行多次迭代收集,并且每次迭代它都必须重新评估不再持有引用的项目(而不是在一个单一周期中收集所有对象及其对其他对象的引用)? - DereckM
1
似乎您的意思是,如果 ObjectA 持有对 ObjectB 的引用,并且 ObjectB 持有对 ObjectC 的引用,则 GC 的一次遍历将意识到 ObjectA 是不可访问的并清除它。然后,下一次遍历将意识到 ObjectB 是不可访问的并清除它。然后,下一次遍历将意识到 ObjectC 是不可访问的并清除它。这是您的理解吗?如果是这样,您有任何相关链接吗?我当然认为,如果 GC 确定 ObjectA 是不可访问的,它也会确定其他两个对象是不可访问的。 - mjwills
3
标记-清除、复制或分代垃圾收集器不能以这种方式工作。如果列表L包含元素A..Z,并且它们是指向A..Z的_唯一_指针,而A..Z包含指向AAA..ZZZ的指针,并且那些AAA..ZZZ只被A..Z及彼此所指向... 那么在将列表L的唯一指针置空后,跟踪收集器(标记/清除)不会跟踪A..Z或AAA..ZZZ,而复制收集器也不会复制它们。引用计数 GC在稍后进行“主要的”(标记/清除或复制)收集之前不会收集彼此相互指向的元素。但据我所知,.NET不使用引用计数。 - davidbak

-1
清空列表可以确保如果由于某些原因列表没有被垃圾回收,那么它所包含的元素仍然可以被处理掉。
正如评论中所述,防止其他引用存在需要仔细规划,并且在将列表置空之前清空列表不会带来足够大的性能损失,以证明试图避免这样做是值得的。

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