[原创]探索CLR原理系列(2):字段在内存中的布局 (适合老鸟,新人勿沉迷其中)

时间:2021-08-22 12:55:41

上一篇文章我们探索了类型,每一个类型在元数据的Typedef表中,会分配一个MdToken(类型标记),当你写的方法需要访问这个类型时,也是使用MdToken到相关Dll的元数据表去加载它到Load Heap,LoadHeap是用来存放类型的空间,它并不保存类型的实例.我们可以为类型定义一系列成员,包括:字段,属性,方法,事件和嵌套类,但我们跟踪类型的EEclass,发现类型中只有两类成员,字段(事件就是一个委托,而委托只是一个类型,所以事件就是一个字段而已,但表现有些特殊后续介绍)和方法(属性实际就是方法).

这篇文章就让我们一起来探索类型中很重要的一部分:字段.首先来说明一下为什么类型需要字段?

在面向对象的语言中,每一个类型或者实例化的对象都代表一个可以解决某种特定问题的实体.那么这些实体就会有描述自身当前状态的需要,如何满足这种需要呢?

类型的状态我们使用静态字段来描述,实例的状态我们使用实例字段来表述,还有一部分状态我们需要它是不可变的(常量字段).那么这三类字段在IL中如何表现,CLR有如何加载分配内存呢,下面我们一起来扒一扒字段的隐私.

  我们来定义一个类型,这个类型中包括静态字段,实例字段和常量字段.这里我们只讨论字段,所以不考虑封装性.  

     public  class TestObjectType1
    {
         public  static  int SX;
         public  o bject X;

public const double CX=10;

public readonly string RX; 

    }

我们来看看IL中是如何表示的.

TypeDef # 2  ( 02000003 // 上篇文章提到的类型标识.
-------------------------------------------------------
     TypDefName: TestDemo1.Type.TestObjectType1  ( 02000003)
    Flags     : [ Public] [AutoLayout] [ Class] [AnsiClass] [ BeforeFieldInit]  ( 00100001)
     Extends   :  01000001 [TypeRef] System.Object

    Field # 1  ( 04000001 // 常量字段标识
    -------------------------------------------------------
        Field  Name: CX ( 04000001)
        Flags     : [ Public]  [Static]  [Literal]//注意这个词 [HasDefault]  ( 00008056)
     DefltValue:  ( R8 10   // 注意值被直接写到这里,常量就是静态的
        Field  type:  R8  // 字段类型double在IL中的表示

    Field # 2  ( 04000002 ) // 静态字段标识
    -------------------------------------------------------
        Field  Name: SX ( 04000002)
        Flags     : [ Public] [Static]  ( 00000016)
        Field  type:  I4   // 字段类型,int32在IL中的表示

    Field # 3  ( 04000003 // 实例字段 标识 .
    -------------------------------------------------------
        Field  Name: X ( 04000003)
        Flags     : [ Public]  ( 00000006)
        Field  type:   Object // 字段类型
    Field #4 (04000004)
  -------------------------------------------------------
Field Name: RX (04000004)
Flags     : [Public] [InitOnly]  (00000026) //只能在初始化时赋值
CallCnvntn: [FIELD]
Field type:  String

 

根据上面的IL,我们看到字段也有它的0400000X标识,实例字段和静态字段只有一点不同,就是[Static]关键字.而常量也是静态字段,但是与普通静态字段相比还多了[HasDefault],代表它有默认值.[Literal]说明在CLR布局类型内存时,不包含此字段.

那么CLR如何在加载一个类型的时候,侦测到类型中的字段呢?

上一篇我们提到了TypeDef元数据表,表中有一个字段是FieldList(参见上一篇探索CLR原理(1)-类型),这个字段会指向一个Field元数据表中,该类型所定义的字段的起始位置,也就是说每个类型定义的字段不管是静态还是动态在元数据表中是连续的。该元数据表以字段的标识做为索引。

[原创]探索CLR原理系列(2):字段在内存中的布局 (适合老鸟,新人勿沉迷其中) 

  我们来看看在CLR中一个类型的静态字段和实例字段在内存中是怎样的。CLR如何访问一个字段?先来编写一段C#代码:            

TestObjectType1 t =  new TestObjectType1();

        t.X =  new object();  // 给实例字段赋值

        TestObjectType1.SX =  20;   // 给静态字段赋值

         double b = TestObjectType1.CX;  // 给常量赋值

看看IL中如何表现这段代码

     // 初始化一个object
   IL_0008:   newobj      instance  void [mscorlib /* 23000001 */]System.Object /* 01000001 */::.ctor()  /*  0A000011  */ 

     // 为实例字段X赋值
   IL_000d:   stfld       object TestDemo1.Type.TestObjectType1 /* 02000003 */::X  /*   04000003  */
     // 将20推送入栈
   IL_0012:   ldc.i4.s    20
     // 为静态字段赋值
   IL_0014:   stsfld      int32 TestDemo1.Type.TestObjectType1 /* 02000003 */::SX  /*   04000002  */
     // 加载常量
   IL_0019:   ldc.r8      10.   //注意这里,并没有使用任何标识,而是直接将10放到了IL中

 

我们在上一篇说到,CLR加载一个类型,是通过类型元数据中的标识在TypeDef的元数据表中查找类型,在加载类型的时候一定需要加载字段,不管是静态还是实例的字段,参见上一篇,开篇时的那幅图。为什么实例字段也需要加载到类型上,实例字段不是属于对象吗?原因是当你的类型被实例化的时候,需要一份已有的实例字段的描述,来布局你的实例的内存。这样讲好象不太容易理解,咱们先用SOS来看看上面代码中实例化的TestObjectType1到底是个什么鸟样?

TestObjectType1 t = new TestObjectType1();


t.X =  new  object();  // 给实例字段赋值

TestObjectType1.SX =  20 ;   // 给静态字段赋值   调试当前点

double b = TestObjectType1.CX;  // 获取常量

!dumpobj  0x022bb928
Name:        TestDemo1.Type.TestObjectType1
MethodTable: 001838e8
EEClass:      00181518
Size:         12( 0xc) bytes
File:         D:\History\Test\ConsoleApplication1\ConsoleApplication1\bin\Debug\ConsoleApplication1.exe
Fields:
      MT    Field   Offset                 Type VT     Attr    Value  Name
68e1f568   4000003         4        System.Object   0  instance  022bb934   X  //可以看到Object被赋值了

68e228f8  4000002       24         System.Int32  1   static        0   SX   

通过上面的调试信息,我们发现二个问题,第一:Offset,第二竟然没有常量的信息!!

首先我们先来看Offset(偏移量),我们先不管这个偏移量是相对于哪个内存地址的偏移。实例字段X的偏移量是4,一个引用类型的指针所占的大小是4,那么静态字段的偏移量应该是8,可是这里竟然是24,为什么?

  为了察看以上信息,笔者不得不更换代码的实现。因为SOS调试信息有些东西看不到。重新定义代码

// 类型定义
  public  class TestObjectType1
{
    public  static  int SX=  10;
    public  int X=  20;  

}


//主函数
 TestObjectType1 t = new TestObjectType1();

 Test(ref t.X); 

Test( ref TestObjectType1.SX); //使用地址传递,以拿到地址

 

Test()方法只是使用地址传递来拿到静态变量的地址,再次启动SOS调试。

0019ebf4  00340109 TestDemo1.Type.TestMain.Main()
     LOCALS:
0x0019ebf8 =  0x0255b928  // t实例的地址

0019ebb0 003401c4 TestDemo1.Type.TestMain.Test( Int32 ByRef)

     PARAMETERS:
        x ( 0x0019ebb0) =  0x0255b92c  // t.X的地址
!
0019ebb0 003401c4 TestDemo1.Type.TestMain.Test( Int32 ByRef)
     PARAMETERS:
        x ( 0x0019ebb0) =  0x001d391c  // TestObjectType1.SX的地址

!dumpobj  0x0255b928
Name:        TestDemo1.Type.TestObjectType1
MethodTable:  001d38f8
EEClass:     001d151c
Fields:
      MT    Field   Offset                 Type VT     Attr    Value Name
68e228f8   4000002         4         System.Int32   1  instance         0 X
68e228f8   4000001        24         System.Int32   1   static        10 SX

Vtable Slots:    4
Total 
Method Slots:  6
 

注意图中以相同颜色标注的两对地址,首先看蓝色的。他们的地址相差4,与Offset4相等,说明实例字段的Offset是指和实例的指针的偏移量。

那么黄色的偏移量是多少呢?呵呵,答案一定是24了,如果你再定义一个静态字段(引用类型)那么肯定就是28了,大家可以试试。 为什么偏移量是24呢?

注意灰色的部分,原来静态字段是放在方法表后面的。。。

  可能很多人会问,这里的测试代码为什么不用引用类型,因为引用类型用地址获取时,是引用类型实例所在的地址,静态变量只是引用了这个地址。笔者也想通过内存来分析这个引用类型,可是笔者实在不知道那内存中显示的16进制的垃圾数字,到底是啥意思。见谅!!

关于值类型的字段,其实和引用类型一样,也是有一个FieldDes来描述所有的字段,当CLR加载类型时,也会将这部分内容加载进去,大致上就是字段类型和偏移地址。元数据是不加载到CLR中的,最多就是一个MdToken的标识,和前一篇说到类型时一样。今天说到的内存结构如下图(前一篇图的部分内容),图中标黄的部分。

[原创]探索CLR原理系列(2):字段在内存中的布局 (适合老鸟,新人勿沉迷其中) 

  那么偏移地址是怎样生成的呢?答案是IL编译时,在DLL的元数据表中还有一个表叫做ClassLayout,表中定义了相应字段的偏移地址,以上面提到的Field元数据表中,字段的标识做索引。

  [原创]探索CLR原理系列(2):字段在内存中的布局 (适合老鸟,新人勿沉迷其中)

  那么CLR如何访问字段,使通过MdToken吗?我们来看看方法执行时的汇编,就一目了然了。 

TestObjectType1 t =  new TestObjectType1();
t.X =  20;

004D0106 8945C4           mov         dword ptr [ebp-3Ch],eax  // 取得当前对象
004D0109 8B45C4           mov         eax,dword ptr [ebp-3Ch]
004D010C C7400414000000   mov         dword ptr [eax+ 4],14h  // 将当前对象 +4 的偏移地址处存储20;

原来在方法执行时,使用的都是偏移地址(除反射动态查找字段以外).

字段在继承时会怎样 ?

有很多人并不是很清楚继承时字段是怎样的,比如静态字段能不能继承?下面我们专门来讨论一下这个问题。有类如下:

     public  class TestObjectType1
    {
         public  static  int SX =  10;
      
         private  int PX;

         protected  int X;
    }

     public  class TestObjectType2 : TestObjectType1
    {}  

我们来看看IL是怎样的。TestObjectType1在这里笔者就不列出来了,主要看它的派生类。

TypeDef # 3 ( 02000004)
-------------------------------------------------------
    TypDefName: TestDemo1.Type.TestObjectType2  ( 02000004)
    Flags     : [Public] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  ( 00100001)
    Extends   :  02000003 [TypeDef] TestDemo1.Type.TestObjectType1

 

我们发现在TestObjectType2中没有任何字段定义,这是为什么?为了搞明白原因,我们再次使用SOS来察看。
!dumpobj  0x0260b928
Name:        TestDemo1.Type.TestObjectType1 //父类型
MethodTable:  00303914 //自己的方法表
EEClass:      00301538
Fields: //自己的字段表
      MT    Field   Offset                 Type VT     Attr    Value Name
6a7128f8   4000002         4         System.Int32   1  instance         0 PX
6a7128f8   4000003         8         System.Int32   1  instance         0 X
6a7128f8   4000001        24         System.Int32   1   static        10 SX

!dumpobj  0x0260b938
Name:        TestDemo1.Type.TestObjectType2
MethodTable:  00303980  //自己的方法表
EEClass:     003015a4
Fields:  //自己的字段表
      MT    Field   Offset                 Type VT     Attr    Value Name
6a7128f8   4000002         4         System.Int32   1  instance         0 PX   //私有字段也被继承。
6a7128f8   4000003         8         System.Int32   1  instance         0 X
6a7128f8   4000001        24         System.Int32   1   static        10 SX

 

这里为了证明子类和父类不是使用同一个字段,我们做一下测试:

TestObjectType1 t1 = new TestObjectType1(); 

TestObjectType2 t2 = new TestObjectType2();

Test(ref TestObjectType1.SX);
Test(ref TestObjectType2.SX);

 

  再次使用SOS,看看两个静态变量是否指向相同地址。

002eeda8  00520224 TestDemo1.Type.TestMain.Test( Int32 ByRef)

     PARAMETERS:
        x ( 0x002eeda8) =  0x003033b8

002eeda8  00520224 TestDemo1.Type.TestMain.Test( Int32 ByRef)
     PARAMETERS:

x (0x002eeda8) = 0x003033b8  

结果是什么呢?原来静态变量的继承时逻辑上的,也就是说物理上来讲父类和子类,共享共有的静态字段。

那么实例变量呢? 肯定是不共享的,大家可以自己做测试。


  总结:

本篇我们探索了类型的第一种成员:字段。字段在IL编译时,会生成MdToken和偏移量,因为对于类型来说,一个类型在编译时就已经确定了字段的个数,所以偏移量对于编译器来说是已知的,字段和偏移量分别由元数据表(Field和ClassLayout)来记录。

在类型的CLR内存布局中,有一个FieldDesList,它指向类型的字段描述(主要是字段签名和偏移量).当CLR首次加载类型时,会根据元数据表来生成 FieldDesList,包括实例和静态字段,其中静态字段的偏移量是相对于方法表中最后一项方法地址而言,所以静态字段在类型方法列表的后面进行布局,也就是说你的静态字段就存储在这个空间,它是属于类型的,也就是说所有这个类型的实例共享这个静态字段。而实例字段则是和实例在一起,属于每一个实例,它的偏移量是以实例的类型指针为基址而计算的。

子类从父类派生时,所有字段全部被派生,与可访问性无关,但对于静态字段来说,子类和父类都指向相同地址,即逻辑继承,物理上则共享. 


下一篇将于大家一起探讨关于类型的另一个成员:方法,内容将会非常多,也是CLR中比较复杂的设计之一.关于实例的部分,笔者将单独分出一篇来和大家一起讨论,届时我们将分析实例的生成(GC堆和栈),以及实例的销毁(垃圾回收和栈资源的释放).