第 21 章 托管堆和垃圾回收

第 21 章 托管堆和垃圾回收

21.1 托管堆基础

以下是访问一个资源所需的步骤。

  1. 调用 IL 执行 newobj,为代表资源的类型分配内存(一般使用 C# new 操作符来完成)。

  2. 初始化内存,设置资源的初始状态并使资源可用。类型的实例构造器负责设置初始状态。

  3. 访问类型的成员来使用资源(有必要可以重复)。

  4. 摧毁资源的状态以进行清理。

  5. 释放内存。垃圾回收器独自负责这一步。

为了进一步简化编程,开发人员经常使用的大多数类型都不需要步骤 4。大多数类型都无需资源清理,垃圾回收器会自动释放内存。

调用Dispose方法清理资源可按照自己的节奏清理资源,而不是非要等着GC介入。一般只有包装了本机资源(文件、套接字和数据库连接等) 的类型才需要特殊清理。

21.1.1 从托管堆分配资源

C# 的 new 操作符导致 CLR 执行以下步骤。

  1. 计算类型的字段(以及从基类型继承的字段)所需的字节数。

  2. 加上对象的开销所需的字节数。每个对象都有两个开销字段:类型对象指针和同步块索引。对于 32 位应用程序,这两个字段各自需要 32 位,所以每个对象要增加 8 字节。对于 64 位应用程序,这两个字段各自需要 64 位,所以每个对象要增加 16 字节。

  3. CLR 检查区域中是否有分配对象所需的字节数。如果托管堆有足够的可用空间,就在 NextObjPtr 指针指向的地址处放入对象,为对象分配的字节会被清零。接着调用类型的构造器(为 this 参数传递 NextObjPtr),new 操作符返回对象引用。就在返回这个引用之前, NextObjPtr 指针的值会加上对象占用的字节数来得到一个新值,即下个对象放入托管堆时的地址。

CLR 要求所有对象都从托管堆分配。进程初始化时,CLR 划出一个地址空间区域作为托管堆。CLR 还要维护一个指针,我把它称作 NextObjPtr。该指针指向下一个对象在堆中的分配位置。刚开始的时候,NextObjPtr 设为地址空间区域的基地址。

托管堆在内存中连续分配这些对象,所以会因为引用的“局部化”(locality)而获得性性能上的提升。具体地说,这意味着进程的工作集会非常小,应用程序只需使用很少的内存,从而提高了速度。

21.1.2 垃圾回收算法

应用程序调用 new 操作符创建对象时,可能没有足够地址空间看来分配该对象。发现空间不够,CLR 就执行垃圾回收。

对象生存期的管理方式:
引用计数法管理对象生存期,然而存在循环引用问题。
引用跟踪法(CLR)遍历堆中的对象,并通过标记阶段和引用跟踪阶段来识别活动根和未使用的对象。

我们将所有引用类型的变量都称为

GC进行垃圾回收时的主要流程

  1. 标记:先假设所有对象都是垃圾,根据应用程序根Root遍历堆上的每一个引用对象,生成可达对象图,对于还在使用的对象(可达对象)进行标记(其实就是在对象同步索引块中开启一个标示位)。

  2. 清除:针对所有不可达对象进行清除操作,针对普通对象直接回收内存,而对于实现了终结器的对象(实现了析构函数的对象)需要单独回收处理。

  3. 压缩:把剩下的对象转移到一个连续的内存,因为这些对象地址变了,还需要把那些 Root 跟指针的地址修改为移动后的新地址。

CLR 开始 GC 时,首先暂停进程中的所有线程。这样可以防止线程在 CLR 检查期间访问对象并更改其状态。然后,CLR 进入 GC 的 标记阶段。在这个阶段,CLR 遍历堆中的所有对象,将同步块索引字段中的一位设为 0。这表明所有对象都应删除。然后,CLR 检查所有活动根。查看它们引用了哪些对象。这正是 CLR 的 GC 称为引用跟踪 GC 的原因。如果一个根包含 null, CLR 忽略这个根并继续检查下个根。

任何根如果引用了堆上的对象,CLR 都会标记那个对象,也就是将该对象的同步块索引中对的位设为 1。一个对象被标记后, CLR 会检查那个对象中的根,标记它们引用的对象。如果发现对象已经标记,就不重新检查对象的字段。这就避免了因为循环引用而产生死循环。

GC压缩: CLR 知道哪些对象可以幸存,哪些可以删除后,压缩所有幸存下来的对象,使它们占用连续的内存空间。CLR 还要从每个根减去所引用的对象在内存中偏移的字节数。这样就能保证每个根还是引用和之前一样的对象。压缩好内存后,托管堆的 NextObjPtr 指针指向最后一个幸存对象之后的位置。

重要提示 静态字段引用的对象一直存在,直到用于加载类型的 AppDomain 卸载为止。内存泄漏的一个常见原因就是让静态字段引用某个集合对象,然后不停地向集合添加数据项。静态字段使集合对象一直存活,而集合对象使所有数据项一直存活。因此,应尽量避免使用静态字段。

21.2 代:提升性能

CLR 的 GC 是基于代的垃圾回收器,它对你的代码做出了以下几点假设。

  • 对象越新,生存期越短。

  • 对象越老,生存期越长。

  • 回收堆的一部分,速度快于回收整个堆。

第 0 代对象就是那些新构造的对象,垃圾回收器从未检查过它们。CLR 初始化时为第 0 代对象选择一个预算容量(以 KB 为单位)。如果分配一个新对象造成第 0 代超过预算,就必须启动一次垃圾回收。

老对象的字段也有可能引用新对象。为了确保对老对象的已更新字段进行检查,垃圾回收器利用了 JIT 编译器内部的一个机制。当 JIT 编译器生成本机(native)代码来修改对象中的一个引用字段时,本机代码会生成对一个 write barrier 方法的调用,设置一个对应的位标志。这样,垃圾回收器就知道自上一次垃圾回收以来,哪些老对象(如果有的话)已被写入。任何被修改的对象引用了第 0 代中的一个对象,被引用的第 0 代对象就会在垃圾回收过程中“存活”。

托管堆只支持三代:第 0 代、第 1 代和第 2 代。没有第 3 代。

CLR 的垃圾回收器是自调节的。这意味着垃圾回收器会在执行垃圾回收的过程中了解应用程序的行为。

如果垃圾回收器回收了第 0 代,发现还有很多对象存活,没有多少内存被回收,就会增大第 0 代的预算。如果没有回收到足够的内存,垃圾回收器会执行一次完整回收。如果还是不够,就抛出OutOfMemoryException 异常。

21.2.1 垃圾回收触发条件

  • CLR 在检测第 0 代超过预算时

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

  • Windows报告低内存情况

  • CLR 正在卸载 AppDomain

  • CLR 正在关闭

21.2.2 大对象

目前认为 85000 字节或更大的对象是大对象。CLR 以不同方式对待大小对象。

  • 大对象不是在小对象的地址空间分配,而是在进程地址空间的其他地方分配。

  • 目前版本的 GC 不压缩大对象,因为在内存中移动它们代价过高。

  • 大对象总是第 2 代。所以只能为需要长时间存活的资源创建大对象。

21.2.3 垃圾回收模式

CLR有两个基本GC模式:工作站和服务器。

工作站模式(Workstation GC)
  • 适用于客户端应用程序,优化GC造成的延时低,不会使用户感到焦虑。
  • GC会更频繁的发生,每次暂停时间都会很短。
  • 无论是否有配置多CPU核心,垃圾回收始终只使用一个CPU核心,只有一个托管堆。
  • 应用程序默认以工作站模式运行。
服务器模式
  • 要是为了满足基于请求处理的WEB等类型应用程序设计的,这意味着它更侧重于需要满足大的吞吐量,零星的停顿不会对齐产生重大的影响。
  • GC的发生频率会降低,优先满足大吞吐量。
  • 内存占用率会更高,因为GC发生的频率变低,内存中可能会有很多垃圾对象。
  • 垃圾回收使用高优先级运行在多个专用线程上。每个CPU核心都提供执行垃圾回收的专用线程和堆,每个CPU核心上的堆都包含小对象、大对象堆。

GC相对于用户线程的操作方式,分为两种主要模式和两种子模式:并发非并发

非并发模式

它适用于工作站和服务器模式,在GC进行过程中,所有的用户线程都会挂起。

并发模式

并发GC模式它和用户线程同时工作,GC进行过程中只有少数几个过程需要挂起用户线程。所以它的实现也更加复杂,但是暂停时间会更短,性能也会更好,不过现在它已经过时,本文不会着重描述它。

后台GC

Background(后台GC),在 .NET Framework 4.0以后,后台GC取代了并发GC,它只适用于Gen2的回收,但是它可以触发对于Gen0、Gen1的回收。根据WorkstationGC和ServerGC的模式会分别在一个或多个线程上执行。

21.3 使用需要特殊清理的类型

本机资源类型在GC回收对象的内存时可能会出现泄露。CLR提供了终结机制,允许对象在被判定为垃圾之前执行一些代码并释放本机资源。任何包装了本机资源(文件、网络连接、套接字、互斥体)的类型都支持终结,System.Object定义了Finalize虚方法。C#使用特殊语法在类名前添加~符号来定义终结方法。

终极基类 System.Object 定义了受保护的虚方法 Finalize。垃圾回收器判定对象是垃圾后,会调用对象的 Finalize 方法(如果重写)

Finalize方法

  • 可终结对象在回收时必须存活,造成它被提升到另一代,使对象活的比正常时间长。
  • Finalize 方法的执行时间是控制不了的。

21.3.1 使用包装了本机资源的类型

类如果想允许使用者控制类所包装的本机资源的生存期,就必须实现如下所示的 IDisposable 接口。

1
2
3
public interface IDisposable {
void Dispose();
}

C# 语言提供了一个 using 语句,它允许用简化的语法来获得和上述代码相同的结果。

using 语句初始化一个对象,并将它的引用保存到一个变量中。然后再 using 语句的大括号内访问该变量。编译这段代码时,编译器自动生成 try 块和 finally 块。在 finally 块中,编译器生成代码将变量转型为一个 IDisposable 并调用 Dispose 方法。但是很显然,using语句只能用于那些实现了 IDisposable 接口的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using System;
using System.IO;
public static class Program {
public static void Main() {
// 创建要写入临时文件的字节
Byte[] bytesToWrite = new Byte[] { 1, 2, 3, 4, 5 };
// 创建临时文件
using (FileStream fs = new FileStream("Temp.dat", FileMode.Create)) {
// 将字节写入临时文件
fs.Write(bytesToWrite, 0, bytesToWrite.Length);
}
// 删除临时文件
File.Delete("Temp.dat"); // 总能正常工作
}
}
1
2
3
4
5
6
7
8
9
10
11
FileStream fs = new FileStream("Temp.dat", FileMode.Create);
try
{
fs.Write(bytesToWrite, 0, bytesToWrite.Length);
}
finally
{
if (fs != null)
fs.Dispose();
}

Finalize() 和 Dispose() 之间的区别?

都是 .NET 中提供释放非托管资源的方式,他们的主要区别在于执行者和执行时间不同:

  • finalize 由垃圾回收器调用;dispose 由对象调用。
  • finalize 无需担心因为没有调用 finalize 而使非托管资源得不到释放,而 dispose 必须手动调用。
  • finalize 不能保证立即释放非托管资源,Finalizer 被执行的时间是在对象不再被引用后的某个不确定的时间;而 dispose 一调用便释放非托管资源。
  • 只有 class 类型才能重写 finalize ,而结构不能;类和结构都能实现 IDispose 。
  • 终结器会导致对象复活一次,也就说会被 GC 回收两次才最终完成回收工作,这也是不建议开发人员使用终结器的主要原因。

21.3.4 终结的内部工作原理

  • 当 CLR 在托管堆上分配对象时,GC 检查该对象是否实现了自定义的 Finalize 方法(析构函数)。如果是,对象会被标记为可终结的,同时这个对象的指针被保存在名为终结队列的内部队列中。终结队列是一个由垃圾回收器维护的表,它指向每一个在从堆上删除之前必须被终结的对象。
  • 当 GC 执行并且检测到一个不被使用的对象时,需要进一步检查 “终结队列” 来查询该对象类型是否含有 Finalize 方法,如果没有则将该对象视为垃圾,如果存在则将该对象的引用移动到另外一张 Freachable 列表,此时对象会被复活一次。
  • CLR 将有一个单独的高优先级线程负责处理 Freachable 列表,就是依次调用其中每个对象的 Finalize 方法,然后删除引用,这时对象实例就被视为不再被使用,对象再次变成垃圾。
  • 下一个 GC 执行时,将释放已经被调用 Finalize 方法的那些对象实例。

注意,可终结对象需要执行两次垃圾回收才能释放它们占用的内存。