C#/.NET引用类型的内存分配
引用类型(reference type)内存分配的复杂程度远高于值类型。
引用类型的内存分配永远是两部分:一个引用它的对象,加上堆上的一个对象,如下图所示。
引用类型对象包括方法表指针和同步块索引(值类型则没有这两样东西),方法表指针指向该引用类型自己的类型对象。
引用类型的默认值为 null。将某个引用类型设置为 null,实际上是将它与某个堆上的对象之间的关联切断。此时,该引用类型变量将不指向任何堆上的对象。
如果 GC 堆上的某个对象不被任何其他对象关联(即没有任何栈上的对象地址是它的所在地址),则它成为垃圾,等待垃圾回收器进行回收。
概括地说,GC 堆上的一个对象可以被如下的对象引用:
【实例】我们创建一个新的工程 TypeFundamentalLab,采用下面的类型作为示例类型,在主程序中写如下的代码:
在进行计算时,不考虑方法,只考虑字段和嵌套类型。而且,需要加上方法表指针和同步块索引这两项,在 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 可以合并到一起。
例如,上一实例中我们的类型含有父类型 testRef,在定义中,我们故意隔着声明 byte 类型成员,如果 CLR 按照我们的定义顺序来建立对象,那么考虑到对齐,需要 32 个字节(其中,每个 byte 字节后面的 3 字节都被浪费)。
但实际上,CLR 会将四个 byte 放在最后:
然后才出现 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 字节)。
在程序运行时,CLR 管理一个同步块数组。它是一个总共 32/64 位的多功能结构,其中,前 6 位的值提示访问者目前同步块索引的功能是什么,高 6 位就像 6 个开关,有的打开(1),有的关闭(0),不同位的打开和关闭有着不同的意义。
它的用处非常广泛,例如线程同步和 GC 都会用到它,它还会储存对象的哈希码。
同步块索引在线程同步中用来判断对象是被使用还是闲置。
默认的情况是,同步块索引被赋予一个特殊的值,此时对象没有被线程独占。当一个线程拿到对象,并打算对其操作时,它会检查对象的同步块索引。
如果索引的值为特殊值,说明没有任何线程正在操作它,此时这个线程获得它的操作权。
同时在 CLR 的同步块数组中分配一个新的同步块,并将该块的索引值写入实例的同步块索引值中。
这时,如果有其他线程来访问该实例,它就不能操作这个实例了,因为它的同步块索引的值不为特殊值。
当独占的线程操作完之后,同步块索引的值被重设回特殊值。
类型对象由 CLR 在加载堆中创建,创建时机为加载该程序集时。类型对象最重要的成员为类型的静态字段和方法表,创建完之后就不会改变,通过这个事实,可以验证静态字段的全局性。
因为类型对象存储了静态字段和方法表,它们被所有的该类型实例共享。
因此为了做到这点,需要满足如下条件:
静态字段很好理解,方法表就是类型所有的方法,包括静态方法和实例方法。方法会在初次执行时,经由 JIT 编译为机器码,并将机器码存在内存之中,获得一个入口地址。
此时,方法表中的该方法指向一个 jmp 指令,使得其可以跳跃到该入口地址。在下次调用该 方法时,直接跳到入口地址,无需再次编译。
类型对象是反射的重要操作对象。System.Type 类会返回类型对象(包括静态成员和方法表)。获得类型对象之后,就可以得到该对象所有的信息。
注意,类型对象也有类型对象指针,这是因为类型对象本质上也是对象。所有类型对象的“类型对象指针”都指向 System.Type 类型对象。
值得提岀的是,System.Type 类型对象本身也是一个对象,内部的“类型对象指针”指向它自己。
例如,验证类型所有的实例都指向同一个类型对象:
如果希望在应用程序域被卸载时执行一些代码,可以向 System.AppDomain.DomainUnload 事件登记一个回调方法。
JIT 会在进行编译时找到这些静态成员的地址,并在之后的编译时硬编码它们,然后写在机器码中。
这样,再次访问静态成员时就不需要通过类型对象了。如果你还不知道属性是什么,这里可以简单地理解为属性等于一个支持字段加两个方法,用来获得和写入属性的值。
程序中所有类型的静态成员组成一个全局的数组,它包括每一个类型中的基元类型静态成员的内存地址。
数组的地址会被钉死 (pinned),使得它不会被 GC 回收掉(除非卸载应用程序域),这样一来,机器码中的硬编码将一直有意义,直到程序终止。
引用类型的内存分配永远是两部分:一个引用它的对象,加上堆上的一个对象,如下图所示。
引用类型对象包括方法表指针和同步块索引(值类型则没有这两样东西),方法表指针指向该引用类型自己的类型对象。
引用类型的默认值为 null。将某个引用类型设置为 null,实际上是将它与某个堆上的对象之间的关联切断。此时,该引用类型变量将不指向任何堆上的对象。
如果 GC 堆上的某个对象不被任何其他对象关联(即没有任何栈上的对象地址是它的所在地址),则它成为垃圾,等待垃圾回收器进行回收。
概括地说,GC 堆上的一个对象可以被如下的对象引用:
- 栈上的一个变量(最常见的情况)。
- P/Invoke 情形下的句柄表。
- Finalizer queue,即终结队列。
- 寄存器。
【实例】我们创建一个新的工程 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 个字节。
整个内存布局如下:
- 同步块索引。
- 方法表指针(指向方法表,它位于类型对象中,而类型对象一般位于同一个应用程序域的加载堆中)。
- 类型所有父对象的实例成员(静态成员存储在类型的类型对象中),其中,所有引用类型成员都分配 4 字节,因为只需要分配地址。分配顺序不定,CLR 会尽量消除对齐带来 的负面影响。
- 类型自己的实例成员(静态成员存储在类型的类型对象中),引用类型成员分配同上。
当然,我们的 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 在加载堆中创建,创建时机为加载该程序集时。类型对象最重要的成员为类型的静态字段和方法表,创建完之后就不会改变,通过这个事实,可以验证静态字段的全局性。
因为类型对象存储了静态字段和方法表,它们被所有的该类型实例共享。
因此为了做到这点,需要满足如下条件:
- 一个类型无论有多少个实例,这些实例在堆中的内存的类型对象指针都指向同一个类型对象
- 类型对象的位置在不受 GC 控制的加载堆中。即使没有任何实例类型指向它,它也不会被回收。如果它被回收,下次实例类型的创建会伴随类型对象的创建,而这是没有必要的。
静态字段很好理解,方法表就是类型所有的方法,包括静态方法和实例方法。方法会在初次执行时,经由 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 回收掉(除非卸载应用程序域),这样一来,机器码中的硬编码将一直有意义,直到程序终止。
所有教程
- C语言入门
- C语言编译器
- C语言项目案例
- 数据结构
- C++
- STL
- C++11
- socket
- GCC
- GDB
- Makefile
- OpenCV
- Qt教程
- Unity 3D
- UE4
- 游戏引擎
- Python
- Python并发编程
- TensorFlow
- Django
- NumPy
- Linux
- Shell
- Java教程
- 设计模式
- Java Swing
- Servlet
- JSP教程
- Struts2
- Maven
- Spring
- Spring MVC
- Spring Boot
- Spring Cloud
- Hibernate
- Mybatis
- MySQL教程
- MySQL函数
- NoSQL
- Redis
- MongoDB
- HBase
- Go语言
- C#
- MATLAB
- JavaScript
- Bootstrap
- HTML
- CSS教程
- PHP
- 汇编语言
- TCP/IP
- vi命令
- Android教程
- 区块链
- Docker
- 大数据
- 云计算