在本系列的第一篇文章《C#堆栈对比(Part Three)》中,介绍了值类型和引用类型在Copy上的区别以及如何实现引用类型的克隆以及使用ICloneable接口等内容。html
本文为文章的第四部分,主要讲解内存回收原理与注意事项,以及如何提升GC效率等问题。数据库
注:限于本人英文理解能力,以及技术经验,文中若有错误之处,还请各位不吝指出。小程序
C#堆栈对比(Part Four)ide
让咱们从GC的角度来看一看。若是咱们负责“倒垃圾”(taking out the trash),咱们须要高效率的作这件事。显然,咱们要判断什么东西是垃圾,什么东西不是(这对那些什么东西都不舍得仍的人会有一些麻烦)。函数
为了决定留下哪些东西,咱们首先假设在垃圾箱中的都是没有用的东西(如角落里的报纸、阁楼里的垃圾箱、厕所里的全部东西等等)。想象一下,咱们正在和两个“朋友”生活在一块儿:约瑟夫-伊凡-托马斯(Joseph Ivan Thomas, JIT)和辛迪-洛林-里士满(Cindy Lorraine Richmond,CLR)。约瑟夫和辛迪记录着内存的使用以及给咱们反馈记录信息。咱们将最开始的反馈信息列表称做“根”列表,由于咱们将从用它开始。咱们将保持一个主列表去描绘一个图形,这个图形显示了在房间中每同样东西的位置。任何咱们须要的使事物能工做的东西咱们都将增长进这个列表中(就像咱们看电视的时候不会把遥控器放的很远,咱们玩电脑的时候就会把键盘和显示器放在“列表”中)。工具
注:做者文章中的JIT和CLR只是以首字母人名的方式来简称概念名称。这一段文章的意思是引出一个概念:性能
这也是GC如何决定回收与否的原理。GC收到从JIT编译器和CLR根列表之中的对象引用,而后递归地查找对象引用,这样就能建立一个图来描述那些对象咱们应该保存。
根列表的组成:
● 全局/静态指针。在静态变量中这是一个经过保持引用的方式来确保咱们的对象不被回收。
● 指针是在栈(线程栈)上的。咱们不想将线程还须要继续执行的任何东西扔掉。
● CPU注册的指针。任何一个CPU指向一个内存地址的在托管堆上的指针都将被保护(不要丢掉这些指针)。
上图中,Object1,Objetc3和Object5在托管堆中是被根列表所引用的,Object1和Object5是被直接引用(指针指向)的,而Object3是在递归搜索中发现的。若是咱们将这个例子和电视机遥控器例子来作对比的话,就会发现Object1是电视机,Object3是遥控器。当这些都被图形化(图形化显示引用关系)后,咱们将进行下一步,压制操做(compacting)。
如今,咱们已经绘制出了咱们须要保留的对象的图形,咱们能把“保留的对象”放在一块儿。
注:灰色Box是没有被引用的对象,咱们能够移除,而且从新整理托管堆,使还保持引用的对象能“挨的近一些”,以便保持托管堆空间整齐。
幸运的是,在生活中咱们可能在咱们放其余一些东西的时候不须要整理屋子。因为Object2没有被引用,GC将向下移动Object3而且修复Object1的指针。
注:这里说的“修复Object3”的指针是由于Object3移动以后其内存地址改变了,因此也同时要更新指向Object3的指针地址,原理上来说指针仅仅知道一个地址值而不知道哪一个是Object3.
做者在原文中没提到的是GRAPH指向Object的指针也须要更新地址值,固然这不是主要关注点,以上为我的观点。
下一步,GC将Object5向下移动,以下图:
如今咱们已经整理好了托管堆,咱们仅仅须要一个便条而后放置在咱们刚刚压制好的托管堆的顶部来让Claire(实际上是Cindy彷佛做者总记错女友的名字J,CLR)知道在应该在那里放置新对象,以下图所示:
了解GC的本质能帮助咱们更好的理解可能很是低效的内存对象移动的状况。正如你所见到的,若是咱们下降咱们须要移动的对象的大小那将是有意义的,因为产生了更小的对象拷贝,这将总体上为GC提升很大的工做效率。
注:这里可能涉及的意义在于LOH大对象堆在内存中的管理问题,通常来讲,依据咱们的业务场景来“设计”内存数据分布,进而更好的管理大对象和一些常常要被建立和删除的内存碎片对象。第二个好处是帮助咱们理解GC是如何回收垃圾数据的,回收以后又有哪些操做,这些操做有什么样的影响,以及GC是如何依据“代”来管理垃圾的等等。
做为一个负责回收垃圾的的人,一个问题是当咱们清理屋子的时候,汽车中的东西该怎么处理。假设的前提是咱们清理东西的时候,咱们每样东西都要清理的。也就是说若是笔记本在屋子里,电池在汽车中该如何处理?
注:依据上下文的理解来看,做者想表达的意思是屋子里的垃圾天然迟早都会扔掉(托管资源),汽车里的垃圾大多数状况下可能因为开车人的疏忽而没有扔掉(相似于非托管资源),而咱们又是一个追去完美的人,必须清楚掉全部垃圾(包括汽车里的),那该怎么作呢?
现实的状况是GC须要执行代码去清理非托管资源,如文件句柄、数据库链接、网络链接等等。处理这些一个极可能的方式是利用终结函数(finalizer被称做析构器,这里借用C++的表达方式,其实本质是同样的 )。
注:析构函数不只仅在C++中可用,在C#代码中仍然可用,只是在更多的时候咱们会在代码中继承并实现IDisposeable接口去让GC调用Dispose()方法回收资源(更多请参考标准Dispose模式),终结器是在Dispose以后执行的而且确保当调用者没有调用Dispose的状况下也执行类的垃圾回收,不少时候使用Using(var a = new Class())语法糖的时候程序会自动执行Class的Dispose 方法,若是没有调用Dispose方法并且还存在非托管资源,这将会致使内存泄漏(Memory Leak)。
class Sample { ~Sample() { // FINALIZER: CLEAN UP HERE } }
在对象建立期间,全部带有终结器的对象被加入到了终结队列。咱们假设Object一、Object4和Object5带有终结函数而且在终结队列中。让咱们看看发生了什么,当对象Object2和Object4再也不被程序所引用时,他们已经为垃圾回收准备好了,以下图:
对象Object2按照正常的方式回收。然而,当咱们回收对象Object4时,GC知道它在终结队列中而且代替直接回收资源而将Object4(指针)移动到一个新的名叫Freachable的队列中。
专门的线程会管理Freachable队列,当Object4终结器被执行时,它将被从Freachable队列中移除,这样Object4才准备好被回收,以下图:
因此,Object4将在下次GC回收时被回收掉。
由于在类中增长终结器会给GC增长额外的工做,因此这将是一个很昂贵的操做而且给垃圾回收增长负面性能上的影响。当你肯定须要这样作时才能使用终结器,不然要十分谨慎。
能够确定的作法是回收非托管资源。正如你所想的,最好是明确额关闭链接,而且使用IDisposeable接口代替手动编写终结器。
实现IDisposeable接口的类会有一个清理方法Dispose()(这个方法是IDisposeable接口惟一干的一件事)。因此咱们用这个接口代替终结器:
public class ResourceUser { ~ResourceUser() // THIS IS A FINALIZER { // DO CLEANUP HERE } }
用IDisposable接口重构以后的代码,以下:
public class ResourceUser : IDisposable { #region IDisposable Members public void Dispose() { // CLEAN UP HERE!!! } #endregion }
IDisposeable接口被集成进了Using关键字(语法糖),在Using结束时Dispose方法被调用。在Using内部的对象将失去做用域,由于本质上它被认为已消失(回收)而且等待GC回收。
public static void DoSomething() { ResourceUser rec = new ResourceUser(); using (rec) { // DO SOMETHING } // DISPOSE CALLED HERE // DON'T ACCESS rec HERE }
我喜欢使用Using语法糖,由于从直观感受上更有意义而且rec临时变量在using块的外部没有存在的意义。因此,using (ResourceUser rec = new ResourceUser())这样的模式更符合实际须要和存在的价值。
注:这里做者强调的是rec变量的做用域问题,若是只在Using块内部则须要在Using后的括号内生命。
经过Using使用实现了IDisposeable接口的类,这样咱们就能代替那些须要编写终结器产生GC耗能的方式。
class Counter { private static int s_Number = 0; public static int GetNextNumber() { int newNumber = s_Number; // DO SOME STUFF s_Number = newNumber + 1; return newNumber; } }
若是两个线程同时调用GetNextNumber方法而且都在S_Number增长前,他们将返回相同的结果!只有一种方式能保证结果符合预期,就是同时只有一个线程能进入到代码中。做为一个最佳实践,你将尽量的Lock住一段小程序,由于线程不可不在队列中等待Lock住的方法执行完毕,即便多是低效的。
class Counter { private static int s_Number = 0; public static int GetNextNumber() { lock (typeof(Counter)) { int newNumber = s_Number; // DO SOME STUFF newNumber += 1; s_Number = newNumber; return newNumber; } } }
注:1. Lock本质是线程信号量的锁定方式,在原文中有人对lock(typeof(Counter))指出了质疑,虽然做者并未回复,但做者确实犯了这个错误,“咱们永远不要锁住类型Typeof(Anything)或者是lock(this)”,用private readonly static object syncLock = new Object(); lock(syncLock){…}这种方式,这里只说结论不作代码演示,各位若是想了解的话可网上搜索一下。
2. 这里有一个细节要说一下,在C#4以前的代码lock极可能会编译成:
object tmp = listLock; System.Threading.Monitor.Enter(tmp); try { // TODO: Do something stuff. System.Threading.Thread.Sleep(1000); } finally { System.Threading.Monitor.Exit(tmp); }
设想一下这种状况:若是第一个线程在执行完Enter(tmp)以后意外退出,也就是没有执行Exit(tmp),则第二个线程将永远阻塞在Enter这里等待其余人释放资源,这就是一个典型的死锁案例。
在C#4以及以后的Framework中增长了对Monitor.Enter的重载,将会为咱们在必定程度上解决可能发生的死锁问题:
bool acquired = false; object tmp = listLock; try { #region Description //// //// Summary: //// Attempts to acquire an exclusive lock on the specified object, and atomically //// sets a value that indicates whether the lock was taken. //// //// Parameters: //// obj: //// The object on which to acquire the lock. //// //// lockTaken: //// The result of the attempt to acquire the lock, passed by reference. The input //// must be false. The output is true if the lock is acquired; otherwise, the //// output is false. The output is set even if an exception occurs during the //// attempt to acquire the lock. //// //// Exceptions: //// System.ArgumentException: //// The input to lockTaken is true. //// //// System.ArgumentNullException: //// The obj parameter is null. //[TargetedPatchingOptOut("Performance critical to inline across NGen image boundaries")] //public static void TryEnter(object obj, ref bool lockTaken); #endregion System.Threading.Monitor.Enter(tmp,ref acquired); // TODO: Do something stuff. System.Threading.Thread.Sleep(1000); } finally { if (acquired) { System.Threading.Monitor.Exit(tmp); } }
——以上内容出自《深刻理解C# Edition2》
咱们第二个要注意的地方是静态变量的引用。记住,被“根”列表索引的对象没有被回收。这里举一个最丑陋的例子:
class Olympics { public static Collection<Runner> TryoutRunners; } class Runner { private string _fileName; private FileStream _fStream; public void GetStats() { FileInfo fInfo = new FileInfo(_fileName); _fStream = _fileName.OpenRead(); } }
因为Olympics类中的Runner集合是静态的,因此它没有被GC释放(还被“根”列表所引用),可是你可能也注意到了,当咱们每次调用GetStats方法时,它打开了一个文件。又因为它没有被关闭也没有被回收,咱们将面临一个大灾难。想象一下咱们有10万个Runner报名参加奥林匹克。咱们将以许多不可回收的对象而结束。Ouch!咱们在谈论的是低性能问题!
一个保持对象更节省资源的方式是只保持一个对象在应用程序全局。咱们将用GoF的单例模式。
经过“工具类”(静态的Utility类 or XXXHelper类)在内存中保持一个单例是节省资源的小把戏。最佳实践是单例模式。咱们要当心的用静态变量,由于他们真的是“全局变量”而且致使咱们头疼和遇到不少奇奇怪怪的行为在改变线程状态的多线程程序中。若是咱们使用单例模式,咱们应该想清楚。
public class SingltonPattern { private static Earth _instance = new Earth(); private SingltonPattern() { } public static Earth GetInstance() { return _instance; } }
咱们定义了私有类型的构造函数因此SingltonPattern类不能在外部被实例化。咱们只能经过静态方法GetInstance获取实例。这将是线程安全的,由于CLR对静态变量的保护。这个例子是我所见到的单例模式中最优雅的方式。
注:原文读者有人质疑过此单例模式。在这里做者的单例模式更符合单例的原则。
咱们总结一下能提升GC效率的方法:
1. 清理干净。不要保持资源一直开启!肯定的关闭全部已打开的链接,尽量的清理全部非托管对象。当使用非托管资源时一个原则是:尽量晚的初始化对象而且尽快释放掉资源。
2. 不要过分的使用引用。合理的利用引用对象。记住,若是咱们的对象还存活,咱们应该将对象设置为null。我在设置空值时的一个技巧是使用NullObject模式来避免空引用带来的异常。当GC开始回收时越少的引用对象存在,越有利于性能。
3. 简单的用终结器。对GC来讲终结器十分消耗资源,咱们只有在十分肯定的方式下使用终结器。若是咱们能用IDisposeable代替终结器,它将十分有效率,由于咱们的GC一次就能回收掉资源,而不是两次。
4. 将对象和其子对象放在一块儿。便于GC拷贝大数据而不是数据碎片,当咱们声明一个对象时,尽量将内部全部对象声明的近一些。
2016-01-04 更新垃圾回收图片,多谢@basonson指出图片错误。