首页 > 代码库 > [翻译]理解C#对象生命周期
[翻译]理解C#对象生命周期
看到网上的一篇讲C#对象生命周期(Object Lifetime)的文章,通俗易懂,而且有图,很适合初学者学习,就翻译过来了。后来发现这是Pro C# 2010 and the .NET 4 Platform的第八章中的一部分。(感谢 大乖乖 提醒)。文中的专业名词第一次出现时,括号里会标注对应的英文单词。
请尊重作者劳动,转载请注明出处:http://www.cnblogs.com/Jack47/archive/2012/11/14/2770748.html。
----2012年11月15日修改----
找到了文章的出处,并添加了最后一部分代码的截图。
----正文-----
.NET 对象是在一个叫做托管堆(managed heap)的内存中分配的,它们会被垃圾回收器(garbage collector)自动销毁。
在讲解之前,你必须知道类(class),对象(object),引用(reference),栈(stack)和堆(heap)的意思。
一个类只是一个描述这种类型的实例(instance)在内存中布局的蓝图。当然,类是定义在一个代码文件中(在C#中代码文件以.cs作为后缀)。
一个简单的Car 类,定义在一个叫做SimpleGC的C# Console Application中:
1 //Car.cs 2 public class Car 3 { 4 private int currSp; 5 private string petName; 6 public Car(){} 7 public Car(String name, int speed) 8 { 9 petName = name; 10 currSp = speed; 11 } 12 public override string ToString(){ 13 return string.Format("{0} is going {1} MPH", petName, currSp); 14 } 15 }
当一个类被定义好了,就可以使用C# new关键字来分配任意数量的这个类。
需要理解的是,new关键字返回的是一个在堆上面的对象的引用,不是这个对象本身。这个引用变量存储在栈上,以便之后在程序中使用。
当想在某个对象上调用成员函数时,对存储的这个对象的引用使用C# . 操作符:
1 class Program{ 2 static void Main(string[] args){ 3 //在托管堆上面创建一个新的Car对象。返回的是一个指向这个对象的引用 4 Car refToMyCar = new Car("Benz", 50); 5 //C# . 操作符用来在引用变量引用的对象上调用函数 6 Console.WriteLine(refToMyCar.ToString()); 7 Console.ReadLine(); 8 } 9 }
下图展示了类,对象和引用之间的关系
对象生命周期的基础知识
当你在创建C#应用程序时,托管堆不需要你的直接干预。实际上,.NET 内存管理的黄金原则很简单:
使用new关键字在托管堆上申请一个对象
一旦实例化后,当对象不再被使用的,垃圾回收器会销毁它。
对于读者来说,下一个明显的问题是:
“垃圾回收器怎么确定托管堆中的对象是不再被使用?”
简洁的答案是:
当你的代码不再使用堆上面的这个对象,垃圾回收器会将这个对象删除。
假设你在程序的类里有一个方法分配了一个Car对象的局部变量:
1 static void MakeACar(){ 2 //如果myCar是Car对象的唯一引用 3 //它可能会在这个方法返回时被销毁 4 Car myCar = new Car(); 5 }
注意:Car对象的引用 (myCar) 是在MakeACar()函数中直接创建的并且没有被传递到函数外部(通过一个返回值或者 ref/out 参数)。
因此,一旦这个函数调用完成,myCar的引用不再可访问,并且和这个引用相关联的Car对象可以被垃圾回收了。但是,不能保证在MakeACar()函数调用完成后这个对象被立即从内存中销毁。
在此时只能假设当CLR 执行下次垃圾回收时,myCar对象能够被安全的销毁。
The CIL of new
当C#编译器遇到new关键字,它会在函数实现中插入一个 CIL newobj指令。如果你编译当前的例子代码并使用ildasm.exe来浏览生成的代码,你会在MakeACar()函数中发现如下的CIL语句:
在我们知道托管堆中的对象什么时候被移除的确切条件之前,仔细查看一下CIL newobj指令的作用。
首先,需要明白托管堆不仅仅是一个可由CLR访问的随机内存块。.NET垃圾回收器是一个整洁的堆管家,出于优化的目的它会压缩空闲的内存块(当需要时)。为了辅助压缩,托管堆会维护一个指针(通常被叫做下一个对象指针(the next object pointer)或者是新对象指针(new object pointer)),这个指针用来标识下一个对象在堆中分配的地址。( 译者注:为了改进性能,运行时会在一个单独的堆中为大型对象(>85,000Bytes)分配内存 。一般情况下都是数组,很少有这么大的对象。 垃圾回收器会自动释放大型对象的内存。 但是,为了避免移动内存中的大型对象(耗时),不会压缩此内存。 )
这些信息表明,newobj指令通知CLR来执行下列的核心任务:
- 计算要分配的对象所需的全部内存(包括这个类型的数据成员和类型的基类所需的内存)。
- 检查托管堆来确保有足够的空间来放置所申请的对象。如果有足够的空间,会调用这个类型的构造函数,构造函数会返回一个指向内存中这个新对象的引用,这个新对象的地址刚好就是下一个对象指针上一次所指向的位置。
- 最后,在把引用返回给调用者之前,让下一个对象指针指向托管堆中下一个可用的位置。
下面的图解释了在托管堆上分配对象的细节。
由于你的程序忙着分配对象,在托管堆上的空间最终会满。当处理newobj指令的时候,CLR 发现托管堆没有足够空间分配请求的类型时,它会执行一次垃圾回收来释放内存。因此,垃圾回收的下一个规则也很简单:
如果托管堆没有足够的空间分配一个请求的对象,则会执行一次垃圾回收。
当执行垃圾回收时,垃圾收集器临时挂起当前进程中的所有的活动线程来保证在回收过程中应用程序不会访问到堆。(一个线程是一个正在执行的程序中的执行路径)。一旦垃圾回收完成,挂起的线程又可以继续执行了。还好,.NET 垃圾回收器是高度优化过的。
把对象引用置为null
有了这些知识,你可能会想在C#里,把对象引用置为null会有什么事发生。
例如,假设MakeACar()更新如下:
1 static void MakeACar(){ 2 Car myCar = new Car(); 3 myCar = null; 4 } 5
当你给对象引用赋值为null,编译器会生成CIL代码来确保这个引用(这个例子中是myCar)不会指向任何对象。如果还是用idasm.exe查看修改后MakeACar()的CIL 代码,会发现ldnull这个操作码(它会向虚拟执行栈上面压入一个null)之后是一个 stloc.0操作码(它在分配的Car对象上设置null的引用):
但是,你必须理解的是,设置引用为null不会强制垃圾回收器在此时启动并从堆上删除这个对象。你唯一完成的事是显式地切断了引用和它之前指向的对象之间的联系。
应用程序的根的作用(application roots)
回到垃圾回收器如何决定一个对象是不再被使用的。为了理解细节,你需要知道应用程序根的概念。
简单来说,一个根是一个引用,这个引用指向堆上面的一个对象的。严格来说,一个根可以有以下几种情况:
- 指向全局对象(global objects)的引用(尽管C#不支持,但CIL代码允许分配全局对象)
- 指向任何静态对象(static objects)/(static fields)
- 指向一个应用程序代码中的局部对象
- 指向传入到一个函数中的对象参数
- 指向等待被终结(finalized)的对象
- 任何一个指向对象的CPU寄存器
译者注:每个应用程序都有一组根。
在一次垃圾回收的过程中,运行环境会检查托管堆上面的对象是否仍然是从应用程序根可到达的。为了检查可达,CLR会建立一个代表堆上每个可达对象的图。对象图用来记录所有可达的对象。同时,注意垃圾回收器绝不会在图上标记一个对象两次,因此避免了烦人的循环引用。
假设托管堆上有名字为A,B,C,D,E,F和G的对象集合。在一次垃圾回收过程中,会检查这些对象(同时包括这些对象可能包含的内部对象引用)是否是根可达的。一旦图被建立起来,不可达的对象(在此是对象C和F)被标记为垃圾。
下图是上述场景的一个可能的对象图(你可以把箭头读作依赖或者需要,例如"E依赖于G,间接依赖于B,“A不依赖任何对象”等)。
创建的对象图是用来决定哪些对象是应用程序根可达的。
一旦一个对象已经被标记为终结(此例子中是C和F--在图中没有他俩),它在内存中就被清理掉了。在此时,堆上的剩余内存空间被压缩(compact 翻译为压缩不太合适,但也不知道啥更好的词了,压缩后,分配的内存空间都是在一起,连续的),这会导致CLR修改活动的应用程序根集合(和对应的指针)来指向正确的内存位置(这个操作是自动透明的)。最后,调整下一个对象指针来指向下一个可用的内存位置。
下图阐明了清除和压缩堆的过程。
理解对象的代(object generations)
在尝试找到不可达的对象时,CLR并不是检查托管堆上的每个对象。很明显,这样做会消耗大量时间,尤其在大型(例如现实中)程序中。
为了帮助优化这个过程,堆上的每个对象被分配到一个特殊的"代”。代这个概念背后的想法很简单:对象在堆上存活的时间越长,接下来它继续存在的可能性也就越大,即较旧的对象生存期长,较新的对象生存期短。例如,实现Main()的对象一直在内存中,直到程序结束。相反,最近才被放到堆中的对象(例如在一个函数范围里分配的对象)很可能很快就不可达。在堆上的每个对象属于以下的某一个代:
- Generation 0: 标识一个最近分配的还没有被标记为回收的对象
- Generation 1: 标识一个经历了一次垃圾回收而存活下来的对象(例如,他被标记为回收,但由于堆空间够用而没有被清除掉)
- Generation 2:标识一个经历了不止一轮垃圾回收而存活下来的对象。
垃圾回收器首先会检查generation 0的所有对象。如果标记并清理这些对象(译者注:因为新对象的生存期往往较短,并且期望在执行回收时,应用程序不再使用第 0 级托管堆中的许多对象)后产生了足够使用的内存空间,任何存活下来的对象就被提升到Generation 1。为了理解一个对象的代如何影响回收的过程,可以查看下图。下图解释了generation 0中一次垃圾回收后,存活的对象被提升的过程。
generation 0 中的存活对象被提升到generation 1
如果所有的generation 0对象都被检查了,但是产生的内存空间仍然不够用,就检查一遍generation 1中的所有对象的可达性并回收。存活下来的generation 1对象被提升到generation 2。如果垃圾回收器仍然需要额外的内存,generation 2的对象就经历检查并被回收。此时,如果一个generation 2的对象存活下来,它仍然是一个generation 2的对象。
通过给堆中的对象赋予一个generation的值,新对象(比如局部变量)会被很快回收,而老一些的对象(如一个应用程序对象)不会被经常骚扰。
为了解释如何用System.GC类来获得垃圾回收的细节信息,考虑下面的Main函数,里面用到了一些GC的成员函数。
1 static void Main(string[] args){ 2 Console.WriteLine("*****Fun with System.GC *****"); 3 //打印出堆上面的大致字节数 4 Console.WriteLine("Estimated bytes on heap:{0}", 5 GC.GetTotalMemory(false)); 6 //MaxGeneration基于0,所以为了显示而加一 7 Console.WriteLine("This OS has{0} object generations.\n" 8 (GC.MaxGeneration+1)); 9 Car refToMyCar = new Car("Zippy", 100); 10 Console.WriteLine(refToMyCar.ToString()); 11 //打印出refToMyCar对象的generation 12 Console.WriteLine("Generation of refToMyCar is:{0}", 13 GC.GetGeneration(refToMyCar)); 14 Console.ReadLine(); 15 } 16
强制进行一次垃圾回收
.NET 垃圾回收器就是用来自动管理内存的。但是,在某些极端情况下,通过使用GC.Collect()来强制一次垃圾回收是有用的。尤其是:
- 你的程序将要进入一段不想被可能的垃圾回收所中断的代码。
- 你的程序刚完成分配大量数目的对象的过程,你想尽可能清理出更多内存来。
如果你觉得让垃圾回收器检查不可达内存是十分有必要的,你像如下代码一样显示触发一次垃圾回收:
1 static void Main(string[] args){ 2 //强制一次垃圾回收并等待每个对象被终结。 3 GC.Collect(); 4 GC.WaitForPendingFinalizers(); 5 }
当你手动强制执行一次垃圾回收,你应该调用GC.WaitForPendingFinalizers()。利用这个函数,你可以确保所有的等待被终结的对象在你的程序继续往下执行之前拥有机会执行一些所需的清理工作。GC.WaitForPendingFinalizers()会在清理期间挂起调用它的线程。这是一个好事,它确保你的代码不会在当前正要被销毁的对象上调用函数。
GC.Collect函数接收一个数字参数来标识在哪个generation上执行垃圾回收。例如,如果你想让CLR只检查generation 0上的对象,你需要这样写:
1 static void Main(string[] args){ 2 //只检查generation 0上的对象 3 GC.Collect(0); 4 GC.WaitForPendingFinializers(); 5 }
.Net 3.5中,Collect函数可以传入一个值为GCCollectionMode的枚举类型作为第二个参数,来精确定义执行环境如何执行垃圾回收。这个枚举定义了如下的值:
1 public enum GCCollectionMode{ 2 Default,//Fored是当前的默认值 3 Forced,//告诉执行环境立即执行回收 4 Optimized//让运行环境来决定当前时间是否是清理对象的最佳时间 5 }
像其他的垃圾回收一样,调用GC.Collect()会提升存活下来的对象。假设Main函数代码更新如下:
1 static void Main(string[] args) 2 { 3 Console.WriteLine("***** Fun with System.GC *****"); 4 //打印出堆上的大致字节数 5 Console.WriteLine("Estimated bytes on heap:{0}", GC.GetTotalMemory(false)); 6 //MaxGeneration基数为0. 7 Console.WriteLine("This OS has {0} object generations.\n", (GC.MaxGeneration+1)); 8 Car refToMyCar = new Car("Zippy", 100); 9 Console.WriteLine(refToMyCar.ToString()); 10 //Print out generation of refToMyCar. 11 Console.WriteLine("\nGeneration of refToMyCar is:{0}", GC.GetGeneration(refOfMyCr)); 12 //为了测试创建大量对象 13 object[] tonsOfObjects = new object[50000]; 14 for(int i=0;i<50000;i++) 15 tonsOfObjects[i] = new object(); 16 //只回收gen0的对象 17 GC.Collect(0, GCCollectionMode.Forced); 18 GC.WaitForPendingFinalizers(); 19 //打印出refToMyCar的generation 20 Console.WriteLine("Generation of refToMyCar is:{0}", GC.GetGeneration(refToMyCar)); 21 //查看tonsOfObjects[9000] 是否还存活着 22 if(tonsOfObjects[9000]!=null) 23 { 24 Console.WriteLine("Generation of tonsOfObjects[9000] is :{0}", GC.GetGeneration(tonsOfObjects[9000])); 25 } 26 else 27 Console.WriteLine("tonsOfObjects[9000] is no longer alive."); 28 //打印出每个Generation经历了多少次回收 29 Console.WriteLine("\nGen 0 has been swept {0} times", GC.CollectionCount(0)); 30 Console.WriteLine("Gen 1 has been swept {0} times", GC.CollectionCount(1)); 31 Console.WriteLine("Gen 2 has been swept {0} times", GC.CollectionCount(2)); 32 Console.ReadLine(); 33 } 34
这里,我们为了测试目的有意的创建了一个非常大的对象数组(50,000个)。你可以从下图中看到输出,尽管这个Main()函数只显示调用了一次垃圾回收(通过GC.Collect()函数), 但CLR 在幕后执行了多次垃圾回收。
[翻译]理解C#对象生命周期