• 托管堆与垃圾收集


    一、Windows内存架构简介

      在用户态(user mode)中运行的进程通常会使用一个或多个堆托管器。最常见的堆管理器就是Windows堆管理器(windows heap manager)。另一个常见的堆管理器就是CLR堆管理器,它是在.Net应用程序中使用。

      

      Windows堆管理器负责满足大多数的内存分配/回收请求,它从Windows虚拟内存管理器中分配大块内存空间(内存段),并且通过维持特定的数据记录和空闲列表,以一种高效的方式将大块内存空间分割为许多更小的内存块来满足进程的分配请求。CLR堆管理器的功能类似,它为托管进程中的所有内存分配请求提供一站式服务。与Windows堆管理器相似的是,它同样从Windows虚拟内存管理器中分配内存段,然后用这些内存段来满足所有的内存分配/回收请求。这两种堆管理器之间的关键差异在于,二者在维持堆完整性时使用的记录数据的结构是不同的。

      

      从上图看出,CLR堆管理器的运行模式有两种:工作站模式和服务器模式。

      服务器模式的主要特点是,它不是只有一个堆,而是每个处理器都对应一个堆,并且堆中内存段的大小通常大于工作站堆中内存段的大小。服务器模式有一个专门的线程在管理所有的GC操作,而工作站模式中,则是在执行内存分配的线程上执行GC操作。

      在2.0版本之前,工作站GC是在mscorwks.dll中实现的,而服务器GC则是在mscorsvr.dll中实现的。到了2.0版本,这两种实现被合并到同一个二进制文件中(mscorwks.dll)。这是二进制级别上的合并。

      1、内存段

      当加载CLR时,每个托管进程起初都有两个堆,并且每个堆都有各自的内存段。

    • 第一个堆是小对象堆(small object heap),这个堆中有一个初始内存段,在工作站模式中的大小为16MB(服务器模式更大)。小对象堆用来荣达大小不超过85KB的对象。
    • 第二个堆被称为大对象堆(Large Object Heap,LOH),它同样有一个初始内存段,大小为16MB。LOH用来容纳大于等于85KB的对象。

      需要注意的是,当创建一个内存段时,不会立即提交段中的所有内存,CLR堆管理器会保留一部分内存空间,并且根据需要来进行提交。当小对象堆中的内存都被耗尽时,CLR堆管理器将触发GC操作;如果内存空间仍然不足,将对堆进行扩展。然而,如果大对象堆中的内存被耗尽时,堆管理器将创建一个新的内存段来提供内存。相应地,当垃圾收集器释放内存时,内存段中的空间可以回收,当一个内存段中的所有空间都被回收时,这个内存段就会被彻底释放。

      通过内存地址来查看内容的SOS命令是!address。

      驻留在托管堆上的每个对象都附带一些元数据。具体来说,每个对象的前面都有8个字节。

      在托管堆上的每个对象中,前4个字节是同步块(Sync Block)索引,后面4个字节是方法表(Method Table)的指针。

      2、分配内存

      在不需要执行GC就可以成功分配内存时,满足内存分配请求将是一个非常高效的过程。这种情况下会执行两个主要的任务,即递进内存分配指针和清空相应的内存区域。

      递进内存分配指针:新分配的内存将紧跟内存段中最后一个已分配的对象。当另一个分配请求被满足时,内存分配指针会再次被递进,依次类推。

      这种分配模式与Windows堆管理器中的模式有着极大的不同,因为Windows堆管理器并不会像这样来管理各个对象之间的位置。在Windows堆管理器中,当一个分配请求到来时,可以使用内存段中的任何一块空闲内存来满足这个请求。

      注意:

      1、当达到内存阈值时,将触发GC来执行垃圾收集动作。在这种情况下,会首先执行GC,然后再尝试满足内存分配请求。
      2、判断被分配的对象是否是可终结的。严格来说,这并非是堆管理器必须实现的一个功能,但它属于分配过程的一部分。如果对象是可终结的,那么将在GC中记录这个对象,以便正确地管理对象的生命周期。

      内存分配流程图:

      

    二、垃圾收集器的内部工作机制

      GC的定义和在设计和实现GC时遵循的一些假设:

      假设1、如果没有特别声明,所有的对象都是垃圾。这表示,除非特别声明,GC会收集托管堆中的所有对象。它为所有活跃的对象都实现一种引用跟踪模式,如果一个对象没有任何引用指向它,那么这个对象就被认为是垃圾对象,并且可以被收集。
      假设2、托管堆上的所有对象的活跃时间都是短暂的,相对于更长久活跃的对象来说,GC将更为频繁地收集短暂活跃的对象。这种行为基于这种假设:如果一个对象已经活跃了一段时间,那么它很可能在更长一段时间内也是活跃的,因此不需要再次收集这个对象。
      假设3、通过代(generation)的概念来跟踪对象的持续时间。活跃时间短的对象被归为第0代,而活跃时间更长的对象呗归为第1代和第2代。随着对象活跃时间的增长,与其相应的代也会不断递增。因此,我们可以认为代定义了对象的年龄(即活跃时间)。

      基于以上假设,我们可以得出CLR GC的定义:它是一个基于引用跟踪和代的垃圾收集器。

      1、代

      CLR GC定义了3个级别的代,分别称之为第0代、第1代和第2代。在每一代中包含了特定年龄(age)的对象。第0代年龄最小,第2代年龄最大。一个对象可以从某一代迁移到下一代,以避免作为垃圾对象被收集。如果没有被收集,那么意味着在执行垃圾收集期间,这个对象仍将被引用。

      在任何时刻,每一代的所有对象都可能作为垃圾对象但执行垃圾收集操作的频率不同。

      按照CLR的假设1.它认为大多数对象都是短暂活跃的(即位于第0代中)。基于这个假设,第0代被收集的频率远远高于第2代被收集的频率,这是为了尽快地消除这些短暂存活的对象。

      

      当新的内存分配请求到来并且第0代中无法再容纳新的对象时,就会触发垃圾收集过程。如果是这种情况,那么垃圾收集器将收集所有不存在的根对象(root)的对象,并且将所有带有根对象的对象提升到第1代。

      正如第0代中定义了预定空间容量,在第1代也同样定义了一个预定空间容量。如果将第0代中的对象提升到第1代时超过了第1代的预定空间容量,那么GC将在第1代中收集没有根对象的对象,并将有根对象的对象提升到第2代。如果在提升到第2代后,GC仍然无法收集任何对象并且超过了第2代的预定空间容量,那么CLR堆管理器会尝试分配另一个内存段来容纳第2代中的对象。如果在创建新的内存段时失败了,就会抛出一个OutOfMemoryException异常。如果内存段不再被使用,那么CLR堆管理器将释放他们。

      其他触发垃圾收集的动作

      除了由于在分配内存时超出第0、1、2代的阈值而触发的垃圾收集动作外,还有其他一些情况也会触发GC动作。首先可以通过GC.Collect以及相关的API来强制执行垃圾收集。其次,垃圾收集器非常清楚系统中内存的使用情况,通过与操作系统之间的进行协调,当系统整体上存在极大的内存压力时,垃圾收集器就会启动收集动作。

    SOSEX调试器扩展器的dumpgen命令能够很容易得到某个代中的所有对象。

      2、根对象

      在垃圾收集中,一个最重要的功能就是要能判断哪些对象仍然被引用,哪些对象没有被引用。GC本身并不会检测哪些对象仍然被引用,而是使用CLR中其他了解对象生命周期的组件。如CLR通过以下组件来判断哪些对象仍然被其他对象引用。

      即时编译器(JIT):JIT编译器负责将IL转换为机器代码,因此能够清楚地知道在任意时刻有哪些局部变量仍然被认为是活跃的。JIT编译器将这些信息维护在一张表中,当GC在后面查询仍然存活的对象时,会用到这张表。

      栈遍历器(stack walker):当对执行引擎进行非托管调用时,将使用栈遍历器。在这些非托管调用过程中使用的任意托管对象都必须属于引用跟踪系统。

      终结队列(finzlize queue):虽然从应用程序的角度来看,这些对象已经消亡了,但仍然能保持活跃以便被消除。
    如果对象是上述任何一种类别对象中的一个成员。

      在上面的探查阶段中,GC还会根据对象的状态(是否存在根对象引用)来标记它们。一旦所有的组件都被探查了,GC会对所有对象启动垃圾收集操作,并将所有仍然存在根对象引用的对象提升到下一代。如果给定托管堆上某个对象的地址,我们可以借助SOS的gcroot命令来确定对象的引用链。关于gcroot命令的详细说明可以查看《.Net高级调试》180页。或者本系列文章其他篇。

      3、终结操作

      有时候,需要在对象中封装一些其他资源,并且在销毁对象时清除这些资源。一个常见的示例就是在对象中封装一个底层资源,例如文件句柄。如果没有明确的清除代码,那么尽管对象的内存被GC清除,但对象所封装的底层句柄却不会被清除(因为GC并不知道这些句柄的存在)。这样的结果造成了资源浪费。为了提供合适的清除机制,CLR引入了终结器的概念。终结器的作用类似于C++中的析构函数。当一个对象被释放时(或者被作用垃圾收集时)。析构函数(或者终结器)就会运行。在C#中,终结器的生命方式类似于C++中的析构函数,使用的语法是~<class name>。如:

    public class MyClass
    {
        ~MyClass()
        {
            //执行清除工作的代码
        }
    }

      当这个类被编译为IL时,终结方法会被编译为一个名为Finalize的函数。由于垃圾收集器需要执行对象的终结代码,为了记录哪些对象拥有终结器,垃圾收集器维护了一个终结队列(Finalization Queue)。如果在托管堆上创建的对象中包含有终结器,那么在创建过程中将被自动放入终结队列中。

      需要注意的是,终结队列并没有包含那些被认为是垃圾的对象,而是包含了所有带有终结器并在托管堆上处于活跃状态的对象。(这个我也没想到,难道是用于资源泄露?)

      如果某个带有终结器的对象不存在任何根对象引用了,并且此时启动了垃圾收集过程,那么GC会把这个对象放入另一个队列中,即F-Reachable队列。这个队列包含所有带有终结器并且将被作为垃圾收集的对象,这些对象的终结器都将被执行。要注意在垃圾收集过程中并不会执行F-Reachable队列中每个对象的终结器代码,这些代码将在一个特殊的线程中执行,它就是每个.NET进程的终结线程(Finalization Thread)。在收到GC的请求时,终结线程会启动并且查看F-Reachable队列的状态。如果在F-Reachable队列上有任何对象存在,那么它会一次执行这些对象的终结方法。

      为什么不在垃圾收集过程中执行终结方法呢?由于在终结方法中包含了托管代码,并且在GC过程中托管代码线程被挂起,因此终结线程不在GC过程中运行。

      在垃圾收集过程结束后,带有终结器的对象会出现在F-Reachable队列中(根对象引用存在且是活跃的),直到终结线程执行它们的Finalize方法。此时,对象将从F-Reachable队列中移走,并且这些对象也被认为不存在根对象引用,从而可以真正地被垃圾收集器回收。下一次启动垃圾收集过程时,这些对象会被收集,下面是示意图。

      

    • 在步骤1中分配的对象D和对象E,它们各自带有一个Finalize方法。在分配过程中,这些对象除了被放在托管堆上外,还被放在终结队列中,表明这些对象不被使用时需要执行终结操作。
    • 在步骤2中,当垃圾收集过程启动时,Obj D和Obj E都不存在根对象引用。此时,这两个对象将从终结队列移动到F-Reachable队列中,表明可以运行它们的Finalize方法了。
    • 步骤3被执行,终结线程也会启动并且开始执行这两个对象上的Finalize方法。即使在终结器执行完成后,这两个对象仍然位于F-Reachable队列中。
    • 步骤4中再次启动垃圾收集过程,这些对象会被移出F-Reachable队列(不再有跟对象引用),然后由垃圾收集器从托管堆中回收。需要注意的是,虽然有一个专门的线程来执行这些Finalize方法,但CLR并不保证这些线程将在何时启动并执行。因此,带有终结器的对象在被真正消除之前,可能需要等待一段时间。由于在对象中包含了一些资源和在等待资源被回收时需要的时间过长,所以这种方式或许行不通。在这种情况下,最好有一种明确的清除模式,如IDisposable或者Close等模式。最后,通过一个专门的线程来执行Finalize方法意味着你将无法控制这个线程的状态,如果基于这个状态来做出一些假设,那么可能会对程序造成破坏。

      当使用终结类型时,在幕后执行了大量的操作。CLR不仅需要额外的数据结构(例如终结队列或F-Reachable队列)。而且还需要一个专门的线程来为每个被收集的对象运行Finalize方法。另外,带有Finalize的对象无法仅通过一次垃圾收集操作就被回收,而是需要两次,这意味着带有Finalize方法的对象在它们真正被销毁之前,通常会被提升到第1代,从而使它成为开销较高的对象。

      4、回收GC内存
      当对象被作为垃圾收集时,GC将如何处理对象占据的内存?这块内存是否会被放入到某个空闲链表,然后在下次分配请求中被重用?这块内存是否被释放?在托管堆中内存碎片是否会造成问题?答案都是肯定的。
    如果在第0代和第1代中执行的垃圾收集操作使得在托管堆上出现了内存空间缝隙,那么垃圾收集器将对所有的活跃对象执行压缩操作,这样它们的内存位置可以彼此相邻,并将托管堆上的所有空闲块都合并成一块更大的空闲内存,这块内存就位于最后一个活跃对象之后。

      

      托管堆初始状态包含了5个存在根对象引用的对象(从A到E)。在执行过程的某个时刻,对象B和D的根对象引用会被消除,因此这两个对象可以在下一次垃圾收集过程中被回收。当垃圾收集操作执行时,会回收对象B和D占据的内存,这将导致在托管对上出现缝隙。为了消除这些缝隙,垃圾收集器把剩下的活跃对象(A、C、E)进行紧缩,并将多个空闲的内存块(B、D原来占用的块)合并成一个大的空闲块。最后,根据对象紧缩与合并的结果来更新当前的内存分配指针。这个内存段中包含了第0代和第1代的对象(并且也是第2代的一部分),但第2代却可以包含多个托管内存段。

      随着越来越多的对象进入到第2代,增长第二代内存空间的需求也同样增长。CLR堆管理器增长第2代内存空间的方式是分配更多的内存段。当第2代中的对象被收集时,CLR堆管理器将回收这些内存段,并且当不再需要某个内存段时彻底释放它。CLR2.0引入了一种虚拟内存累积机制,这种机制本质上并不会释放内存段,而是在一个单独的列表中记录他们,当需要更多的内存时就会使用这个列表。要使用虚拟内存累积这个功能,CLR宿主本身必须明确指出它需要启用这个功能。

      5、大对象堆

      大对象堆(LOH)包含的对象通常大于或等于85000个字节。将这种大小的对象单独放入一个堆的原因是:在垃圾收集的紧缩阶段,在对某个对象执行紧缩操作时的开销与对象的大小成正比。因此,我们没有将大对象放在标准的堆上,而是创建了LOH。LOG最好被视为第2代内存空间的扩展,并且堆LOG中对象的手机操作只有在收集完第2代中的对象之后才会进行,这也意味着对LOH中对象的收集操作只会在完全垃圾收集中进行。因为对大对象进行压缩的操作的开销是非常高的,因此GC会避免在LOH上进行紧缩操作,取而代之是执行一个清扫(sweeping)操作,这个操作中会维持一个空闲链表,用于跟踪LOG内存段中的可用内存。

      暂停学习,感觉作用不明显。敲了也白敲。

  • 相关阅读:
    机器人走方格问题
    一道数列的规律题(使用递归解决)
    反转单链表
    求一个二叉树的深度以及如何判断一个二叉树是一个平衡二叉树
    打印素数
    DAY28-mysql扩展与预处理-查出问题的关键
    DAY31
    jQuery很简单很基础的
    JavaScript中的事件委托及好处
    结合个人经历总结的前端入门方法
  • 原文地址:https://www.cnblogs.com/kissdodog/p/3782439.html
Copyright © 2020-2023  润新知