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

  • A+
所属分类:.NET技术
摘要

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


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

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

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

针对托管堆的内存管理工作完全是由垃圾回收器负责的,但是除此之外的其他资源则必须由开发者来管理。
有两种机制可以控制非托管资源的生存期

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

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

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

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

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

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

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

但也并不是说,如何时候都优先在声明的时候直接初始化,在下面三种情况下,声明的时候直接初始化是不建议的,甚至会带来问题:

  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版)》 比尔·瓦格纳