为什么C#中的Lambda表达式会导致内存泄漏?

15
注意:这不是一些随意无用的代码,而是尝试在C#中重现lambda表达式和内存泄漏问题的代码。
检查以下C#程序。这是一个控制台应用程序,简单地执行以下操作:
1.创建一个Test类型的新对象 2.向控制台写入对象已创建的消息 3.调用垃圾回收 4.等待任何用户输入 5.关闭
我使用JetBrains DotMemory运行此程序,并且拍摄了两个内存快照:一个是在对象初始化之后,另一个是在其被收集之后。我比较这些快照并得到我期望的结果:一个Test类型的死对象。
但是,这里有个问题:然后我在对象的构造函数中创建了一个本地lambda表达式,并且没有在任何地方使用它。它只是一个本地构造变量。我在DotMemory中运行相同的过程,突然间,我得到了一个Test+<>类型的对象,它可以在垃圾回收之后继续存在。
从DotMemory附带的保留路径报告中可以看出:lambda表达式具有指向Test+<>对象的指针,这是预期的。但是谁有指向lambda表达式的指针,为什么它会保存在内存中?
此外,这个Test+<>对象——我认为它只是一个临时对象,用于保存lambda方法,并与原始Test对象无关,我对吗?
public class Test
{
    public Test()
    {
        // this line causes a leak
        Func<object, bool> t = _ => true;
    }

    public void WriteFirstLine()
    {
        Console.WriteLine("Object allocated...");
    }

    public void WriteSecondLine()
    {
        Console.WriteLine("Object deallocated. Press any button to exit.");
    }
}

class Program
{
    static void Main(string[] args)
    {
        var t = new Test();
        t.WriteFirstLine();
        Console.ReadLine();
        t.WriteSecondLine();
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        Console.ReadLine();
    }
}

DotMemory retention path report


1
代码是否经过优化?(即发布模式) - user1228
如果您仍然持有对测试对象的引用,那么您如何指望GC.Collect()实际收集它呢?在调用GC.Collect()之前尝试将t设置为null,看看会发生什么。 - Zohar Peled
@ZoharPeled - 永远不要将对象设置为null。如果您在第10行中使用了一个对象,但是第500行调用object = null,则将其设置为null实际上会使其保持活动状态。另外,我从不干扰GC,我只是在这里出于测试目的而这样做,因为我使用内存分析器。 - user884248
@ZoharPeled 把所有东西都设置为 null,对我来说就像是“干扰垃圾回收器”。老实说,你根本不需要考虑垃圾回收。当你想使用对象时,请使用它们,并相信 GC 会在不再需要它们时清理它们。除非你正在使用非托管资源,否则你真的不需要考虑内存管理。 - Servy
我记得Lambda表达式开始被编译为静态对象,但在某个较近版本的.NET中它们曾经是实例对象,但我不记得是哪个版本了。正在寻找相关文章。 - Scott Chamberlain
显示剩余6条评论
2个回答

20

如果你使用类似 dotpeek 的工具反编译你的代码,你会看到编译器生成了类似这样的内容:

public class Test {
    public Test() {
        if (Test.ChildGeneratedClass.DelegateInstance != null)
            return;
        Test.ChildGeneratedClass.DelegateInstance = 
            Test.ChildGeneratedClass.Instance.DelegateFunc;
    }

    public void WriteFirstLine() {
        Console.WriteLine("Object allocated...");
    }

    public void WriteSecondLine() {
        Console.WriteLine("Object deallocated. Press any button to exit.");
    }

    [CompilerGenerated]
    [Serializable]
    private sealed class ChildGeneratedClass {
        // this is what's called Test.<c> <>9 in your snapshot
        public static readonly Test.ChildGeneratedClass Instance;
        // this is Test.<c> <>9__0_0
        public static Func<object, bool> DelegateInstance;

        static ChildGeneratedClass() {
            Test.ChildGeneratedClass.Instance = new Test.ChildGeneratedClass();
        }

        internal bool DelegateFunc(object _) {
            return true;
        }
    }
}

因此,它创建了一个子类,将您的函数作为该类的实例方法,创建了该类的单例实例,并最终创建了一个具有Func<object,bool>引用方法DelegateFunc静态字段。因此,编译器生成的这些静态成员不能被垃圾回收器清除。当然,这些对象并不是为每个创建的Test对象创建的,而只创建一次,因此我不能真正称之为“泄漏”。


2
非常感谢您提供如此详尽和出色的解释。我终于明白了。不,这根本不是泄漏!当我对我的真实应用程序进行分析并一直跟踪到看起来像是由lambda引起的泄漏时,我发现了这个问题。我现在理解了它的工作原理。这可能是我在StackOverflow上得到的最好的答案。谢谢! - user884248

7
我猜测你所看到的是编译器优化的影响。
假设多次调用Test()函数。编译器可以每次创建一个新的委托,但这似乎有点浪费。Lambda表达式既不捕获this,也不捕获任何局部变量或参数,因此单个委托实例可重用于所有Test()调用。编译器会生成延迟创建委托的代码,并将其存储在静态字段中。因此,它就像这样:
private static Func<object, bool> cachedT;

public Test()
{
    if (cachedT == null)
    {
        cachedT = _ => true;
    }
    Func<object, bool> t = cachedT;
}

现在这样创建的对象永远不会被垃圾回收,但如果频繁调用Test,它可以减少GC压力。不幸的是,编译器无法真正知道哪种方法更好。

通过查看lambda表达式产生的委托,可以使用引用相等性进行检测。例如,这将打印True(至少对我来说是这样;这是编译器实现的细节):

using System;

class Test
{
    private Func<object> CreateFunc()
    {
        return () => new object();
    }

    static void Main()
    {
        Test t = new Test();
        var f1 = t.CreateFunc();
        var f2 = t.CreateFunc();
        Console.WriteLine(ReferenceEquals(f1, f2));
    }
}

但是,如果您将lambda表达式更改为() => this;,则会打印False。

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