引用类型(reference type)内存分配的复杂程度远高于值类型。@H_
404_0@@H_
404_0@
引用类型的内存分配永远是两部分:一个引用它的对象,
加上堆上的一个对象,如下图所示。@H_
404_0@@H_
404_0@
@H_
404_0@
引用类型对象
包括方法表指针和同步块索引(值类型则没有这两样东西),
方法表指针指向该引用类型自己的类型对象。@H_
404_0@@H_
404_0@
引用类型的默认值为 null。将某个引用类型设置为 null,实际上是将它与某个堆上的对象之间的关联切断。此时,该引用类型变量将不指向任何堆上的对象。@H_
404_0@@H_
404_0@
如果 GC 堆上的某个对象不被任何其他对象关联(即没有任何栈上的对象地址是它的所在地址),则它成为
垃圾,等待
垃圾回收器进行回收。@H_
404_0@@H_
404_0@
概括地说,GC 堆上的一个对象可以被如下的对象引用:@H_
404_0@
-
栈上的一个变量(最常见的情况)。
-
P/Invoke 情形下的句柄表。
-
Finalizer queue,即终结队列。
-
寄存器。
@H_
404_0@
【实例】我们创建一个新的工程 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)。@H_
404_0@@H_
404_0@
在进行计算时,不考虑
方法,只考虑字段和嵌套类型。而且,需要
加上方法表指针和同步块索引这两项,在 32 位机器上它们各占 4 个字节,64 位机器上它们各占 8 个字节。@H_
404_0@@H_
404_0@
那么,如果请求 CLR 建立一个 ExampleRef 实例,该实例在堆上的部分需要多少字节?在栈上的部分需要多少字节?我们以 32 位机为例进行讨论。@H_
404_0@@H_
404_0@
首先,对内存分配从同步块索引开始,它占据 4 个字节。@H_
404_0@@H_
404_0@
栈上的引用将指向同步块索引后边的部分(称为偏移量),也就是说,同步块索引的地盘是从 -4 字节到 0。@H_
404_0@@H_
404_0@
然后,
方法表指针(又名类型对象指针)上场,占据 4 个字节。@H_
404_0@@H_
404_0@
这 8 个字节是每个引用类型都一定会有的,没有办法直接操作它们(这会破坏类型安全性),无论是
C# 还是 IL。@H_
404_0@@H_
404_0@
下面,就轮到类型的实例字段(静态字段在类型对象中)。32 位机上,任何对象占据的字节数都必须是 4 的倍数。@H_
404_0@@H_
404_0@
所以,即使一个引用类型仅有一个 byte 类型的字段,它也占据 12 字节(实际占据9 字节,3 字节被浪费),而下一个引用类型不能从第 10 字节,而必须从第 13 字节开始分配内存,这称为内存的对齐(alignment)。@H_
404_0@@H_
404_0@
而在 64 位机上,任何对象占据的字节数都必须是 8 的倍数,所以,仅有一个 byte 类型的字段的引用类型占据 24 字节(实际占据 17 字节,7 字节被浪费)。@H_
404_0@@H_
404_0@
默认情况下,CLR 会智能地将可以合并到 4/8 字节的对象尽量合并到一起,以免内存空间浪费,除非你显式地阻止它。例如,64 位机器上两个 int,四个 short,8 个 byte 可以合并到一起。
字段的对齐
类型字段最终被创建的顺序不一定就是它在
代码中的顺序,这是因为 CLR 会选择一个较好的方式排列这些字段,尽量消除对齐带来的负面影响。@H_
404_0@@H_
404_0@
例如,上一实例中我们的类型含有
父类型 testRef,在定义中,我们故意隔着声明 byte 类型成员,如果 CLR 按照我们的定义顺序来建立对象,那么考虑到对齐,需要 32 个字节(其中,每个 byte 字节后面的 3 字节都被浪费)。@H_
404_0@@H_
404_0@
但实际上,CLR 会将四个 byte 放在最后:@H_
404_0@
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 )。@H_
404_0@@H_
404_0@
然后才出现 e、e3、e5 和 e7。八个字段仅仅会占据 20 字节,这也是最省空间的布局方式。@H_
404_0@@H_
404_0@
那么,示例类型的
父类型就讨论完了,它占据 20 字节。示例类型本身含有两个实例字段 a 和 b。@H_
404_0@@H_
404_0@
对于 int 类型,它占据 4 个字节,而对于字符串,它是一个引用类型,所以,这里只会有一个引用,引用是一个地址,地址的大小和计算机的位有关,例如 32 位机的地址长度为 4 个字节。@H_
404_0@@H_
404_0@
静态字段 c 不在实例对象中,而在类型对象中。因此,整个堆上的部分需要 36 个字节。@H_
404_0@@H_
404_0@
在栈上的部分则只是一个引用地址,32 位机的地址长度为 4 个字节。@H_
404_0@@H_
404_0@
整个内存布局如下:@H_
404_0@
-
同步块索引。
-
方法表指针(指向方法表,它位于类型对象中,而类型对象一般位于同一个应用程序域的加载堆中)。
-
类型所有父对象的实例成员(静态成员存储在类型的类型对象中),其中,所有引用类型成员都分配 4 字节,因为只需要分配地址。分配顺序不定,CLR 会尽量消除对齐带来 的负面影响。
-
类型自己的实例成员(静态成员存储在类型的类型对象中),引用类型成员分配同上。
@H_
404_0@
当然,我们的 ExampleRef 类型含有一个字符串,因此,初始化字符串时,还需要在堆上建立字符串这个 class 的一个新的实例,值为 test。@H_
404_0@@H_
404_0@
它占据的空间大小为 14 (64 位机器则为 26)+ 字符串本身的长度 * 2。@H_
404_0@@H_
404_0@
因此,ExampleRef 类型的字符串 b 拥有初始值 test,它的大小应为 14+4*2=22。不过,通常我们不把这部分空间(字符串字面量,string literals)算成 ExampleRef 实例的一部分。@H_
404_0@@H_
404_0@
当完成内存大小计算之后,0 代 GC 堆的 NextObjPtr 指针后移 36 个字节。@H_
404_0@@H_
404_0@
然后,
调用类型的构造
函数,这会造成字符串的初始化,又需要堆上 22 (实际为 24)字节的空间。@H_
404_0@@H_
404_0@
最后,返回
方法表指针的地址给栈上的 ExampleRef 类型的对象。@H_
404_0@@H_
404_0@
如果为 64 位机器,上面所有的 4 字节都要改为 8 字节,因此 64 位机器的 ExampleRef 类型的一个实例会占据 48 字节 (36 字节 + 同步块索引,
方法表指针额外的 8 字节 +string 地址额外的 4 字节)。@H_
404_0@
同步块索引
同步块索引(synchronization block index)是类的标准配置,它位于类在堆上定义的开头 -4(或 -8)至 0 字节。@H_
404_0@@H_
404_0@
在程序运行时,CLR 管理一个同步块数组。它是一个总共 32/64 位的多
功能结构,其中,前 6 位的值
提示访问者目前同步块索引的
功能是什么,高 6 位就像 6 个开关,有的打开(1),有的
关闭(0),不同位的打开和
关闭有着不同的意义。@H_
404_0@@H_
404_0@
它的用处非常广泛,例如线程同步和 GC 都会用到它,它还会储存对象的哈希码。@H_
404_0@@H_
404_0@
同步块索引在线程同步中用来判断对象是被使用还是闲置。@H_
404_0@@H_
404_0@
默认的情况是,同步块索引被赋予一个特殊的值,此时对象没有被线程独占。当一个线程拿到对象,并打算对其操作时,它会检查对象的同步块索引。@H_
404_0@@H_
404_0@
如果索引的值为特殊值,说明没有任何线程正在操作它,此时这个线程获得它的操作权。@H_
404_0@@H_
404_0@
同时在 CLR 的同步块数组中分配一个新的同步块,并将该块的索引值写入实例的同步块索引值中。@H_
404_0@@H_
404_0@
这时,如果有其他线程来访问该实例,它就不能操作这个实例了,因为它的同步块索引的值不为特殊值。@H_
404_0@@H_
404_0@
当独占的线程操作完之后,同步块索引的值被重设回特殊值。@H_
404_0@
方法表指针和类型对象
方法表指针(method table pointer)又叫类型对象指针(TypcHandle)。@H_
404_0@@H_
404_0@
类型对象由 CLR 在加载堆中创建,创建时机为加载该程序集时。类型对象最重要的成员为类型的静态字段和
方法表,创建完之后就不会改变,通过这个事实,可以验证静态字段的全局性。@H_
404_0@@H_
404_0@
因为类型对象存储了静态字段和
方法表,它们被所有的该类型实例共享。@H_
404_0@@H_
404_0@
因此为了做到这点,需要满足如下条件:
-
一个类型无论有多少个实例,这些实例在堆中的内存的类型对象指针都指向同一个类型对象
-
类型对象的位置在不受 GC 控制的加载堆中。即使没有任何实例类型指向它,它也不会被回收。如果它被回收,下次实例类型的创建会伴随类型对象的创建,而这是没有必要的。
@H_
404_0@
静态字段很好理解,
方法表就是类型所有的
方法,
包括静态
方法和实例
方法。
方法会在初次执行时,经由 JIT 编译为机器码,并将机器码存在内存之中,获得一个入口地址。@H_
404_0@@H_
404_0@
此时,
方法表中的该
方法指向一个 jmp 指令,使得其可以跳跃到该入口地址。在下次
调用该
方法时,直接跳到入口地址,无需再次编译。@H_
404_0@@H_
404_0@
类型对象是反射的重要操作对象。System.Type 类会返回类型对象(
包括静态成员和
方法表)。获得类型对象之后,就可以得到该对象所有的信息。@H_
404_0@@H_
404_0@
注意,类型对象也有类型对象指针,这是因为类型对象本质上也是对象。所有类型对象的“类型对象指针”都指向 System.Type 类型对象。@H_
404_0@@H_
404_0@
值得提岀的是,System.Type 类型对象本身也是一个对象,内部的“类型对象指针”指向它自己。@H_
404_0@@H_
404_0@
例如,验证类型所有的实例都指向同一个类型对象:@H_
404_0@
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 字节。类型对象在所处的应用程序域加载时创建,并在应用程序域被卸载时才会被跟着销毁。@H_
404_0@@H_
404_0@
如果希望在应用程序域被卸载时执行一些
代码,可以向 System.AppDomain.DomainUnload 事件登记一个回调
方法。@H_
404_0@
静态字段和属性
类型的静态字段和静态
属性的
支持字段(例如 int)存储在类型对象(加载堆)中。@H_
404_0@@H_
404_0@
JIT 会在进行编译时找到这些静态成员的地址,并在之后的编译时硬编码它们,然后写在机器码中。@H_
404_0@@H_
404_0@
这样,再次访问静态成员时就不需要通过类型对象了。如果你还不知道
属性是什么,这里可以简单地理解为
属性等于一个
支持字段加两个
方法,用来获得和写入
属性的值。@H_
404_0@@H_
404_0@
程序中所有类型的静态成员组成一个全局的数组,它
包括每一个类型中的基元类型静态成员的内存地址。@H_
404_0@@H_
404_0@
数组的地址会被钉死 (pinned),使得它不会被 GC 回收掉(除非卸载应用程序域),这样一来,机器码中的硬编码将一直有意义,直到程序终止。