前言
在本系列的第一篇文章《C#堆栈对比(Part Three)》中,介绍了值类型和引用类型在Copy上的区别以及如何实现引用类型的克隆以及使用ICloneable接口等内容。
本文为文章的第四部分,主要讲解内存回收原理与注意事项,以及如何提高GC效率等问题。
注:限于本人英文理解能力,以及技术经验,文中如有错误之处,还请各位不吝指出。
目录
C#堆栈对比(Part Four)
图形表示
让我们从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
}
}
在对象创建期间,所有带有终结器的对象被加入到了终结队列。我们假设Object1、Object4和Object5带有终结函数并且在终结队列中。让我们看看发生了什么,当对象Object2和Object4不再被程序所引用时,他们已经为垃圾回收准备好了,如下图:
对象Object2按照正常的方式回收。然而,当我们回收对象Object4时,GC知道它在终结队列中并且代替直接回收资源而将Object4(指针)移动到一个新的名叫Freachable的队列中。
专门的线程会管理Freachable队列,当Object4终结器被执行时,它将被从Freachable队列中移除,这样Object4才准备好被回收,如下图:
所以,Object4将在下次GC回收时被回收掉。
因为在类中增加终结器会给GC增加额外的工作,所以这将是一个很昂贵的操作并且给垃圾回收增加负面性能上的影响。当你确定需要这样做时才能使用终结器,否则要十分谨慎。
可以肯定的做法是回收非托管资源。正如你所想的,最好是明确额关闭连接,并且使用IDisposeable接口代替手动编写终结器。
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 = ; public static int GetNextNumber()
{
int newNumber = s_Number; // DO SOME STUFF s_Number = newNumber + ; return newNumber;
}
}
如果两个线程同时调用GetNextNumber方法并且都在S_Number增加前,他们将返回相同的结果!只有一种方式能保证结果符合预期,就是同时只有一个线程能进入到代码中。作为一个最佳实践,你将尽可能的Lock住一段小程序,因为线程不可不在队列中等待Lock住的方法执行完毕,即使可能是低效的。
class Counter
{
private static int s_Number = ; public static int GetNextNumber()
{
lock (typeof(Counter))
{
int newNumber = s_Number; // DO SOME STUFF newNumber += ;
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(); } finally { System.Threading.Monitor.Exit(tmp); }
C# 4之前
设想一下这种情况:如果第一个线程在执行完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(); } finally { if (acquired) { System.Threading.Monitor.Exit(tmp); } }
C#4以及之后
——以上内容出自《深入理解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指出图片错误。