1

在为我的 C# 代码创建一个单元测试时WeakReferences,我遇到了一些奇怪的 GC 行为 - 很奇怪,因为我无法对此做出解释。

问题源于在 GC 旨在收集它之后从我的弱引用中获取的对象上使用?. null 条件运算符。

这是复制它的最小代码:

    public class XYZClass
    {
        public string Name { get; set; }
    }

    public class Tests
    {
        public void NormalBehavior()
        {
            var @ref = new WeakReference<XYZClass>(new XYZClass { Name = "bleh" });

            GC.Collect();
            GC.WaitForPendingFinalizers();

            XYZClass t;
            @ref.TryGetTarget(out t);

            Console.WriteLine(t == null); //outputs true
        }

        public void WeirdBehavior()
        {
            var @ref = new WeakReference<XYZClass>(new XYZClass { Name = "bleh" });

            GC.Collect();
            GC.WaitForPendingFinalizers();

            XYZClass t;
            @ref.TryGetTarget(out t);

            Console.WriteLine(t == null); //outputs false
            Console.WriteLine(t?.Name == null); //outputs false
        }
    }

使用 linqpad 运行此代码时未显示该行为。我还检查了编译的 IL 代码(使用 linqpad),但仍然无法识别出任何问题。

4

1 回答 1

2

这与空条件运算符无关。您可以通过将其替换为普通成员访问来轻松看到这一点:

Console.WriteLine(t == null); //outputs false
Console.WriteLine(t.Name == null); //outputs false

对新XYZClass对象的原始引用在调试构建(并在调试器下运行)中永远不会“超出范围”。在 LINQPad 中关闭优化,您还会看到它t不为空。但请注意,所有这些都是实现细节 - 根据您系统的具体情况,您可以获得任一结果(例如,我得到了您在 32 位调试版本上得到的结果,但不是 64 位调试版本)。

.NET 中托管对象生命周期的唯一保证是终结器外部的强引用将阻止对象被收集。忘记所有确定性内存管理——它根本不存在。完全没有垃圾收集器的 .NET 实现将是完全有效的。

因此,让我们特别看一下在我的机器上生成的代码。在 64 位构建中,t.Name == null得到t?.Name == null完全相同的结果(当然t.Name == null会导致 aNullReferenceException而不是返回 true)。那么 32 位版本呢?

t.Name == null部分大大缩短:

00533111  mov         ecx,dword ptr [ebp-44h]   ; t
00533114  cmp         dword ptr [ecx],ecx  ; null check
00533116  call        00530D28  ; t.get_Name
0053311B  mov         dword ptr [ebp-54h],eax  ; Name string
0053311E  cmp         dword ptr [ebp-54h],0  ; is null?
00533122  sete        cl  
00533125  movzx       ecx,cl  
00533128  call        708B09F4  

您可以看到我们使用了两个寄存器(ecx 和 eax),以及两个堆栈槽(-44h 和 -54h)。那个t?.Name == null呢?

001F3111  cmp         dword ptr [ebp-44h],0   ; is t null?
001F3115  jne         001F311F  
001F3117  nop  
001F3118  xor         edx,edx  
001F311A  mov         dword ptr [ebp-54h],edx  ; result is false
001F311D  jmp         001F312A  
001F311F  mov         ecx,dword ptr [ebp-44h]  ; t
001F3122  call        001F0D28                 ; t.get_Name
001F3127  mov         dword ptr [ebp-54h],eax  
001F312A  cmp         dword ptr [ebp-54h],0    ; is name null?
001F312E  sete        cl  
001F3131  movzx       ecx,cl  
001F3134  call        708B09F4  
001F3139  nop  

我们仍然使用相同的两个堆栈槽,但需要另一个寄存器 - edx。这可能是我们正在寻找的吗?完全正确!如果我们看一下对象最初是如何创建的:

001F30A0  mov         ecx,2C0814h  
001F30A5  call        001330F4  ; new XYZClass
001F30AA  mov         dword ptr [ebp-48h],eax  ; tmp
001F30AD  mov         ecx,dword ptr [ebp-48h]  
001F30B0  call        001F0D38  ; tmp.XYZClass()
001F30B5  mov         edx,dword ptr ds:[36B230Ch]  ; "bleh"
001F30BB  mov         ecx,dword ptr [ebp-48h]  
001F30BE  cmp         dword ptr [ecx],ecx  
001F30C0  call        001F0D30  ; tmp.set_Name("bleh")
001F30C5  nop  
001F30C6  mov         ecx,2C0858h  
001F30CB  call        710F9ECF  ; new WeakReference
001F30D0  mov         dword ptr [ebp-4Ch],eax  
001F30D3  mov         ecx,dword ptr [ebp-4Ch]  
001F30D6  mov         edx,dword ptr [ebp-48h]  ; EDX references tmp!
001F30D9  call        709090B0  
001F30DE  mov         eax,dword ptr [ebp-4Ch]  
001F30E1  mov         dword ptr [ebp-40h],eax  

您可以看到,空条件版本使用的寄存器与用于保存对 的临时引用的寄存器相同XYZClass。这就是差异的根源——运行时不能排除edx访问是对临时引用的使用,因此它会安全地播放它并保持对象的根,从而防止它被收集。

64 位版本(并且在没有附加调试器的情况下运行)没有看到差异,因为它重用了不同的寄存器 - 在我的特定机器上,64 位版本重用rcx(包含对WeakReference, not的引用XYZClass),并且非调试器 32 位版本重用eax(包含对 的引用"bleh")。由于edx(and rdx) 从未在该方法中使用,因此临时引用不再是根目录,并且可以自由收集。

为什么调试器版本edx特别使用?最有可能的是,它试图提供帮助。在 null 条件运算符的中间,您希望同时查看tand的值t?.Name,以便更好地访问它们(您可以在 Locals 中将其视为“XYZClass.Name.get 返回“bleh”字符串”)。

同样,请注意,这完全是特定于实现的。合同仅指定何时不得回收对象 - 它没有说明何时回收

于 2017-02-23T11:39:47.003 回答