65.9K
CodeProject 正在变化。 阅读更多。
Home

编译器带来的意外垃圾回收结果

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2013年8月9日

Ms-PL

3分钟阅读

viewsIcon

19686

澄清 .NET GC 如何识别要回收的对象,一劳永逸... 希望如此...

引言

关于 .NET 垃圾回收器 (GC),仍然存在一些常见的误解。最常见的误解似乎是,回收的资格以某种方式与对象使用或变量作用域相关联。事实上,GC 对这两者都漠不关心。它只关心对象是活的还是死的(即,不可达的)。 表面上证明其中一个的运行时效果仅仅是由于源代码的不同 MSIL 表示形式造成的。 但让我们一步一步来。

“证明”GC 关心使用情况

这是一个小程序,它是常见示例的一个变体,该示例据称证明了 GC 分析变量的使用情况。或者不是。程序很简单。它启动一个计时器,延迟和间隔为 1 秒,等待用户按下按键,然后强制回收,然后等待第二个按键退出。

public static void Main(string[] args)
{
    Timer timer = new Timer(state => Console.WriteLine("Tick..."), null, 1000, 1000);

    Console.WriteLine("Press any key to force a garbage collection.");
    Console.ReadKey(true);
    GC.Collect();

    Console.WriteLine("Press any key to exit.");
    Console.ReadKey(true);
    Console.WriteLine("Good bye!");
} 

如果 GC 考虑使用情况,它可以在方法体第一行之后自由地回收计时器,并且在强制回收时肯定会回收它。对于第一个实验,我使用未优化的调试版本运行了该程序(稍后您会看到原因)。结果如下

Press any key to force a garbage collection.
Tick...
Tick...
Press any key to exit.
Tick...
Tick...
Good bye! 

所以我们已经证明了 GC 考虑使用情况。或者我们证明了吗?

“证明”GC 确实关心使用情况

现在让我们重新运行该程序,但这次使用优化的发布版本。

Press any key to force a garbage collection.
Tick...
Tick...
Press any key to exit.
Good bye!

这似乎证明了 GC 确实考虑了使用情况。但是这两个陈述不能同时为真。事实上,它们都是错的。同样,GC 回收不可达的对象,仅此而已。然后,一个明显的问题是,为什么 GC 回收一个看似可达的对象,但仅当我们使用代码优化编译时。

解开谜团

首先,让我们记住,高级编程语言(如 C#)中定义的作用域并不直接转换为已编译的代码。当然,在 MSIL 中,一个方法是一个作用域,原生代码表示中的函数也是一个作用域,但是这些作用域不必匹配。考虑这个毫无意义的方法

public static void Scope()
{
    {
        int i = 0;
    }
    {
        int i = 0;
    }
}

在 C# 中,这是两个不同的变量,都称为 i,但每个变量都有自己的作用域。然而,编译器后端对此毫不在意。第一个作用域需要一个 int 类型的局部变量,在 C# 源代码中,在我们退出第一个作用域后就不存在了。当然,MSIL 中的实际局部变量仍然存在,这意味着它可以重复用于第二个作用域中的整数 i。因此,编译器只发出一个局部变量

.method public hidebysig static void  Scope() cil managed
{
  // Code size       10 (0xa)
  .maxstack  1
  .locals init ([0] int32 i)
  IL_0000:  nop
  IL_0001:  nop
  IL_0002:  ldc.i4.0
  IL_0003:  stloc.0
  IL_0004:  nop
  IL_0005:  nop
  IL_0006:  ldc.i4.0
  IL_0007:  stloc.0
  IL_0008:  nop
  IL_0009:  ret
} // end of method Program::Scope

已编译的原生代码中的作用域可能会因优化(例如内联)而有所不同。但与手头的问题更相关的是,仅仅因为我们在 C# 代码中有一个局部变量,并不意味着在已编译的 MSIL 中一定必须有一个局部变量。当没有优化编译时,计时器程序简化为第一行,看起来像这样。

.method public hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       50 (0x32)
  .maxstack  4
  .locals init ([0] class [mscorlib]System.Threading.Timer timer)
  IL_0000:  nop
  IL_0001:  ldsfld     class [mscorlib]System.Threading.TimerCallback GCTest.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
  IL_0006:  brtrue.s   IL_001b
  IL_0008:  ldnull
  IL_0009:  ldftn      void GCTest.Program::'<Main>b__0'(object)
  IL_000f:  newobj     instance void [mscorlib]System.Threading.TimerCallback::.ctor(object,
                                                                                     native int)
  IL_0014:  stsfld     class [mscorlib]System.Threading.TimerCallback GCTest.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
  IL_0019:  br.s       IL_001b
  IL_001b:  ldsfld     class [mscorlib]System.Threading.TimerCallback GCTest.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
  IL_0020:  ldnull
  IL_0021:  ldc.i4     0x3e8
  IL_0026:  ldc.i4     0x3e8
  IL_002b:  newobj     instance void [mscorlib]System.Threading.Timer::.ctor(class [mscorlib]System.Threading.TimerCallback,
                                                                             object,
                                                                             int32,
                                                                             int32)
  IL_0030:  stloc.0
  IL_0031:  ret
} // end of method Program::Main

这是有道理的。我们声明了一个局部变量,所以在 MSIL 中有一个局部变量。我们创建一个新实例并将引用存储在局部变量中,MSIL 也是如此。一旦我们打开优化,情况就会发生巨大变化,编译器会胜过我们。它说:“好吧,你想将引用写入局部变量,但你不会再读回它了。永远。知道吗,我直接省略它!”

.method public hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       47 (0x2f)
  .maxstack  8
  IL_0000:  ldsfld     class [mscorlib]System.Threading.TimerCallback GCTest.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
  IL_0005:  brtrue.s   IL_0018
  IL_0007:  ldnull
  IL_0008:  ldftn      void GCTest.Program::'<Main>b__0'(object)
  IL_000e:  newobj     instance void [mscorlib]System.Threading.TimerCallback::.ctor(object,
                                                                                     native int)
  IL_0013:  stsfld     class [mscorlib]System.Threading.TimerCallback GCTest.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
  IL_0018:  ldsfld     class [mscorlib]System.Threading.TimerCallback GCTest.Program::'CS$<>9__CachedAnonymousMethodDelegate1'
  IL_001d:  ldnull
  IL_001e:  ldc.i4     0x3e8
  IL_0023:  ldc.i4     0x3e8
  IL_0028:  newobj     instance void [mscorlib]System.Threading.Timer::.ctor(class [mscorlib]System.Threading.TimerCallback,
                                                                             object,
                                                                             int32,
                                                                             int32)
  IL_002d:  pop
  IL_002e:  ret
} // end of method Program::Main

没错,优化的 MSIL 没有局部变量,这实际上解释了为什么计时器在发布版本中被回收,而不是在调试版本中。在发布版本中,没有引用保留在堆栈或任何其他地方。是的,该对象是“活的”,它正在做某事。但是对于 GC 来说,它是死的,因为它不可达;任何调用者都无法访问它。所以回收它没问题。就这么简单。

历史

  • 2013-08-10: 更改标题并添加标签
  • 2013-08-09: 初始发布
© . All rights reserved.