起因

最近看到了一段C#程序,一开始就简单的以为只是一个for循环的0~9的打印,但是深入了解之后发现我错了,而且对其中的原理相知甚少。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void Main(string[] args)
{
Action[] actions = new Action[10];
for (int i = 0; i < 10; i++)
{
actions[i] = () => {Console.WriteLine(i.ToString()); };
}

foreach (var action in actions)
{
action();
}
}

这段代码的运行结果输出是10个10,如果做对了,想必已经是了解其中的原理,下面的内容可以不用看了。

原因

原因其实很简单,使用编辑器查看IL代码,可以很清晰的看到:编译器为匿名函数生成了一个名为<>c__DisplayClass0_0的类,并把int i作为了类内的成员变量,并在类中生成了

b__0()方法,在方法中打印成员变量i。在函数运行时,new了一个名为cDisplayClass00的实例,并且在for循环中是不断的自增实例中的变量,最后把自己的
b__0()方法传入Action中。C#中class是默认分配在堆上的,所以每次增加的i变量都是同一个地址上的数据,最后执行action委托时,调用的也是同一个类的
b__0()方法,所以最后的结果自然全是10。
ILCode

闭包中存在的问题

既然匿名函数在使用时在堆上new了一个实例,分配了内存,所以必定涉及到了GC。那么代码中的哪种写法导会有严重的GC问题,哪种写法又能尽量避免GC。搜索之后,发现猫仙人的这篇文章总结得非常到位:文章传送连接,以下是我对他文章结论的进一步总结,具体论证细节可以点击连接去查看。

捕获变量的分类

外部变量的分类重要分为3种:
1、捕获静态变量
2、捕获实例字段
3、捕获外部方法的局部变量
其中分配内存的多少为:3>2>1>无闭包,并且捕获外部方法的局部变量的内存分配量高达2n次,为了记录外部局部变量的值,编译器每次都new了一个新的匿名类实例和Action实例。

优化方法

文章中指出了,可以通过增加参数数量,使用方法参数去传递要捕获的变量,避免掉对外部变量的捕获,同时避免闭包的额外内存分配。对于该文章开头的代码,若想改成输出0~9,就有两种办法,以下给出代码截图。第二种用了优化建议,内存性能要优于第一种。

捕获外部方法的局部变量

编译器new了n个匿名类实例和Action实例
ILCode

增加参数变量

编译器只new对应的Aciton实例
ILCode

总结

对于以上验证,可以简单粗暴的认为:runtime直接不使用()=>{}这种匿名函数的写法,能非常有效的避免额外内存开销。