GC原理解析(c#)

来源:互联网 发布:奚梦瑶失误 知乎 编辑:程序博客网 时间:2024/05/13 21:45


在.NET框架包含一个托管堆,所有的.NET语言在分配引用类型对象都要使用它,像值类型这样的轻量级别对象始终分配在栈中,但是所有的类实例和数组都被生成在一个内存池中,这个内存池就是托管堆

垃圾收集器的托管的基本算法很简单:

1、将所有的托管内存标记为垃圾

2、寻找正在使用的内存快,并将他们标记为有效

3、释放所有没有被使用的内存块

4、整理堆以减少碎片

看上去很简单,但是垃圾回收器实际采用的步骤和堆管理系统的其他部分并非微不足道,其中常常涉及为提高性能而作的优化设计。举例来说,垃圾收集遍历整个内存池具有很高的开销。然而,研究表明大部分在托管堆上分配的对象只有很短的生存期,因此堆被分成三个段,称作generations。新分配的对象被放在generation 0中。这个generation是最先被回收的----在这个generation中最可能找到不再使用的内存,由于它的尺寸很小(小到足以放进处理器L2cache中),因此在它里面的回收将是最快和最高效的。

托管堆的另外一种优化操作于locality of reference股则有关。该规则表明,一起分配的对象经常被一起使用。如果对象们在堆中位置很紧凑的话,高速缓存的性能将会得到提高。由于托管堆的天性,对象们总是被分配在连续的地址上,托管堆总是保持紧凑,结果使得对象们始终彼此靠近,永远不会分的很远。这一点于标准堆提供的非托管代码形成了鲜明的对比,在标准堆中,堆很容易变成碎片,而且一起分配的对象经常分得很远。

还有一种优化是与大对象有关的,通常,大对象具有很长的生存期。当一个大对象在.NET托管堆中产生时,它被分配在堆的一个特殊部分中,这部分堆永远不会被整理。因为移动大对象所带来的开销超过了整理这部分堆所提高的性能。

关于外部资源(External  Resource)的问题

   垃圾收集器能够有效的管理从托管堆中释放的资源,但是资源回收操作只有在内存紧张而触发一个回收动作时才执行。那么,类是怎样来管理像数据库连接或者窗口句柄这样有限资源的呢?等待,直到垃圾回收被触发之后再清理数据库连接或者文件句柄并不是一个好方法,这回严重降低系统的性能。

所有拥有外部资源的类,在这些资源已经不再用到的时候,都应当执行Close或者Dispose方法。从Beta2(译注:本文所有的Beta2)开始,Dispose模式通过IDisposable接口来实现。

需要清理外部资源的类还应当一个终止操作(finalizer)。在c#中,创建终止操作的首选方式是在析构函数中实现,在Framwork层,终止操作的实现则是通过重载System.Object.Finalize方法。以下两种实现终止操作的方法是等效的:

~OverdueBookLocator()

{

    Dispose(false);

}

public  void Finalize()

{

  base.Finalize();

  Dispose(false);

}

在c#中,同时在Finalize方法和析构函数实现终止操作将会导致错误的产生。

除非你有足够的理由,否则你不应该创建析构函数或者Finalize方法。终止操作会降低系统的性能,并且增加执行期的内存开销。同时,由于终止操作被执行的方式,你并不能保证何时一个终止操作会被执行。

内存分配和垃圾回收的细节

对GC有了一个总体印象之后,让我们来讨论关于托管堆中的分配于回收工作的细节。托管堆看起来与我们已经收悉的c++编程中的传统堆一点都不像。在传统的堆中,数据结构习惯于大块的空闲内存。在其中查找特定大小的内存快是一件很耗时的工作,尤其是当内存中充满碎片的时候。与此不同,在托管堆中,内存被组制成连续的数组,指针总是巡着已经被使用的内存和未被使用的内存之间的边界移动。当内存被分配的时候,指针只是简单递增——有此而来的一个好处是,分配操作的效率得到了很大的提升。

当对象被分配的时候,他们一开始被放在generation 0中。当generation 0的大小快要达到它的上限的时候,一个只在generation 0中执行的回收操作被触发。由于generation 0的大小很小,因此这将是一个非常快得GC过程。这个GC过程的结果是将generation 0彻底的刷新了一遍。不再使用的对象被释放,确实正被使用的对象被整理并移入generation 1中。

    当generation 1的大小随着generation 0中移动的对象数量的增加而接近他的上限的时候,一个回收动作被触发来在generation 0和generation 1中执行GC过程,如同generation 0中一样,不再使用的对象被释放,正被使用的对象被整理并移入了下一个generation中。大部分GC过程的主要目标是generation 0,因为在generation 0中最可能存在大量的已不再使用的临时变量。堆generation 2的回收过程具有很高的开销,并且此过程只有在generation 0和generation 1的GC过程不能释放足够的内存时才会被触发。如果对generation 2的过程GC过程仍然不能释放足够的内存,那么系统就抛出OutOfMemoryException异常(内存溢出)

   带有终止操作的对象的垃圾收集过程要稍微复杂一些。当一个带有终止操作的对象被标记为垃圾时,它并不会被立即释放。相反,它会被放置在一个终止队列(finalization  queue)中,此队列为这个对象建立一个引用,来避免这个对象被回收。后台线程为队列中的每个对象执行他们各自的终止操作,并且将已经执行终止操作的对象从终止队列中删除。只有那些已经执行过终止操作的对象才会在下一次垃圾回收过程中被内存中删除。这样做的一个后果是,等待被终止的对象有可能在它被清空之前,被移入更高一级generaton中,从而增加它被清除的延迟时间。

    需要执行终止操作的对象应当实现IDisposeable接口,以便客户程序通过此接口快速执行终止动作。IDispoable接口包含一个方法---Dispose.这个被Beta2引入的接口,采用一种在Beta2之前就已经被广泛使用的模式实现。从本质上讲,一个需要终止操作的对象暴漏出Dispose方法。这个方法被用来释放外部资源并抑制终止操作,就像下面这个程序片段演示的那样:

public class OverdueBookLocator:IDisposeable

{

   ~OverdueBookLocator()

   {

         InternalDispose(false);

   }

public void Dispose()

   {

        InternalDispose(true);

   }

protected void InternalDispose(bool diposing)

{

  if(isposing)

    {

        GC.SuppressFinalize(this);

    }

}

}

使用基于CLR的语言编译器开发的代码都成为托管代码。

托管堆是CLR中自动内存管理的基础。初始化新进程时,运行会为进程保留一个连续的地址空间区域。这个保留的地址空间被称为托管堆。托管堆维护着一个指针,用它指向将在堆中分配的下一个对象的地址。最初,该指针设置为指向托管堆的基址。

以下代码说明的很形象:

//引用类型(‘class’类类型)

class SomeRef

{

   public int32 x;

}

//值类型(‘struct’)

struct SomeVa

{

    public Int32 x;

}

static void Value TypeDemo()

{

   SomeRef r1=new SomeRef();//分配在托管堆

   SomeVal v1=new SomeVal();//分配堆栈上

   r1.x=5;//解析指针

   v1.x=5;//在堆栈上修改

  SomeRef r2=r1;//仅拷贝引用(指针)

  SomerVal v2=v1;//先在堆栈上分配,然后拷贝成员

   r1.x=8;//改变了r1,r2的值

   v1.x=9;//改变了v1,没有改变v2


}

-------------------------------------------------------------------------------------------------------

栈是内存中完全用于存储局部变量或成员(值类型数据)的高效区域,但其大小有限制。

托管堆占内存比栈大得多,当访问速度较慢。托管堆只用于分配内存,一般有CLR来出来释放问题。

当创建值类型数据时,在栈上分配内存;

当创建引用型数据时,在托管堆上分配内存并返回对象的引用。注意这个对象的引用,像其他局部变量一样也是保存在栈中,该引用指向的值则位于托管堆中。

如果创建一个包含值类型的引用类型,比如数组,其元素的值也是存放在托管堆中的某个地方,由使用该实体的变量引用;而值类型存储在使用它们的地方,有几处在使用,就有几个副本存在。

对于引用类型,如果在声明变量的时候没有使用new运算符,运行时不会给它分配托管堆的内存空间,而是在栈伤给她分配一个包含null值的引用。对于值类型,运行时会给它分配栈上的空间,并且调用构造函数来初始化对象的状态。

------------------------------------------------------------------------------------------------------

一、栈和托管堆

    通用类型系统(CTS)区分两种基本类型:值类型和引用类型。它们之间的根本区别在于它们在内存中的存储方式。.NET使用两种不同的物理内存快来存储数据------栈和托管堆:

   值类型总是在内存中占用一个预定义的字节数(例如,int类型占4个字节,而string类型占用的字节数会根据字符串的长度而不同)。当生命一个值类型变量时,会在栈中分配适当大小的内存(除了引用类型的值类型成员外,如类的int字段)。内存中的这个空间用来存储变量所包含的值。.NET维护一个栈指针,它包含栈中下一个可用的内存空间的地址。当一个变量离开作用域时,栈指针指向下移动被释放变量所占有的字节数。所以它仍指向下一个可用地址。

    引用变量也利用栈,但这时候栈包含的只是对另一个内存位置的引用,而不是实际值。这个位置是托管堆中的一个地址,和栈一样,他也维护一个指针,包含堆中下一个可用的内存地址。但是,堆不是先入后出的,因为对对象的引用可在我们的程序中传递(例如,作为参数传递给方法调用)。堆中的对象不会在程序的一个预定点离开作用域。为了在不适用在堆中分配的内存时将它释放。.NET定期执行垃圾回收集,垃圾收集递归检查应用程序中所有对象的引用。引用不再有效的对象使用的内存无法从程序中访问,该内存就可以回收。

二、类型层次结构

CTS定义了一种类型层次结构,该结构不仅仅描述了不同预定义类型,还指出了用户定义类型的层次结构中的

 

三、引用类型

引用类型包含一个指针,指向堆中存储对象本身的位置。因为引用类型只包含实际的值,对方法体内参数所做的任何修改都将影响传递给方法调用的引用类型的变量。

下图显示了声明一个字符串变量并把它作为参数传递给一个方法时所发生的事情。

string  s1="something";

DoSomething(s1);

   //.....

DoSomething(string  s2)

{

   //....

}

当声明一个字符变量s1时,一个值被压入栈中,它指向栈中的一个位置,在上图中,引用存放在地址1243044中,而实际的字符串存放在堆地址12662032中,当该字符串传递给一个方法中,在栈伤对应输入参数声明了一个新德变量(这次是在地址1243032上),保存在引用变量,即堆中内存位置中的值被传递给这个新德变量。

委托是引用方法的一种引用类型,类似于c++中的函数指针(两者的主要区别于委托包括调用其方法的对象)。

四、预定义的引用类型

有两种引用类型在c#中受到了特别的重视,他们的c#别名和预定义值类型的c#别名很相像。第一种是object(c#别名是object,o小写)。这是所有值类型和引用类型的最终基类。因为所有的类型派生object,所以可以把任何类型转换成Object,甚至值类型也可以转换。这个把值类型转换为Object的过程称为装箱。所有的值类型都派生自引用类型,在这件事看似矛盾的事情背后,装箱的作用不可或缺。

第二种是String类,字符串代表一个固定不变的Unicode字符序列,这种不变性意味着,一旦在堆中分配了一个字符串,他的值将永远不会改变,如果值该改变了,.NET就创建一个全新的String对象,并把它赋值给该变量,这意味着,字符串在很多方面都像值类型,而不像引用类型。如果把一个字符串传递给方法。然后在方法体内改变参数的值,这不会影响最初的字符串(当然,除非参数按引用传递的)。c#提供了别名String(s小写)来代表Ststem.String类,如果代码中使用String,必须在代码一开始添加Using System;这一行。使用内建的别名string则不需要using System; 

0 0