托管堆和垃圾回收
一、托管堆基础
每个程序都要使用这样或那样的资源,包括文件、内存缓存区、屏幕空间、网络连接、数据库资源等。事实上,在面对对象的环境中,每个类型都代表可供程序使用的一种资源。要使用这些资源,必须为代表的资源的类型分配内存。以下是访问一个资源所需的步骤。
1)、调用IL指令newobj,为代表资源的类型分配内存(一般使用C#new操作符来完成)。
2)、初始化内存,设置资源的初始状态并使用资源可用。类型的实例构造器负责设置初始化状态。
3)、访问类型的成员来使用资源(有必要可以重复)。
4)、摧毁资源的状态以进行清理。(大多数类型都不需要)
5)、释放内存。垃圾回收器独自负责这一步。
如果需要程序员手动管理内存(例如,原生C++开发人员就是这样的),这个看似简单的模式就会成为导致大量编程错误的“元凶”之一。想想看,有多少次程序员忘记释放不再需要的内存而造成内存泄露①?又有多少次试图使用已经释放的内存,然后由于内存被破坏而z造成程序错误和安全漏洞?而且,这两种bug要比其他大多数bug都要严重,因为一般无法预测他们的后果和发生时间。如果是其他bug,一旦发现程序行为异常,改正出问题的代码就可以了。
现在,只要写的是可验证的、类型安全的代码(不要用C# unsfae关键字),应用程序就不可能会出现内存被破坏的情况。内存仍有可能泄露,但不想以前那样是默认行为。现在内存泄露一般是因为在集合中存储了对象,但不需要对象的时候一直不去删除。
为了进一步简化编程,开发人员经常使用的大多数类型都不需要步骤4。所以,托管堆除了能避免前面提到的bug,还能为开发人员提供一个简化的编程模型:分配并初始化资源并直接使用。大多数类型都无需资源清理,垃圾回收器会自动释放内存。
使用需要特殊清理的类型是,编程模型还是像刚才描述的那样简单。只是有时需要尽快清理资源,而不是非要等着GC介入。可这些类中调用一个额外的方法(Dispose),按照自己的节奏清理资源。另一方面,实现这样的类需要考虑到较多的问题。一般只有包装了本机资源(文件、套接字和数据库连接等)的类型才需要特殊清理。
1、从托管堆分配资源
CLR要求所有对象都从托管堆分配。进程初始化时,CLR划出一个地址空间区域作为托管堆。CLR还要维护一个指针,我把它称为NextObjPtr。该指针指向下一个对象在队中的分配位置。刚开始的时候,NextObjPtr设为地址空间区域的基地址。
一个区域被非垃圾对象填满后,CLR会分配更多的区域。这个过程一直重复,直到整个进程地址被填满。所以,你的应用程序的内存受进程的虚拟地址空间的限制。32位进程最多能分配1.5GB,64位进程最多能分配8TB。
C#的new操作符导致CLR执行以下步骤。
1)、计算类型的字段(以及父类的字段)所需要的字节数。
2)、加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针④和同步块索引⑤。对于32位应用程序,这两个字段各自需要32位,所以每个程序要增加8字节。对于64为应用程序,这两个字段各自需要64位,所以每个对象要增加16字节。
3)、CLR检查区域中是否有分配对象所需的字节数。如果托管堆有足够的可用空间,就在NextObjPtr指针指向的地址处放入对象,为对象分配的字节会被清零②。接着调用类型的构造器(为this参数传递NextObjPtr)③,new操作符返回对象引用。就在返回这个引用之前,NextObjPtr指针的值会加上对象占用的字节数来得到一个新值,即下一个对象放入托管对的地址。
对于托管堆,分配对象只需在指针上加一个值--速度相当快。在许多应用程序中,差不多同时分配的对象彼此间有较强的联系,而且经常差不多在同一时间访问。例如,经常在分配一个BinaryWriter对象之前分配一个FileStream对象。然后,应用程序使用BinaryWriter对象,而后者在内部使用FileStream对象。由于托管堆在内存中连续分配这些对象,所一会因为引用的“局部化”而获得性能上的提升。具体的说,这意味着进程的工作集会非常小,应用程序只需使用很小的内存,从而提高了速度。还意味着代码使用的对象可以全部驻留在CPU的缓存中。结果是应用程序能以惊人的速度访问这些对象,因为CPU在执行大多数操作时,不会因为缓存未命中而被迫访问较慢的RAM。
托管堆分配对象速度很快,但是这是在内存无限大的前提下的,但是内存不可能无限,所以CLR通过GC技术删除你的应用程序中不需要的对象。
2、垃圾回收算法:
应用程序调用new操作符创建对象时,可能没有足够地址空间来分配对象。发现空间不够,CLR就执行垃圾回收⑤。
对于对象的生存期的管理,有的系统采用的是某种引用计数算法。事实上Microsoft自己的“组件端详模型”(COM)用的就是引用计数。在这种系统中,对上的每个对象都维护着一个内存字段来统计程序中多少“部分”使用对象。随着每一“部分”达到代码中某个不再需要对象的地方,就递减对象的计数字段。计数字段变成0,对象就可以从内存中删除了。许多引用计数系统最大的问题是处理不好循环引用⑦。例如在GUI引用程序中,窗口将容纳对子UI元素的引用,而子元素UI将容纳对父窗口的引用。这种引用会阻止两个对象的计数器达到0,所以两个对象永远不会删除,及时引用程序本身不再需要窗口了。
鉴于引用计数垃圾回收器算法存在的问题,CLR改为使用一种引用跟踪算法。引用跟踪算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象;值类型变量直接包含值类型实例。引用类型变量可在许多场合使用,包括类的静态和实例字段,或者方法的参数和局部变量。我们将所有引用类型的变量称为根。
CLR开始GC时,首先暂停程序中的所有线程。这样可以防止线程在CLR检查期间访问对象并更改其状态。然后CLR进入GC的标记阶段。在这个阶段,CLR遍历堆中的所有对象,将同步块索引字段中的一位设置为0.这表明所有对象都应删除。然后,CLR检查所有活动根。查看他们引用了哪些对象。这正是CLR的GC称为引用跟踪GC的原因。如果一个根包含null⑥,CLR忽略这个根并继续检查下一个根。
下图展示了一个堆,其中包含几个对象。应用程序的根直接引用对象A,C,D,和F。所以对象都已标记。标记对象D时,垃圾回收器发现这个对象含有一个引用H对象的字段,造成对象H也被标记。标记过程会持续,知道应用程序的所有根所有检查完毕。
①、内存泄露说白了就是内存使用完之后没有回收。可能造成的后果就是一个进程会榨干操作系统所能提供所有进程的内存而出现系统崩溃。
②、1)和2)分配的字节在临时的一个内存区域?,不是,现在想想应该是说分配给他的字节上的说有【位】的值归零,即初始值。
③、调用构造函数this指向原先NextObjPtr
④、类型对象指针:应该指的就是this
⑤、并不是说分配的空间不够,CLR就执行垃圾回收,而事故在第0代满的时候发生的。
⑥、每一个根代表一个引用变量(字段和局部变量),所以null就跳过,这个根我怀疑就是在zhan
⑦、参考垃圾回收之循环引用。