《Effective C#》笔记(2) - .NET的资源管理

理解并善用.NET的资源管理机制

.NET环境会提供垃圾回收器(GC)来帮助控制托管内存,这使得开发者无须担忧内存泄漏等内存管理问题。尽管如此,但若是开发者可以把本身应该执行的那些清理工做作好,那么垃圾回收器会表现得更为出色。非托管的资源是须要由开发者控制的,例如数据库链接、GDI+对象、IO等;此外,某些作法可能会令对象在内存中所待的时间比你预想的更长,这些都是须要咱们去了解、避免的。数据库

GC的检测过程是从应用程序的根对象出发,把与该对象之间没有通路相连的那些对象断定为不可达的对象,也就是说,凡是没法从应用程序中的活动对象(live object)出发而到达的那些对象都应该获得回收。应用程序若是再也不使用某个实体,那么就不会继续引用它,因而,GC就会发现这个实体是能够回收的。
垃圾回收器每次运行的时候,都会压缩托管堆,以便把其中的活动对象安排在一块儿,使得空闲的内存可以造成一块连续的区域。网络

针对托管堆的内存管理工做彻底是由垃圾回收器负责的,可是除此以外的其余资源则必须由开发者来管理。
有两种机制能够控制非托管资源的生存期app

  • 一种是finalizer/destructure(析构函数)
  • 另外一种是IDisposable接口。

在这两种方式中,应该优先考虑经过IDisposable接口来更为顺畅地将资源及时返还给系统,由于finalizer做为一种防御机制,虽然能够确保对象老是可以把非托管资源释放掉,但这种机制有一些缺陷ide

  • 首先,C#的finalizer执行得并不及时。当垃圾回收器把对象断定为垃圾以后,它会择机调用该对象的finalizer,但开发者并不知道具体的时机,所以,finalizer只能保证由某个类型的对象所分配的非托管资源最终能够获得释放,但并不保证这些资源可以在肯定的时间点上获得释放,所以,设计与编写程序的时候,尽可能不要建立finalizer,即使建立了,也不要过多地依赖于它的执行时机。函数

  • 另外,依赖finalizer还会下降程序的性能,由于垃圾回收器须要执行更多的工做才能终结这些对象。若是GC发现某个对象已经成为垃圾,但该对象还有finalizer须要运行,那么就没法马上把它从内存中移走,而是要等调用完finalizer以后,才能将其移除。调用finalizer的那个线程并非GC所在的线程。GC在每个周期里面会把包含finalizier可是还没有执行的那些对象放在队列中,以便安排其finalizer的运行工做,而不含finalizer的对象则会直接从内存中清理掉。等到下一个周期,GC才会把已经执行了finalizer的那些对象删掉。性能

声明字段时,尽可能直接为其设定初始值

类的构造函数有时不止一个,若是某个成员变量的初始化在构造函数进行,就会有忘记给某些成员变量设定初始值的可能性。为了完全杜绝这种状况,不管是静态变量仍是实例变量,最好都在声明的时候直接初始化,而不要等实现每一个构造函数的时候再去赋值。this

表面上看,在构造函数初始化和在声明的时候直接初始化等效,但实际上若是选择在声明的时候直接初始化,编译器会把由这些语句所生成的程序码放在类的构造函数以前。这些语句的执行时机比基类的构造函数更早,它们会按照本类声明相关变量的前后顺序来执行。spa

但也并非说,如什么时候候都优先在声明的时候直接初始化,在下面三种状况下,声明的时候直接初始化是不建议的,甚至会带来问题:操作系统

  1. 把对象初始化为0或null。系统在执行开发者所编写的代码以前,自己就会生成初始化逻辑,以便把相关的内容全都设置成0,这是经过底层CPU指令来作的。这些指令会把整块内存全都设置成0,所以,你若是还要编写初始化语句,让编译器会添加相关指令,把那些内存再度清零,那就显得多余了。线程

  2. 若是不一样的构造函数须要按照各自的方式来设定某个字段的初始值,那么就不该该再在声明的时候初始化了,由于它只适用于那些老是按相同方式来初始化的变量。
    就相似这样的写法:

public class MyClass
{
  private List<string> labels = new List<string>();
  
  public MyClass(int size)
  {
    labels = new List<string>(size);
  }
}

这会在构造类实例的过程当中建立出两个不一样的List对象,并且先建立出来的那个List立刻就会被后建立的List取代,实际上等因而白建立了一次。这是由于字段的初始化语句会先于构造函数而执行,因而,程序在初始化labels字段时,会根据其初始化语句的要求建立出一个List,而后,等到执行构造函数时,又会根据其中的赋值语句建立出另外一个List,并致使前一个List失效。
编译器所生成的代码至关于下面这样:

public class MyClass
{
  private List<string> labels;
  
  public MyClass(int size)
  {
    labels = new List<string>();
    labels = new List<string>(size);
  }
}
  1. 若是初始化变量的过程当中有可能出现异常,那么就不该该使用初始化语句,而是应该把这部分逻辑移动到构造函数里面。因为成员变量的初始化语句不能包裹在try-catch块中,所以初始化的过程当中一旦发生异常,就会传播到对象以外,从而令开发者没法在类里面加以处理,应该把这种初始化代码放在构造函数中,以便经过适当的代码将异常处理好。

用适当的方式初始化类中的静态成员

经过静态初始化语句或者静态构造函数均可以初始化类中的静态成员。若是只需给静态成员分配内存便可将其初始化,那么用一条简单的初始化语句就足够了,反之,如果必须经过复杂的逻辑才能完成初始化,则应考虑建立静态构造函数。
静态初始化语句与实例字段的初始化语句同样,静态字段的初始化语句也会先于静态构造函数而执行,而且有可能比基类的静态构造函数执行得更早。若是静态字段的初始化工做比较复杂或是开销比较大,那么能够考虑运用Lazy 机制,将初始化工做推迟到首次访问该字段的时候再去执行。

静态构造函数是特殊的函数,会在初次访问该类所定义的其余方法、变量或属性以前执行,能够用来初始化静态变量、实现单例(singleton)模式,或是执行其余一些必要的工做,以便使该类可以正常运做。
当程序码初次访问应用程序空间(application space,也就是AppDomain)里面的某个类型以前,CLR会自动调用该类的静态构造函数。这种构造函数每一个类只能定义一个,并且不能带有参数。

因为静态构造函数是由CLR自动调用的,所以必须谨慎处理其中的异常。若是异常跑到了静态构造函数外面,那么CLR就会抛出TypeInitialization-Exception以终止该程序。调用方若是想要捕获这个异常,那么状况将会更加微妙,由于只要AppDomain尚未卸载,这个类型就一直没法建立,也就是说,CLR根本就不会再次执行其静态构造函数,这致使该类型没法正确地加以初始化,并致使该类及其派生类的对象也没法得到适当的定义。所以,不要令异常脱出静态构造函数的范围。

不要建立无谓的对象

虽然垃圾回收器可以有效地管理应用程序所使用的内存,但在堆上建立并销毁对象仍需耗费必定的时间,所以应尽可能避免过多地建立对象,也不要建立那些根本不用去从新构建的对象。此外,在函数中以局部变量的形式频繁建立引用类型的对象也是不合适的,应该把这些变量提高为成员变量,或是考虑把最经常使用的那几个实例设置成相关类型中的静态对象。

绝对不要在构造函数里面调用虚函数

这里有个构造函数里面调用虚函数的demo,运行后打印出的结果是"VFunc in B",仍是"VFunc in B1",仍是"Msg from main"?答案是"VFunc in B1"。

public class B
{
  protected B()
  {
    VFunc();
  }

  protected virtual void VFunc()
  {
    Console.WriteLine("VFunc in B");
  }
}

public class B1 : B
{
    private readonly string msg = "VFunc in B1";

    public B1(string msg)
    {
      this.msg = msg;
    }

    protected override void VFunc()
    {
      Console.WriteLine(msg);
    }

    public static void Init()
    {
      _ = new B1("Msg from main");
    }
}

为何会这样呢,这要从构建某个类型的首个实例时系统所执行的操做提及,步骤以下:

  1. 把存放静态变量的空间清零。
  2. 执行静态变量的初始化语句。
  3. 执行基类的静态构造函数。
  4. 执行本类的静态构造函数。
  5. 把存放实例变量的空间清零。
  6. 执行实例变量的初始化语句。
  7. 适当地执行基类的实例构造函数。
  8. 执行本类的实例构造函数。

因此会先初始化B1.msg,而后执行基类B的构造函数。基类的构造函数调用了一个定义在本类中可是为派生类所重写的虚函数VFunc,因而程序在运行的时候调用的就是派生类的版本,由于对象的运行期类型是B1,而不是B。在C#语言中,系统会认为这个对象是一个能够正常使用的对象,由于程序在进入构造函数的函数体以前,已经把该对象的全部成员变量全都初始化好了。尽管如此,但这并不意味着这些成员变量的值与开发者最终想要的结果相符,由于程序仅仅执行了成员变量的初始化语句,而还没有执行构造函数中与这些变量有关的逻辑。

在构建对象的过程当中调用虚函数有可能令程序中的数据混乱,也会让基类的代码严重依赖于派生类的实现细节,而这些细节是没法控制的,这种作法很容易出问题。因此应该避免这样作。

实现标准的dispose模式

dispose模式用于对非托管资源进行释放,托管资源是指受GC管理的内存资源,而非托管资源与之相对,则不受GC的管理,当使用完非托管资源后,必须显式释放它们。 最经常使用的非托管资源类型是包装操做系统资源的对象,如文件、窗口、网络链接或数据库链接。 虽然垃圾回收器能够跟踪封装非托管资源的对象的生存期,但没法了解如何发布并清理这些非托管资源。
好比System.IO.File中的FileStream,它属于.NET的类被GC管理,但它的内部又依赖了操做系统提供的API,所以能够看做是一个Wrapper, 所以要实现dispose模式,在自身被GC销毁的时候,释放文件句柄。

标准的dispose(释放/处置)模式既会实现IDisposable接口,又会提供finalizer,以便在客户端忘记调用IDisposable.Dispose()的状况下也能够释放资源。

在类的继承体系中,位于根部的那个基类应该作到如下几点:

  • 实现IDisposable接口,以便释放资源。
  • 若是自己含有非托管资源,那就添加finalizer,以防客户端忘记调用Dispose()方法。如果没有非托管资源,则不用添加finalizer。
  • Dispose方法与finalizer(若是有的话)都把释放资源的工做委派给虚方法,使得子类可以重写该方法,以释放它们本身的资源。

继承体系中的子类应该作到如下几点:

  • 若是子类有本身的资源须要释放,那就重写由基类所定义的那个虚方法,若是没有则没必要重写。
  • 若是子类自身的某个成员字段表示的是非托管资源,那么就实现finalizer,不然就没必要实现。
  • 记得调用基类的同名函数。

下面两个类UnManaged与MyUnManaged做为非托管资源的示例,假设UnManaged类中直接使用了非托管资源:

public class UnManaged : IDisposable
{
  private bool alreadyDisposed;

  public void Dispose()
  {
    Dispose(true);
    GC.SuppressFinalize(this);
  }

  protected virtual void Dispose(bool isDisposing)
  {
    if (alreadyDisposed)
      return;
    if (isDisposing)
    {
      // free managed resource here
    }

    // free unmanaged resource here
    alreadyDisposed = true;
  }

  public void ExampleMethod()
  {
    if (alreadyDisposed)
      throw new ObjectDisposedException(nameof(UnManaged), "Call methods on disposed object");

    // do something
  }

  ~UnManaged()
  {
    Dispose(false);
  }
}

public class MyUnManaged : UnManaged
{
  private bool alreadyDisposedInDerived;

  protected override void Dispose(bool isDisposing)
  {
    if (alreadyDisposedInDerived)
      return;
    if (isDisposing)
    {
      // free managed resource here
    }

    // free unmanaged resource here

    base.Dispose(isDisposing); // call base.Disposes

    alreadyDisposedInDerived = true;
  }
}

UnManaged直接使用了非托管资源,所以须要析构函数。虽然前面提到存在析构函数的对象不会被GC当即回收,但做为一种防范机制是必须的,若是使用者忘调用Dispose,finalizer仍然确保非托管资源能够获得释放。尽管程序性能或许会所以而有所降低,但只要客户代码可以日常调用Dispose方法,就不会有这个问题。Dispose方法中经过GC.SuppressFinalize(this)来通知GC没必要再执行finalizer。

实现IDisposable.Dispose()方法时,要注意如下四点:

  1. 把非托管资源全都释放掉。
  2. 把托管资源全都释放掉(这也包括再也不订阅早前关注的那些事件)。
  3. 设定相关的状态标志,用以表示该对象已经清理过了。若是对象已经清理过了以后还有人要访问其中的公有成员,那么你能够经过此标志得知这一情况,从而令这些操做抛出ObjectDisposedException。
  4. 阻止垃圾回收器重复清理该对象。这能够经过GC.SuppressFinalize(this)来完成。

但finalizer中执行的操做与Dispose有所区别,它只应释放非托管资源,所以为了代码复用,添加了Dispose的重载方法protected virtual void Dispose(bool isDisposing),它声明为protected virtual,能够被子类重写。被IDisposable.Dispose()方法调用时,isDisposing参数是true,那么应该同时清理托管资源与非托管资源,finalizer中调用时isDisposing为false,则只应清理非托管资源。

还有另一些注意事项:

  • 基类与子类对象采用独立的disposed标志来表示其资源是否获得释放,这么写是为了防止出错。假如共用同一个标志,那么子类就有可能在释放本身的资源时率先把该标志设置成true,而等到基类运行Dispose(bool)方法时,则会误觉得其资源已经释放过了。
  • Dispose(bool)与finalizer都必须编写得很可靠,也就是要具有幂等(idempotent)的性质,这意味着屡次调用Dispose(bool)的效果与只调用一次的效果应该是彻底相同的。
  • 在编写Dispose或finalizer等资源清理的方法时,只应该释放资源,而不该该作其余的处理,不然极有可能致使内存泄漏等问题。

参考书籍

《Effective C#:改善C#代码的50个有效方法(原书第3版)》 比尔·瓦格纳

相关文章
相关标签/搜索