Reference:

  1. C#-垃圾回收机制(GC)
  2. 垃圾回收的基本知识
  3. 浅谈C#的GC机制

C# GC

官网中有这么一句话:

The garbage collector is a common language runtime component that controls the allocation and release of managed memory

垃圾回收机制(Garbage Collection)简称GC,是CLR的一个组件,它控制内存的分配与释放。

概括:就是GC会帮你自动管理内存,分配内存,回收内存,采用的就是对应的GC的算法。

由于 C# 的编译流程为先由 C# 编译器编译为中间语言代码(IL),再由公共语言运行时(CLR)执行。因此 C# 中的 GC 功能实际上由 CLR 提供。

C# GC 工作原理

垃圾收集器的本质,就是跟踪所有被引用到的对象,整理不再被引用的对象,回收相应的内存。

以应用程序的root为基础,遍历应用程序在Heap上动态分配的所有对象,通过识别它们是否被引用来确定哪些对象是已经死亡的、哪些仍需要被使用。已经不再被应用程序的root或者别的对象所引用的对象就是已经死亡的对象,即所谓的垃圾,需要被回收。

垃圾回收的算法有多种,在 .Net中采用了一种叫做"标记与清除(Mark-Sweep)"算法,该算法分两个步骤:

  1. 标记——垃圾的识别:

CLR开始GC时,首先暂停进程中所有的线程,这样可以防止线程在CLR检查期间访问底线并更改其状态。然后正式进入标记状态。

从应用程序的root出发,利用相互引用关系,遍历其在Heap上动态分配的所有对象,没有被引用的对象不被标记,即成为垃圾;存活的对象被标记,即维护成了一张"根-对象可达图"。其实,CLR会把对象关系看做"树图",这样会加快遍历对象的速度。.Net中利用栈来完成检测并标记对象引用,在不断的入栈与出栈中完成检测:先在树图中选择一个需要检测的对象,将该对象的所有引用压栈,如此反复直到栈变空为止。栈变空意味着已经遍历了这个局部根能够到达的所有对象。树图节点范围包括局部变量、寄存器、静态变量,这些元素都要重复这个操作。一旦完成,便逐个对象地检查内存,没有标记的对象变成了垃圾。

如果过程中发现对象已标记,则不重新检查,避免了循环引用而造成的死循环。

  1. 清除——回收内存:

标记完可回收对象后,便进入压缩阶段。首先要搞清楚的是,这里的压缩并不是指把对象的内存空间压缩变小了,而是把存活下来的对象进行移位,使他们占用连续的内存空间。但是大对象(large object heap)除外,GC不会移动一个内存中巨无霸。通常,大对象具有很长的生存期,当一个大对象在 .NET托管堆中产生时,它被分配在堆的一个特殊部分中,移动大对象所带来的开销超过了整理这部分堆所能提高的性能。

实际上相比起“压缩”,用“碎片整理”这一词应该更贴近这一阶段的行为。这一行为可以使得引用恢复“局部化”,减少应用程序工作集,并提升了访问这些对象时的性能,并且对于压缩后的可使用空间,也会变成连续的,可容纳更大的对象,解决了空间碎片化问题。

在内存中移动了对象位置后,引用幸存对象的根仍然引用着对象压缩前的位置,压缩阶段最后一步还会把每个根减去所引用的对象在内存中偏移的字节数,来保证每个根引用的还是和之前一样的对象

压缩好内存后,托管堆的NextObjPtr指针指向最后一个幸存对象之后的位置。下一个分配的对象将放到这个位置。压缩阶段完成后,CLR恢复应用程序的所有线程。这些线程继续访问对象,就好象GC没有发过一样。

Compact算法除了会提高再次分配内存的速度,如果新分配的对象在堆中位置很紧凑的话,高速缓存的性能将会得到提高,因为一起分配的对象经常被一起使用(程序的局部性原理),所以为程序提供一段连续空白的内存空间是很重要的。

简单地把 .NET的GC算法看作Mark-Compact算法。阶段1: Mark-Sweep 标记清除阶段,先假设heap中所有对象都可以回收,然后找出不能回收的对象,给这些对象打上标记,最后heap中没有打标记的对象都是可以被回收的;阶段2: Compact 压缩阶段,对象回收之后heap内存空间变得不连续,在heap中移动这些对象,使他们重新从heap基地址开始连续排列,类似于磁盘空间的碎片整理。Heap内存经过回收、压缩之后,可以继续采用前面的heap内存分配方法,即仅用一个指针记录heap分配的起始地址就可以。

image.png

主要处理步骤:将线程挂起→确定roots→创建reachable objects graph→对象回收→heap压缩→指针修复。可以这样理解roots:heap中对象的引用关系错综复杂(交叉引用、循环引用),形成复杂的graph,roots是CLR在heap之外可以找到的各种入口点。

GC搜索roots的地方包括全局对象静态变量局部对象函数调用参数当前CPU寄存器中的对象指针(还有finalization queue)等。主要可以归为2种类型:已经初始化了的静态变量、线程仍在使用的对象(stack+CPU register)

Reachable objects:指根据对象引用关系,从roots出发可以到达的对象。例如当前执行函数的局部变量对象A是一个root object,他的成员变量引用了对象B,则B是一个reachable object。从roots出发可以创建reachable objects graph,剩余对象即为unreachable,可以被回收 。

指针修复是因为compact过程移动了heap对象,对象地址发生变化,需要修复所有引用指针,包括stack、CPU register中的指针以及heap中其他对象的引用指针。

Debug和release执行模式的区别

Debug和release执行模式之间稍有区别,release模式下后续代码没有引用的对象是unreachable的,而debug模式下需要等到当前函数执行完毕,这些对象才会成为unreachable,目的是为了调试时跟踪局部对象的内容。传给了COM+的托管对象也会成为root,并且具有一个引用计数器以兼容COM+的内存管理机制,引用计数器为0时,这些对象才可能成为被回收对象。Pinned objects指分配之后不能移动位置的对象,例如传递给非托管代码的对象(或者使用了fixed关键字),GC在指针修复时无法修改非托管代码中的引用指针,因此将这些对象移动将发生异常。pinned objects会导致heap出现碎片,但大部分情况来说传给非托管代码的对象应当在GC时能够被回收掉。

Generational 分代算法

GC 算法的设计考虑到了4个因素:

  1. 对于较大内存的对象,频繁的进行GC将耗费大量的资源,成本很高且效果较差
  2. 大量新创建的对象生命周期都较短,老对象的生命周期都较长
  3. 小部分的进行 GC 比大块的进行GC效率更高,消耗更少
  4. 新创建的对象在内存分配上多为连续,且关联程度较强,关联度较强有利于CPU Cache命中。

基于此,按照寿命长短,托管堆被分为了三个年龄层,分别是Generation 0,Generation 1, Generation 2。垃圾收集器在第 0 代存储新对象。在应用程序生命周期早期创建的在收集过程中幸存下来的对象被提升并存储在第 1 代和第 2 代中。因为压缩托管堆的一部分比压缩整个堆要快,因此该方案允许垃圾收集器在特定代中释放内存,而不是在每次执行收集时释放整个托管堆的内存。

image.png

  • 第 0 代

    这是最年轻的一代,包含生命周期很短的对象。短期对象的一个例子是临时变量。垃圾收集在这一代发生得最频繁。新分配的对象形成了第0代的对象,并且是隐式的第 0 代集合。但当新分配的对象很大时,它们将进入大对象堆 (LOH),有时也称为第 3 代(实际上是第二代)。第 3 代可以理解为物理代,作为第二代的衍生。大多数对象在第 0 代被回收用于垃圾收集,并且不会存活到下一代。

    如果应用程序在第 0 代已满时尝试创建新对象,垃圾收集器将执行收集以尝试释放对象的地址空间。垃圾收集器首先检查第 0 代中的对象,而不是托管堆中的所有对象。单独的第 0 代集合通常会回收足够的内存,使应用程序能够继续创建新对象。

  • 第 1 代

    这一代包含短期对象,并作为短期对象和长期对象之间的缓冲区。在垃圾收集器执行第 0 代的收集后,它会压缩可访问对象的内存并将它们提升到第 1 代。因为在收集中幸存下来的对象往往具有更长的生命周期,所以将它们提升到更高的代是有意义的。垃圾收集器不必在每次执行第 0 代收集时重新检查第 1 代和第 2 代中的对象。如果第 0 代的集合没有为应用程序回收足够的内存来创建新对象,则垃圾收集器可以执行第1 代的收集,然后是第 2 代。第 1 代中在集合中幸存下来的对象将被提升到第 2 代。

  • 第 2 代

    这一代包含长期存在的对象。长寿命对象的一个示例是服务器应用程序中的对象,其中包含在进程持续期间有效的静态数据。在集合中存活的第 2 代对象将保留在第 2 代中,直到它们被确定在未来的集合中不可访问。大对象堆(有时称为第 3 代)上的对象也在第 2 代中收集。

    当条件允许时,垃圾收集发生在特定的世代。收集一代意味着收集该一代及其所有年轻一代的对象。第 2 代垃圾回收也称为完整垃圾回收,因为它回收所有代中的对象(即托管堆中的所有对象)。

    当垃圾收集器检测到某一代存活率较高时,会增加该代的分配阈值。下一个集合获得大量回收内存。CLR 不断平衡两个优先级:不让应用程序的工作集因延迟垃圾收集而变得太大,以及不让垃圾收集运行得太频繁。

分代回收

程序在初始化时,托管堆不包含任何对象,这个时候新添加到堆中的对象,成为第0代对象。简单地说,第0代对象就是那些新构造的对象,垃圾回收器从来未检查过这些对象。CLR初始化时会为第0代对象选一个预算容量,当分配一个新的对象超出这个预算时,就会启动一次垃圾回收。在一次垃圾回收之后,没被回收的对象会成为第1代对象,此时第0代空间中已经不包含任何对象,原来的对象可能已被回收,可能已被放置到第1代中。

新分配的对象会继续第0代空间中,直到第0代空间不足分配新对象,会再次触发垃圾回收,开始垃圾回收时,垃圾回收器必须决定需要检查哪些代,前文提到,CLR初始化时会为第0代对象选择预算,实际上,CLR还必须为第1代选择预算。这时,垃圾回收器会检查第1代的空间,若远小于预算,则这次回收只会检查第0代,基于“对象越新,生存期越短”的假设,第0代包含更多垃圾的可能性很大,能回收更多的内存。因为忽略了第1代中的对象,尽管第1代中可能存在不可达的垃圾对象,但这加快了垃圾回收的速度。对性能的有效提升,主要是在于,现在不需要遍历堆中的每一个对象,因为如果根或对象引用了老一代的某一个对象, 垃圾回收器会忽略老一代对象内部的所有引用。

当然,也存在老对象会引用了新对象的可能性,为了避免老一代对象引用了新一代对象,垃圾回收时却没检查到这一引用,而把新一代对象回收了的情况发生,垃圾回收器利用了JIT编译器内部的一个机制,这个机制在对象的引用字段发生变化时,会设置一个对应的标记位,这样一来垃圾回收器就会知道自上一次垃圾回收以来,哪些老对象的引用字段发生了变化,这样就算这次回收只回收新生代,也会去检测引用字段发生了变化的老对象,是否引用了新生代对象

当下一次第0代分配空间超出预算,开始执行垃圾回收,并发现第一代空间也超出预算时,垃圾回收器就会对第0代和第1代都进行回收,垃圾回收后,第0代的存活对象会被提升到第1代,而第1代的对象会被提升到第二代,而第0代空间再次空了出来。

代预算的动态调节

CLR初始化的时候会为每一代选择预算。而且,CLR的垃圾回收器是会根据实际运行情况动态调节预算的,例如在回收第0代后发现存活下来的对象很少,就可能减少第0代的预算,这意味着会更加频繁地执行垃圾回收,但每次回收需要做的事情少了(若第0代所有对象都是垃圾,垃圾回收就不需要压缩内存,直接让NextObjPtr指针指回第0代的起始处即可,速度上会快很多)。相反,如果回收了第0代后发现还有很多存活的对象,没有多少内存可以回收,就会增大第0代的预算,这样垃圾回收的次数就会减少,但每次进行垃圾回收时,能会收到的内存就会变多。如果没有会收到足够的内存,垃圾回收器会执行一次完整的回收,如果还是不够,就会抛出OutOfMemoryException异常。上述仅对第0代进行垃圾回收后动态调整预算的讨论,但垃圾回收器还会用类似的方法来调整第1代和第2代的预算,最终结果就是,垃圾回收器会根据应用程序要求的内存负载来进行自我优化。

GC 的触发条件

上文说到的检测到第0代超出预算的时候会触发垃圾回收,这是最常见的一种触发条件,除此之外还有以下条件可以触发垃圾回收:

  1. 代码显式调用System.GC的静态Collect方法

代码可显式请求CLR进行垃圾回收,但微软强烈反对这种请求,托管语言应该信任它本身的垃圾回收机制。

  1. Windows报告低内存情况

如果Windows报告低内存,CLR会强制执行垃圾回收。

  1. CLR正在卸载AppDomain

当一个AppDomain卸载时,CLR认为其中一切都不是根,会执行涵盖所有代的垃圾回收。

  1. CLR正在关闭

CLR在进程正常终止时关闭,CLR认为其中一切都不是根,对象有机会进行资源清理,但CLR不会试图压缩或释放内存。进程终止时,Windows会回收进程的全部内存。

大对象

CLR把对象分为大对象和小对象,上文都是对小对象的讨论,目前认为85000字节以上的对象为大对象,CLR会以不同的方式对待大小对象。

  1. 大对象不在小对象的地址空间中分配,而是在进程地址空间的其他地方分配(大对象堆)

  2. 目前版本GC不压缩大对象,因为在内存中移动大对象的代价太高,这可能会导致空间碎片化。(CLR有可能在未来版本压缩大对象)

  3. 大对象总是第2代,不会在其他代,所以为了性能考虑,只能为需要长时间存活的资源创建大对象,分配短时间存活的大对象会导致第2代被更频繁地回收,损害性能。大对象一般是大字符串或用于IO操作的直接数组。

  4. 一般情况下可以忽视大对象的存在,仅出现如空间碎片化等情况时才对大对象进行特殊处理。

需要特殊清理的类型

Finalize 方法

有些类型除了内存外,还需要一些本机资源(也就是非托管资源)才能正常工作,例如System.IO.FileStream类型需要打开一个文件并保存文件的句柄。在包含非托管资源的类型被GC时,GC会回收对象在托管堆中的内存,如果直接进行回收,本机资源未释放,会导致本机资源的泄漏,为了解决这一问题CLR提供了终结机制,允许对象在被判定为垃圾后,在被回收之前执行一些代码,之后再从托管堆中回收对象。我们称这种对象为可终结的。

System.Object定义了受保护的虚方法Finalize,如果类型重写了这个方法,垃圾回收器会在判定对象是垃圾后调用对象的Finalize方法。C#要求要用特殊的语法来定义Finalize方法,如下所示(类似于 C++ 的析构函数定义):

1
2
3
4
5
6
7
class SomeType
{
~SomeType()
{
//....
}
}

虽然System.Object定义了Finalize方法,但CLR会忽略它,只有类型重写了这个方法,才会被认为是“可终结的”

C#的Finalize方法在语法上与C++的析构函数非常相似,但实际上工作方式完全不同,与C++中类型实例会被确定性析构不一样,面向CLR的语言,C#无法提供确定性析构这种机制

对于类型定义了Finalize方法的对象的回收机制

应用程序在创建新的对象时,new 操作符会在堆中分配内存,如果对象的类型定义了 Finalize 方法,那么这个实例在被构造之前,会将一个指向该对象的指针放到一个终结列表中(finalization list),终结列表是由垃圾回收器控制的一个内部数据结构,列表中的每一个项都指向一个类型定义了 Finalize 方法的对象。

垃圾回收器在标记完垃圾对象后,会在终结列表中查找是否有包含垃圾对象的引用,即检查这些垃圾对象是否定义了 Finalize 方法,若检测到有,则会把这个引用从终结列表中移除,并加到freachable 队列中。freachable 队列也是垃圾回收器的一种内部数据结构,队列中每一个引用都代表准备要调用 Finalize 方法的对象。垃圾回收完毕后,没有定义 Finalize 方法的对象已被回收,但定义了 Finalize 方法的对象(即此时在 freachable 队列中的对象)会存活下来,因为此时他们的 Finalize 方法还没被调用。

简单地说,当一个对象不可达时,垃圾回收器就把它视为垃圾。但是,当垃圾回收器把对象的引用从终结列表移到 freachable 队列时,对象不再被认为是垃圾,我们可以说对象被“复活了”,相应的,标记 freachable 队列中的对象时,还会递归对象中的引用字段所引用的对象,这些对象都会“复活”,最后在回收过程中存活下来。然后垃圾回收器会压缩内存,并把“复活”的对象提升到老一代,之后CLR 会用特殊的终结线程去调用 freachable 队列中每个对象的 Finalize 方法,并清空队列。

也就是说,这些定义了 Finalize 方法的“可终结”的对象,由于在第一次回收时,会被“复活”以执行 Finalize 方法,并可能会被提升到老一代,所以至少需要执行两次垃圾回收才能释放掉它们占用的托管堆内存,更需要注意到的是,这些对象中的引用字段所引用的对象也会存活下来并提升到老一代,会造成更大的性能负担。所以,要尽量避免为引用类型的字段定义为“可终结”对象。

最后要注意,Finalize方法的执行时间和执行顺序是无法保证的,所以在Finalize方法中不要访问定义了Finalize方法的其他类型的对象,那些对象可能已经终结了,但是访问值类型实例或者没有定义Finalize方法类型的对象是安全的

此外, .NET Framework的System.GC类提供了控制Finalize的两个方法:ReRegisterForFinalizeSuppressFinalize。前者是请求系统完成对象的Finalize方法,后者是请求系统不要完成对象的Finalize方法。ReRegisterForFinalize方法其实就是将指向对象的指针重新添加到Finalization Queue中。这就出现了一个很有趣的现象,因为在Finalization Queue中的对象可以复生,如果在对象的Finalize方法中调用ReRegisterForFinalize方法,这样就形成了一个在堆上永远不会死去的对象,像凤凰涅槃一样每次死的时候都可以复生。

C# 的 GC 机制存在的问题

  1. GC并不是能释放所有的资源。它不能自动释放非托管资源。
  2. GC并不是实时性的,这将会造成系统性能上的瓶颈和不确定性。

GC并不是实时性的,这会造成系统性能上的瓶颈和不确定性。所以有了IDisposable接口,IDisposable接口定义了Dispose方法,这个方法用来供程序员显式调用以释放非托管资源。使用using语句可以简化资源管理。

using 语句确保在其作用域结束时调用 Dispose 方法,自动释放资源。using 语句是 try-finally 语句的简化版本,不需要显式地调用 Dispose 方法。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/// <summary>  
/// 执行SQL语句,返回影响的记录数
/// </summary>
/// <param name="SQLString">SQL语句</param>
/// <returns>影响的记录数</returns>
public static int ExecuteSql(string SQLString)
{
using (SqlConnection connection = new SqlConnection(connectionString))
{
using (SqlCommand cmd = new SqlCommand(SQLString, connection))
{
try
{
connection.Open();
int rows = cmd.ExecuteNonQuery();
return rows;
}
catch (System.Data.SqlClient.SqlException e)
{
connection.Close();
throw e;
}
finally
{
cmd.Dispose();
connection.Close();
}
}
}
}

当你用Dispose方法释放未托管对象的时候,应该调用GC.SuppressFinalize。如果对象正在终结队列(finalization queue), GC.SuppressFinalize会阻止GC调用Finalize方法。因为Finalize方法的调用会牺牲部分性能。如果你的Dispose方法已经对委托管资源作了清理,就没必要让GC再调用对象的Finalize方法(MSDN)。