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

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

目录

  • 理解垃圾回收平台的基本工作原理
  • 垃圾回收算法
  • 垃圾回收与调试
  • 使用终结操作来释放本地资源
  • 对托管资源使用终结操作
  • 是什么导致Finalize方法被调用
  • 终结操作揭秘
  • Dispose模式:强制对象清理资源
  • 使用实现了Dispose模式的类型
  • C#的using语句
  • 手动监视和控制对象的生存期
  • 对象复活
  • 线程劫持
  • 大对象

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

  1. 值类型(含所有枚举类型)、集合类型、String、Attribute、Delegate和Event所代表的资源无需执行特殊的清理操作。
  2. 如果一个类型代表着或包装着一个非托管资源或者本地资源(比如数据库连接、套接字、mutex、位图等),那么在对象的内存准备回收时,必须执行资源清理代码。
  3. CLR要求所有的资源都从托管堆分配。
  4. 进程初始化时,CLR要保留一块连续的地址空间,这个地址空间最初没有对应的物理存储空间。这个地址空间就是托管堆。托管堆还维护着一个指针,可以称为NextObjPtr。它指向下一个对象在堆中的分配位置。刚开始时,NextObjPtr设为保留地址空间的基地址。IL指令使用newobj创建一个对象。newobj指令将导致CLR执行以下步骤:
    1. 计算类型(及其所有基类型)所需要的字节数。
    2. 加上对象的额外开销的字节数——“类型对象指针”和“同步块索引”。 
    3. CLR检查保留区域是否能分配出相应的字节数。如果托管堆有足够的可用空间,对象将被放入。注意对象这在NextObjPtr指针指向的地址放入的,并且为它分配的字节会被清零。接着,调用类型的实例构造函数(为this参数传递NextObjPtr),IL指令newobj将返回对象的地址。就在地址返回之前,NextObjPtr指针的值会加上对象占据的字节数,这样就会得到一个新的NextObjPtr值,它指向下一个对象放入托管堆时的地址。
  5. 托管堆之所以能这么做,是因为它做了一个相当大胆的假设——地址空间和存储是无限的。这个假设显然是荒谬的。所以,托管堆必须通过某种机制来允许它做这样的假设。这种机制就是垃圾回收。
  6. 对象不断的被创建,NextObjPtr也在不断的增加,如果NextObjPtr超过了地址空间的末尾,表明托管堆已满,就必须强制执行一次垃圾回收。

二、 垃圾回收算法

  1. 每个应用程序都包含一组。每个根都是一个存储位置,其中包含指向引用类型对象的指针。该指针要么引用托管堆中的一个对象,要么为null。只有引用类型的变量才会被认为是根;值类型的变量永远不被认为是根。
  2. 垃圾回收开始执行时,它假设堆中所有对象都是垃圾。
    1. 第一个阶段为标记阶段。这个阶段,垃圾回收器沿着线程栈向上检查所有根。如果发现一个根引用了一个对象,就进行”标记”。该标记具有传递性。标记好根和它的字段引用的对象之后,垃圾回收器会检查下一个根,并继续标记对象。如果垃圾回收期试图标记先前已经标记了的根,就会停止沿着这个路径走下去。检查好所有根之后,堆中将包含一组已标记和未标记的对象。已标记的对象是通过应用程序的代码可以到达的对象,而未标记的对象是不可达的。不可达的对象就是垃圾,它们的内存是可以回收的。
    2. 第二个阶段为压缩(可以理解成"内存碎片整理")阶段。在这个阶段中,垃圾回收器线性遍历堆,以寻找未标记对象的连续内存块。如果这个内存块较小,垃圾回收器会忽略它们。反之,垃圾回收器会把非垃圾的对象移动到这里已压缩堆,其实在这是内存碎片整理或许更会适用。自然的,包含那些”指向这些对象的指针”的变量和CPU寄存器现在都会变得无效。所以,垃圾回收器必须重新访问应用程序的所有根,并修改它们来指向对象的新内存位置。堆内存压缩之后,托管堆的NextObjPtr指针将指向紧接在最后一个非垃圾回收对象之后的位置。
  3. 所以,垃圾回收器会造成显著的损失,这是使用托管堆的主要缺点。当然,垃圾回收只在第0代满的时候才会发生。在此之前,托管堆性能远远高于C运行时堆。

三、垃圾回收与调试

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

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

  1. 终结是CLR提供的一种机制,允许对象在垃圾回收器回收其内存之前执行一些得体的清理工作。
  2. 任何包装了本地资源的类型都必须支持终结操作。简单的说,类型实现了一个命名为Finalize的方法。当垃圾回收期判断一个对象是垃圾时,会调用对象的Finalize方法。
  3. C#团队认为,Finalize方法是编程语言中需要特殊语法的一种方法。在C#中,必须在类名前加一个~符号来定义Finalize方法。
Internal sealed class SomeType {

     ~SomeType(){

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

    }

}

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

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

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

  1. 永远不要对托管资源使用终结操作,这是有一种非常好的编程习惯。因为对托管资源使用终结操作是一种非常高级的编码方式,只有极少数情况下才会用到。
  2. 设计一个类型时,处于以下几个性能原因,应避免使用Finalize方法:
    1. 可终结的对象要花费更长的时间来分配,因为指向它们的指针必须先放到终结列表中。("终结列表"在第七节会说到)
    2. 可终结对象会被提升到较老的一代,这会增加内存压力,并在垃圾回收器判定为垃圾时,阻止回收。除此之外,对该对象直接或间接引用的对象都会提升到较老的一代。("代"在第十三节会说到)
    3. 可终结的对象会导致应用程序运行缓慢,因为每个对象在进行回收时,需要对它们进行额外操作。
  3. 我们无法控制Finalize方法何时运行。CLR不保证各个Finalize的调用顺序。

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

  1. 第0代满 只有第0代满时,垃圾回收器会自动开始。该事件是目前导致调用Finalize方法最常见的一种方式。("代"在第十三节会说到)
  2. 代码显式调用System.GC的静态方法Collect  代码可以显式请求CLR执行即时垃圾回收操作。
  3. Windows内存不足  当Windows报告内存不足时,CLR会强制执行垃圾回收。
  4. CLR卸载AppDomain  一个ApppDomain被卸载时,CLR认为该AppDomain不存在任何根,因此会对所有代的对象执行垃圾回收。
  5. CLR关闭  一个进程结束时,CLR就会关闭。CLR关闭会认为进程中不存在 任何根,因此会调用托管堆中所有的Finalize方法,最后由Windows回收内存。

七、终结操作揭秘

  1. 应用程序创建一个新对象时,new操作符会从堆中分配内存。如果对象的类型定义了Finalize方法,那么在该类型的实例构造器调用之前,会将一个指向该对象的指针放到一个终结列表(finalization list)中。
  2. 终结列表是由垃圾回收器控制的一个内部数据结构。列表中的每一项都指向一个对象,在回收该对象之前,会先调用对象的Finalize方法。
  3. 下图1展示了包含几个对象的一个托管堆。有的对象从应用程序的根可达,有的不可达(垃圾)。对象C,E,F,I,J被创建时,系统检测到这些对象的类型定义来了Finalize方法,所有指向这些对象的指针要添加到终结列表中。

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

  5. 从图2中我们可以看出B,E和H已经从托管堆中回收了,因为它们没有Finalize方法,而E,I,J则暂时没有被回收,因为它们的Finalize方法还未调用。
  6. 一个特殊的高优先级的CLR线程负责调用Finalize方法。使用专用的线程可避免潜在的线程同步问题。freachable队列为空时,该线程将睡眠。当队列中有记录项时,该线程就会被唤醒,将每一项从freachable队列中移除,并调用每一项的 Finalize方法。
  7. 如果一个对象在freachable队列中,那么意味这该对象是可达的,不是垃圾。
  8. 原本,当对象不可达时,垃圾回收器将把该对象当成垃圾回收了,可是当对象进入freachable队列时,有奇迹般的”复活”了。然后,垃圾回收器压缩(内存脆片整理)可回收的内存,特殊的CLR线程将清空freachable队列,并调用其中每个对象的Finalize方法。
  9. 垃圾回收器下一次回收时,发现已终结的对象成为真正的垃圾,因为应用程序的根不再指向它,freachhable队列也不再指向它。所以,这些对象的内存会直接回收。
  10.  整个过程中,可终结对象需要执行两次垃圾回收器才能释放它们占用的内存。可在实际开发中,由于对象可能被提升到较老的一代,所以可能要求不止两次进行垃圾回收。图3展示了第二次垃圾回收后托管堆中的情况。

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

  1. Finalize方法非常有用,因为它确保了当托管对象的内存被释放时,本地资源不会泄漏。但是,Finalize方法的问题在于,他的调用时间不能保证。另外,由于他不是公共方法,所以类的用户不能显式调用它。
  2. 类型为了提供显式进行资源清理的能力,提供了Dispose模式。
  3. 所有定义了Finalize方法的类型都应该同时实现Dispose模式,使类型的用户对资源的生存期有更多的控制。

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

  1. 调用Dispose或Close只是为了能在一个确定的时间强迫对象执行清理;这两个方法并不能控制托管堆中的对象所占用的内存的生存期。这意味着即使一个对象已完成了清理,仍然可在它上面调用方法,但会抛出ObjectDisposedException异常。
  2. 建议只有在以下两种情况下才调用Dispose或Close:
    1. a)   确定必须清理资源
    2. b)   确定可以安全的调用Dispose或Close,并希望将对象从终结列表中删除,禁止对象提升到下一代,从而提升性能。

十、C#的using语句

  1. 如果决定显式地调用Dispose和Close这两个方法之一,强烈建议把它们放到一个异常处理finally中。这样可以保证清理代码得到执行。
  2. Using语句就是一种对第1点进行简化的语法。

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

  1. CLR为每一个AppDomain都提供了一个GC句柄表。该表允许应用程序监视对象的生存期,或手动控制对象的生存期。
  2. 在一个AppDomain创建之初,该句柄表是空的。句柄表中的每个记录项都包含以下两种信息:一个指针,它指向托管堆上的一个对象;一个标志(flag),它指出你想如何监视或控制对象。
  3. 为了在这个表中添加或删除记录项,应用程序要使用如下所示的System.Runtime.InteropServices.GCHandle类型。

十二、对象复活

  1. 前面说过,需要终结的一个对象被认为死亡时,垃圾回收器会强制是该对象重生,使它的Finalize方法得以调用。Finalize方法调用之后,对象才真正的死亡。
  2. 需要终结的一个对象会经历死亡、重生、在死亡的”三部曲”。一个死亡的对象重生的过程称为重生
  3. 复活一般不是一件好事,应避免写代码来利用CLR这个”功能”。

十三、代

  1. 代是CLR垃圾回收器采用的一种机制,它唯一的目的就是提升应用程序的性能
  2. 一个基于代的垃圾回收器做出了以下几点假设:
    1. 对象越新,生存期越短。
    2. 对象越老,生存期越长。
    3. 回收堆的一部分,速度快于回收整个堆。
  3. 代的工作原理:
    1. 托管堆在初始化时不包含任何对象。添加到堆的对象称为第0代对象。第0代对象就是那些新构造的对象,垃圾回收器从未检查过它们。图4展示了一个新启动的应用程序,它分配了5个对象。过会儿,对象C和E将变得不可达。
    2. CLR初始化时,它会为第0代对象选择一个预算容量,假定为256K(实际容量可能有所不同)。所以,如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。假定对象A到E刚好占用256K内存。对象F分配时,垃圾回收器必须启动。垃圾回收器判定对象C和E为垃圾,因为会压缩(内存碎片整理)对象D,使其与对象B相邻。之所以第0代的预算容量为256K,是因为所有这些对象都能装入CPU的L2缓存,使之压缩(内存碎片整理)能以非常快的速度完成。在垃圾回收中存活的对象(A、B和D)被认为是第1代对象。第1代对象已经经历垃圾回收的一次检查。此时的对如图5所示。
    3. 一次垃圾回收后,第0代就不包含任何对象了。和前面一样,新对象会分配到第0代中。在图6中,应用程序继续运行,并新分配了对象F到对象K。另外,随着应用程序继续运行,对象B、H和J变得不可达,它们的内存将在某一个回收。
    4. 现在,假定分配新对象L会造成第0代超过256KB的预算。由于第0代达到预算,所以必须启动垃圾回收器。开始一次垃圾回收时,垃圾回收器必须决定检查哪些代。
    5. 前面说过,当CLR初始化时,他为第0代对象选择了一个预算。同样的,它还必须为第1代选择一个预算。假定为第1代选择的预算为2MB。
    6. 垃圾回收开始时,垃圾回收器还会检查第1代占据了多少内存。由于在本例中。第一代占据的内存远远小于2MB,所以垃圾回收器只检查第0代。因为此时垃圾回收器只检查第0代,忽略第1代,所以大大加快了垃圾回收器的速度。但是,对性能最大的提升就是现在不必遍历整个托管堆。如果一个对象引用了一个老对象,垃圾回收器就可以忽略那个老对象的所有内部引用,从而能更快的构造好可达对象的图。
    7. 如图7所示,所有幸存下来的第0代对象变成了第1代的一部分。由于垃圾回收器没有检查第1代,所以对象B的内存并没有被回收,即使它在上次垃圾回收时变得不可达。在一次垃圾回收后,第0代不包含任何对象,等着分配新对象。
    8. 假定程序继续运行,并分配对象L到对象O。另外,在运行过程中,应用程序停止使用对象G,I,M,是它们变得不可达。此时的托管堆如图8所示。
    9. 假设分配对象P导致第0代超过预算,垃圾回收发生。由于第1代中所有对象占据的内存仍小于2MB,所以垃圾回收器再次决定只回收第0代,忽略第1代不可达的垃圾(对象B和G)。回收后,堆的情况如图9所示。
    10. 从图9中可以看到,第1代正在缓慢增长。假定第1代的增长导致它所有对象占据的内存刚好达到2MB。这时,随着应用程序的运行,并分配了对象P到对S,使第0代对象达到了它的预算容量。这是的堆如图10所示。
    11. 应用程序试图分配对象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。

十四、   线程劫持

  1. 前面讨论的垃圾回收算法有一个很大的前提就是:只在一个线程运行。
  2. 在现实开发中,经常会出现多个线程同时访问托管堆的情况,或至少会有多个线程同时操作堆中的对象。一个线程引发垃圾回收时,其它线程绝对不能访问任何线程,因为垃圾回收器可能移动这些对象,更改它们的内存位置。
  3. CLR想要进行垃圾回收时,会立即挂起执行托管代码中的所有线程,正在执行非托管代码的线程不会挂起。然后,CLR检查每个线程的指令指针,判断线程指向到哪里。接着,指令指针与JIT生成的表进行比较,判断线程正在执行什么代码。
  4. 如果线程的指令指针恰好在一个表中标记好的偏移位置,就说明该线程抵达了一个安全点。线程可在安全点安全地挂起,直至垃圾回收结束。如果线程指令指针不在表中标记的偏移位置,则表明该线程不在安全点,CLR也就不会开始垃圾回收。在这种情况下,CLR就会劫持该线程。也就是说,CLR会修改该线程栈,使该线程指向一个CLR内部的一个特殊函数。然后,线程恢复执行。当前的方法执行完后,他就会执行这个特殊函数,这个特殊函数会将该线程安全地挂起。
  5. 然而,线程有时长时间执行当前所在方法。所以,当线程恢复执行后,大约有250毫秒的时间尝试劫持线程。过了这个时间,CLR会再次挂起线程,并检查该线程的指令指针。如果线程已抵达一个安全点,垃圾回收就可以开始了。但是,如果线程还没有抵达一个安全点,CLR就检查是否调用了另一个方法。如果是,CLR再一次修改线程栈,以便从最近执行的一个方法返回之后劫持线程。然后,CLR恢复线程,进行下一次劫持尝试。
  6. 所有线程都抵达安全点或被劫持之后,垃圾回收才能使用。垃圾回收完之后,所有线程都会恢复,应用程序继续运行,被劫持的线程返回最初调用它们的方法。
  7. 实际应用中,CLR大多数时候都是通过劫持线程来挂起线程,而不是根据JIT生成的表来判断线程是否到达了一个安全点。之所以如此,原因是JIT生成表需要大量内存,会增大工作集,进而严重影响性能。

十五、大对象

  1. 任何85000字节或更大的对象都被自动视为大对象
  2. 大对象从一个特殊的大对象堆中分配。这个堆中采取和前面小对象一样的方式终结和释放。但是,大对象永远不压缩(内存碎片整理),因为在堆中下移850000字节的内存块会浪费太多CPU时间。
  3. 大对象总是被认为是第2代的一部分,所以只能为需要长时间存活的资源创建大对象。如果分配短时间存活的大对象,将导致第2代被更频繁地回收,进而会损害性能。
时间: 2024-11-02 22:02:53

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

java内存管理和回收机制

java类文件是以 .java为后缀的文件,经过javac命令编译后,编译成class文件,class文件中都是二进制格式的数据,所以想要看编译后的内容是什么,可以采用jdk自带的javap命令查看. JVM中有个组成部分为类加载器(ClassLoader),负责java文件编译后class文件的加载,加载到哪呢,加载到内存.那下面来说一下JVM的内存管理. java通过类加载器来加载class文件,加载到内存后,会把类.方法.常变量放到堆内存中.因为java是自动进行垃圾回收的,所以放入堆内存

JVM内存管理及GC机制

一.概述 Java GC(Garbage Collection,垃圾收集,垃圾回收)机制,是Java与C++/C的主要区别之一,作为Java开发者,一般不需要专门编写内存回收和垃圾清理代码,对内存泄露和溢出的问题,也不需要像C程序员那样战战兢兢.经过这么长时间的发展,Java GC机制已经日臻完善,几乎可以自动的为我们做绝大多数的事情. 虽然java不需要开发人员显示的分配和回收内存,这对开发人员确实降低了不少编程难度,但也可能带来一些副作用: 1. 有可能不知不觉浪费了很多内存 2. JVM花

C# 语言规范--1.4 自动内存管理

规范 手动内存管理要求开发人员管理内存块的分配和回收.手动内存管理可能既耗时又麻烦.在 C# 中提供了自动内存管理,使开发人员从这个繁重的任务中解脱出来.在绝大多数情况下,自动内存管理可以提高代码质量和开发人员的工作效率,并且不会对表达能力或性能造成负面影响. 示例 using System; public class Stack {    private Node first = null;    public bool Empty {       get {          return

Java虚拟机自动内存管理

生活规律告诉我们,在享受便利的同时一般都会付出巨大的代价,如果你在享受了便利的同时,还没有为此付出代价,不是说明没有,只是还没到付出的时候.试问,有哪个Java系统架构师不懂Java虚拟机?纵观Java程序员的发展历程,又有多少人是卡在了Java虚拟机之上.所以如果你还没有感觉到为此付出代价,说明你已经Java虚拟机的糖衣炮弹所击中,且被毒害之深.Java的自动内存管理就是这样,像毒药一样,一旦上瘾就很难戒掉,而且会沉迷于此.而正确的做法就是了解其原理,拿到尚方宝剑,当虚拟机不好好为你提供服务时

从JVM的内存管理角度分析Java的GC垃圾回收机制_java

一个优秀的Java程序员必须了解GC的工作原理.如何优化GC的性能.如何与GC进行有限的交互,因为有一些应用程序对性能要求较高,例如嵌入式系统.实时系统等,只有全面提升内存的管理效率 ,才能提高整个应用程序的性能.本篇文章首先简单介绍GC的工作原理之后,然后再对GC的几个关键问题进行深入探讨,最后提出一些Java程序设计建议,从GC角度提高Java程序的性能.    GC的基本原理    Java的内存管理实际上就是对象的管理,其中包括对象的分配和释放.     对于程序员来说,分配对象使用ne

你知道.NET框架下的自动内存管理吗?

C#使用的自动内存管理,使用开发者从繁重的手工分配.释放内存的操作解放出来.内存的自动管理是由垃圾回收器来执行.一个对象使用内存的生命周期是这样的: 当对象被创建时,它便分配了一定的内存,当构造器中的代码开始运行时,这个对象就"活"了. 当这个对象或者是它的任何一部分在可以预计的将来已经没有任何作用时,这个对象将不会再使用,它就应当被销毁. 一旦这个对象符合了对销毁的条件,在一定的时间后,这个对象的销毁器就将被执行,一般情况下,除非被显示地重写,这个销毁器只能运行一次. 一旦销毁器被运

JVM内存管理、JVM垃圾回收机制、新生代、老年代以及永久代

如果大家想深入的了解JVM,可以读读周志明<深入理解Java虚拟机:JVM高级特性与最佳实践>      需要掌握的东西,包括以下内容.判断对象存活还是死亡的算法(引用计数算法.可达性分析算法).常见的垃圾收集算法(复制算法.分代收集算法等以及这些算法适用于什么代)以及常见的垃圾收集器的特点(这些收集器适用于什么年代的内存收集).            JVM运行时数据区由程序计数器.堆.虚拟机栈.本地方法栈.方法区部分组成,结构图如下所示.      JVM内存结构由程序计数器.堆.栈.本地

Oracle 11g的Memory_target与自动内存管理

Oracle11g的自动内存管理(Automatic Memory Management)的新特性是Oracle在内存管理上的又一重要增强.如果这个参数设置过高,在实例启动时可能会出现如下错误提示: SQL*Plus: Release 11.1.0.5.0 - Beta on Sun Jul 29 08:35:28 2007 Copyright (c) 1982, 2007, Oracle. All rights reserved. Connected to an idle instance.

Oracle 11g的自动内存管理

Oracle11g的自动内存管理(Automatic Memory Management)的新特性是Oracle在内存管理上的又一重要增强.如果这个参数设置过高,在实例启动时可能会出现如下错误提示: SQL*Plus: Release 11.1.0.5.0 - Beta on Sun Jul 29 08:35:28 2007 Copyright (c) 1982, 2007, Oracle. All rights reserved. Connected to an idle instance.