为什么存在try/finally块会阻止垃圾回收器工作?

7

让我先进行一次演示:

[TestMethod]
public void Test()
{
    var h = new WeakReference(new object());
    GC.Collect();
    Assert.IsNull(h.Target);
}

这段代码按预期工作。垃圾回收完成后,h中的引用被置空。现在,这里有一个小细节:

[TestMethod]
public void Test()
{
    var h = new WeakReference(new object());
    GC.Collect();
    try { }      // I just add an empty
    finally { }  // try/finally block
    Assert.IsNull(h.Target); // FAIL!
}

我在测试中添加了一个空的try/finally块,在GC.Collect()行之后,发现弱引用对象没有被收集!如果在GC.Collect()行之前添加一个空的try/finally块,则测试通过。这是怎么回事?有人能解释一下try/finally块如何影响对象的生命周期吗?
注意:所有测试都在Debug模式下进行。在Release模式下,两个测试都通过。
注意2:要重现此问题,必须将应用程序定位到.NET 4或.NET 4.5运行时,并以32位方式运行(目标x86,或使用“优先选择32位”选项的任何CPU)。

1
在等效的控制台程序上无法复现(VS 2010,调试和发布均如此)。 - xanatos
请再检查一下,我能够在等效的控制台程序中使用VS2010和VS2012进行复现,在调试模式下。 - Stefan Dragnev
也许这取决于 .NET 框架的子版本。 - xanatos
或者关于机器速度的问题...尝试在GC.Collect()之后添加GC.WaitForPendingFinalizers()。 - xanatos
新增信息 - 要重现必须针对.NET 4+并且必须在32位下运行。在64位或.NET 3.5(任何位数)中不会发生。WaitForPendingFinalizers没有效果。 - Stefan Dragnev
2个回答

3
当调试器被附加时,JIT编译器会改变本地变量的生命周期。详见这个答案。简而言之,没有调试器时,变量的生命周期在代码中最后一次使用结束,有调试器时,为了允许表达式观察器工作,它被延长到方法的结尾。
虽然在您的代码中new object()表达式看起来没有存储在变量中,但编译器生成器完成后仍然会有一个变量。对象引用存储在堆栈帧上,位置为[ebp-44h],与本地变量的使用方式无法区分。唯一能看到它的方式是通过查看生成的机器代码,使用调试+窗口+反汇编。否则完全正常,这种冗余的内存存储被JIT优化器消除,但不适用于调试版本。
即使它是一个临时变量,这个变量仍然需要报告给GC,以存储引用。这是必要的,以防止在对象构造函数调用和WeakReference构造函数调用之间发生GC时对象被回收。如果程序中的另一个线程触发了收集,那么就可能发生。
没有try/finally块,JIT仍然可以发现堆栈帧插槽存储一个临时变量,而实际上并没有需要扩展其生命周期的必要。因此,在GC.Collect()调用之前停止报告临时变量的生命周期,对象被垃圾回收。
但是,使用try/finally块时,JIT放弃尝试确定try或finally块中是否存在堆栈帧插槽的可能使用。并通过将其生命周期延长到方法的结尾来解决该问题,就像普通本地变量一样。
这都是相当正常的,您不能合理地假设在非优化代码中对待本地变量引用的方式。这也应该是对任何使用单元测试运行的[TestMethod]的人的强烈警告,永远不要测试代码的Debug版本,只测试Release版本。它与用户机器上的工作方式不同。

看起来x86 JIT有点弱。这个问题在x64 JIT或任何.NET 2.0 JIT中都不会发生 - 这显然是JIT的缺陷(或回归错误)。 - Stefan Dragnev
当您有意禁用优化器时,抖动程序没有义务生成优化代码。它唯一的要求是生成正确且可调试的代码。您可以使用connect.microsoft.com报告回归,但我预见会很快被标记为“不予修复”。这个问题实际上不需要修复。 - Hans Passant

0
为了更容易调试,在调试模式下,本地声明的对象不会被处理。虽然我无法重现您的问题,但使用此代码:
var x = new object();
var h = new WeakReference(x);
GC.Collect();
try { }      // I just add an empty
finally { }  // try/finally block
Console.WriteLine(h.Target != null);
Console.ReadKey();

我成功地重现了这个问题。如果GC.Collect()能够“收集”new object(),那么在Console.ReadKey()之后设置断点,你将能够看到一个已经被处理的对象(x)。

有人在这里问了类似的问题: https://dev59.com/VkfRa4cB1Zd3GeqP7Txr#755688

一个评论很有趣:

由于发布模式中的优化,引用范围仅在其最后使用而不是定义它的整个代码块中有效。

显然,在调试模式下相反,引用范围是定义它的整个范围。


是的,我理解为什么你的代码片段会重现这个问题(在调试版本中,x 的生命周期延长到作用域的末尾)。然而,我不能认为你的帖子是一个答案,因为我仍然能够在一个没有 x 变量的原始代码片段的控制台应用程序中重现这个问题,所以那里没有作用域问题。 - Stefan Dragnev

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