一、资源生命周期
每个程序运行都需要各种资源,如文件、内存缓冲区、数据库等。要使用这些资源,就必须为代表资源的类型分配内存。访问一个资源所需的步骤如下:
- 调用IL指令
newobj
,为代表资源的类型分配内存(在C#中一般用new操作符完成) - 初始化内存,设置资源的初始状态并使资源可用
- 访问类型成员来使用资源
- 摧毁资源状态以进行清理
- 释放内存(这一步由垃圾回收器负责)
二、从托管堆分配资源
CLR要求所有对象都从托管堆分配。进程初始化时,CLR划分出一个地址空间区域作为托管堆。同时CLR还维护着一个指针,这里把它称作NextObjPtr
。该指针指向下一个对象在堆中的分配位置。初始时NextObjPtr
设置为地址空间区域的基地址。
当我们使用new
操作符实例化对象时,CLR会检查区域中是否有足够的空间分配对象。如果空间足够,就在NextObjPtr
指向的地址放入对象,并将这块区域清零。然后调用类型构造器,并为this
参数传递NextObjPtr
,new
操作符返回对象的引用。在返回这个引用之前,NextObjPtr
的值会加上这个对象占用的字节数,从而指向下一个可分配空间的地址。
由于托管堆在内存中连续分配这些对象,所以会因为引用的"局部化"而获得性能上的提升。因为在差不多时间分配的对象彼此间有较强的联系,很可能也会在差不多的时间访问。对于这类对象,如果在内存上的位置是连续的,那么进程的工作集就会很小,应用程序只需要使用很少的内存,从而提高了速度。同时,这还意味着代码使用的对象可以全部驻留在CPU缓存中。因而在CPU执行大多数操作时,不会因为"缓存未命中"而被迫访问较慢的RAM。
三、垃圾回收
3.1 垃圾回收算法
对于对象生存期的管理,有些系统采用的是引用计数算法。在这种系统中,堆上的每个对象都维护着一个内存字段来统计程序中有多少地方正在使用对象。随着每个地方不再需要对象,就会递减对象的计数字段。当计数字段变为0时,对象就可以从内存中删除了。
但引用计数最大的问题就是无法处理循环引用的问题。当对象a引用对象b,对象b又引用对象a时,两个对象的计数器就永远无法达到0,对象也就永远不会被删除。
为了避免这种问题,CLR改为使用一种引用跟踪算法 。引用跟踪算法只关心引用类型的变量,我们将所有的引用类型变量都称为根。
CLR开始GC时,首先暂停进程中的所有线程。这样可以防止线程在CLR检查期间访问对象并更改其状态。然后CLR进入GC的标记阶段 。在这个阶段,CLR遍历堆上的所有对象,将同步块索引字段中的一位设置为0,表明所有的对象都需要删除。然后CLR检查所有活动根,查看它们引用了哪些对象。如果一个根包含null
,则忽略这个根并继续检查下个根。
任何根只要引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引中的位设置为1。一个对象被标记后,CLR会检查那个对象中的根,并标记它们引用的对象。如果发现对象已经标记,就不重新检查对象的字段。这样就避免了循环引用而产生的死循环。
检查完毕后,堆中的对象要么就已标记,要么就未标记。已标记的对象不能被垃圾回收,因为还有引用存在。这种对象称为可达 的。未标记的对象是不可达的。
此时CLR就已经知道了哪些对象可以幸存,哪些可以直接删除。接下来就进入GC的压缩阶段。在这个阶段,CLR将所有幸存的对象进行移动,使其在内存中紧挨在一起。这一操作一方面恢复了引用的"局部化",从而提升了性能;另一方面也使剩余空间变得连续,解决了空间碎片化的问题。
因为幸存对象所在位置都被移动了,所以CLR还要从每个根减去所引用对象在内存中偏移的字节数。这样就保证了每个根引用的还是之前的对象。
3.2 代
CLR的GC是基于代的垃圾回收器,它假设你的代码符合以下条件:
- 对象越新,生存期越短
- 对象越老,生存期越长
- 回收堆的一部分,比回收整个堆快
对于大多数的应用程序,这些假设都是成立的。
现在假设托管堆刚刚初始化完成,我们向其中添加了一些新构造的对象,此时这些对象都属于第0代。此时垃圾回收器还从未检查过它们。一段时间后,其中的某些对象变得不可达(深色表示可达,浅色表示不可达)
在CLR初始化时会为第0代设置一个预算容量 ,如果分配一个新对象使第0代超出了预算,就会启动一次垃圾回收,存活的对象会成为第1代。
CLR在初始化时同样会为第1代设置一个预算容量。当开始一次垃圾回收时,垃圾回收器会检查第1代占用了多少内存。如果第1代占用的内存小于预算容量,那么就只需要检查第0代中的对象。由于第0代对象存活的时间更短,因而第0代包含更多垃圾的可能性也更大,所以也可以回收更多的内存。
忽略第1代中的对象可以提升垃圾回收器的性能。在进行垃圾回收时不必遍历托管堆中的每个对象。如果根或对象引用了老一代的某个对象,垃圾回收器可以忽略老对象内部的所有引用,从而加快构建"可达对象图"。老对象中的字段也有可能引用新的对象,为了确保对老对象的已更新字段进行检查,JIT编译器内部会在对象的引用字段发生变化时设置一个对应的标志位。这样垃圾回收器就知道从上次垃圾回收以来,哪些老对象已经被写入。只有字段发生变化的老对象才需要检查是否引用了第0代中的新对象。
当第0代达到了容量预算,第1代也达到了容量预算,那么在本次垃圾回收中,垃圾回收器就会检查第0代和第1代中所有的对象,并将幸存的对象都提升1代
托管堆最多只支持三代:第0代、第1代、第2代。CLR初始化时,会为每一代都设置一个预算。但这个预算是会动态变化的,它取决于应用程序的行为。比如垃圾回收器发现回收第0代后存活下来的对象很少,就会减少第0代的预算。这意味着垃圾回收会更频繁地发生,但每次回收所做的事情也会减少。
3.3 垃圾回收触发条件
- CLR检测到第0代超出容量预算时
- 代码显式调用
System.GC.Collect()
方法 - Windows报告低内存情况
- CLR正在卸载AppDomain
- CLR正在关闭
3.4 大对象
CLR将对象分为大对象和小对象。目前认为85000字节或更大的对象是大对象。
大对象具有以下特点:
- 大对象在独立的进程地址空间进行分配
- GC不压缩大对象,因为在内存中移动它们的代价过高。但这也会导致大对象之间的地址空间碎片化。
- 大对象总是第2代,绝不可能是第0代或第1代。所以只能为需要长时间存活的资源创建大对象。
3.5 垃圾回收模式
GC有两种基本的模式:
- 工作站模式。该模式针对客户端应用优化GC。GC造成的延时很低,应用程序线程挂起的时间很短。该模式GC假定机器上运行的其他应用不会消耗太多CPU资源。
- 服务器模式。该模式针对服务器应用优化GC。该模式GC假定机器上没有运行其他应用,所有的CPU都可以用来辅助完成GC。该模式会造成托管堆被拆分成几个区域,每个CPU负责一个区域。垃圾回收开始时,垃圾回收器在每个CPU上运行一个特殊线程,它们并发回收自己的区域。
除了上述两种模式外,GC还支持两种子模式:并发和非并发。在并发方式中,垃圾回收器有个额外的后台线程,能在应用运行时并发标记对象。一个线程因为分配对象造成第0代超出预算时,GC首先挂起所有线程,再判断要回收哪些代。如果要回收第0代或第1代,则仍然用非并发方式进行回收。
但如果要回收第2代,就会增大第0代的大小,以便在第0代中分配新对象,然后应用程序的线程恢复运行。此时垃圾回收器运行一个普通优先级的后台线程来查找不可达对象。找到之后,垃圾回收器再次挂起所有线程,判断是否需要压缩内存。如果执行压缩,所需的时间也会比平常少,因为不可达对象集合已经由后台线程构造好了。但如果可用内存多,垃圾回收器可能会选择不压缩。
四、终结器
GC可以回收对象在托管堆中的内存,但如果对象包含本机资源(如文件、网络连接等),直接回收对象就会造成本机资源的泄露。CLR为这种情形提供了终结机制,允许对象在被回收前执行一些代码,来释放持有的本机资源。
在System.Object
类中定义了虚方法Finalize
。垃圾回收器判定对象是垃圾后,就会调用对象的Finalize
方法。C#使用在类名前添加~
符号来定义Finalize
方法。
csharp
public class MyClass
{
~MyClass()
{
// ...
}
}
如果对象的类型定义了Finalize
方法,那么在该类型的实例构造器被调用之前,会将指向该对象的指针放到一个终结队列 中。
垃圾回收器扫描终结队列以查找这些对象的引用。找到一个引用后,该引用会从终结队列中移除,并添加到freachable队列。这个队列代表Finalize
方法已准备好调用的对象。
CLR使用一个特殊的、高优先级的专用线程调用Finalize
方法来避免死锁。当freachable队列为空时,该线程将会休眠,一旦有记录项就会被唤醒。该线程将freachable队列中的每一项移除,并调用其Finalize
方法。
当一个对象不可达时,垃圾回收器将其视为垃圾。但当对象的引用被移至freachable队列时,对象不再被认为是垃圾,不能回收其内存(包括其引用的对象)。也就是说对象被复活 了。被复活的对象会提升到较老的一代,直到下一次对老一代进行垃圾回收时,已终结的对象才会成为真正的垃圾并被回收。也就是说,可终结对象需要执行至少两次垃圾回收才能释放它们占用的内存。
五、参考资料
[1].《CLR via C#(第四版)》