技术开发 频道

从微软CLR视角看.net用户自定义类型


【IT168 技术文档】 这篇文章包括与微软CLR 2.0(下面称为CLR)里不同类型(值类型,引用类型,委托类型等等)的操作相关的技术信息。通过在VS.NET 2005 和 C# 编译器中使用Son Of Strike (SOS)调试扩展名,我做了大量的研究和分析,这篇文章中的理论都是建立的我的分析及.NET类型行为的研究之上的。在不同的MSDN博客,MSDN文章以及MSDN书籍中也都讨论了这些概念。然而,这些形式都包含在一个更广泛的主题内,很难找到在更好的,重要的知识点。我的这篇文章给人们提供了单一的引用,它是与类型的内部工作方式相关,是一些更好的重要的知识点。

    这篇文章假设读者非常了解.NET里不同类型分类的工作方式。同时这篇文章没有讨论怎样建立和使用不同的类型分类,而主要描写了检查CLR里不同类型分类的不同方面的操作细节。开始我只是将这些内容作为引用文件,但是想把它作文一篇文章发表,可能对.NET集体有益。我非常高兴能收到大家的评论,指出文章的不足来改进这篇文章内容。

    在.NET里有两个主要的类型分类,每一个类型都(直接或间接的通过一个基底类型)从一个叫做系统.对象的根引用类型派生出来。

• 值类型
o 用户自定义值类型
o 枚举类型
• 引用类型
o 用户自定义类型
o 数组类型
o 委托类型

    除了这些类型的实例是否是分配在迭式存储器上还是分配在器堆上的一些简单区别外,在定义,行为以及这些类型的实例中也存在核心的内在的设计差别。编译器和CLR一起在值类型 和引用类型的编译时间和执行时创建并保持了区别。了解CLR是怎样执行这些类型,以及怎样与这些类型一起工作将允许开发者设计更好的.NET应用程序。
 
用户自定义类型(值类型 和引用类型)

存储单元

    值类型分配在迭式存储器上。主要为类型(这些类型代表基础的数据项目)减少垃圾回收机制上的冲突就能完成这一分配。因为重的分配,垃圾回收循环以及动态的记忆而产生了这个冲突。而且重的分配,垃圾回收循环以及动态都需要被OS.要求。这样也就使得人们能接受使用值类型的性能,并使这一性能有效。引用类型安置在垃圾回收器堆上。

存储配置

    值类型实例仅仅包括它范围内的值。然而引用类型的实例包括额外设备来处理垃圾回收, 同步, 应用程序域证明和类型信息。这个额外的设备给每一个引用类型实例都增加了8字节。这个被提到的值类型实例的变量代表被分配在迭式存储器上的值类型实例的起始地址。代表值类型本地变量的地址被命名为托管指针,通常用于迭式存储器上的参引参数。

    代表引用类型实例的变量被称为对象引用,引用类型实例的变量是引用类型实例+ 4字节起始地址的指针。任何引用类型实例的起始地址是一个4字节值,被称为同步字组地址,它指向process-wide表格里的位置,此process-wide表格包含用来同步访问引用类型实例的结构。下面的4字节包括存储器(特殊的应用程序域)地址,此存储器包括一个结构,结构中包含有此对象实例化或指向的引用类型的方法表。这个方法表中又有一个其他结构的引用,这个引用为相对应引用类型保持执行时类型信息。

    在他们的实例中,类型中的方法表中出现指针使得引用类型成为自描述类型。电脑程序和CLR可以发现关于引用类型实例的信息,也可以用于多个执行时设备中,例如:类型转换,多型,反射等等。另一方面,因为值类型实例在他们实例中缺乏类型方法表,只是储存了大量的记忆,而没有给人们留下任何线索,告诉人们这个储存的到底是什么。正因为这个原因,值类型不是自描述类型。


下面的图显示了值类型和引用类型实例的存储配置。




实例化

    在实例化过程中,一个值类型的所有方面都实例为0(值类型群组)或者是null(引用类型群组)。观念上,当使用新的输入员实例化值类型时,就要使用值类型实例或者访问这个值类型.但是在技术上,在值类型实例的声明过程中,当这些群组已经被CLR零化时,就不需要值类型实例。但是只有当值类型实例群组已经明确建立到某些值时,或者通过使用新输入员已经建立了值类型实例时,一些语言(例如:C#)才会允许程序使用或者访问值类型实例群组。这一行为是为了给值类型节约额外的构造器调用,这些值类型已经大量地应用宰了在应用程序里。值类型可以由参数构造器,可以明确调用构造器来船舰值类型实例。在这种实例下,参数构造器要实例化值类型的所有区域。

    另一方面,要使用新的操作器来分配引用类型,而且必有一个缺省构造者或参数构造者。当建立引用类型的实例时,CLR首先将会实例化引用类型的所有群组,然后才会创造缺省或者参量构造器,缺省或者参量构造器是建立在新的输入员指定的构造器的基础上。

变量和GC Roots

    值类型的变量是迭式存储器上值类型实例地址的直接表达。
    一个值类型实例的一个引用变量被命名为托管指针,而且它是迭式存储器上值类型实例的起始地址。
    一个引用类型 (统一数据传送, 数组, 字符串, 托管类型和接口 类型变量)的变量是垃圾回收机制上建造的引用类型实例的指针。
    CPU寄存器包括托管指针或者对象引用。
    应用程序域控制代码表包括垃圾回收机制,垃圾回收机制是存储器里连接在引用类型上面的指针。这些控制代码表也包括静态值类型实例的托管指针,以及静态引用类型实例的对象引用。

线程本地存储区包括对象引用

    FReachable Queue包括这个引用类型的对象引用,这些引用类型没有被上面任何一个变量类型引用,并且最终决定未决的方法调用。

    CLR的垃圾回收也称为GC roots,在碎片帐集中,使用上面的变量来追踪目标引用。因为在上面任何一个变量类型中没有对象引用,位于垃圾回收器堆中的任何引用类型实例都被认为是碎片帐集的代替品,并从垃圾回收器堆中删除。如果被删除的引用类型实例执行最终决定方法,对象引用就会被一个单独的Finalizer thread放在FReachable Queue上,用于调用完成方法。一旦终结器线程完成了对象引用上的完成方法调用,相应的引用类型实例就会从垃圾回收器堆中删除。

构造器


    就像在实例化部分讨论的,值类型既不能也没有缺省构造器。他们可以有参量构造器,通过使用新的输入来明确调用他们。值类型引用类型里的构造器仅仅只是类型的另一个实例方法。编译者暗中使用他们来示例化类型群组,并且执行一些示例化操作。但是当程序被示例化后,编译者不允许程序明确调用类型变量上的构造器。但是在技术上,当一个类型的构造器被示例化后,可以调用一个类型的构造器(例如:在类型实例的构造器的一生中可以调用多次)。通过欺骗编译者,写某些IL代码,就可以完成这一过程。通过使用ILDASM,一个人可以把装配分解为IL,修改IL,包括调用类型构造器,再使用ILASM重新分解配置。然而,并不要求这样做,如果在类型示例的一生中要求多次示例化同一类型实例,开发者可以在类型上定义一个公共的方法,这个类型可以和它的构造器座做同样的工作。

    值类型构造器和参数类型构造器之间的区别是:引用类型构造器应该总是调用他的基类的缺省构造器,这是因为CLR将不会自动执行这一任务,而且完成这一任务是引用类型的责任。但是不要求值类型这样做,原因很明显,因为值类型没有任何缺省构造器,它不能当作基类工作。对于引用类型,在给MSIL编译资源时,编译者(例如:C#)自动把对派生类型构造器的调用插入基类。

继承(区分值类型和引用类型的方法)

    值类型是由一个特别的被称为系统值类型派生而来,而系统值类型是从系统.对象派生而来的。任何一个从系统值类型派生而来的类型被CLR当作值类型对待。CLR使用这一知识工作在类型实例上,正如上面部分以及下面部分描述的。引用类型在他们的基类层次里没有系统值类型。事实上,一些编译器不会允许任何类型直接从系统值类型派生。编译者总是提供一个间接的可执行的方法指定一个类型,此类型是从系统值类型派生而来的,而且在这些类型上执行特定的定义规则。在这些可执行的语言规则的基础上,编译者将产生MSIL代码,此代码适合于值类型。而且必须要求这样,因为当把 代码编成MSIL级(或者处于ILASM中)时,任何一个类型都可以从系统值类型派生出来,而且它可以包括缺省构造器和间接的MSIL指令,值类型中没有这些间接指令和缺省构造器(因为缺省构造器永远都不会被调用)

    值类型不能为其它类型作为基本类型来使用。很明确,一个值类型不能作为引用类型的基本级别,因为值类型没有缺省构造者,它的储存配置与引用类型的储存配置不同。但是为什么值类型不能作为另一个值类型的基本类型?答案在值类型实例的储存配置里。.NET使用方法表来完成执行时多型(虚拟方法分派)。因为一个值类型的实例不能包含方法表,CLR不能正确地使用它来分派虚拟方法调用(下章将讨论方法分派内部原理)。正因为如此,.NET不能为值类型s提供执行时多型。没有执行时多型,从对象为导向的设计角度来看,继承就是不完整的。因此所有的编译者包括ILASM将会把从系统值类型派生的任何类型标记为密封的。任何一个被密封的类型都不能当作基使用,而且在下载一个类型的执行时中,这种限制由CLR来执行。如果CLR发现正在被下载的类型有一个被标志为密封的基本类型,CLR就会阻止下载派生类型,并且引发系统.类型下载异常。

等式和哈希代码

    系统值类型覆盖它的基本类型系统.对象里的相等功能 和Get哈希代码虚拟方法。任何一个从系统值类型派生出来的类型使用相等功能来对比它的实例,而且需要覆盖这两个方法来提高性能。这是因为相等功能方法在比较两个值类型实例时,如果不能在比特级上比较值类型的群组,它就使用反射。在公平比较两个值类型 实例时,消除反射以及与它相关的性能处罚对结构(从系统值类型派生出来的类型)覆盖这些方法以及执行特定的公平检查和哈希代码生成都更好些。

    对于引用类型实例,他们直接或间接的从系统.对象派生而来的,相等功能 and GetHashCode方法安置在系统.对象,而且在目标引用值的基础上执行工作,而不是在类型实例的真实群组values上。

    这两个方法往往结合在一起,因此值类型的两个实例无论是使用相等功能还是使用GetHashCode,都可以同等比较。考虑给用户定义值类型(结构)覆盖字符串. 相等功能以及GetHashCode方法的另一个原因就是:如果使用值类型变量调用这些方法时,却没有将它们覆盖起来,CLR就需要把密封值类型,然后调用值类型上被密封的实例。

实例拷贝语义学(数值和变量分配的方法变元传递)

    值类型实例总是由数值拷贝成同一值类型的另一个变量。当一个方法参量期待同一个值类型 ,且传递值类型的一个变量时作为参数时,这个拷贝就产生了。当指派(在示例化过程中没有示例)一个值类型变量到同一个值类型的另一个变量时,也会发生这个拷贝。值类型可能包括群组,这些群组都是原始的数据类型(不能再被拆开)值类型或者引用类型。如果这个群组是原始类型或者值类型 ,它的数值就会拷贝成和它本身的一样。如果这个群组是引用类型,群组中的值类型就会烤成与目标值类型实例相对应的群组。

“this”指针

    值类型实例方法里可用的“this”指针指向类型临时储存的第一个实例群组的地址。因此在实例方法中,访问一个实例群组,而且是从被“this”指针+由CLR分配的群组的偏移指向的地址直接访问。在下载类型过程中,偏移由CLR决定,而且在AppDomain生存期内,它将用于类型的整个实例。

    引用类型的实例方法中,可用的“this”指针指向这个类型临时储存的方法表信息块的地址。因此如果一个实例地址在实例方法内被访问,它将被由“this”+4字节+由CLR分配的群组的偏移地址访问。加一个4数值会把指针带到实例的第一个实例。在下载类型的过程中,这个群组的偏移由CLR决定,而且在应用程序域生存期内,它将用于类型的整个实例。

封箱和非封箱

    迭式存储器上,没有任何方法表的一个值类型实例被称为值类型 实例的取消装箱值。如果这个数值必须要分配到一个系统.目标变量上,我们就需要存储器上的值类型实例,存储器上的值类型实例在存储配置上有方法表。这是必须的,因为系统.目标是一个带有虚拟方法((相等功能, GetHashCode and 字符串)的引用类别。因此这些方法的任何调用要求有效的非零对象引用。但是我们可以说程序使用了值类型的群组数据来连接GetHashCode方法,并创造了一个属于它的特定的GetHashCode方法。

    现在,在GetHashCode覆盖方法内,如果使用了任何类型的群组,就可以通过使用”this”指针的起始地址来访问他们。但是如果我们封装他们,为值类型 实例创建一个对象引用,并使用它调用GetHashCode,当这个指针进入覆盖方法时,就会通过对象引用的起始地址(方法表的起始地址)。因为这个方法期待它的群组数值而不是方法表,所以在方法执行过程中会产生一些问题。为了避免产生问题,当封装值类型 实例时,CLR一定要保证被系统.对象变量调用的虚拟方法在这个类型实例中含有第一个群组项目的起始地址。也就是说CLR处理传输建立在类型实例基础上的正确的起始地址,对象引用和托管指针都可以,这个地址既可以是直接的值类型实例,封装的值类型 实例 或者直接的引用类型实例.

    装箱要求创建目标引用,这个目标引用指向建立在存储单元基础上的器堆,创建这个目标引用来保持值类型数据(群组);封装也要求从值类型 实例复制数据到类型实例的群组部分。取消装箱要求创建一个基于迭式存储器的类型实例,从目标引用复制群组数据到基于迭式存储器的类型实例。当系统.目标变量被安排到值类型 实例时,就取消装箱了。

非虚拟实例方法分派(方法调用)

    分配在值类型和引用类型的非虚拟实例方法是一样的。使用调用IL指令就可以完成非虚拟实例方法分派,但是要求类型实例指针,”this”指针作为第一个方法变元 在迭式存储器上可用,比其他方法变元都要快。在编译方法调用站点时,JIT将把群组方法储存在的方法主体(代码)转成机器代码。这个方法地址是从类型方法表结构中得到的。

    正如在存储配置里讨论的一样,在类型实例的开端,目标引用指向4字节地址,4字节地址包括类型方法表的地址。除了其他信息,方法表还包括以下信息。

• 应用程序域接口 偏移表的指针包括开始槽的地址,在那里,由类型分配的接口IIDs按序列的形式存在,而且在序列中也是由类型分配的。在基于接口的分派中就要使用这个。下一章节将讨论细节问题。
• 由类型分配的接口数量

• 每一个槽包括方法代码地址的方法表。被访问的地址内容包括一个标记(证明代码的类型),一个MSIL 或 JITTED 代码。在MSIL 事例中,在标记之后一个被访问的地址包括MSIL 代码。在JITTED 事例中,标记之后一个被访问的地址包括一个对内存的JMP语句,内存中存在JITTED代码。 The Method table is arranged in the order of inherited virtual methods, implemented virtual methods, 实例 methods and static methods. CLR determines the 偏移 of a method within this Method table based on its token, which is computed by the compiler during compilation of the assembly containing the type.按照继承虚拟方法分配方法表,执行虚拟方法,实例方法,静态方法。CLR 决定这个方法表中的方法偏移 ,而此方法表是建立在它自己的标记基础上的,在装配(包含类型)的编译中,由编译器计算。Call site (method call instruction in the code using the type) contains reference to the method token. Since CLR knows the method slot 偏移 based on its token, during JITTING phase all call sites are properly patched with method slot address based on the method token and the type of the variable (实例 method calls) or the type pointed to by the 目标引用 (virtual method calls)

• 调用站点(使用这一类型的代码中的方法调用指令)包括方法标记的一些内容。因为CLR知道方法槽 偏移建立在它自己的标记之上,在JITTING过程中,所有调用站点都正确地装饰有方法槽地址(建立在方法标记之上)和变量类型(实例方法调用)或者由目标引用(虚拟方法调用)指向的类型。

• Static fields. For primitive types each slot contains the value itself and for UDT the slot contains the address of the slot in the AppDomain wide Handle table. As described in the Variables and GC Roots section, each slot in the Handle table contains either Pinned 目标引用, 托管指针 or 目标引用

• Method slots for each interface implemented by the type

• 静态实例 对于原始类型,每一个槽都包含它自己的数值,而对于UDT每一个槽都包含由应用程序域句柄表里槽的地址。正如变量和垃圾回收根章节中描述的,句柄表中每一个槽都包含有连接的目标引用, 托管指针或者目标引用 。

    在创建非虚拟实例方法调用时,CLR 不检查实例指针的有效性。因此在现实中,可以使用类型变量调用实例方法,此类型变量还没有被示例化,或者此类型变量没有零目标引用 (你可以通过修改配置的IL来实践)。但是记住如果被调用调用的方法使用了零目标引用,包括到类型群组的途径或者调用其它方法(访问类型群组)的调用,在这些方法里执行这些调用的时候, CLR会引起系统.零引用异常。这是因为在访问实例群组时,CLR检查了目标引用的有效性。这一实例将再次发生,这是因为在存储单元(被分配给类型实例)里存在这些群组,对于零实例,则没有分配存储单元。

    在零目标引用上允许存在方法调用是很危险的,且不可预知的。正因为这个原因,许多.NET编译者(例如:C#)将发送callvirt IL调用,即使在调用非虚拟群组方法的时候也会这样做。Callvirt IL调用将指示CLR生成机械代码,此机械代码首先检查目标引用的有效性。如果目标引用是零,生成的机械代码将引起异常,或者是方法要求的其它东西。记住:即使在非虚拟实例方法中通过C#使用callvirt不会产生性能终结。这是因为非虚拟实例方法上的callvirt仅仅一个额外的指令,除了一个指令已经直接的跳入了方法执行的方法地址这个指令将目标引用和指令中的零比较开来,。在虚拟实例方法使用callvirt这一事例中,有额外的指令,这些指令试图计算出方法地址(此地址建立在类型的基础上,而事实上,此类型受目标引用的指示)。将在下章仔细讨论虚拟方法分配问题。

    CLR把调用IL 指令的调用转换成下面的机械代码。(伪码)

• 把目标引用/托管指针地址移到ECX寄存器里,这个指针总是被下载到ECX寄存器里,这个指针也是第一个隐藏的参数,而在调用任何一个实例方法(虚拟或非虚拟)时,需要经过此隐藏的参数。这是因为CLR使用快速调用惯例,快速调用惯例要求从寄存器,ECX 和 EDX里尽可能多的为快速访问使用前两个方法变元 。

• CLR 连接方法代码的地址,并向那个地址发送调用。在包括调用站点的方法JIT期间, CLR只为每一个调用做一次这样的工作。CLR为方法调用使用变量类型的方法表,在收集方法代码地址时,变量并没有指向对象的方法表。
 
0
相关文章