前言
简单讲述一下垃圾回收,我们能做的一些控制。
正文
强制回收
class Program
{static void Main(){var str = new StringBuilder();var x = "";for (int i = 0; i < 500; i++){x += "xxxxxxxxsadasdasdsadsaewqeqczxcxzgsfaswqeqwrqwewqeasdasqweqwrqsdasddas";}for (int i = 0; i < 10000; i++){str.Append(x);}var str2 = str.ToString();GC.Collect(2, GCCollectionMode.Forced);// 等待用户按 Enter 键Console.ReadLine();}
}
当我们执行完GC.Collect之后,我们发现str2不存在了,被回收了。
这里之所以参数是2,是因为str2是大对象,那么天生就在第二代。
可在进程中调用几个方法来监视垃圾会回收器。具体地说,GC类提供了以下静态方法,可调用它们查看某一代发生了多少次垃圾回收,或者托管堆中的对象当前使用了多少内存。
Int32 CollectionCount(Int32 generation);
Int64 GetTotalMemory(Boolean forceFullCollection);
我们如何去监控垃圾回收呢?
对于.net 而言我们会有自己的工具之类的。
还有一个很出色的工具可分析内存和应用程序的性能,它的名字是 PerfView。 该工具能收集 “Windows 事件跟踪”(Event Tracing for Windows, ETW)日志并处理它们。获取该工具最好的办法是网上搜索 PerfView 。最后还应该考虑一下 SOS Debugging Extension(SOS.dll),它对于内存问题和其他 CLR 问题的调试颇有帮助。对于内存有关的行动, SOS Debugging Extension 允许检查进程中为托管堆分配了多少内存,显示在终结队列中登记终结的所有对象,显示每个 AppDomain 或整个进程的 GCHandle 表中的记录项,并显示是什么根保持对象在堆中存活。
这样可以通过外部事件的方式暴露出来。
通用,我们的内存资源被托管了,那么我们的本机资源我们如何清理呢?
例如,System.IO.FileStream 类型需要打开一个文件(本机资源)并保存文件的句柄。然后,类型的Read 和 Write 方法用句柄操作文件。类似地,System.Threading.Mutex 类型要打开一个 Windows 互斥体内核对象(本机资源)并保存其句柄,并在调用 Mutex 的方法时使用该句柄。
包含本机资源的类型被 GC 时, GC 会回收对象在托管堆中使用的内存。但这样会造成本机资源(GC 对它一无所知)的泄露,这当然是不允许的。所以,CLR 提供了称为终结(finalization)的机制,允许对象在被判定为垃圾之后,但在对象内存被回收之前执行一些代码。任何包装了本机资源(文件、网络连接、套接字、互斥体)的类型都支持终结。CLR 判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后,GC 会从托管堆回收对象。
终极基类 System.Object 定义了受保护的虚方法 Finalize。垃圾回收器判定对象是垃圾后,会调用对象的 Finalize 方法(如果重写)。Microsoft 的 C# 团队认为 Finalize 在编程语言中需要特殊语法(类似于 C# 要求用特殊语法定义构造器)。因此,C# 要求在类名前添加 ~符号来定义 Finalize 方法,如下例所示:
internal sealed class SomeType {// 这是一个 Finalize 方法~SomeType() {// 这里的代码会进入 Finalize 方法}
}
编译上述代码,用 ILDasm.exe 检查得到的程序集,会发现 C# 编译器实际是在模块的元数据中生成了名为 Finalize 的 protected override 方法。查看 Finalize 的 IL,会发现方法主体的代码被放到一个 try 块中,在 finally 块中则放入了一个 base.Finalize 调用。
被视为垃圾的对象在垃圾回收完毕后才调用 Finalize 方法,所以这些对象的内存不是马上被回收的,因为 Finalize 方法可能要执行访问字段的代码。可终结对象在回收时必须存活,造成它被提升到另一代,使对象活的比正常时间长。这增大了内存耗用,所以应尽可能避免终结。更糟的是,可终结对象被提升时,其字段引用的所有对象也会被提升,因为它们也必须继续存活。所以,要尽量避免为引用类型的字段定义可终结对象。
另外要注意,Finalize 方法的执行时间是控制不了的。应用程序请求更多内存时才可能发生 GC,而只有 GC 完成后才运行 Finalize。另外,CLR 不保证多个 Finalize 方法的调用顺序。所以,在 Finalize 方法中不要访问定义了Finalize方法的其他类型的对象;那些对象可能已经终结了。但可以安全地访问值类型的实例,或者访问没有定义 Finalize 方法的引用类型的对象。调用静态方法也要当心,这些方法可能在内部访问已终结的对象,导致静态方法的行为变得无法预测。
CLR 用一个特殊的、高优先级的专用线程调用 Finalize 方法来避免死锁①。如果 Finalize 方法阻塞(例如进入死循环,或等待一个永远不发出信号的对象),该特殊线程就调用不了任何更多的 Finalize 方法。这是非常坏的情况,因为应用程序永远回收不了可终结对象占用的内存————只要应用程序运行就会一直泄露内存。如果 Finalize 方法抛出未处理的异常,则进程终止,没办法捕捉该异常。
创建封装了本机资源的托管类型时,应该先从 System.Runtime.InteropServices.SafeHandle 这个特殊基类派生出一个类。该类的形式如下(我在方法中添加了注释,指明它们做的事情):
public abstract class SafeHandle : CriticalFinalizerObject, IDisposable {// 这是本机资源的句柄protected IntPtr handle;protected SafeHandle(IntPtr invalidHandleValue, Boolean ownsHandle) {this.handle = invalidHandleValue;// 如果 ownsHandle 为 true,那么这个从 SafeHandle 派生的对象被回收时,// 本机资源会被关闭}protected void SetHandle(IntPtr handle) {this.handle = handle;}// 可调用 Dispose 显式释放资源// 这是 IDisposable 接口的 Dispose 方法public void Dispose() { Dispose(true); }// 默认的 Dispose 实现(如下所示)正是我们希望的。强烈建议不要重写这个方法protected virtual void Dispose(Boolean disposing) {// 这个默认实现会忽略 disposing 参数:// 如果资源已经释放,那么返回;// 如果 ownsHandle 为 false, 那么返回;// 设置一个标志来指明该资源已经释放;// 调用虚方法 ReleaseHandle;// 调用 GC.SuppressFinalize(this)方法来阻止调用 Finalize 方法;// 如果 ReleaseHandle 返回 true,那么返回;// 如果走到这一步,就激活 releaseHandleFailed 托管调试助手(MDA)。}// 默认的 Finalize 实现(如下所示)正是我们希望的。强烈建议不要重写这个方法~SafeHandle() { Dispose(false); }// 派生类要重写这个方法以实现释放资源的代码protected abstract Boolean ReleaseHandle();public void SetHandleAsInvalid(){// 设置标志来指出这个资源已经释放// 调用 GC.SuppressFinalize(this) 方法来阻止调用 Finalize 方法}public Boolean IsClosed{get{// 返回指出资源是否释放的一个标志}}public abstract Boolean IsInvalid{// 派生类要重写这个属性// 如果句柄的值不代表资源(通常意味着句柄为 0 或 -1),实现应返回 trueget;}// 以下三个方法涉及安全性和引用计数,本节最后会讨论它们public void DangerousAddRef(ref Boolean success) { ... }public IntPtr DangerousGetHandle() { ... }public void DangerousRelease() { ... }
}
举一个简单点的例子:
using System;class ResourceWrapper : IDisposable
{private IntPtr handle;private bool disposed = false;public ResourceWrapper(){handle = SomeNativeLibrary.OpenResource();}public void DoSomething(){if (disposed)throw new ObjectDisposedException("ResourceWrapper");// Use the resource}public void Dispose(){Dispose(true);GC.SuppressFinalize(this);}protected virtual void Dispose(bool disposing){if (!disposed){if (disposing){// Release managed resources}// Release unmanaged resourcesSomeNativeLibrary.CloseResource(handle);handle = IntPtr.Zero;disposed = true;}}~ResourceWrapper(){Dispose(false);}
}class Program
{static void Main(){using (ResourceWrapper resource = new ResourceWrapper()){resource.DoSomething();}}
}
可以看下这个disposing:
if (disposing)
{// Release managed resources
}
Release managed resources 是什么呢? 其实就是封装好的文件流啥的,这个时候我们可以手动释放掉。
释放托管资源通常包括清理对象持有的其他托管对象或资源,例如关闭文件流、释放数据库连接、清理缓存等。这些资源由.NET框架管理,不需要手动释放内存,但需要确保在不再需要时正确释放资源以避免内存泄漏。
在C#中,托管资源由.NET的垃圾回收器自动管理。垃圾回收器会跟踪对象的引用情况,并在对象不再被引用时自动释放其占用的内存。这种自动内存管理机制减少了内存泄漏的风险,简化了开发人员的工作,因此不需要手动释放托管资源。
然后呢,在终结器里面呢,我们不能去这么做。
为什么呢? 这是因为终结器里面可能这个对象都不在了,去调用这个对象可能产生意想不到的结构。
然后看到dispose 里面,我们看到了这个:GC.SuppressFinalize(this);
为什么我们要抑制终结器呢?
我们在调用dispose的时候呢,如果我们后面不用终结器的话,那么是很好的,为什么呢?
因为前面说过,终结器是在垃圾回收之后执行的,也就是说终结器对象不会立即消失,并且和其引用存留到下一代,这对垃圾回收不好。
如果我们能关掉,那么就已经抑制了,那么就不会这么消耗性能。
那么我们怎么使用呢?
using System;
using System.IO;public static class Program {public static void Main() {// 创建要写入临时文件的字节Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };// 创建临时文件FileStream fs = new FileStream("Temp.dat", FileMode.Create);// 将字节写入临时文件fs.Write(bytesToWrite, 0, bytesToWrite.Length);// 删除临时文件File.Delete("Temp.dat"); // 抛出 IOException 异常}
}
这里我们学过操作系统的话,那么这个时候大多数时候会出现问题的。
因为fs的时候已经拿到了句柄,然后File.Delete("Temp.dat")再去删除的话,那么会出现问题。
在我们学完垃圾回收后,那么是有可能可以成果的。
为什么呢? 因为垃圾回收机制会回收fs的句柄,然后正好File 就可以删除了。
这种概率比较好。
using System;
using System.IO;public static class Program {public static void Main() {// 创建要写入临时文件的字节Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };// 创建临时文件FileStream fs = new FileStream("Temp.dat", FileMode.Create);// 将字节写入临时文件fs.Write(bytesToWrite, 0, bytesToWrite.Length);// 写入结束后显式关闭文件fs.Dispose();// 删除临时文件File.Delete("Temp.dat"); // 总能正常工作}
}
这个时候我们可以手动释放了,就能正常工作。
这似乎也可以。
我一直有一个想法,为啥不交给我们去释放呢?而要交给垃圾回收呢。
using System;
using System.IO;public static class Program {public static void Main() {// 创建要写入临时文件的字节Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };// 创建临时文件FileStream fs = new FileStream("Temp.dat", FileMode.Create);// 将字节写入临时文件fs.Write(bytesToWrite, 0, bytesToWrite.Length);// 写入结束后显式关闭文件fs.Dispose();// 关闭文件后继续写入fs.Write(bytesToWrite, 0, bytesToWrite.Length); // 抛出 ObjectDisposedException// 删除临时文件File.Delete("Temp.dat"); // 总能正常工作}
}
这里在调用第二个fs.Write,那么会抛出异常,也就是说ObjectDisposedException,就是说被disposed了。
这里告诉我们一个事情,那就是很多时候我们不知道我们啥时候要disposed,因为对象可能相互引用。
除非是知道了,以后再也不会使用这个资源对象了,才可以disposed。
我们一般释放的时候这样写:
using System;
using System.IO;public static class Program {public static void Main() {// 创建要写入临时文件的字节Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };// 创建临时文件FileStream fs = new FileStream("Temp.dat", FileMode.Create);try {// 将字节写入临时文件fs.Write(bytesToWrite, 0, bytesToWrite.Length);}finally {// 写入字节后显式关闭文件if (fs != null) fs.Dispose();}// 删除临时文件File.Delete("Temp.dat"); // 总能正常工作}
}
然后我们可以这样写:
using System;
using System.IO;public static class Program {public static void Main() {// 创建要写入临时文件的字节Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };// 创建临时文件using (FileStream fs = new FileStream("Temp.dat", FileMode.Create)) {// 将字节写入临时文件fs.Write(bytesToWrite, 0, bytesToWrite.Length);} // 删除临时文件File.Delete("Temp.dat"); // 总能正常工作}
}
一个容易忽略的问题:
奇怪的依赖:
FileStream fs = new FileStream("DataFile.dat", FileMode.Create);
StreamWriter sw = new StreamWriter(fs);
sw.Write("Hi there");// 不要忘记写下面这个 Dispose 调用
sw.Dispose();
// 注意:调用 StreamWriter.Dispose 会关闭 FileStream
// FileStream 对象无需显式关闭
当sw调用Dispose,会将内存的数据刷入到磁盘,同时Dispose会调用FileStream的Dispose。
也就是说和资源一起刷完。
那么能不能不调用Dispose呢? 让终结器自己去刷呢?
是不能的,因为可能出现一个问题,因为FileStream和StreamWriter不是同一个对象。
那么StreamWriter使用终结器的时候呢,可能FileStream被终结了,那么刷新数据的时候呢,就会报错。
那么怎么办呢?那只能去调用这个StreamWriter去解决问题。
不然就可能丢失。
那么GC 为本机还提供了什么其他功能呢?
GC 没有提供让我们手动去控制啥时候回收呢?
也是有的。
比如内存达到多少的时候。
public static void AddMemoryPressure(Int64 bytesAllocated);
public static void RemoveMemoryPressure(Int64 bytesAllocated);
那有没有句柄数量达到一定的时候呢?
也是有的。
public sealed class HandleCollector {public HandleCollector(String name, Int32 initialThreshold);public HandleCollector(String name, Int32 initialThreshold, Int32 maximumThreshold);public void Add();public void Remove();public Int32 Count { get; }public Int32 InitialThreshold { get; }public Int32 MaximumThreshold { get; }public String Name { get; }
}
其实他们的原理,还是调用GC.Collect,只是多一个监控而已。
那么我们重新来看一下终结器的原理:
首先呢,gc会像往常一样进行回收,还记得我们的gc怎么回收的吗?
这个时候e,i,j 都是垃圾了。
然后又一个问题,那就是终结器列表中,有这几个,所以不能回收。
这个时候e,i,j的引用要标记为可以复活,也就是说继续到下一代。
并且把e,i,j 移动到freachable列表中去。
然后将会有一个独立的线程,去执行终结器,然后从freachable列表中移除。
下一次执行垃圾回收的时候呢,因为终结列表中没有,所以可以直接清除掉。
这个也不一定是下一次,因为升代了,所以呢,可以几次gc后才回收。
结
那么我们还可以控制gc的对象的回收,我们下下章就行解释。