.NET对象的创建、垃圾回收、非托管资源的手动处理

.NET对象的创建、垃圾回收、非托管资源的手动处理

本篇用来梳理对象的创建、垃圾的回收,以及非托管资源的手动处理。


→首先运行应用程序,创建一个Windows进程。

 

→CLR创建一块连续的虚拟地址空间,这个地址空间就是托管堆。而且,这个地址空间最初并没有对应的物理存储空间。

 

虚拟地址空间分成2段。一个区段是普通堆,也叫GC堆,大小小于85000字节的引用类型对象的实例被分配在这里;另一个是大对象堆,大小大于等于85000字节的引用类型对象的实例被分配在这里。

 

对于客户端应用程序,每个区段的大小大致是16MB;对于服务端应用程序,每个区段的大小大致是64MB。另外,每个区段的大小还会因CPU的数量,是否是32位或64位操作系统而各异。

 

随着每个区段被装满对象,CLR会分配更多的区段,直到整个进程空间都满了为止,每个进程可使用4GB的内存。

 

托管堆上维护着一个指针NextObjPtr,指向下一个对象在托管堆上分配的位置。

 

托管堆根据存储信息的不同分为垃圾回收堆GC Heap和加载堆Loader Heap。垃圾回收堆GC Heap存储对象实例,受GC管理;加载堆Loader Heap存储AppDomain中的元数据信息,例如基类型、静态字段、静态方法、接口信息等,不受GC管理,它的生命周期从创建AppDomain开始到卸载AppDomain结束。此时,托管堆以及其它方面大致是这样分布的:

 

.NET对象的创建、垃圾回收、非托管资源的手动处理

→在托管堆上创建对象

 

□ 引用类型对象创建

 

使用new关键字创建引用类型对象。

FileStream fs = new FileStream(@"",FileMode.Open);

 

以上代码经编译器编译,在生成的中间IL代码中实际上是一个newobj指令。IL相关的指令还包括:ldstr指令用于创建string类型对象,newarr用于分配新的数组对象,box指令用于在值类型转换为引用类型时,将值类型字段拷贝到托管堆上。

 

通过以下代码来体会托管堆上创建对象的大致过程:
 

 

public class Employee
{
    private int _id;
    private Status _status;
    public Employee()
    {
        _id = 1;
        _status = new Status();
    }
}
public class Sales : Employee
{
    public bool _isLive;
    public bool IsLive()
    {
        return _isLive;
    }
    public static void Main()
    {
        Sales _sales;
        _sales = new Sales();
        _sales._isLive = true;
        Console.WriteLine(_sales.IsLive());
    }
}
public class Status
{
    private int _years;
    private char _level = "A";
}

 

1、执行Sales _sales,在线程堆栈上开辟4byte的内存空间,用于保存Sales对象的托管堆地址,此时为null。


2、_sales = new Sales(),递归计算Sales实例对象的字节总数,递归从Sales本身开始,到父类Employee,一直到基类System.object,具体字节计算过程如下:

 

Sales对象实例字节数=字段_isLive的字节数为1byte
Sales对象实例的附加成员TypeHandler字节数=4byte
Sales对象实例的附加成员SyncBlockIndex字节数=4byte
Sales对象实例的父类Employee字节数=字段_id的字节数4byte + 字段_status保存指向Status对象实例的引用为4byte

1+4+4+4+4=17byte,考虑到内存块总是按照4byte进行内存对齐,因此补齐到20byte。

 

由于在Sales的父类Employee的构造函数中,实例化了一个Status类,所以还需要计算Status对象实例的字节数:
Status对象实例字节数=字段_years的字节数4byte + 字段_level的字节数2byte
Status对象实例的附加成员TypeHandler字节数=4byte
Status对象实例的附加成员SyncBlockIndex字节数=4byte
4+1+4+4=13byte,考虑到内存块总是按照4byte进行内存对齐,因此补齐到16byte。

 

综上,创建Sales对象实例所需总字节数为36。

 

3、在托管堆上向高地址扩展出36byte的连续空间,并为其分配内存地址。

 

4、进行Sales对象实例的初始化工作。

 

a:构造Sales类型的Type对象,并分配到托管堆中的Loader Heap上。
b:初始化Sales对象实例的2个附加成员:TypeHandler和SyncBlockIndex。TypeHandler指向Load Heap上的Method Table,SyncBlockIndex指向Synchronization Block内存块,用于在多线程环境下对实例对象的同步操作。
c:实例字段的初始化,先初始化System.Object的实例字段,然后是Employee,最后是Sales的实例字段。

 

5、将Sales实例对象的托管堆地址赋值给线程栈变量_sales。


□ 值类型对象创建

 

对于值类型(不包括类中值类型实例成员),比如struct,通过new关键字创建,实际上是在线程栈上创建了对象,线程堆栈不受CLR的垃圾回收控制,而是由操作系统直接管理,当值类型实例所在方法结束时,其存储单位被自动释放。在线程栈上,操作系统维护者一个堆栈指针,指向下一个*空间的地址。

 

线程堆栈的内存地址是由高位向低位填充,也就是入栈时由高地址向低地址扩展,出栈时由低地址向高地址删除内存。通过以下代码来走一遍整个过程:

public void SaySth()
{
    int a = 1;
    char b = 'x';
}

1、方法执行之前,线程堆栈指针指向一个高位,比如100
2、开始执行SaySth方法,SaySth方法的返回地址首先入栈,线程堆栈指针指向一个较低位置,比如99
3、执行int a = 1,在线程堆栈上分配4byte的内存空间,将值1保存在该地址空间内,线程堆栈指针向下移动4个字节指向一个较低位置,比如98
4、执行char b = 'x',在线程堆栈上分配2byte的内存空间,将值"x"保存在该地址空间内,线程堆栈指针向下移动4个字节指向一个较低位置,比如97
5、方法执行结束,依次上次b和a的内存,线程堆栈指针回到返回地址,即99

 

→垃圾回收

 

在托管堆上,设计了一个叫作"GC roots",这些内存空间总是可到达的,存储着对象实例的引用,"GC roots"中的这些对象被标记为"live",所有被"GC root"引用的对象实例也被标记为"live",GC会按照这种方式一直循环遍历下去,直到某个对象实例没有引用对象实例。这就好像一颗"节点树",从根节点开始遍历,一直到没有子节点的节点循环遍历才结束。

 

在垃圾回收的时候,所有没有被标记为"live"的对象实例都会回收,然后托管堆的内存空间会重新压缩,托管堆低位的内存空间准备迎接下一批实例对象。

 

"GC root"有不同的类型:
● 当前方法内的局部变量被视作"GC root"
● 静态变量也被视作"GC root"
● 如果一个托管对象实例需要被传递给COM+库,也会被视作"GC root"
● 如果一个对象有析构器,在垃圾回收的时候是不会被回收的,只有调用finalizer的时候才对该对象进行回收,也就是说,带有析构器的对象也被视作"GC root"

 

为了提高垃圾回收的效率,GC引入了一个"代龄"策略,将托管堆中的对象分为三代,分别为0,1和2。而且会为不同的代龄设置不同的阙值容量,第0代大约256KB,第1代大约2MB,第2代大约10MB。被新建的对象起初都是第0代,GC也总是回收第0代没有被标记为"live"的对象实例。具体运作过程如下:

 

1、CLR初始化时,所有被添加到托管堆中的对象为第0代。
2、当有垃圾回收时,未被回收的对象代龄提升1级,变为第1代。
3、第1代对象如果没有达到阙值容量,就不会被回收,这样有效地提高了垃圾回收的效率。
4、仅当第0代阙值容量不足以创建新的对象,同时第1代对象实例的大小超出了第1代阙值容量,GC会同时回收第0代和第1代的对象,还未被回收的第1代对象升级为2代对象,还未被回收的第0代对象升级为1代对象。
5、如此反复。

 

→非托管资源的清理

对于一些托管资源,CLR的垃圾回收器能帮我们自动清理内存,而对于一些非托管资源,比如数据库连接、文件句柄、COM对象等,仍然需要开发者手动清理。具体请参考这里