《CLR via C#》读书笔记 之 自动内存管理(垃圾回收)
第二十一章 自动内存回收(垃圾回收)
2013-03-19
本章讨论托管应用程序如何构造新对象,托管堆如何控制这些对象的生存期,以及如何回收这些对象的内存。
21.1 理解垃圾回收平台的基本工作原理
从托管堆分配资源
托管堆和C运行时堆比较
21.2 垃圾回收算法
21.3 垃圾回收与调试
21.4 使用终结器操作来释放本地资源
21.4.1使用CriticalFinalizerObject类型确保终结
21.6 什么会导致Finalize被调用
21.7 终结操作揭秘
21.8 Dispose模式:强制对象清理资源
21.10 C#using语句
21.1 理解垃圾回收平台的基本工作原理
每个程序都要使用资源,比如文件、内存缓冲、网络连接、数据库资源等。事实上,在面向对象的环境里,每个类型都代表可供程序使用的一种资源。要使用这些资源,必须为代表资源的类型分配内存。以下是访问资源所需的具体步骤:
(1)调用IL指令newobj,为代表资源的类型分配内存。在C#中是new操作符;
(2)初始化内存,设置资源的初始状态,是资源可用。由类型的实例构造器负责;
(3)访问类型的成员来使用资源;
(4)摧毁资源状态来进行清理。(21.8 Dispose模式:强制对象清理资源);
(5)释放内存。由垃圾回收器负责。
在进行非托管编程时,常发生两种bug:程序员忘记释放不再需要的内存造成(内存泄露);试图调用已被释放的内存造成(对象损坏)。内存泄露、内存溢出以及解决方法
垃圾回收器(garbage collection)就是为了处理以上问题二产生的。
注意:(参考第四章 的 托管堆的内存分配机制)托管堆又根据存储信息的不同划分为多个区域,其中最重要的是垃圾回收堆(GC Heap)和加载堆(Loader Heap),GC Heap用于存储对象实例,受GC管理;Loader Heap又分为High-Frequency Heap、Low-Frequency Heap和Stub Heap,不同的堆上又存储不同的信息。Loader Heap最重要的信息就是元数据相关的信息,也就是Type对象,每个Type在Loader Heap上体现为一个Method Table(方法表),而Method Table中则记录了存储的元数据信息,例如基类型、静态字段、实现的接口、所有的方法等等。Loader Heap不受GC控制,其生命周期为从创建到AppDomain卸载。
从托管堆分配资源
IL指令newobj用于创建一个对象时,就会从托管堆调拨可用空间,步骤如下:
(1)计算类型(及所有基类型)的字段需要的字节数;
(2)加上对象两个附加字段:一个是类型对象指针,一个是同步索引块。
(3)CLR检查托管堆是否有足够的可用空间。若有,放入。注意:是在NextObjPtr指针后放入的,并且为它分配的字节清零。接着,调用类型的实例构造器(为this参数传递NextObjPtr)。而后,NextObjPtr的指针的值会加上对象占据的字节数,得到一个新值,如下图所示:
图1 构造对象B后的状态
C运行时对是链表来组织的。托管堆是连续的,只加了个指针来分割可用空间和不可用空间。
因此,托管堆的分配速度几乎可以跟线程栈的分配速度相媲美。
另外,连续分配的对象可以确保他们在内存中是连续的。由于差不多同时分配的对象彼此间常常有更紧密的联系,经常会在同一时间被访问。这样他们同时驻留在cpu缓存中的概率就更高,进而不会因为“缓存未命中”而被迫访问较慢的RAM。
21.2 垃圾回收算法
如何保证指针NextObjPtr右边的有足够可用空间呢?那就需要一个算法,来回收那些不再使用的对象。
回收算法分两步:
第一步:确定那些对象可用,并标记(marking)。
每个应用程序都包含一组根(root)。每个根都是一个存储位置,其中包含指向引用类型对象的指针。该指针要么引用托管堆中的一个对象,要么为null。
图2 回收之前托管堆
上图中,D对象引用了对象F,所有F也标记了。在标记过程中,为避免陷入循环引用的死循环,对先前标记过的对象不再检查下去。
第二步:压缩(compact)
压缩过程中较小的内存快回忽略。
在移动内存中对象之后,包含“指向对象的指针”的变量和cpu寄存器会变得无效,垃圾回收器会在移动后重新设置。
21.3 垃圾回收与调试
当执行代码GC.Collect强制执行一次垃圾回收,若有对象不可达,就会被回收掉,看以下代码。
1 using System; 2 using System.Threading; 3 public sealed class Progam { 4 public static void Main() { 5 // Create a Timer object that knows to call our TimerCallback method once every 2000 milliseconds. 6 var t = new System.Threading.Timer(TimerCallback, null, 0, 2000); 7 8 // Wait for the user to hit <Enter> 9 Console.ReadLine(); 10 } 11 12 private static void TimerCallback(Object o) { 13 // Display the date/time when this method got called. 14 Console.WriteLine("In TimerCallback: " + DateTime.Now); 15 16 // Force a garbage collection to occur for this demo. 17 GC.Collect(); 18 } 19 }
观察上述代码,你可能认为TimerCallback会每个2秒调用一次。但请注意,在TimerCallback方法中调用了GC.Collect()。垃圾回收器会检查哪些对象不可达,发现变量t在初始化后,再没有被调用过,所以,垃圾回收器回收了分配给t的Timer对象。
解决上述问题,你可以通过在Console.ReadLine();后加上t.Dispose();来告诉垃圾回收器它仍然被调用;而不是加t=null;因为这样代码对编译器来说毫无意义,而被优化掉
但你若不是用C#编译器的Release开关,而是用Debug开关,会发现TimerCallback仍被重复调用。因为编译器会让变量t存活到它所在方法结束。
注意:读完本节后,不必担心你的对象被过早的回收。这里的Timer类非常特殊,它不会静静的像其他对象一个呆在托管堆中,而是定期调用一个方法。这里使用它只是为了更好的展示根的工作原理与对象生存周期的关系。所有非Timer对象都会根据应用程序的需要而自动存活。
21.4 使用终结器操作来释放本地资源
有些类型只需内存就可以正常工作;有些类型,除了内存,还要使用本地资源,如:文件、网络连接、套接字、互斥体等。
终结(Finalization)是CLR提供的一种机制,允许对象在垃圾回收器回收器内存之前执行一些得体的清理工作。
Finalize方法在编程语言中需要特殊的语法,如下代码:
1 class SomeType 2 { 3 ~SomeType() 4 { 5 //这里的代码会进入Finalize方法 6 } 7 }
你用反编译器看Finalize方法是,会发现方法主体放在try块中,finally块则放入了一个对base.Finalize的调用。
实现Finalize方法时,一般都会调用Win32 CloseHandle函数,并向该函数传递本地资源的句柄。如果包装了本地资源的类型没有定义Finalize方法,本地资源就得不到关闭,导致资源泄露,直至进程终止。
21.4.1使用CriticalFinalizerObject类型确保终结
它的主要功能是它的派生类字构造过程中,就对其继承层次中所有对象的Finalize进行JIT编译,确保对象被回收之前,本地资源被释放。
21.6 什么会导致Finalize被调用
有5种事件:
(1)第0代满
(2)调用静态方法System.GC.Collect
(3)Windows报告内存不足
(4)CLR卸载AppDomain
(5)CLR关闭 一个进程正常终止,CLR就会关闭。
对于前4种事件,当前CLR使用一个特殊、专用的线程来调用Finalize方法,如果一个Finalize方法进入无线循环,这个特殊线程会被阻塞,其他Finalize得不到调用。这种情况很糟糕,因为不能回收可终结对象所占的内存。
对于第5种事件,当前CLR的限制时间是每个Finalize方法2秒,超过,直接杀死进程。
21.7 终结操作揭秘
若一个对象的类型定义了Finalize方法,那么在该类型的实例构造函数被调用之前,将会指向该对象的一个指针放到终结列表(Finalization list)中。它是由GC控制的一个内部数据结构。
图3 托管堆的终结列表包含了指向对象的指针
注意:虽然System.Object定义了一个Finalize方法,但CLR知道忽略它。你要在派生类重写Object的Finalize方法。
上图中,当垃圾回收开始时,垃圾回收器会扫描终结列表看那些对象要回收。上图中F对象没有被引用到,且在终结列表中,因此,F指针会移到freachable(发音f-reachable)列队中,表示finalize已准备好调用的一个对象。
图4 托管堆的终结列表包含了指向对象的指针
结果如上图所示,对象B占用的内存被回收,因为它没有Finalize方法。但是对象F占用的内存暂时不能回收,因为他们还没有调用Finalize方法。一个特殊的高优先级CLR线程专门负责调用Finalize方法。这样,可以避免潜在的线程同步问题。CLR未来可能使用多个终结器线程。
当第二次垃圾回收时,对象F执行完Finalize调用,内存会被释放,F指针会从freachable移除。
21.8 Dispose模式:强制对象清理资源
Finalize方法确保本地资源的清理,但它的问题是调用时间不确定。另外,由于它不是公共方法,类的用户不能显式调用它。Dispose模式提供了显示进行资源清理的能力。
注意:Dispose只是为了能在确定的时间强迫对象执行清理;并不能控制托管堆中对象所占用内存的生存期。这意味着,即使对象已完成清理,仍可以在它上面调用方法,只不过会抛出System.ObjectDisposedException
System.Runtime.InteopServices.SafeHandle类实现了Dispose模式,但为了更清楚了展现Dispose模式,看如下代码:
1 using System; 2 using System.Runtime.InteropServices; 3 public class AnotherResource:IDisposable { 4 public void Dispose() { } 5 } 6 public class SampleClass : IDisposable 7 { 8 //演示创建一个非托管资源 9 private IntPtr nativeResource = Marshal.AllocHGlobal(100); 10 //演示创建一个托管资源 11 private AnotherResource managedResource = new AnotherResource(); 12 private bool disposed = false; 13 14 /// <summary> 15 /// 实现IDisposable中的Dispose方法 16 /// </summary> 17 public void Dispose() 18 { 19 //对象被显式地Dispose,而非终结,所以要清理与该对象关联的托管资源 20 Dispose(true); 21 //通知垃圾回收机制不再调用终结器(析构器) 22 GC.SuppressFinalize(this); 23 } 24 25 /// <summary> 26 /// 必须,以备程序员忘记了显式调用Dispose方法 27 /// </summary> 28 ~SampleClass() 29 { 30 //必须为false 31 Dispose(false); 32 } 33 34 /// <summary> 35 /// 非密封类修饰用protected virtual 36 /// 密封类修饰用private 37 /// </summary> 38 /// <param name="disposing"></param> 39 protected virtual void Dispose(bool disposing) 40 { 41 if (disposed) 42 { 43 return; 44 } 45 if (disposing) 46 { 47 // 清理托管资源 48 if (managedResource != null) 49 { 50 managedResource.Dispose(); 51 managedResource = null; 52 } 53 } 54 // 清理非托管资源 55 if (nativeResource != IntPtr.Zero) 56 { 57 Marshal.FreeHGlobal(nativeResource); 58 nativeResource = IntPtr.Zero; 59 } 60 //让类型知道自己已经被释放 61 disposed = true; 62 } 63 64 //若对象已释放后被调用,抛异常 65 public void SamplePublicMethod() 66 { 67 if (disposed) 68 { 69 throw new ObjectDisposedException("SampleClass", "SampleClass is disposed"); 70 } 71 //省略 72 } 73 }
21.10 C#using语句
如果决定显式调用 类型的Dispose或Close方法(有些类用Close代替Dispose方法),建议把它们放在异常处理的finally块中。令人惊喜的是C#的using语句提供了相同的功能,代码如下:
1 public static class Program 2 { 3 public static void Main() 4 { 5 byte[] bytesToWrite = new byte[] { 1, 2, 3, 4, 5 }; 6 const string fileName = "Temp.dat"; 7 //put dispose in finally block 8 OperateFile(bytesToWrite, fileName); 9 File.Delete("Temp.dat"); 10 11 //use 'using' sentence instead 12 OperateFileinUsing(bytesToWrite, fileName); 13 File.Delete("Temp.dat"); 14 } 15 private static void OperateFile(byte[] fileContent,string fileName) 16 { 17 FileStream fs = new FileStream("Temp.dat", FileMode.Create); 18 try 19 { 20 fs.Write(fileContent, 0, fileContent.Length); 21 } 22 finally 23 { 24 if (fs != null) fs.Dispose(); 25 } 26 } 27 private static void OperateFileinUsing(byte[] fileContent, string fileName) 28 { 29 using(FileStream fs = new FileStream("Temp.dat", FileMode.Create)) 30 { 31 fs.Write(fileContent, 0, fileContent.Length); 32 } 33 } 34 }
注意:using语句只能用于实现了IDisposable接口的类型。
21.14 代
代(generation)是垃圾回收器采用的一种机制,它唯一的目的是提升应用程序的性能。一个基于代得垃圾回收器做出了以下几点假设:
- 对象越新,生存期越短。
- 对象越老,生存期越长。
- 回收堆的一部分,速度快于回收整个堆。
CLR的托管对只支持三代:第0代、第1代和第2代。CLR初始化是,会为每一代做预算。预算的大小以提升性能为宜。预算越大,垃圾回收的频率越低。
假设第0代预算容量为256KB(顺便说一句,之所以第0代预算容量为256KB,是因为这些象都能装入CPU的L2缓存,使内存压缩能非常快的速度完成。),第1代预算容量为2M,第2代预算容量为10M。
- 一个新的初始化的堆,其中包含了一些对象,所有的对象都是第0代,垃圾回收尚未发生;
- 当第0代得对象刚好占用256K,又要分配对象时,垃圾回收器启动。垃圾回收器先判断哪些对象为垃圾(参考21.7),压缩可达对象。垃圾回收后,这些存活的对象被认为是第1代对象;
- 在第1代未满,第0代满时,垃圾回收器只垃圾回收器只回收第0代,见第2步;
- 当第0代满,第一代也满了,垃圾回收器会回收第1代,第0代。垃圾回收后,第0代的可达对象被提升至第1代,第1代的可达对象被提升至第2代.
CLR的垃圾回收器是自调节的。这意味着垃圾回收器会在执行垃圾回收的过程中了解应用程序的行为。如果垃圾回收期发现在回收0代存活下来的对象很少,就可能将第0代的预算从256KB减少至128KB。少的预算意味着垃圾回收更频繁的发生,但垃圾回收器需要做的工作会减少,从而减少进程的工作集。