C# CLR via 对象内存中堆的存储【类型对象指针、同

最近在看书,看到了对象在内存中的存储方式。

写在前面

看《CLR via C#》第四章时,看到了类型对象指针和同步块索引这两个概念,不知如何解释,查看过相关资料之后,在此记录。

 

讲到了对象存储在内存堆中,分配的空间除了类型对象的成员所需的内存量,还有额外的成员(类型对象指针、 同步块索引 ),看到这个我就有点不懂了,不知道类型对象指针是什么,指向的什么?

类型对象指针

《CLR via C#》中的原话:

任何时候在堆上创建对象,CLR都自动初始化内部的“类型对象指针”成员来引用 与对象对应的类型对象。

在JIT编译器将IL代码转换成本机CPU指令的时候,利用程序集的元数据,CLR提取与代码中类型有关的信息,创建一些数据结构来表示类型本身。

CLR开始在一个进程中运行时,利用MSCorLib.dll中定义的System.Type类型创建一个特殊的类型对象,代码中的类型对象都是该类型的“实例”,因此,它们的类型对象指针成员会初始化成对的System.Type类型对象的引用

System.Object的GetType方法返回存储在指定对象的“类型对象指针”成员中的地址。也就是说,GetType方法返回指向对象的类型对象的指针。这样就可以判断系统中任何对象的真实类型。

一、引子

从网上找也没有找到,最后往下看,书中有些描述。说下我的理解:

同步块索引

先看如下图:

图片 1

首先,CLR创建类Manager,在内存中分配类Manager所占用的空间,当创建Manager的实例M1的时候,M1的类型对象指针就指向Manager。

当用lock来锁定M1的时候,M1的同步块索引就指向一个同步块(这里说明一下同步块,CLR负责创建同步块,可以把它理解为一个数组,数组中的每一个元素就是一个同步块)。

M1的同步块索引初始为一个负数,表示M1没有同步,当用lock的时候,CLR负责在同步块数组中寻找空闲的同步块,并把M1的同步块索引被设置为一个整数S,S为找到的同步块在同步块数组中的索引。

当lock结束之后,M1的同步块索引又被重新设置为负数。

图片 2

关于lock的一些有关概念,可以看我的上一篇博客。

  假如有一个Point2D类表达一个二维空间--点,每个坐标都是一个short类型,整个对象有4个字节。如果存储100万个点,会用多少字节的空间?答案是取决于Point2D是值类型还是引用类型,如果是引用类型,100万个点将会存储100万个引用,这些引用在32位操作系统上就是40M左右,但这些对象本身还要占最少同样的空间,事实上,每个Point2D将会占12个字节的空间,这样算下来总的内存数在160M。但如果是值类型,没有一个多余字节的浪费,就是整整40M,只有引用类型时的1/4,不同就在于值类型的内存密度。
  存储成引用类型还有一个缺点是如果想在这个巨型的堆对象引用数组(非连续存储)内游走要比值类型困难的多,因为值类型是连续存储的。

类型对象指针:指向类型对象存储的地址,假如有一个类型Person,它在堆中有一块区域存储它内部的字段和成员以及两个额外成员(类型对象指针、 同步块索引 ),类型对象的类型对象指针指向的是System.Type的地址。

写在最后

总结:

1、好好钻研《CLR via C#》这本书!很多有意思的知识,可以深入的理解C#的运行机制。

2、.NET真是太棒了。

  总之我们最好能清楚地知道CLR的内存布局以及值类型和引用类型的不同。

因为Person类型在内存中相对于System.Type也是作为一个对象存在的,System.Type类型也是一个类型对象,它的类型对象指针指向本身;

二、细节分析

实例化一个Person对象,Person p = new Person(); p对象在内存堆中也分配一块区域存储它内部的字段和成员以及两个额外成员(类型对象指针、 同步块索引 ),p的类型对象指针指向Person类型在堆中的地址。

  

 

图片 3

同步块索引:先说一下同步块,.NET团队在设计基本框架时充分考虑了线程同步的问题,其结果就是.NET为每一个堆内对象都提供了支持线程同步的功能,这就是同步机制的雏形【参考:】

上图是值类型与引用类型的Point2D数组在内存中的区别。
引用类型包括class,delegate,interface,arrays.string(System.String)。值类型包括enum和struct,int, float, decimal这些基本类型也是值类型。

但是对每个堆内对象都分配同步块有一个较大的弊端,就是这样增大了内存的消耗。在一般的系统中,需要同步机制支持的对象可能只占少数,这样对于大多数对象来说,一个同步块的内存消耗就完全被浪费了。鉴于这一点,.NET框架采用了一种折中的办法,就是实际只为每个堆内对象分配一个同步索引,该索引中只保存一个表明数组内索引的整数。.NET在加载时会新建一个同步块数组,当某个对象需要被同步时,.NET会为其分配一个同步块,并且把该同步块在同步块数组中的索引加入该对象的同步块索引中。

值类型和引用类型在语义上的区别:

同步块机制包含如下的几点:
· 在.NET被加载时初始化同步块数组。
· 每一个被分配在堆上的对象都会包含两个额外的字段,其中一个存储类型指针,而另外一个就是同步块索引,初始时被赋值为-1。
· 当一个线程试图使用该对象进入同步时,会检查该对象的同步索引。如果索引为负数,则会在同步块数组中寻找或者新建一个同步块,并且把同步块的索引值写入该对象的同步索引中。如果该对象的同步索引不为负值,则找到该对象的同步块并且检查是否有其他线程在使用该同步块,如果有则进入等待状态,如果没有则申明使用该同步块。

传递参数时:引用类型只传引用值,意思是当这个参数改变时,同时将改变传递给所有其他的引用。而值类型会拷贝一个复本传递过去,除非用ref或out声明,否则这个参数改变的不会影响到调用之外的值。
赋值时:引用类型只把引用值赋给目标,两个变量将引用同一个对象。而值类型会将所有内容赋给目标,两个变量将拥有同样的值但没有任何关系。
用==比较时:引用类型只比较引用值,如果两个变量引用的是同一个对象,则返回相同。而值类型比较内容,除非两个变量内的值完全相同才返回相同。

同步块是指.NET维护的同步块数组中的某个元素

存储,内存分配,内存回收:

 

引用类型从托管堆上分配,托管堆区域由.NET的GC控制。从托管堆上分配一个对象只涉及到一个增量指针,所以性能上的代价很小。如果在多核机器上,如果多个进程存取同一个堆,则需要同步,但是代价还是很小,要比非托管的malloc代价小多了。
GC回收内存的方式是不确定的,一次完全GC的代价很高,但平均算下来,还是比非托管的成本低。
注意:有一种引用类型可以从栈内分配,那就是基本类型的数组,比如int型数组,可以在unsafe上下文中用stackalloc关键字从栈内分配,或者用fixed关键字将一个大小固定的数组嵌入自定义的结构体。其实用fixed和stackalloc创建的对象不是真正的数组,和从中分配的标准数组的内存布局是不一样的。
单独的值类型一般从正在执行线程的栈中分配。值类型可以嵌在引用类型中,在这种情况下就是在堆上分配,或者也可以通过装箱,将自己的值转移到堆上。从栈上给值类型分配空间的代价是相当低的,只需要修改栈指针(ESP),而且能立即分配几个对象。回收栈内存也很快,反向修改栈指针(ESP)就行。

下面这个函数是典型的从托管方法编译成32位机器码的开场和收场,函数内有4个本地变量,这4个本地变量在开场时立即分配,收场时立即回收。

 

int Calculation(int a, int b)
{
int x = a   b;
int y = a - b;
int z = b - a;
int w = 2 * b   2 * a;
return x   y   z   w;
}

; parameters are passed on the stack in [esp 4] and [esp 8]
push ebp
mov ebp, esp
add esp, 16 ; allocates storage for four local variables
mov eax, dword ptr [ebp 8]
add eax, dword ptr [ebp 12]
mov dword ptr [ebp-4], eax
; ...similar manipulations for y, z, w
mov eax, dword ptr [ebp-4]
add eax, dword ptr [ebp-8]
add eax, dword ptr [ebp-12]
add eax, dword ptr [ebp-16] ; eax contains the return value
mov esp, ebp ; restores the stack frame, thus reclaiming the local storage space
pop ebp
ret 8 ; reclaims the storage for the two parameters

注意:C#中的new并不代表在堆中分配,其他托管语言也一样。因为也可以用new在栈上分配,比如一些struct。

栈和堆的不同:

.NET里处理堆和栈都差不多,栈和堆无非是在虚拟内存的地址范围不同,但地址范围不同也没什么大不了的,从堆上存取内存比在栈上也快不了,而主要是有以下几个考虑因素,在某些类中,从栈中取内存要快一些:

  1. 在栈中,同一时间分配意味着同一地点分配(意思是同时申请的内存是挨着很近的),反过来,一起分配的对象一起存取,顺序栈的性能往往在CPU缓存和操作系统分布系统上表现良好。
  2. 栈中的内存密度往往比堆中高(因为引用类型有头指针),高内存密度往往效率更高,因为CPU缓存中填充了更多的对象。
  3. 线程栈往往相当小,Windows中默认配认栈空间最大为1MB,大多数线程往往只用了一点点空间,在现代操作系统中,所有程序的线程的栈都可以填进CPU缓存,这样速度就相当快了,而堆很少能塞进CPU缓存。

这也不是说就应该把所有内存分配放到栈上,线程栈在windows上是有限制的,而且很容易就会用完。

深入引用类型的内部:

引用类型的内存结构相当复杂,这里用一个Employee的引用类型来举例说明:

public class Employee
{
private int _id;
private string _name;
private static CompanyPolicy _policy;
public virtual void Work() 
{
  Console.WriteLine(“Zzzz...”);
}
public void TakeVacation(int days) 
{
  Console.WriteLine(“Zzzz...”);
}
public static void SetCompanyPolicy(CompanyPolicy policy) 
{
  _policy = policy;
}
}

现在来看这个Employee引用类型实例在32位.NET上的内存结构:

图片 4

_id和_name在内存中的顺序是不一定的(在值类型中可以用StructLayout属性控制),这个对象的开头是一个4个字节叫做同步对象索引(sync object index)或对象头字节(object head word),接下来的4个字节叫做类型对象指针(type object pointer)或函数表指针(method table pointer),这两块区域不能用.NET语言直接存取,它们为JIT和CLR服务,对象引用指针(object reference)指向函数表指针(method table pointer)的开头,所以对象头字节在这个对象地址(object head word)的偏移量是负的。
注意:在32位系统上,堆上的对象是4字节对齐的,意味着一个只有单字节成员的对象也仍然需要在堆中占12个字节,事实上,一个没有任何成员的空类实例化的时候也要占12个字节,64位系统不是这样的:首先函数表指针(method table pointer)占8个字节,对象头字节(object head word)也占8个字节; 第二,堆中的对象是以邻近的8字节对齐的,意味着单字节员的对象在64位堆中占24个字节。

 函数表(Method Table)

函数表指针指向一个叫做MT(Method Table)内部的CLR结构,这个MT又指向另一个叫做EEClass(EE=Excution Engine)的内部结构。MT和EEClass包括了调度虚函数,存取静态变量,运行时对象的类型判定,有效存取基本类型方法以及一些其他目的所需的信息。函数表包括了临界机制的运行时操作(比如虚函数调度)需要频繁存取的信息。EEClass包括了一些不需要频繁存取的信息,但一些运行时机制仍然要用(比如反射)。我们可以用!DumpMT和!DumpClass这两个SOS命令学习这两个数据结构。
注意:SOS(son of strike)命令是一个debugger扩展dll,帮助调试托管程序的,可以在VisualStuido的即时窗口里调用。

EEClass决定静态变量的存储位置,基本类型(如Int)在存储在堆中动态分配的位置上,自定义值类型和引用类型存储以间接引用的形式存储在堆上。存取一个静态变量,不需要找MT和EEClass,JIT编译器可以将静态变量的地址硬编码成机器码。静态变量数组的引用是固定的,所以在GC的时候,其存储地址不变,而且MT中的原始静态字段也不归GC管,以保证硬编码的内存地址能被定位。

public static void SetCompanyPolicy(CompanyPolicy policy)
{
_policy = policy;
}
mov ecx, dword ptr [ebp 8] ;copy parameter to ECX
mov dword ptr [0x3543320], ecx ;copy ECX to the static field location in the global pinned array

MT包括一组代码地址,包括类内所有方法的地址,包括继承下来的虚方法,如下图所示:

图片 5

我们可以用!DumpMT检查MT的结构,-md 参数会输出函数的描述表,包括代码地址,每个函数的描述,JIT列会标明是PreJIT/JIT/NONE中的一个。PreJIT表示函数被NGEN编译过,JIT表示函数是JIT编译的,NONE表示没有被编译过。

0:000> r esi
esi=02774ec8
0:000> !do esi
Name: CompanyPolicy
MethodTable: 002a3828
EEClass: 002a1350
Size: 12(0xc) bytes
File: D:Development...App.exe
Fields:
None
0:000> dd esi L1
02774ec8 002a3828
0:000> !dumpmt -md 002a3828
EEClass: 002a1350
Module: 002a2e7c
Name: CompanyPolicy
mdToken: 02000002
File: D:Development...App.exe
BaseSize: 0xc
ComponentSize: 0x0
Slots in VTable: 5
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
Entry MethodDe JIT Name
5b625450 5b3c3524 PreJIT System.Object.ToString()
5b6106b0 5b3c352c PreJIT System.Object.Equals(System.Object)
5b610270 5b3c354c PreJIT System.Object.GetHashCode()
5b610230 5b3c3560 PreJIT System.Object.Finalize()
002ac058 002a3820 NONE CompanyPolicy..ctor()

注意:这不像C 的虚函数表,CLR的函数表包括代码地址和所有函数(非虚的也在),函数在表里的顺序是不一定的,但依次是继承的虚函数,新的虚函数,非虚函数,静态函数。
函数表中的存储的代码地址是JIT编译器编译函数第一次调用时生成的,除非NGEN已经用过。不管怎么样,函数表的使用者不用操心编译的麻烦,当函数表创建时,被pre-JIT的指针填满,编译完成时,控制权交给新的函数。函数在JIT之前的函数描述是这样的:

本文由糖果派对电玩城发布于独家专题,转载请注明出处:C# CLR via 对象内存中堆的存储【类型对象指针、同

您可能还会对下面的文章感兴趣: