首页 > 编程笔记 > C#笔记

C#/.NET引用类型的内存分配

引用类型(reference type)内存分配的复杂程度远高于值类型。

引用类型的内存分配永远是两部分:一个引用它的对象,加上堆上的一个对象,如下图所示。

引用类型的内存布局简图

引用类型对象包括方法表指针和同步块索引(值类型则没有这两样东西),方法表指针指向该引用类型自己的类型对象。

引用类型的默认值为 null。将某个引用类型设置为 null,实际上是将它与某个堆上的对象之间的关联切断。此时,该引用类型变量将不指向任何堆上的对象。

如果 GC 堆上的某个对象不被任何其他对象关联(即没有任何栈上的对象地址是它的所在地址),则它成为垃圾,等待垃圾回收器进行回收。

概括地说,GC 堆上的一个对象可以被如下的对象引用:

【实例】我们创建一个新的工程 TypeFundamentalLab,采用下面的类型作为示例类型,在主程序中写如下的代码:
class Program
{
    static void Main(string[] args)
    {
        var a = new ExampleRef();
        Console.WriteLine("调用前");
        Console.ReadKey();
        Console.WriteLine("调用后");
        a.NormalMethod();
        Console.ReadKey();
    }
}

class testRef
{
    public byte e = 1;
    public string e2 = "test";
    public byte e3;
    public int e4;
    public byte e5;
    public int e6;
    public byte e7;
    public int e8;
}

class ExampleRef : testRef
{
    private int a = 1;
    public string b = "test";
    private static string c = "static";
}
引用类型在申请内存时,需要计算它本身所需要的内存以及它的父类成员需要的内存,一直算到 System.Object (不过它没有成员,所以一般没指定父类的引用类型计算内存就只需要算它自己就够了,因为对于没指定父类的引用类型来说,其父类为 System.Object)。

在进行计算时,不考虑方法,只考虑字段和嵌套类型。而且,需要加上方法表指针和同步块索引这两项,在 32 位机器上它们各占 4 个字节,64 位机器上它们各占 8 个字节。

那么,如果请求 CLR 建立一个 ExampleRef 实例,该实例在堆上的部分需要多少字节?在栈上的部分需要多少字节?我们以 32 位机为例进行讨论。

首先,对内存分配从同步块索引开始,它占据 4 个字节。

栈上的引用将指向同步块索引后边的部分(称为偏移量),也就是说,同步块索引的地盘是从 -4 字节到 0。

然后,方法表指针(又名类型对象指针)上场,占据 4 个字节。

这 8 个字节是每个引用类型都一定会有的,没有办法直接操作它们(这会破坏类型安全性),无论是 C# 还是 IL。

下面,就轮到类型的实例字段(静态字段在类型对象中)。32 位机上,任何对象占据的字节数都必须是 4 的倍数。

所以,即使一个引用类型仅有一个 byte 类型的字段,它也占据 12 字节(实际占据9 字节,3 字节被浪费),而下一个引用类型不能从第 10 字节,而必须从第 13 字节开始分配内存,这称为内存的对齐(alignment)。

而在 64 位机上,任何对象占据的字节数都必须是 8 的倍数,所以,仅有一个 byte 类型的字段的引用类型占据 24 字节(实际占据 17 字节,7 字节被浪费)。

默认情况下,CLR 会智能地将可以合并到 4/8 字节的对象尽量合并到一起,以免内存空间浪费,除非你显式地阻止它。例如,64 位机器上两个 int,四个 short,8 个 byte 可以合并到一起。

字段的对齐

类型字段最终被创建的顺序不一定就是它在代码中的顺序,这是因为 CLR 会选择一个较好的方式排列这些字段,尽量消除对齐带来的负面影响。

例如,上一实例中我们的类型含有父类型 testRef,在定义中,我们故意隔着声明 byte 类型成员,如果 CLR 按照我们的定义顺序来建立对象,那么考虑到对齐,需要 32 个字节(其中,每个 byte 字节后面的 3 字节都被浪费)。

但实际上,CLR 会将四个 byte 放在最后:
MT           Field      Offset              Type  VT       Attr   Value Name
6fc53234   4000001          14    System.Byte      1   instance            e
6fc51d64   4000002          4     System.String    0   instance            e2
6fc53234   4000003          15    System.Byte      1   instance            e3
6fc53c04   4000004          8     System.Int32     1   instance            e4
6fc53234   4000005          16    System.Byte      1   instance            e5
6fc53c04   4000006          c     System.Int32     1   instance            e6
6fc53234   4000007          17    System.Byte      1   instance            e7
6fc53c04   4000008          10    System.Int32     1   instance            e8
这是通过 WinDbg 获得的资料,通过上表中的 Offset(偏移量)可以看到,第一个字段实际上是 e2,它的偏移量为 4 因为它前面是方法表指针,然后是 e4、e6 和 e8 (这里的偏移量是 16 进制所以 c = 12, 10 =16 )。

然后才出现 e、e3、e5 和 e7。八个字段仅仅会占据 20 字节,这也是最省空间的布局方式。

那么,示例类型的父类型就讨论完了,它占据 20 字节。示例类型本身含有两个实例字段 a 和 b。

对于 int 类型,它占据 4 个字节,而对于字符串,它是一个引用类型,所以,这里只会有一个引用,引用是一个地址,地址的大小和计算机的位有关,例如 32 位机的地址长度为 4 个字节。

静态字段 c 不在实例对象中,而在类型对象中。因此,整个堆上的部分需要 36 个字节。

在栈上的部分则只是一个引用地址,32 位机的地址长度为 4 个字节。

整个内存布局如下:

当然,我们的 ExampleRef 类型含有一个字符串,因此,初始化字符串时,还需要在堆上建立字符串这个 class 的一个新的实例,值为 test。

它占据的空间大小为 14 (64 位机器则为 26)+ 字符串本身的长度 * 2。

因此,ExampleRef 类型的字符串 b 拥有初始值 test,它的大小应为 14+4*2=22。不过,通常我们不把这部分空间(字符串字面量,string literals)算成 ExampleRef 实例的一部分。

当完成内存大小计算之后,0 代 GC 堆的 NextObjPtr 指针后移 36 个字节。

然后,调用类型的构造函数,这会造成字符串的初始化,又需要堆上 22 (实际为 24)字节的空间。

最后,返回方法表指针的地址给栈上的 ExampleRef 类型的对象。

如果为 64 位机器,上面所有的 4 字节都要改为 8 字节,因此 64 位机器的 ExampleRef 类型的一个实例会占据 48 字节 (36 字节 + 同步块索引,方法表指针额外的 8 字节 +string 地址额外的 4 字节)。

同步块索引

同步块索引(synchronization block index)是类的标准配置,它位于类在堆上定义的开头 -4(或 -8)至 0 字节。

在程序运行时,CLR 管理一个同步块数组。它是一个总共 32/64 位的多功能结构,其中,前 6 位的值提示访问者目前同步块索引的功能是什么,高 6 位就像 6 个开关,有的打开(1),有的关闭(0),不同位的打开和关闭有着不同的意义。

它的用处非常广泛,例如线程同步和 GC 都会用到它,它还会储存对象的哈希码。

同步块索引在线程同步中用来判断对象是被使用还是闲置。

默认的情况是,同步块索引被赋予一个特殊的值,此时对象没有被线程独占。当一个线程拿到对象,并打算对其操作时,它会检查对象的同步块索引。

如果索引的值为特殊值,说明没有任何线程正在操作它,此时这个线程获得它的操作权。

同时在 CLR 的同步块数组中分配一个新的同步块,并将该块的索引值写入实例的同步块索引值中。

这时,如果有其他线程来访问该实例,它就不能操作这个实例了,因为它的同步块索引的值不为特殊值。

当独占的线程操作完之后,同步块索引的值被重设回特殊值。

方法表指针和类型对象

方法表指针(method table pointer)又叫类型对象指针(TypcHandle)。

类型对象由 CLR 在加载堆中创建,创建时机为加载该程序集时。类型对象最重要的成员为类型的静态字段和方法表,创建完之后就不会改变,通过这个事实,可以验证静态字段的全局性。

因为类型对象存储了静态字段和方法表,它们被所有的该类型实例共享。

因此为了做到这点,需要满足如下条件:
静态字段很好理解,方法表就是类型所有的方法,包括静态方法和实例方法。方法会在初次执行时,经由 JIT 编译为机器码,并将机器码存在内存之中,获得一个入口地址。

此时,方法表中的该方法指向一个 jmp 指令,使得其可以跳跃到该入口地址。在下次调用该 方法时,直接跳到入口地址,无需再次编译。

类型对象是反射的重要操作对象。System.Type 类会返回类型对象(包括静态成员和方法表)。获得类型对象之后,就可以得到该对象所有的信息。

注意,类型对象也有类型对象指针,这是因为类型对象本质上也是对象。所有类型对象的“类型对象指针”都指向 System.Type 类型对象。

值得提岀的是,System.Type 类型对象本身也是一个对象,内部的“类型对象指针”指向它自己。

例如,验证类型所有的实例都指向同一个类型对象:
var a = new AStruct();
var b = new AStruct();
Console.WriteLine(ReferenceEquals(a, b) ) ; //False
Console.WriteLine(ReferenceEquals(a.GetType(), b.GetType())); //True
类型对象指针在 32 位机器上占用 4 字节,64 位机器则为 8 字节。类型对象在所处的应用程序域加载时创建,并在应用程序域被卸载时才会被跟着销毁。

如果希望在应用程序域被卸载时执行一些代码,可以向 System.AppDomain.DomainUnload 事件登记一个回调方法。

静态字段和属性

类型的静态字段和静态属性的支持字段(例如 int)存储在类型对象(加载堆)中。

JIT 会在进行编译时找到这些静态成员的地址,并在之后的编译时硬编码它们,然后写在机器码中。

这样,再次访问静态成员时就不需要通过类型对象了。如果你还不知道属性是什么,这里可以简单地理解为属性等于一个支持字段加两个方法,用来获得和写入属性的值。

程序中所有类型的静态成员组成一个全局的数组,它包括每一个类型中的基元类型静态成员的内存地址。

数组的地址会被钉死 (pinned),使得它不会被 GC 回收掉(除非卸载应用程序域),这样一来,机器码中的硬编码将一直有意义,直到程序终止。

所有教程

优秀文章