在工作中,经常会碰到诸如内存泄漏的问题,有时候会听到同事讨论在.NET中如何释放内存,比如说“不要使用using,要手动调用dispose”,“要手动的编写析构函数”等等,其中很多观点我觉得不对,今天稍微整理一下,本文译自Effective C#。

一 问题的由来


    在.NET这种托管环境中,垃圾回收为我们管理内存,和其他一些语言如C++不同,我们不必操心内存泄漏,非法指针,没有实例化的指针,以及其他一些内存管理的问题。但是垃圾回收也不是万能的。在有些时候,我们也必须自己手动的对使用过的资源进行清理。对一些非托管的资源,如文件句柄、数据库连接、GDI+对象,COM对象以及其他一些系统级别的对象进行访问后,我们需要进行手动的清理。另外,有时候可能会使得某些对象在内存中的存留时间比我们预期的要长,比如在我们创建事件或者代理的时候。一些查询表达式,因为有延迟执行的特性,会使得一些对象的生存期会比我们预想的要长。查询表达式会捕获闭包中的局部变量,这些变量需要等到我们离开调用代码域之后,才能被释放。

二 解决方法


    幸运的是,GC负责内存管理,GC通过标记-清除以及代的方式来为我们执行垃圾收集。GC工作在自己专有的线程里面,他会为我们回收不需要的内存。并且会对托管推进行压缩,压缩的过程中会涉及到对象的移动,以使得剩余的空间在内存中连续排列,以提高效率和节省空间。下图展示了垃圾收集前后的内存布局,所有剩余的空间在垃圾收集之后,已经连续排在一起了。

垃圾收集1

    垃圾收集完全负责托管推上的垃圾收集。其他用到的系统资源需要我们手动的进行管理。有两种机制可以帮助我们控制非托管资源的生存期: 终结器(finalizers) 和IDisposable 接口。终结器是一种防御性的编程机制,它能够保证你的对象总有一种方式能够释放非托管的资源。但是终结器有自身的缺点,所以,我们有IDisposable接口,来帮助我们以一种确定性的方式释放系统资源。

    垃圾收集器会调用方法的终结器,我们不知道什么时候被调用,只知道在对象不可达变为垃圾之后的某一时候会被调用。在.NET中,这和C++有很大的不同,所以这会影响到我们编写代码的方式。一般的,在C++中,我们会编写构造函数和析构函数。

// Good C++, bad C#:
class CriticalSection
{
    // Constructor acquires the system resource.
    public CriticalSection()
    {
      EnterCriticalSection();
    }

    // Destructor releases system resource.
    ~CriticalSection()
    {
      ExitCriticalSection();
    }

    private void ExitCriticalSection()
    {
       throw new NotImplementedException();
    }

    private void EnterCriticalSection()
    {
      throw new NotImplementedException();
    }
}

// usage:
void Func()
{
    // The lifetime of s controls access to
    // the system resource.
    CriticalSection s = new CriticalSection();
    // Do work.
    //...
    // compiler generates call to destructor.
    // code exits critical section.
}

 

    在C++中,这种方式能够确保在执行完成之后能够销毁资源。但是在C#中却不是这样,确定性销毁不是.NET环境或者C#的一部分。将C++中的处理方式搬到C#中可能不能很好的工作。在C#中,终结器会执行,但是是在非确定的时间执行的。在前面的例子中,虽然会执行析构函数,但是并不是在离开函数的调用域就立即执行的。他是在离开函数的调用域之后的某一时间执行的,我们不知道,也不能知道什么时候会执行。终结器是唯一一种能够保证对象非配的非托管资源会最终释放的方式,但是他会在非确定的时间执行,因此,我们在写代码的时候应该尽量不要创建终结器,也尽量不要执行需要终结操作的动作。下面将会介绍什么时候必须创建终结器,以及如何减少其负面影响。

    使用终结器也会导致性能损耗。对象需要终结操作会对垃圾收集器产生性能影响。当垃圾收集器发现一个对象已经成为垃圾,但是该对象又需要进行终结,他不能直接将该对象移除。首先,他需要调用该对象的终结操作。但是终结操作的执行和垃圾收集并不在同一个线程中。所以GC将这些需要进行终结操作的对象放到一个队列中,然后再创建另外一个线程来执行这些对象的终结操作。然后继续下一次收集,将垃圾从内存中移除。在下个GC周期中这些已经执行过终结操作的对象才能从内存中移除。在下图中,显示了三种不同的GC操作以及不同的内存使用。注意到,需要进行终结操作的对象在内存中会存续额外的垃圾收集周期。

垃圾收集2

    上面为了简单的说明具有终结操作的对象会存续额外的一个GC周期,这只是为了简化,事实上,由于.NET垃圾收集的策略,情况会复杂的多。.NET垃圾收集器采用了代的机制来优化垃圾收集开销。代能够帮助GC快速的找到最有可能成为垃圾的对象。最新收集的对象为第0代,在第0代收集中存活下来的对象为第1代,在前两次,或者多次收集中存活下来的对象为第2代。通常第0代大多为局部对象,成员变量及全局变量很快会成为第1代。

    GC通过限制检查第1代和第2代对象的频率来优化性能。每次GC时钟周期都会检查第0代对象, 10个GC时钟周期大概才会检查第0代和第1代对象,大概100个GC时钟周期才会检查到所有对象。现在再来看看由于需要进行终结操作而导致的开销。一个需要进行终结操作的对象可能在内存中会比不需要进行终结操作的对象多存续9个GC时钟周期。如果在第1代中仍没有被终结,那么就会进入第2代。在第2代中,对象会多存活近100个GC时钟周期,知道进行下一次对第2代对象的收集。

    上面讲了这么多来说明为啥终结器是一个不是特别好的解决方法,但是,我们仍然需要进行资源的释放,下面就来说明如何使用IDisposable接口以及标准的资源释放模式。

三 标准的资源释放模式


    前面讨论了使用终结器来处理非托管资源释放存在的问题,现在来看看,当我们创建的对象中包含非托管资源的时候,如何编写资源管理代码。在.NET框架中,对非托管资源的释放有一套标准的模式。在我们编写自己的对象时也应该遵循这一模式。标准的释放非托管代码的模式是:如果用户记得,那么就会调用IDisposable接口,如果用户忘记了就防御性的使用终结器操作。这是处理非托管资源的正确方式。

    基类中的对象应该实现IDisposable接口来释放资源,对象还应该添加终结器来作为一种防御性的机制,这些操作能够将资源释放操作放到虚方法中,因此继承的类能够覆写这些方法来对自己的资源进行管理。子对象只有在需要释放自己拥有的资源时才需要覆写虚方法,在覆写的方法中必须调用父类的该版本的方法。

    首先,如果你的类使用到了非托管方法,那么就需要一个终结器,我们不能够指望用户总是会调用Dispose方法,当用户忘记调用时,如果没有终结器的话,就会发生内存泄露。他们忘记调用Dispose是他们的问题,但是我们却需要承担责任。唯一能够保证非托管资源能够有效释放的操作是创建一个终结器。那就创建一个吧。

   在垃圾收集器工作时,它会立即移除内存中的那些没有终结器的垃圾对象。所有具有终结器的对象会留存在内存中。这些对象会添加到终结队列中,然后垃圾收集器会触发另外一个新的线程来执行这些对象的终结操作,在终结操作线程执行完成之后,垃圾收集器就可以将这些对象从内存中移除了。拥有终结操作的对象比没有改操作的对象在内存中会存续更多的时间。但是如果防御性编程中,如果我们的类型中使用到了非托管资源,我们必须这样做。现在先不要担心性能问题。后面我们会看到在使用终结器的时候,如何避免性能损耗。

    实现IDisposable接口是一种标准的通知用户以及运行时,我的对象必须在一个确定的时间点释放的方式。IDisposable接口里面只有一个方法:

public interface IDisposable
{
    void Dispose();
}

实现我们自己的IDisposable.Dispose() 方法主要执行以下任务:

  1. 释放所有的非托管资源
  2. 释放所有的托管资源,包括未注册的事件
  3. 设定一个标志位来标识对象已经被释放。我们需要检查这个标志位,然后如果在对象释放后再调用这个方法,需要抛出ObjectDisposed异常。
  4. 阻止终结操作(suppressing finalization),我们可以调用GC.SuppressFinalize(this)来完成该操作。

    我们通过两件事情来实现IDisposable操作:我们需要为调用者提供一种在确定性时间内来释放所有托管资源的机制,以及为调用者提供一种标准的释放所有非托管资源的方式。当在我们的类型中实现了IDisposable接口,调用者就能够避免终结操作所带来的开销了。这也是.NET框架设想我们需要做的。

    但是,这里面还是有一个问题。子类如何释放自己的资源,同时仍然要求基类清理资源。如果继承的类覆写了终结操作,或者添加了自己的IDisposable实现,这些方法必须调用基类的方法,否则基类不能够很好的释放资源。因此,终结器和Dispose方法拥有一些共同的职责:我们在终结方法和Dispose方法中拥有一些重复的代码。覆写接口函数并不能向我们那样预期的工作。标准终结模式中的第三个方法,一个受保护的虚方法,能够帮我们解决这些任务,它能够在继承类中添加一个钩子来释放他们分配的资源,基类中包含了核心接口的代码。虚函数为继承类提供了钩子来释放Dispose或者终结器中的资源。 

protected virtual void Dispose(bool isDisposing)

   这个重载的方法为终结器或者Dispose方法做了一些必要的操作。因为他是虚的,所以为所有继承类提供了一个接口,继承类可以覆写这个方法,提供一个合适的实现来释放自己的资源,然后调用基类的方法。当isDisposing对象为true的时候,清理托管和非托管资源,当isDisposing为false的时候,只清理非托管资源。在所有的情况中,都需要调用积累的Dispose方法,让他来释放自己的资源。

    下面是一个简单的例子,展示了如何实现这一模式。MyResourceHog类展示了实现IDisposable方法,并创建了一个虚的Dispose方法。

public class MyResourceHog : IDisposable
{

    // Flag for already disposed
    private bool alreadyDisposed = false;
    // Implementation of IDisposable.
    // Call the virtual Dispose method.
    // Suppress Finalization.
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    // Virtual Dispose method
    protected virtual void Dispose(bool isDisposing)
    {
        // Don't dispose more than once.
        if (alreadyDisposed)
            return;

        if (isDisposing)
        {
            // elided: free managed resources here.
        }
        // elided: free unmanaged resources here.
        // Set disposed flag:
        alreadyDisposed = true;
    }

    public void ExampleMethod()
    {
        if (alreadyDisposed)
            throw new ObjectDisposedException(
            "MyResourceHog",
            "Called Example Method on Disposed object");
        // remainder elided.
    }
}

 

    如果继承类需要执行额外的清理,需要实现受保护的Dispose方法。

public class DerivedResourceHog : MyResourceHog
{
    // Have its own disposed flag.
    private bool disposed = false;
    protected override void Dispose(bool isDisposing)
    {
        // Don't dispose more than once.
        if (disposed)
            return;

        if (isDisposing)
        {
            // TODO: free managed resources here
        }
        // TODO: free unmanaged resources here.
        // Let the base class free its resources.
        // Base class is responsible for calling
        // GC.SuppressFinalize( )
        base.Dispose(isDisposing);
        // Set derived class disposed flag:
        disposed = true;
    }
}

    注意到,不论是基类还是继承类都有一个标志位来指示对象的释放状态。这仅仅是一种防御性的机制。重复的标志位能够封装所有可能的错误。

    我们需要为Dispose和终结器编写防御性代码。释放对象可能以任意顺序进行。我们会遇到类型中的某些对象在我们调用Dispose()方法已经被释放了。我们不应该认为这是个错误,因为Dispose()方法可能会被调用多次。如果某个对象中,该方法已经调用过一次,那么第二次调用的时候,应该什么都不做。终结器方法也有类似的逻辑。任何你引用的对象仍然在内存中,我们不需要检查是否为空引用。但是,任何你引用的对象在被终结时,很可能已经被dispose掉了。

    MyResourceHog和DerivedResourceHog中都没有终结器。因为上面的代码中并没有包含非托管的资源,所以并不需要终结器。这意味着,上面的代码永远不会调用 Dispose(false)方法。这是一种正确的模式,除非我们的代码包含非托管的资源,否则我们不应该实现终结器。即使终结器不会被调用,终结器的存在也会引入较大的性能损失。除非对象需要终结器,否则不要添加。但是,我们应该正确实现这一模式,如果我们的继承类确实拥有了一些非托管资源,他们可以添加终结器,并实现Dispose(bool)方法来正确处理非托管资源。

    我们在dispose和终结器中,只应该进行释放资源的操作,不要在这些方法中执行一些其他的处理逻辑。否则,会引入一些列复杂的对象的生命周期的问题。当我们创建对象时,他们产生,当垃圾回收器回收时,他们死亡。我们可以认为,当我们的程序不再访问这些对象时,他们处于休眠状态,如果我们访问不到那个对象,我们不能够调用它的任何方法。在某种意义上,可以认为他已经死亡。终结器应该除了清理非托管资源,其他的什么都不应该做。如果终结器使得某个对象又可以访问了,那么他就复苏了。虽然处于存活状态,但是不完整,下面是一个例子。

public class BadClass
{
    // Store a reference to a global object:
    private static readonly List<BadClass> finalizedList =
    new List<BadClass>();

    private string msg;
    public BadClass(string msg)
    {
        // cache the reference:
        msg = (string)msg.Clone();
    }
    ~BadClass()
    {
        // Add this object to the list.
        // This object is reachable, no
        // longer garbage. It's Back!
        finalizedList.Add(this);
    }
}

    当BadClass执行终结操作,他在全局列表中添加了一个对自己的引用。他仅仅是时使得自己能够被访问,他又存活的。这种方式引入了很多问题。对象已经被终结了,因此垃圾收集器认为不需要再次调用其终结操作了。如果我们实在需要终结一个已经复苏的对象,办不到了。其次,一些资源可能变得不可用。GC不会将可达的对象,仅仅根据对象在终结队列中,就会将其从内存中移除。但是她有可能已经被终结了。如果是这样,他们在很大程度上已经不能够使用。虽然BadClass拥有的成员还在内存中,他们仍有可能被dispose或者终结掉。但是我们没有办法控制终结的顺序。所以一定不要这么做。

四 总结


    在托管环境下,我们并不需要为创建的每一个类型编写终结器,我们只需要为包含有非托管资源,或者类型中的成员实现了IDisposable的类型实现终结器操作。即使我们只需要Disposable接口,而不是终结器,我们也需要实现整个模式。遵循上面的标准模式,我们能够便利类的使用者,以及后续从该类的继承的类型。

 

参考


https://stackoverflow.com/questions/18336856/implementing-idisposable-correctly

https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1063

https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose