您的位置:首页 > 编程语言 > C#

[CLR via C#]21. 自动内存管理(垃圾回收机制)

2014-06-11 16:40 344 查看

目录

理解垃圾回收平台的基本工作原理

垃圾回收算法

垃圾回收与调试

使用终结操作来释放本地资源

对托管资源使用终结操作

是什么导致Finalize方法被调用

终结操作揭秘

Dispose模式:强制对象清理资源

使用实现了Dispose模式的类型

C#的using语句

手动监视和控制对象的生存期

对象复活



线程劫持

大对象

一、理解垃圾回收平台的基本工作原理

值类型(含所有枚举类型)、集合类型、String、Attribute、Delegate和Event所代表的资源无需执行特殊的清理操作。

如果一个类型代表着或包装着一个非托管资源或者本地资源(比如数据库连接、套接字、mutex、位图等),那么在对象的内存准备回收时,必须执行资源清理代码。

CLR要求所有的资源都从托管堆分配。

进程初始化时,CLR要保留一块连续的地址空间,这个地址空间最初没有对应的物理存储空间。这个地址空间就是托管堆。托管堆还维护着一个指针,可以称为NextObjPtr。它指向下一个对象在堆中的分配位置。刚开始时,NextObjPtr设为保留地址空间的基地址。IL指令使用newobj创建一个对象。newobj指令将导致CLR执行以下步骤:
计算类型(及其所有基类型)所需要的字节数。

加上对象的额外开销的字节数——“类型对象指针”和“同步块索引”。

CLR检查保留区域是否能分配出相应的字节数。如果托管堆有足够的可用空间,对象将被放入。注意对象这在NextObjPtr指针指向的地址放入的,并且为它分配的字节会被清零。接着,调用类型的实例构造函数(为this参数传递NextObjPtr),IL指令newobj将返回对象的地址。就在地址返回之前,NextObjPtr指针的值会加上对象占据的字节数,这样就会得到一个新的NextObjPtr值,它指向下一个对象放入托管堆时的地址。

托管堆之所以能这么做,是因为它做了一个相当大胆的假设——地址空间和存储是无限的。这个假设显然是荒谬的。所以,托管堆必须通过某种机制来允许它做这样的假设。这种机制就是垃圾回收。

对象不断的被创建,NextObjPtr也在不断的增加,如果NextObjPtr超过了地址空间的末尾,表明托管堆已满,就必须强制执行一次垃圾回收。

二、 垃圾回收算法

每个应用程序都包含一组。每个根都是一个存储位置,其中包含指向引用类型对象的指针。该指针要么引用托管堆中的一个对象,要么为null。只有引用类型的变量才会被认为是根;值类型的变量永远不被认为是根。

垃圾回收开始执行时,它假设堆中所有对象都是垃圾。
第一个阶段为标记阶段。这个阶段,垃圾回收器沿着线程栈向上检查所有根。如果发现一个根引用了一个对象,就进行”标记”。该标记具有传递性。标记好根和它的字段引用的对象之后,垃圾回收器会检查下一个根,并继续标记对象。如果垃圾回收期试图标记先前已经标记了的根,就会停止沿着这个路径走下去。检查好所有根之后,堆中将包含一组已标记和未标记的对象。已标记的对象是通过应用程序的代码可以到达的对象,而未标记的对象是不可达的。不可达的对象就是垃圾,它们的内存是可以回收的。

第二个阶段为压缩(可以理解成"内存碎片整理")阶段。在这个阶段中,垃圾回收器线性遍历堆,以寻找未标记对象的连续内存块。如果这个内存块较小,垃圾回收器会忽略它们。反之,垃圾回收器会把非垃圾的对象移动到这里已压缩堆,其实在这是内存碎片整理或许更会适用。自然的,包含那些”指向这些对象的指针”的变量和CPU寄存器现在都会变得无效。所以,垃圾回收器必须重新访问应用程序的所有根,并修改它们来指向对象的新内存位置。堆内存压缩之后,托管堆的NextObjPtr指针将指向紧接在最后一个非垃圾回收对象之后的位置。

所以,垃圾回收器会造成显著的损失,这是使用托管堆的主要缺点。当然,垃圾回收只在第0代满的时候才会发生。在此之前,托管堆性能远远高于C运行时堆。

三、垃圾回收与调试

当JIT编译器将方法的IL代码编译成本地代码时,JIT编译器会检查两点:定义方法的程序集在编译时没有优化;进行当前在一个调试器中执行。如果这两点都成立,JIT编译器在生成方法的内部根表时,会将变量的生存期手动延长至方法结束。

四、使用终结操作来释放本地资源

终结是CLR提供的一种机制,允许对象在垃圾回收器回收其内存之前执行一些得体的清理工作。

任何包装了本地资源的类型都必须支持终结操作。简单的说,类型实现了一个命名为Finalize的方法。当垃圾回收期判断一个对象是垃圾时,会调用对象的Finalize方法。

C#团队认为,Finalize方法是编程语言中需要特殊语法的一种方法。在C#中,必须在类名前加一个~符号来定义Finalize方法。

Internal sealed class SomeType {

~SomeType(){

//这里的代码会进入Finalize方法

}

}


  5. 编译上述代码,会发现C#编译器实际是在模块的元数据中生成一个名为Finalize的protected override方法。方法主体被放到try块中,finally块放入了一个对base.Finalize的调用。

  6.实现Finalize方法时,一般都会调用Win32 CloseHandle函数,并向该函数传递本地资源的句柄。

五、对托管资源使用终结操作

永远不要对托管资源使用终结操作,这是有一种非常好的编程习惯。因为对托管资源使用终结操作是一种非常高级的编码方式,只有极少数情况下才会用到。

设计一个类型时,处于以下几个性能原因,应避免使用Finalize方法:
可终结的对象要花费更长的时间来分配,因为指向它们的指针必须先放到终结列表中。("终结列表"在第七节会说到)

可终结对象会被提升到较老的一代,这会增加内存压力,并在垃圾回收器判定为垃圾时,阻止回收。除此之外,对该对象直接或间接引用的对象都会提升到较老的一代。("代"在第十三节会说到)

可终结的对象会导致应用程序运行缓慢,因为每个对象在进行回收时,需要对它们进行额外操作。

我们无法控制Finalize方法何时运行。CLR不保证各个Finalize的调用顺序。

六、是什么导致Finalize方法被调用

第0代满 只有第0代满时,垃圾回收器会自动开始。该事件是目前导致调用Finalize方法最常见的一种方式。("代"在第十三节会说到)

代码显式调用System.GC的静态方法Collect 代码可以显式请求CLR执行即时垃圾回收操作。

Windows内存不足 当Windows报告内存不足时,CLR会强制执行垃圾回收。

CLR卸载AppDomain 一个ApppDomain被卸载时,CLR认为该AppDomain不存在任何根,因此会对所有代的对象执行垃圾回收。

CLR关闭 一个进程结束时,CLR就会关闭。CLR关闭会认为进程中不存在 任何根,因此会调用托管堆中所有的Finalize方法,最后由Windows回收内存。

七、终结操作揭秘

应用程序创建一个新对象时,new操作符会从堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器调用之前,会将一个指向该对象的指针放到一个终结列表(finalization list)中。

终结列表是由垃圾回收器控制的一个内部数据结构。列表中的每一项都指向一个对象,在回收该对象之前,会先调用对象的Finalize方法。

下图1展示了包含几个对象的一个托管堆。有的对象从应用程序的根可达,有的不可达(垃圾)。对象C,E,F,I,J被创建时,系统检测到这些对象的类型定义来了Finalize方法,所有指向这些对象的指针要添加到终结列表中。



垃圾回收开始时,对象B,E,G,H,I和J被判定为垃圾。垃圾回收器扫描终结列表以查找指向这些对象的指针。找到一个指针后,该指针会从终结列表中移除,并追加到freachable队列中。freachable队列(发音是“F-reachable”)是垃圾回收器的内部数据结构。Freachable队列中的每个指针都代表其Finalize方法已准备好调用的一个对象。图2展示了回收完毕后托管堆的情况。



从图2中我们可以看出B,E和H已经从托管堆中回收了,因为它们没有Finalize方法,而E,I,J则暂时没有被回收,因为它们的Finalize方法还未调用。

一个特殊的高优先级的CLR线程负责调用Finalize方法。使用专用的线程可避免潜在的线程同步问题。freachable队列为空时,该线程将睡眠。当队列中有记录项时,该线程就会被唤醒,将每一项从freachable队列中移除,并调用每一项的 Finalize方法。

如果一个对象在freachable队列中,那么意味这该对象是可达的,不是垃圾。

原本,当对象不可达时,垃圾回收器将把该对象当成垃圾回收了,可是当对象进入freachable队列时,有奇迹般的”复活”了。然后,垃圾回收器压缩(内存脆片整理)可回收的内存,特殊的CLR线程将清空freachable队列,并调用其中每个对象的Finalize方法。

垃圾回收器下一次回收时,发现已终结的对象成为真正的垃圾,因为应用程序的根不再指向它,freachhable队列也不再指向它。所以,这些对象的内存会直接回收。

整个过程中,可终结对象需要执行两次垃圾回收器才能释放它们占用的内存。可在实际开发中,由于对象可能被提升到较老的一代,所以可能要求不止两次进行垃圾回收。图3展示了第二次垃圾回收后托管堆中的情况。



八、Dispose模式:强制对象清理资源

Finalize方法非常有用,因为它确保了当托管对象的内存被释放时,本地资源不会泄漏。但是,Finalize方法的问题在于,他的调用时间不能保证。另外,由于他不是公共方法,所以类的用户不能显式调用它。

类型为了提供显式进行资源清理的能力,提供了Dispose模式。

所有定义了Finalize方法的类型都应该同时实现Dispose模式,使类型的用户对资源的生存期有更多的控制。

九、使用实现了Dispose模式的类型

调用Dispose或Close只是为了能在一个确定的时间强迫对象执行清理;这两个方法并不能控制托管堆中的对象所占用的内存的生存期。这意味着即使一个对象已完成了清理,仍然可在它上面调用方法,但会抛出ObjectDisposedException异常。

建议只有在以下两种情况下才调用Dispose或Close:
a) 确定必须清理资源

b) 确定可以安全的调用Dispose或Close,并希望将对象从终结列表中删除,禁止对象提升到下一代,从而提升性能。

十、C#的using语句

如果决定显式地调用Dispose和Close这两个方法之一,强烈建议把它们放到一个异常处理finally中。这样可以保证清理代码得到执行。

Using语句就是一种对第1点进行简化的语法。

十一、手动监视和控制对象的生存期

CLR为每一个AppDomain都提供了一个GC句柄表。该表允许应用程序监视对象的生存期,或手动控制对象的生存期。

在一个AppDomain创建之初,该句柄表是空的。句柄表中的每个记录项都包含以下两种信息:一个指针,它指向托管堆上的一个对象;一个标志(flag),它指出你想如何监视或控制对象。

为了在这个表中添加或删除记录项,应用程序要使用如下所示的System.Runtime.InteropServices.GCHandle类型。

十二、对象复活

前面说过,需要终结的一个对象被认为死亡时,垃圾回收器会强制是该对象重生,使它的Finalize方法得以调用。Finalize方法调用之后,对象才真正的死亡。

需要终结的一个对象会经历死亡、重生、在死亡的”三部曲”。一个死亡的对象重生的过程称为重生

复活一般不是一件好事,应避免写代码来利用CLR这个”功能”。

十三、代

代是CLR垃圾回收器采用的一种机制,它唯一的目的就是提升应用程序的性能

一个基于代的垃圾回收器做出了以下几点假设:
对象越新,生存期越短。

对象越老,生存期越长。

回收堆的一部分,速度快于回收整个堆。

代的工作原理:
托管堆在初始化时不包含任何对象。添加到堆的对象称为第0代对象。第0代对象就是那些新构造的对象,垃圾回收器从未检查过它们。图4展示了一个新启动的应用程序,它分配了5个对象。过会儿,对象C和E将变得不可达。


CLR初始化时,它会为第0代对象选择一个预算容量,假定为256K(实际容量可能有所不同)。所以,如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。假定对象A到E刚好占用256K内存。对象F分配时,垃圾回收器必须启动。垃圾回收器判定对象C和E为垃圾,因为会压缩(内存碎片整理)对象D,使其与对象B相邻。之所以第0代的预算容量为256K,是因为所有这些对象都能装入CPU的L2缓存,使之压缩(内存碎片整理)能以非常快的速度完成。在垃圾回收中存活的对象(A、B和D)被认为是第1代对象。第1代对象已经经历垃圾回收的一次检查。此时的对如图5所示。


一次垃圾回收后,第0代就不包含任何对象了。和前面一样,新对象会分配到第0代中。在图6中,应用程序继续运行,并新分配了对象F到对象K。另外,随着应用程序继续运行,对象B、H和J变得不可达,它们的内存将在某一个回收。


现在,假定分配新对象L会造成第0代超过256KB的预算。由于第0代达到预算,所以必须启动垃圾回收器。开始一次垃圾回收时,垃圾回收器必须决定检查哪些代。

前面说过,当CLR初始化时,他为第0代对象选择了一个预算。同样的,它还必须为第1代选择一个预算。假定为第1代选择的预算为2MB。

垃圾回收开始时,垃圾回收器还会检查第1代占据了多少内存。由于在本例中。第一代占据的内存远远小于2MB,所以垃圾回收器只检查第0代。因为此时垃圾回收器只检查第0代,忽略第1代,所以大大加快了垃圾回收器的速度。但是,对性能最大的提升就是现在不必遍历整个托管堆。如果一个对象引用了一个老对象,垃圾回收器就可以忽略那个老对象的所有内部引用,从而能更快的构造好可达对象的图。

如图7所示,所有幸存下来的第0代对象变成了第1代的一部分。由于垃圾回收器没有检查第1代,所以对象B的内存并没有被回收,即使它在上次垃圾回收时变得不可达。在一次垃圾回收后,第0代不包含任何对象,等着分配新对象。


假定程序继续运行,并分配对象L到对象O。另外,在运行过程中,应用程序停止使用对象G,I,M,是它们变得不可达。此时的托管堆如图8所示。


假设分配对象P导致第0代超过预算,垃圾回收发生。由于第1代中所有对象占据的内存仍小于2MB,所以垃圾回收器再次决定只回收第0代,忽略第1代不可达的垃圾(对象B和G)。回收后,堆的情况如图9所示。


从图9中可以看到,第1代正在缓慢增长。假定第1代的增长导致它所有对象占据的内存刚好达到2MB。这时,随着应用程序的运行,并分配了对象P到对S,使第0代对象达到了它的预算容量。这是的堆如图10所示。


应用程序试图分配对象T时,由于第0代已满,所以必须开始垃圾回收。但是,这次垃圾回收器发现第1代占据的内存超过了2MB。所以垃圾回收器这次决定检查第1代和第0代中的所有对象。两代都被回收之后,托管堆情况如图11所示。


  4. 像前面一样,垃圾回收后,第0代的幸存者被提升到了第1代,第1代的幸存者被提升到了第2代,第0代再次空出来,准备迎接新对象的到来。第2代中的对象会经过2次或更多次的检查。只有在第1代到达预算容量是才会检查第1代中的对象。而对此之前,一般已经对第0代进行了好几次垃圾回收。

  5. CLR的托管堆只支持三代:第0代、第1代和第2代。第0代的预算约为256KB,第1代的预算约为2MB,第2代的预算容量约为10MB。

十四、 线程劫持

前面讨论的垃圾回收算法有一个很大的前提就是:只在一个线程运行。

在现实开发中,经常会出现多个线程同时访问托管堆的情况,或至少会有多个线程同时操作堆中的对象。一个线程引发垃圾回收时,其它线程绝对不能访问任何线程,因为垃圾回收器可能移动这些对象,更改它们的内存位置。

CLR想要进行垃圾回收时,会立即挂起执行托管代码中的所有线程,正在执行非托管代码的线程不会挂起。然后,CLR检查每个线程的指令指针,判断线程指向到哪里。接着,指令指针与JIT生成的表进行比较,判断线程正在执行什么代码。

如果线程的指令指针恰好在一个表中标记好的偏移位置,就说明该线程抵达了一个安全点。线程可在安全点安全地挂起,直至垃圾回收结束。如果线程指令指针不在表中标记的偏移位置,则表明该线程不在安全点,CLR也就不会开始垃圾回收。在这种情况下,CLR就会劫持该线程。也就是说,CLR会修改该线程栈,使该线程指向一个CLR内部的一个特殊函数。然后,线程恢复执行。当前的方法执行完后,他就会执行这个特殊函数,这个特殊函数会将该线程安全地挂起。

然而,线程有时长时间执行当前所在方法。所以,当线程恢复执行后,大约有250毫秒的时间尝试劫持线程。过了这个时间,CLR会再次挂起线程,并检查该线程的指令指针。如果线程已抵达一个安全点,垃圾回收就可以开始了。但是,如果线程还没有抵达一个安全点,CLR就检查是否调用了另一个方法。如果是,CLR再一次修改线程栈,以便从最近执行的一个方法返回之后劫持线程。然后,CLR恢复线程,进行下一次劫持尝试。

所有线程都抵达安全点或被劫持之后,垃圾回收才能使用。垃圾回收完之后,所有线程都会恢复,应用程序继续运行,被劫持的线程返回最初调用它们的方法。

实际应用中,CLR大多数时候都是通过劫持线程来挂起线程,而不是根据JIT生成的表来判断线程是否到达了一个安全点。之所以如此,原因是JIT生成表需要大量内存,会增大工作集,进而严重影响性能。

十五、大对象

任何85000字节或更大的对象都被自动视为大对象

大对象从一个特殊的大对象堆中分配。这个堆中采取和前面小对象一样的方式终结和释放。但是,大对象永远不压缩(内存碎片整理),因为在堆中下移850000字节的内存块会浪费太多CPU时间。

大对象总是被认为是第2代的一部分,所以只能为需要长时间存活的资源创建大对象。如果分配短时间存活的大对象,将导致第2代被更频繁地回收,进而会损害性能。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: