Weakreferences 和 null 条件运算符的奇怪 C# GC 行为
Weird C# GC behavior for Weakreferences and the null-conditional operator
在为使用 WeakReferences
的 C# 代码创建单元测试时,我 运行 遇到了一些奇怪的 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),但仍然无法识别任何错误。
这与空条件运算符无关。您可以通过将其替换为普通成员访问权限来轻松看到它:
Console.WriteLine(t == null); //outputs false
Console.WriteLine(t.Name == null); //outputs false
对新 XYZClass
对象的原始引用在调试版本中永远不会 "out of scope"(在调试器下 运行)。在 LINQPad 中关闭优化,您还会看到 t
不为空。但请注意,所有这些都是一个实现细节 - 根据您系统的具体情况,您可以获得任一结果(例如,我得到了您在 32 位调试版本上获得的结果,但不是 64 位调试版本)。
关于 .NET 中托管对象生命周期的唯一保证是终结器外部的强引用将阻止收集对象。忘掉所有确定性的内存管理——它就是不存在。完全没有垃圾收集器的 .NET 实现是完全有效的。
因此,让我们特别看看在我的机器上生成的代码。在 64 位版本中,t.Name == null
和 t?.Name == null
具有完全相同的结果(当然 t.Name == null
会导致 NullReferenceException
而不是返回 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
您可以看到,null 条件版本使用了用于保存对 XYZClass
的临时引用的同一寄存器。这就是差异的来源 - 运行时不能排除 edx
访问是对临时引用的使用,因此它可以安全地发挥作用并使对象保持根目录,从而防止它被收集。
64 位版本(和未附加调试器的 运行)看不出区别,因为它重用了不同的寄存器 - 在我的特定机器上,64 位版本重用 rcx
(其中包含对 WeakReference
的引用,而不是 XYZClass
),非调试器 32 位版本重用 eax
(其中包含对 "bleh"
的引用)。由于 edx
(和 rdx
)从未在该方法中使用,因此临时引用不再是根目录,可以自由收集。
为什么调试器版本特别使用edx
?最有可能的是,它试图提供帮助。在 null 条件运算符的中间,您想查看 t
和 t?.Name
的值,以便更好地访问它们(您可以在 Locals 中将其视为 "XYZClass.Name.get returned "bleh" 字符串").
再次注意,这完全是特定于实现的。合同仅指定何时 不得 回收对象 - 它没有说明何时 将 回收。
在为使用 WeakReferences
的 C# 代码创建单元测试时,我 运行 遇到了一些奇怪的 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),但仍然无法识别任何错误。
这与空条件运算符无关。您可以通过将其替换为普通成员访问权限来轻松看到它:
Console.WriteLine(t == null); //outputs false
Console.WriteLine(t.Name == null); //outputs false
对新 XYZClass
对象的原始引用在调试版本中永远不会 "out of scope"(在调试器下 运行)。在 LINQPad 中关闭优化,您还会看到 t
不为空。但请注意,所有这些都是一个实现细节 - 根据您系统的具体情况,您可以获得任一结果(例如,我得到了您在 32 位调试版本上获得的结果,但不是 64 位调试版本)。
关于 .NET 中托管对象生命周期的唯一保证是终结器外部的强引用将阻止收集对象。忘掉所有确定性的内存管理——它就是不存在。完全没有垃圾收集器的 .NET 实现是完全有效的。
因此,让我们特别看看在我的机器上生成的代码。在 64 位版本中,t.Name == null
和 t?.Name == null
具有完全相同的结果(当然 t.Name == null
会导致 NullReferenceException
而不是返回 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
您可以看到,null 条件版本使用了用于保存对 XYZClass
的临时引用的同一寄存器。这就是差异的来源 - 运行时不能排除 edx
访问是对临时引用的使用,因此它可以安全地发挥作用并使对象保持根目录,从而防止它被收集。
64 位版本(和未附加调试器的 运行)看不出区别,因为它重用了不同的寄存器 - 在我的特定机器上,64 位版本重用 rcx
(其中包含对 WeakReference
的引用,而不是 XYZClass
),非调试器 32 位版本重用 eax
(其中包含对 "bleh"
的引用)。由于 edx
(和 rdx
)从未在该方法中使用,因此临时引用不再是根目录,可以自由收集。
为什么调试器版本特别使用edx
?最有可能的是,它试图提供帮助。在 null 条件运算符的中间,您想查看 t
和 t?.Name
的值,以便更好地访问它们(您可以在 Locals 中将其视为 "XYZClass.Name.get returned "bleh" 字符串").
再次注意,这完全是特定于实现的。合同仅指定何时 不得 回收对象 - 它没有说明何时 将 回收。