3.3 自动内存管理
CLR最富盛名的提升生产力和正确性的特性是它的自动内存管理(automatic memory management)。更为具体地说,CLR完全代表用户管理内存,采用直观和高效的分配机制,并使用一种垃圾收集(Garbage Collection,GC)算法来避免所有的对代码使用的内存进行显式释放。再也不用使用free和delete命令来回收内存! GC知道何时回收一个对象的内存是合法的(有益的),此时程序中不存在到此对象的引用。此外,它智能地执行此操作以避免花费不必要的CPU周期回收未使用的内存。
当然,可以编写托管代码而不需要了解GC的运作方式,但是对GC有基本的了解可以避免许多开发人员经常犯的错误。本节将会介绍如何分配、管理和回收代码所用的内存,还将说明GC是如何通过一个称为终结(finalization)的过程来提供资源生存期管理特性的。
3.3.1 分配
CLR管理的基本内存区域有3个:栈(stack)、小对象(small object) GC堆和大对象(large object)GC堆。每个方法调用将更多的栈空间用于它的实参和局部变量,称为活动框架。这是通过使用一个指针(存在ESP寄存器中)来跟踪其当前位置的一段内存,它的大小随着向栈中压入框架和从栈中弹出框架而增加和缩减。框架内部任何内存的生存时间完全依赖于此方法存活的时间。但是,每个托管的栈框架向GC报告它内部引用了哪些对象,以便确保GC不会回收仍在使用的对象。栈主要是由操作系统管理的,尽管CLR的操作与它紧密相关。
CLR GC还可管理进程地址空间内部的两种基于进程的Windows动态堆(dynamic heap)—— 每个堆都是一个虚拟内存地址范围—— 可根据需要增加和缩减它们。大对象堆用于尺寸超过80,000字节的对象,而小对象堆用于其他所有对象。在单个进程中这些堆由多个AppDomain共享。这些堆包含的实例能够比在其中分配这些实例的栈框架存活更长的时间。其他堆可以存在于Windows进程中。例如,存在一个用于非托管代码的默认堆(default heap),如C运行库(C Runtime Library,CRT)分配的堆;其他堆可以使用HeapCreate Win32 API来分配。当堆增加和缩小时,CLR负责处理页面的预留、提交和释放。
在使用C语言的时候,必须使用malloc和free指令管理自己的基于堆的内存。C++添加了关键字new来处理字节的分配和清零,但仍要求通过delete(或用于数组的delete[])显式地释放内存。更糟糕的是,实际上典型的malloc实现的成本很高。几乎所有的CRT实现使用链表管理它的堆的空闲段。当请求一块内存时,malloc必须遍历空闲链表来搜寻一个大小足够满足分配的内存段。然后它将内存块分割为两段:一段刚好能够容纳请求的内存尺寸,另一段剩下的内存留作他用。此过程不仅涉及每次分配时对指针执行的内部管理和解除引用,而且还会由于不断的分裂产生内存碎片。内存碎片可能引起的一个问题是会使大空间的内存分配失败,因为找不到足够多的连续的字节。马上会讨论内存碎片问题并说明GC如何通过使用一个称为内存紧缩(compaction)的过程来解决此问题。
1. 当CLR分配内存时
存在几个特殊和明显的内存分配情况:
● 使用newobj指令分配新的对象,也就是说在C#中使用new关键字。
● 使用newarr指令分配新的数组。
● 使用box指令将栈上的值变换为堆对象。
但是总的来说,还存在很多隐式分配内存的情况。例如,throw必须分配内存以便进行包装(如上所述),甚至unbox在需要创建新的Nullable<T>实例时也可以分配内存。考虑到大多数这类分配内存的情况没有正式的规定或牵涉到很多实现细节,在此不再详细介绍它们。
2. 分配过程的内部细节
CLR为开发人员提供分配和堆管理。为了深入了解此问题,考虑图3-3说明的小对象GC堆的布局。由于大多数分配都源自小对象堆,因此下面对大对象作简要介绍。

图3-3 堆 (a)分配一个新的16字节的对象之前 (b) 分配一个新的16字节的对象之后
该堆包含一个虚拟地址范围;这些地址称为页面(Windows上的分配单位,典型为4KB,有时为16KB,如在IA-64上),并将它们组合在一起构成段(CLR的GC使用的内存管理单位,典型值为16MB)。GC使用VirtualAlloc Win32 API来预留段并随着堆的增加获取段。进程的页面文件不支持预留的页,并且这些页不属于进程的工作集。当GC需要在一个预留的段上分配内存时,它首先必须再次使用VirtualAlloc(但使用不同于预留操作的参数)提交该段。一旦GC提交了一个段,它可以在其地址范围内分配对象。当GC收集对象时,它可以回收段(标记未提交段),最后它可以完全释放这些段,因为不再需要它们,所有这些操作都使用VirtualFree API来完成。
当分配新的内存时,GC使用一种非常高效的算法来满足请求。也就是说,当请求分配n个字节时,GC只需将分配指针向前推进n个字节(确保n是完全对齐的),清零旧的指针指值和新的指针值之间的字节(除非此范围源自Windows的零页面列表上的页,在这种情况下已清零了此范围内的字节),并返回一个指向旧位置的指针。使用newobj指令对一个新构造的对象进行完全初始化也需要执行构造函数来初始化状态。当然,有一些分配类型不需要执行这种初始化,例如装箱一个对象或使用initobj IL指令。如果此对象是可以终结的(即它会重写System.Object.Finalize),GC在这个时候会为终结而注册这个新对象。
如果当CLR试图分配新的内存时已用光了地址空间,或者如果由于物理资源不足以支持内存分配而导致提交内存失败,它将会执行收集操作以希望回收一些空闲的空间。如果这种尝试失败(也就是说在回收内存后仍没有足够的可满足请求的内存空间),执行引擎会产生一个OutOfMemoryException异常。需要注意的是,由于CLR不能预留和提交一个完整的段,小的内存分配仍可能会失败。
代
事实上,以上介绍的情况有些不符合实际。GC将小对象堆的段划分成3代:第0代和第1代称为短期存活的代,而第2代称为最年长的(eldest)代。短期存活的代最多只能容纳单个段(即通常为16MB),而最年长的代的尺寸可以不受限制(假定有足够的虚拟地址空间)。正如上面提到的那样,有一个单独的堆用于大对象,但是不将此堆划分成代。每种代包含一组存活时间大致相等的对象。一旦一个对象没有被回收,表明当进行回收时它是可获取的,如果它还没成为最年长的代,那么它将提升为下一代。
新近分配的对象通常存活较短的时间,因此只需通过收集短期存活的段中的代就可以回收大量内存。由于年长的代中存活的对象占很大比例,完全收集(跨越短期存活的代和最年长的代)往往会花费昂贵的成本。相比于短期存活代的收集,为确定常规的最年长的代中的活性,GC必须遍历的对象引用的数量与实际回收的内存量不成正比。简而言之:如果一个对象已经存活了很长时间,它最近死亡的概率很小。CLR只有在绝对必要的时候才执行完全收集,也就是在短期存活的代没有包含足够的可回收内存的情况下。
在执行收集后,GC可以紧缩堆。这要求将对象下滑到邻近的下一代,消除堆中任何空闲的空间。如果有必要记录和更新每个代的边界。以前处于第0代的对象转移到第1代,而那些以前处于第1代的对象转移到第2代;而所有新的分配位于第0代。这意味着相互邻近的对象仍保持临近关系,但是可能会在内存中移动以便在提交的段中释放更多的连续的内存。第2代是终结所有旧对象的位存储桶。图3-4说明了此过程。

图3-4 GC代的分配、收集和紧缩操作
引用的局部性
紧密分配的对象将局部性作为GC在一个连续的地址空间执行分配的结果。此分配方式与CRT的malloc程序操作方式不同,malloc程序必须遍历一个链表,其中每个节点很可能位于地址空间的不同页面上。当对象紧靠在一起时,可以按照处理器的缓存层次结构的等级将它们结合在一起。使用内存中邻近的数据项进行操作所需的平均主内存访问次数通常会更少,从而获得极大的性能收益。当将来过渡到多(内)核机器(缓存层次结构是基于每处理器和每核的)时,局部性对性能的提升更为重要。
3. 未预料到的分配故障
在任何程序中都可能出现两个相关的值得特别关注的内存问题:栈溢出(Stack Overflow, SO)和内存溢出(Out of Memory,OOM)。当由于缺少物理资源而不能提交内存时或由于耗尽进程空间而不能满足预留要求时,这两种内存问题都会出现。在此有必要简要地说明CLR是如何处置这些情况的。
栈溢出
当由于可用的物理资源不够而不能满足栈空间的提交时,会出现栈溢出问题。CLR和Windows协调工作来为托管开发人员处理栈预留和提交。进程中的每个托管线程预留1MB的栈空间。主机可以设置适合它们的栈预留策略;例如,SQL Server(SQL服务器)每个线程只预留500KB的栈空间。
IL中的方法指示它们的以字节衡量的最大的栈需求,以便CLR能够确保单个函数内部的栈上的分配不会溢出。这本质上是预先获得必要数量的可用的栈空间,以便容纳单个已知尺寸的栈框架。因此,CLR能够确保栈溢出出现在方法框架的起始位置而不是内部的一个未确定的位置。但是不可否认的是,一个非常深的调用栈使用的空间仍可能超过可用的栈空间。在最极端的情况下,一个无限的递归将会轻易地引起栈溢出;大多数造成栈溢出的情况要远比此极端情况意外。
注意:
当然,溢出之前能够递归的次数取决于一些实现细节;例如,CLR可以为一些框架过多地预留栈空间,执行动态确定栈的本地代码转移等。 如果单个栈框架是8字节,并且正处理一个1MB的预留栈,它大约能执行130 000次,实际的执行次数可以改变。
在版本2.0中CLR采用与以前的版本不同的方式来响应出现的溢出。也就是说,它使用快速失效的方法立即终止进程。上面描述了此过程。简而言之,关闭进程非常类似于未处理的异常:允许附加调试程序、填写一个Windows Event Log(事件日志)记录项以及提取Watson转储。出现这种行为的原因在于保证从栈溢出的情况执行正确的恢复几乎是不可能的;这样做的企图常常会导致栈的损坏,并因此会引发安全性和可靠性问题。
内存溢出
内存溢出情况只是表明不能满足内存分配请求。造成这种情况的原因很多(稍后将介绍它们)。已经对CLR 2.0进行了加固以应对内存溢出故障,这表明对它进行审查以确保所有代码能够可靠地处理内存溢出情况。CLR中每个单独的分配内存的方法实施检查来确保分配成功,如果分配不成功,它相应地传播分配失效消息。在非托管的C++中,这将意味着检查每个单独的函数调用的返回码,跨越整个代码库实施保障是一个很困难的过程。此外,CLR确保可以释放临界资源而不需要执行分配。这应该可以增强用户对CLR的可靠性的信心。
在以下情况下都可能会出现内存溢出问题:
● 耗尽了进程的地址空间并且不能预留额外的必要的段。在32位Windows上,这只有2GB(除非指定了Windows /3GB开关,在这种情况下地址空间是3GB),因此确实可能出现耗尽内存空间的可能。在64位机器上,地址空间非常巨大,因此在实际使用中可看作是无限的。
● 已经用光了可用的物理内存(包括页面文件)。换句话说,尽管地址空间足够容纳更多的分配,但由于缺乏物理资源而不能提交额外的段。
● 在一些情况下,主机根据自己的策略而会拒绝内存分配请求。例如,SQL Server的一个原则是用光机器上所有的可用内存(不能再多一个字节)。如果分配引起到磁盘的页面调度,它很可能会拒绝分配请求,并在程序中产生内存溢出错误。
当然,在大多数情况下CLR将会尝试回收内存以避免出现内存溢出,但实际情况是在一些情况下CLR的尝试不能成功。出现内存溢出带来的后果通常不如栈溢出严重。因此,对于内存溢出CLR不实施快速失效,相反它会抛出一个普通类型的异常OutOfMemory Exception。此异常可以展开栈并且可以像对待任何其他异常那样捕获和处理它。这样做可能会引起额外的内存溢出(并成为一个逐渐恶化的环境)。
不同的应用程序能够对内存溢出做出合理反应的程度有很大不同。例如,会将耗尽地址空间及/或物理内存看作坏情况,在这种情况下finally块甚至都不能正确运行(因为它们可能涉及分配)。值得庆幸的是,如果编写了临界终结函数,则可以使用它们来完全避免分配内存。但是,出现某段代码试图预留不合理数量的内存的情况并不能表明进程的其他方面有问题,因此代码会尝试继续执行(例如,假定执行了int[] a = new int[int.MaxValue]的操作)。遗憾的是,不能确定是哪种情况触发了内存溢出。
4. 内存门限
为了在执行一系列操作的过程中减少出现内存溢出的概率,可以考虑采用内存门限(memory gate)。内存门限的作用是向GC查询是否存在特定数量的可用的内存,如果没有足够多的可用内存,操作失败并产生OutOfMemoryException异常。但是,实际上并没有预留内存;因此,这种查询依赖于竞争条件。可能要求一定数量的内存,并由于告知存在足够的内存而继续执行,此时可能调度另一个请求大量内存的线程,在这种情况下可能会出现内存溢出。当然,精巧的应用程序可以为分配大量内存的热区代码同步到中心内存门限的访问。
为了使用内存门限,实例化一个新的System.Runtime.MemoryFailPoint,如下所示:
using (MemoryFailPoint gate = new MemoryFailPoint(100))
{
// Some operation that actually uses the 100MB of memory...
}
此构造函数使用一个整数来表示请求的GC堆空间的兆字节数目。GC只需向MemoryFailPoint构造函数回答可以还是不可以,如果得到否定的回答,该构造函数会抛出内存溢出来响应;否则,可行执行程序体。
3.3.2 垃圾收集
现在应该清楚地知道内存管理子系统所做的工作远不只是收集垃圾。它也会产生垃圾!尽管GC是CLR的整个内存管理子系统的通用术语,主要的好处并不是它的灵巧的分配策略—— 尽管这是可喜的事情—— 而是它负责为开发人员收集未使用的内存。不再需要使用free、delete或delete[]指令。本节将较为详细地说明GC在CLR内部的工作机理。
1. 当出现收集时
如前面多次提到过的那样,很多情况下都可能出现收集,包括以下这些情况:
● 前面提到过的任何引发内存溢出的情况。
● 超出了短期存活代的内存段的门限,通常也就是16MB。选择此数值来适合处理器内部缓存。
● 一些API(如GC.Collect)允许开发人员手动地启动收集过程。
过度使用这些API会妨碍GC固有的工作方式,因此不推荐在产品代码中依赖这些API;实际上,它会使对象不必要地提升到存活期更长的代。一种更好的与GC的收集策略交互的方法是采用GC.AddMemoryPressure和RemoveMemoryPressure API,在第5章会深入介绍它们。除此之外,像HandleCollector这样的类按照一种可控的方式使用GC.Collect API来启动GC用于资源管理。
2. 收集过程的内部细节
当必须收集内存时,GC知道如何确定当前内存的使用情况。一旦它弄清内存使用情况,就可以清除未使用的废弃的内存。它跟踪一组根并构成一个这些根保存的对象引用的传递闭包,以便完成此操作。根是一个程序能够直接和活动存取的对象,例如,这类对象包括静态变量、托管线程上当前的任何活动框架上的对象、通过非托管互操作性的处理程序固定的对象以及存储在线程局部存储器(Thread Local Storage)中的数据项。图3-5说明了概念上一个可达性图的构成形式。

图3-5 基于根的可达性图的示例
由于当GC执行时根可能会移位,第一步是短暂地停止所有的托管线程。然后GC递归地查看这些根拥有的所有引用,从而遍历整个可达性图。在此过程中它标记访问的任何对象,当此过程完成时,堆上任何未访问的对象都是符合收集条件的垃圾。按照这种方式标记它们,从而可以紧缩堆。然后恢复托管线程。常把此过程称为标记和清除(mark and sweep)收集算法,因为将清除遍历过程中任何未标记的对象。
在使用上面说明的可达性图执行收集后,堆将会存在多个对象之间的空洞。也就是说,空闲的内存散布于使用的内存之间。此外,CLR GC还是一个紧缩收集器,因为这它可以通过紧缩过程来管理内存碎片—— 稍候会说明此过程。存在大量其他的GC算法;请查看“参考文献”一节来参考一些相关书籍。
3. 存储碎片和紧缩
如果收集注意到一些对象之间的对象已停止使用,它可以紧缩堆。这是一个将对象向堆的底部移动的过程,消除对象之间的所有间隙。有时由于对象被固定(pin)而不能移动。将在第11章讲解非托管互操作性的时候讨论固定技术(pinning),但是总的来说:常常需要确保GC没有移动对象,也就是将到GC堆的指针传递给非托管代码的时候。因此,如果已经固定了对象,GC在收集过程中不能移动它。固定过多的对象可能会导致大量存储碎片,这又会引起不必要的内存溢出情况并会增加工作区。这是因为尽管总的空闲内存足够满足分配,但是不能保证有足够可用的连续的内存。
4. 各种收集形式
尽管从上面的讨论描述的情况看好像是由单个统一的收集算法负责管理内存的,但实际上并非如此。CLR提供两种基本的收集器:工作站收集器和服务器收集器;此外,工作站收集器还包括两种具有细微差别的形式。
工作站垃圾收集
在所有情况下默认使用工作站收集器(包含在mscorwks.dll中)。主机可以重写这种选择而使用服务器收集器,随后会讨论它们的区别。但是在多处理器机器上,CLR使用一种称为并发收集器(concurrent collector)的工作站收集器的变体,除非使用配置或宿主设置重写默认选项(注意在2.0之前,并发收集器存在于mscorsvr.dll中;它现在处于mscorwks.dll中)。
当执行常规的收集时,(如上面提到的那样)在收集期间必须暂停所有托管线程。只有当完成收集后才恢复这些线程。这一过程包括访问根及其可达性图的分析代和清除废弃空间的收集代。但是此过程会暂停所有线程的执行,只允许GC向前推进,有时需要花费较长的时间。并发收集器通过以下两种主要的方法来优化多处理器机器上的执行过程:
● 在单个收集过程中执行多次托管线程的挂起和恢复。这样做允许在收集过程中其他线程能够继续向前执行几次(包括进行分配),从而能够更公平地执行程序以及获得更好的整体响应性。
● GC运行在一个完全独立的线程上,允许引发收集出现的线程在恢复线程执行期间继续向前执行。此外,它意味着在其他线程继续向前执行的同时GC能够执行低优先级的分析。这可以避免不公平地偏向对待那些碰巧没有引起GC出现的线程。它还意味着当启用并发收集器时,托管进程中将有一个额外的GC线程。
需要注意的是,可以将<gcConcurrent enabled="false" />添加到应用程序的配置文件中的<runtime />部分,以便默认不选择并发收集器,如下面的代码所示:
<configuration>
<runtime>
<gcConcurrent enabled="false" />
</runtime>
</configuration>
服务器垃圾收集
只有当配置或宿主设置指定它时才使用服务器收集器(包含在mscorsvr.dll中)。对服务器收集器进行优化以便用于服务器应用程序;这些应用程序在特性上必须能够处理高吞吐量并且对工作区没有严格的限制。例如,ASP.NET使用服务器收集器来改善它的吞吐量性能。
相比于并发收集器,服务器收集器与标准的工作站收集器有着更大的差别。对于每个CPU的每个进程,它均要管理一个独立的小对象堆和一个独立的大对象堆。此外,它在每个CPU上都有一个单独的GC线程,每个运行以最高可能的优先级运行。这允许进程按照一种非常有效和高效能的方式回收线程的局部存储单元。与非并发收集器的情况一样,在收集执行过程中暂停所有的线程。
可以通过在应用程序的配置文件的<runtime/>部分添加<gcServer enabled="true" />标志或通过使用CLR宿主API来启用并发收集器。
3.3.3 终结
许多对象声明对不受GC托管堆控制的资源的所有权。拥有COM引用的类与非托管代码互操作以获取原始内存块,或在系统级操作系统内核对象上配置某种形式的配对操作,必须确保能够确定性地执行清除。通常可以通过使用try/finally块在finally块内部执行资源释放来完成清除任务。但是,开发人员可能忘记调用对应的释放操作,或者进程崩溃后可能没有运行finally块(如上所述)。终结操作确保拥有这类资源的对象在GC回收它们之前有最后一次机会执行清除。
可终结的对象是一个重写Object.Finalize方法的任何类型的实例。在执行分配时将这些对象添加到一个特殊的终结队列。随后一旦GC注意到在收集过程中这些对象是不可达的,就将它们转移到一个准备进行终结的队列。需要注意的是使用可终结对象作为根的整个可达性图必须继续存在,以便Finalize方法能够在必要的时候引用这些对象。这样做的结果是在收集过程中将可终结对象和它的完整的可达对象图提升到下一代。
有一个单独的终结函数线程负责遍历准备进行终结的队列并调用每个对象的Finalize方法。然后对象可以释放它持有的任何资源。一旦执行了终结程序,终结函数线程从队列中取出对象并移动到队列中的下一项。无论对象现在处于哪个代,在下一次收集的过程中将会终结此对象。注意在版本2.0之前如果Finalize抛出一个异常,CLR会接受它。如本章前面提到过的那样,自从2.0起,如果Finalize方法抛出一个未处理的异常将会导致进程崩溃。
第5章将会更为详细地介绍终结、Finalize方法和IDisposable模式。在第5章还会讨论GC类,包括如何向终结队列撤销注册对象或重新注册对象。
终结方法的排序
终结是无序的。这意味着在执行对象的Finalize方法的过程中可以利用任何其他可终结的对象,但是必须非常小心。可能试图访问已终结的对象。要记住排序并没有保证。如果设法在对象上调用方法而导致一个异常—— 如果设法在一个已释放其资源的对象上执行操作时就会出现这种情况—— 这种情况可以轻易地导致未处理的异常破坏进程。
复活
在终结过程中可以再次使对象可达。在终结函数线程上执行的Finalize方法可以访问静态变量、Thread Local和AppDomain Local Storage等存储单元,因此可以出现这种情况。如果此代码恰好将其中的一个存储单元设置为它自身或它引用的一个对象,那么其他代码可以直接访问该对象。不仅如此,其他对象的整个可达的对象图也将变得可达。将此过程称为复活(resurrection) 。
复活具有一些实际用处—— 例如对象池—— 但是对于大多数情况下,使用它是一种危险的做法。由于前面提到过缺乏排序保证,复活很容易出现问题。如果重新发布一个可终结的对象,注定会失败,失败的可能方式有以下两种。其一,它可能是已经终结的对象,也就是说在其他对象之后调用了此对象的Finalize方法。在这种情况下,它的状态很可能处于一种外部调用不可接受的状态。其二,可能发布了一个仍处于待终结队列中的可终结对象。这可能会引入微妙的竞争状态(并产生安全问题),在运行它的终极程序的同时其他代码也在调用此对象!
临界终结
一些主机—— 如SQL Server—— 将会使用AppDomains将各组件彼此隔离以确保可靠性。它们还会粗暴地破坏这些组件,前提是这样做不会损害未完的进程的状态。但是,当关闭AppDomain时,终结并不能总是运行结束。因此,如果任何进程级(或机器级)的状态发生了变化,简单的Finalize方法可能不足以保证清除操作。
为了确保有机会清除所有这类状态,在2.0中添了一种称为临界终结的新的构造。实现临界终结程序非常类似于实现Finalize方法。必须由System.Runtime. ConstrainedExecution.CriticalFinalizerObject派生并重写它的Finalize方法。在关闭AppDomains的过程中主机将会更耐心地对待这种对象。对用户而言,必须遵守约束执行区所施加的一组限制条件,在第11章会仔细讨论约束执行区这一主题。






