C#托管堆和垃圾回收
托管堆基础
访问一个资源所需的步骤:
- 调用IL指令
newobj
,为代表资源的类型分配内存(一般使用C# new 操作符来完成) - 初始化内存,设置资源的初始状态并使资源可用。类型的实例构造器负责设置初始状态
- 访问类型的成员来使用资源(有必要可用重复)
- 摧毁资源的状态以进行清理
- 释放内存。垃圾回收器独自负责这一步
从托管堆分配资源
CLR要求所有对象都从托管堆分配。进程初始化时,CLR划出一个地址空间区域作为托管堆。
CLR还要维护一个指针(NextObjPtr
),该指针指向下一个对象在堆中的分配位置,刚开始的时候,指针设为地址空间区域的基地址。
C#的new操作符导致CLR执行以下步骤:
- 计算类型的字段(以及从基类继承的字段)所需的字节数
- 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引(对于32位应用程序各需要32位,故每个对象增加8字节,64位则增加16字节)
- CLR检查区域中是否有分配对象所需的字节数。
- 若有,在
NextObjPtr
指针指向的地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器,NextObjPtr
指针的值会加上对象占用的字节数来得到一个新值,即下一个对象放入托管堆时的地址,最后new操作符返回对象引用。 - 若无,CLR执行垃圾回收(事实上,垃圾回收是在第0代满的时候发生的,在后面会详细解释)
- 若有,在
垃圾回收算法
CLR使用一种引用跟踪算法,该算法只关心引用类型的变量,因为只有这种变量才能引用堆上的对象;值类型变量直接包含值类型实例。我们将所有引用类型的变量都称为根。
CLR开始GC时:
- 首先暂停进程中的所有线程。这样可以防止线程在CLR检查期间访问对象并更改其状态;
- 然后CLR进入GC的标记阶段。在这个阶段,CLR遍历堆中的所有对象,将同步块索引字段中的一位设为0;
- 然后CLR检查所有活动根,查看它们引用了哪些对象。如果有一个根包含null,CLR忽略这个根并继续检查下个根。任何根如果引用了堆上的对象,CLR都会标记那个对象,也就是将该对象的同步块索引中的位设为1;
- 检查完毕后,已标记的对象不能被垃圾回收,称这种对象是可达的,未标记的对象是不可达的;
- 接下来进入GC的压缩阶段,压缩所有幸存下来的对象,使它们占用连续的内存空间;
- 作为压缩阶段的一部分,CLR还要从每个根减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象;
- 压缩好内存后,托管堆的
NextObjPtr
指针指向最后一个幸存对象之后的位置。下一个分配的对象将放到这个位置;
PS:内存泄漏的一个常见原因就是让静态字段引用某个集合对象,然后不停地向集合添加数据项。
垃圾回收和调试
使用C#编辑器的 /debug
开关编译程序集时,编译器会应用 System.Diagnostics.DebuggableAttribute
,并为结果程序集设置 DebuggingMode
的 DisableOptimizations
标志。运行时编译方法时,JIT编译器看到这个标志,会将所有根的生存期延长至方法结束。
C#编辑器/optimize+
编译器开关会将 DisableOptimizations
禁止的优化重新恢复。
代:提升性能
CLR的GC是基于代的垃圾回收器(generational garbage collector),它做出了以下几点假设:
- 对象越新,生存期越短
- 对象越老,生存期越长
- 回收堆的一部分,速度快于回收整个堆
代的工作原理:
- 托管堆在初始化时不包含对象。托管堆只支持三代:第0代、第1代和第2代。CLR初始化时,会为每一代选择预算。
- 添加到堆的对象称为第0代对象。
- 如果分配一个新对象造成第0代超过预算,就必须启动一次垃圾回收。
- 在垃圾回收中存活的对象现在成为第1代对象。一次垃圾回收后,第0代就不包含任何对象了,新对象会继续分配到第0代中。
- 如果分配一个新对象再次造成第0代超过预算,并且此时第1代也超过预算了,这次垃圾回收器便会检查第1代和第0代中所有对象。
- 和之前一样,垃圾回收后,第0代的幸存者被提升至第1代,第1代的幸存者被提升至第2代。
PS1:如果根或对象引用了老一代的某个对象,垃圾回收器就可以忽略老对象内部的所有引用;如果老对象引用了新对象,为了确保对老对象的已更新字段进行检查,垃圾回收器利用JIT编译器内部的一个机制,这个机制在对象引用字段发生变化时,会设置一个对应的位标志。只有字段发生变化的老对象才需要检查是否引用了第0代中的任何新对象。
PS2:CLR初始化时,会为每一代选择预算。然而,CLR的垃圾回收器是自我调节的。如果垃圾回收器发现在回收0代后存活下来的对象很少,就可能减少第0代的预算。另一方面,如果垃圾回收器回收了第0代,发现还有很多对象存活,没有多少内存被回收,就会增大第0代的预算。
垃圾回收器还会用类似的启发式算法调整第1代和第2代的预算。
如果没有回收到足够的内存,垃圾回收器会执行一次完整回收。
垃圾回收触发条件
-
检测第0代超过预算时触发
-
代码显示调用
System.GC
的静态 Collect 方法详见 “强制垃圾回收”。
-
Windows报告低内存情况
CLR内部使用 Win32 函数
CreateMemoryResourceNotification
和QueryMemoryResourceNotification
监视系统的总体内存使用情况。如果Windows报告低内存,CLR将强制垃圾回收以释放死对象,减小进程工作集。 -
CLR正在卸载 AppDomain
一个 AppDomain 卸载时,CLR认为其一切都不是根,所以执行涵盖所有代的垃圾回收。
-
CLR正在关闭
CLR在进程正常终止(相反的是从外部终止,如任务管理器)时关闭。关闭期间,CLR认为进程中一切都不是根。对象有机会进行资源清理,但CLR不会试图压缩或释放内存。整个进程都要终止了,Windows将回收进程的全部内存。
大对象
另一个性能提升的举措是:CLR将对象分为大对象和小对象(目前认为85000字节或更大的对象是大对象* 不确定大小是否有变化)。
CLR以不同方式对待大小对象:
- 大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配
- 目前版本的GC不压缩大对象,因为在内存中移动它们代价过高。(但可能造成地址空间碎片化,未来可能会压缩)
- 大对象总是第2代。大对象一般是大字符串(XML或JSON)或用于I/O操作的字节数组
使用需要特殊清理的类型
包含本机资源的类型被GC时,GC会回收对象在托管堆中使用的内存,但这会造成本机资源的泄漏,故CLR提供了称为终结(finalization
)的机制,允许对象在被判定为垃圾之后,但在对象内存被回收之前执行一些代码。
任何包装了本机资源(文件、网络连接、套接字、互斥体)的类型都支持终结。
CLR判定一个对象不可达时,对象将终结它自己,释放它包装的本机资源。之后,GC会从托管堆回收对象。
注意:
- 被视为垃圾的对象在垃圾回收完毕后才调用
Finalize
方法,所以这些对象的内存不是马上被回收的,可终结对象在回收时必须存活,造成它被提升到另一代,使对象活得比正常时间长,这增大了内存耗用。更糟糕的是,可终结对象被提升时,其字段引用的所有对象也会被提升。 Finalize
方法的执行时间是控制不了的,应用程序请求更多内存时才可能发生GC,而只有GC完成后才运行Finalize
。- CLR不保证多个
Finalize
方法的调用顺序。 - CLR用一个特殊的、高优先级的专用线程调用
Finalize
方法来避免死锁。
终结的内部工作原理
应用程序创建新对象时,new操作符会从堆中分配内存。如果对象的类型定义了 Finalize
方法,那么在该类型的实例构造器被调用之前,会将指向该对象的指针放到一个终结列表(finalization list)中。列表中的每一项都指向一个对象——回收该对象的内存前应调用它的 Finalize
方法
垃圾回收器判断完垃圾后,会扫描终结列表以查找对这些对象的引用。找到一个引用后,该引用会从终结列表中移除,并附加到 freachable
队列。队列中的每个引用都代表其 Finalize
方法已准备好调用的一个对象。
freachable
队列为空时,该线程将睡眠。但一旦队列中有记录项出现,线程就会被唤醒,将每一项都从 freachable
队列中移除,同时调用每个对象的 Finalize
方法。
简单来说:
- 当一个对象不可达时,垃圾回收器就把它视为垃圾。
- 当垃圾回收器将对象的引用从终结列表移至
freachable
队列时,对象不再被认为是垃圾,不能回收它的内存,即对象被复活了。 - 标记
freachable
对象时,将递归标记对象中的引用类型的字段所引用的对象;所有这些对象也必须复活以便在回收过程中存活。 - 之后,垃圾回收器才结束对垃圾的标识。在这个过程中,一些原本被认为是垃圾的对象复活了。
- 然后垃圾回收器压缩可回收内存,将复活的对象提升到较老的一代。
- 现在,特殊的终结线程清空
freachable
队列,执行每个对象的Finalize
方法。 - 下次对老一代进行垃圾回收时,会发现已终结的对象成为真正的垃圾。