.NET 类型内部机制 - 来自 Microsoft CLR 的视角






4.91/5 (121投票s)
2007 年 9 月 13 日
38分钟阅读

709600
从 CLR 的角度理解 .NET 类型的内部机制
目录
引言
本文包含有关 Microsoft CLR 2.0(下称 CLR)中不同类型(值类型、引用类型、委托等)实现的技术信息。本文介绍的概念基于我使用 VS.NET 2005 的 Son Of Strike (SOS) 调试扩展和 C# 编译器对类型行为的分析和研究。这些概念也在不同的 MSDN 博客、MSDN 文章和书籍中讨论过。然而,这些形式常常是在一个更广泛的主题背景下,精细且重要的点不易察觉。我创建本文是为了提供一个关于 CLR 在类型方面内部工作机制的精细且重要点的单一参考点。
本文假定读者对 .NET 中不同类型的分类有实际的了解。此外,本文不讨论如何创建和使用不同类型的分类,而是通过检查 CLR 中不同类型分类的各个方面实现细节来推进。最初我创建此内容是为了我自己的参考文档,但觉得将其发布为文章,以便它能对 .NET 社区有所帮助。因此,我非常乐意收到任何关于改进本文内容的评论和反馈。
在 .NET 中,类型有两个主要分类,所有类型(直接或间接通过其他基类型)都派生自根引用类型 `System.Object`。
- 值类型
- 用户定义值类型(结构)
- 枚举
- 引用类型
- 用户定义类型(类)
- 数组
- 委托
除了它们的实例是分配在堆栈还是堆上的简单差异外,在类型定义、行为和实例方面存在核心的内部设计差异。编译器和 CLR 在编译时和运行时共同创建并维护值类型和引用类型之间的区别。理解 CLR 如何实现这些类型以及如何与这些类型协同工作,将使开发人员能够设计出更好的 .NET 应用程序。
2. 用户定义类型(值类型和引用类型)
内存位置
值类型分配在堆栈上。这样做主要是为了减少 GC 堆的争用,因为这些类型仅代表基本数据项。争用可能是由于大量的分配、GC 循环以及需要从操作系统请求的动态内存。这反过来将使值类型的性能使用起来可以接受且高效。引用类型分配在 GC 堆上。
内存布局
值类型的实例仅包含其字段的值。而引用类型的实例包含用于处理 GC、同步、AppDomain 标识和类型信息的额外开销。这些额外的开销会给每个引用类型实例增加 8 字节。引用值类型实例的变量代表堆栈上分配的值类型实例的起始地址。表示值类型实例的局部变量的地址称为 **托管指针**,通常用于堆栈上的引用参数。
引用引用类型实例的变量称为 **对象引用**,它是指向引用类型实例起始地址的指针 + 4 字节。任何引用类型实例的起始地址是一个 4 字节值,称为同步块地址,指向进程范围内的表中一个位置,该表包含用于同步对引用类型实例访问的结构。接下来的 4 字节包含一个内存(特定于 AppDomain)的地址,该内存包含一个结构,该结构保存了实例化对象的引用类型的类型表(Method Table),或者指向类型表。该类型表又包含一个指向另一个结构的引用,该结构保存相应引用类型的运行时类型信息。 (Runtime Type Information)。
类型的方法表指针(进而类型的信息)存在于它们的实例中,这使得引用类型成为 **自描述类型**。程序和 CLR 可以发现有关引用类型实例类型的信息,并可用于多种运行时功能,例如类型转换、多态、反射等。另一方面,值类型实例由于其实例中缺乏指向类型方法表的指针,因此它们是简单的内存块,没有任何关于该内存是什么的线索。因此,值类型不是自描述类型。
下图显示了值类型和引用类型实例的内存布局。

实例化
值类型的实例在其声明时创建。值类型没有默认构造函数。实例化期间,值类型的所有字段都初始化为 0(值类型字段)或 null(引用类型字段)。理想情况下,值类型实例的字段应该在使用 `new` 运算符实例化值类型后使用或访问。但技术上并非必需,因为在声明值类型实例时,字段已被 CLR 清零。但 C# 等语言不允许程序在显式设置为某个值或使用 `new` 运算符创建值类型实例之前使用或访问值类型实例字段。此行为是为了节省应用程序中大量使用的简单值类型的额外构造函数调用。值类型可以有参数构造函数,可以通过显式调用来创建值类型实例。在这种情况下,参数构造函数必须初始化其值类型的所有字段。
另一方面,引用类型必须使用 `new` 运算符分配,并且应该有一个默认构造函数或参数构造函数。创建引用类型实例时,CLR 会首先初始化引用类型的所有字段,然后根据 `new` 运算符中指定的构造函数调用默认构造函数或参数构造函数。
变量和 GC 根
CLR 的垃圾收集器使用上述变量(也称为 GC 根)在垃圾回收阶段跟踪对象引用。GC 堆中任何引用类型实例,如果没有任何对象引用在上述任何变量类型中(FReachable Queue 除外),则被视为垃圾回收的候选对象,并从 GC 堆中移除。如果被移除的引用类型实例实现了 Finalize 方法,则对象引用会被放入 FReachable Queue,由单独的 Finalizer 线程调用 Finalize 方法。一旦 Finalizer 线程完成对对象引用的 Finalize 方法调用,相应的引用类型实例就会从 GC 堆中移除。
构造函数
如“实例化”部分所述,值类型不能也没有默认构造函数。它们可以有参数构造函数,并且需要使用 `new` 运算符显式调用。值类型或引用类型的构造函数只是类型的另一个实例方法。它们被编译器隐式使用来初始化类型字段并执行一些初始化操作。但编译器不允许程序在类型变量实例化后显式调用构造函数。但技术上,可以在类型实例化后调用类型的构造函数(即可以在类型实例的生命周期中多次调用)。这可以通过欺骗编译器并编写一些 IL 代码来完成。可以使用 ILDASM 将程序集反汇编为 IL,修改 IL,并包含对类型实例构造函数的调用,然后使用 ILASM 重新汇编程序集。然而,这并非必需,如果在类型实例的生命周期中需要多次初始化,则开发人员可以定义一个公共方法来执行与构造函数相同的操作。
值类型构造函数和引用类型构造函数之间的区别在于,引用类型构造函数必须始终调用其基类的默认构造函数。这是因为 CLR 不会自动执行此操作,而是引用类型的责任。出于显而易见的原因,这不需要对值类型,因为值类型没有任何默认构造函数,也不能作为基类。对于引用类型,编译器(如 C#)在将源代码编译为 MSIL 时,会在派生类型构造函数中自动插入对基类型构造函数的调用。
继承(区分值类型和引用类型的方法)
值类型派生自名为 `System.ValueType` 的特殊类型,该类型又派生自 `System.Object`。任何派生自 `System.ValueType` 的类型都会被 CLR 视为值类型。CLR 使用此知识来处理类型的实例,如前几节和后续几节所述。引用类型在其基类层次结构中没有 `System.ValueType`。实际上,某些编译器不允许任何类型直接派生自 `System.ValueType`。编译器始终提供一种间接的强制方式来指定类型派生自 `System.ValueType` 并对这些类型强制执行某些定义规则。此外,基于这种强制性的语言规则,编译器将生成适合值类型的 MSIL 代码。这是必需的,因为在 MSIL 级别(或 ILASM)编码时,任何类型都可以派生自 `System.ValueType`,并且甚至可以包含默认构造函数和不正确的 MSIL 指令,这些指令(因为默认构造函数永远不会被调用)对于值类型来说不应该存在。
值类型不能用作其他类型的基类型。肯定地说,值类型不能是引用类型的基类,因为值类型没有默认构造函数,其内存布局与引用类型不同,等等。但为什么值类型不能是另一个值类型的基类呢?答案在于值类型实例的内存布局。.NET 使用方法表来实现运行时多态(虚方法分派)。由于值类型实例不包含方法表,CLR 无法使用它来正确分派虚方法调用(方法分派的内部机制将在后续章节中讨论)。因此,.NET 无法为值类型提供运行时多态。而没有运行时多态,从面向对象设计角度来看,继承是不完整的。因此,包括 ILASM 在内的所有编译器都会将任何派生自 `System.ValueType` 的类型标记为 sealed。而任何被标记为 sealed 的类型都 **不能** 用作基类型,并且此限制会在 CLR 在运行时加载类型时强制执行。如果 CLR 发现正在加载的类型有一个被标记为 sealed 的基类型,它将停止加载派生类型并抛出 `System.TypeLoadException` 异常。
相等性和哈希码
`System.ValueType` 重写了其基类型 `System.Object` 的 `Equals` 和 `GetHashCode` 虚方法。任何派生自 `System.ValueType` 并且使用 `Equals` 来比较其实例的类型,都需要重写这两个方法以提高性能。这是因为 `Equals` 方法在无法按位比较值类型实例字段的情况下,使用反射来比较两个值类型实例。为了消除反射及其在值类型实例相等比较期间相关的性能损失,结构体(`System.ValueType` 的派生类型)最好重写这些方法并执行自定义相等检查和哈希码生成。
对于直接或间接派生自 `System.Object` 的引用类型实例,`Equals` 和 `GetHashCode` 方法在 `System.Object` 中实现,并且基于对象引用值而不是类型实例的实际字段值工作。
最常见的是,这两个方法一起被重写,以便通过 `Equals` 或 `GetHashCode` 可以比较两个值类型实例是否相等。对于用户定义值类型(结构),考虑重写 `System.Object` 的 `ToString`、`Equals` 和 `GetHashCode` 方法的另一个原因是在不重写的情况下,如果使用值类型变量调用这些方法,CLR 必须将值类型装箱,然后对值类型的装箱实例调用方法。
实例副本语义(按值传递方法参数和变量赋值)
值类型实例始终按值复制到另一个相同值类型的变量中。这种复制可能发生在将值类型变量作为参数传递给期望相同值类型的方法参数时。这种复制也可能发生在将一个值类型变量分配(非实例化时的初始化)给另一个相同值类型的变量时。值类型可能包含仅由基本(无法进一步细分)数据类型、值类型和/或引用类型组成的字段。如果字段是基本类型或值类型,则按原样复制其值。如果字段是引用类型,则字段中的对象引用会被复制到目标值类型实例的相应字段。
'this' 指针
值类型实例方法中可用的 'this' 指针指向类型实例内存的第一个实例字段的地址。因此,如果在实例方法中访问实例字段,它将直接从 'this' 指针指向的地址 + CLR 布局的字段偏移量处访问。此字段偏移量在类型加载期间由 CLR 确定,并在 AppDomain 的整个生命周期中用于所有类型实例。
引用类型实例方法中可用的 'this' 指针指向类型实例内存的方法表信息块的地址。因此,如果在实例方法中访问实例字段,它将从 'this' 指针指向的地址 + 4 字节 + CLR 布局的字段偏移量处访问。加 4 个字节可以使指针指向实例的第一个实例字段。此字段偏移量在类型加载期间由 CLR 确定,并在 AppDomain 的整个生命周期中用于所有类型实例。
装箱和拆箱
堆栈上的值类型实例(没有方法表)称为该值类型实例的未装箱值。如果此值必须分配给 `System.Object` 变量,那么我们需要一个内存中具有方法表内存布局的值类型实例。这是必需的,因为 `System.Object` 是具有虚方法(`Equals`、`GetHashCode` 和 `ToString`)的引用类型。因此,对这些方法的任何调用都需要一个有效非空的引用。但请稍等。假设程序重写了 `GetHashCode` 方法并使用值类型的字段数据创建了自己的自定义方法。现在,在 `GetHashCode` 重写方法中,如果使用任何类型的字段,则通过 'this' 指针的起始地址访问它们。但如果我们进行装箱并为值类型实例创建对象引用并使用它调用 `GetHashCode` 方法,那么对象引用的起始地址(方法表的起始地址)将被作为 this 指针传递到重写的方法中。这将在方法执行期间导致问题,因为方法期望其字段值而不是方法表。为了避免这种情况,当值类型实例被装箱时,CLR 确保在 `System.Object` 变量上调用的虚方法将具有类型实例中第一个字段项的起始地址。为此,CLR 会确保传递正确的起始地址,无论是对象引用还是托管指针,具体取决于类型实例,该实例可以是直接值类型实例、装箱的值类型实例或直接引用类型实例。
装箱涉及创建一个指向堆上基于内存位置的对象引用,该位置用于保存值类型数据(字段),并将数据从值类型实例复制到类型的实例的字段部分。拆箱涉及创建一个基于堆栈的值类型实例,并将字段数据从对象引用复制到其中。拆箱发生在 `System.Object` 变量被类型转换为值类型实例时。
非虚实例方法分派(方法调用)
值类型和引用类型中的非虚实例方法分派是相同的。它使用 **call** IL 指令完成,该指令要求类型实例指针 'this' 指针在堆栈上作为第一个方法参数可用,然后再传递方法的所有其他参数。JIT 在编译方法调用站点时,会将实例方法槽中存在的方法体的(代码)地址烧录到机器码中。此方法地址是从类型的类型表结构中获取的。
如“内存布局”部分所述,对象引用指向类型实例起始处的 4 字节地址,该地址包含类型的方法表地址。方法表除了其他信息外,还包含以下信息:
- 指向 AppDomain 范围内的 **接口偏移表 (IOT)** 的指针,该表包含类型实现的接口 IID 的起始槽地址,按类型实现它们的顺序排列。这在基于接口的分派中使用,将在下一节中详细讨论。
- 类型实现的接口数量
- 方法表,其中每个槽包含方法代码的地址。寻址位置的内容包含一个标识代码类型的标志,MSIL 或 JITTED 代码(机器码)。如果是 MSIL,标志后面的寻址位置包含 MSIL 代码。如果是 JITTED 代码,标志后面的寻址位置包含指向 JITTED 代码所在内存的 JMP 语句。方法表按继承的虚方法、实现的虚方法、实例方法和静态方法的顺序排列。CLR 根据编译器在编译包含类型的程序集时计算出的方法令牌来确定方法在方法表中的偏移量。调用站点(使用类型的代码中的方法调用指令)包含对方法令牌的引用。由于 CLR 知道方法槽偏移量,因此在 JITTING 阶段,所有调用站点都会根据方法令牌和变量的类型(实例方法调用)或对象引用指向的类型(虚方法调用)正确地用方法槽地址打补丁。
- 静态字段。对于基本类型,每个槽包含值本身,对于 UDT,槽包含 AppDomain 范围内的句柄表中的地址。如“变量和 GC 根”部分所述,句柄表中的每个槽包含固定的对象引用、托管指针或对象引用。
- 类型实现的每个接口的方法槽
在进行非虚实例方法调用时,CLR 不会检查实例指针的有效性。因此,实际上可以通过尚未实例化的类型变量或具有 null 对象引用的类型变量调用实例方法(可以通过修改程序集的 IL 来尝试)。但请记住,如果使用 null 对象引用调用的方法包含对类型字段的访问或对访问类型字段的其他方法的调用,那么在执行方法内的这些指令时,CLR 将抛出 `System.NullReference` 异常。这是因为 CLR 在访问实例字段时会检查对象引用的有效性。这再次是因为字段存在于为类型实例分配的内存位置,而对于 null 实例,没有分配内存。
允许对 null 对象引用进行方法调用被认为危险且不可预测。因此,许多 .NET 编译器(如 C#)即使在调用非虚实例方法时也会发出 **callvirt** IL 指令。Callvirt IL 指令会指示 CLR 生成机器码,该代码首先检查对象引用的有效性。如果对象引用为 null,则生成的机器码将引发异常,否则将调用方法。请记住,C# 即使对非虚方法调用使用 callvirt 也不会有性能损失。这是因为对非虚实例方法使用 callvirt 除了一个直接跳转到方法地址执行方法的指令外,只有一个额外的指令来将对象引用与 null 进行比较。在使用虚实例方法时,callvirt 需要额外的指令来根据对象引用实际指向的类型来确定方法地址。虚方法分派将在下一节中详细讨论。
CLR 将 `call` IL 指令转换为以下机器码(伪代码):
- 将对象引用/托管指针地址移动到 ECX 寄存器。'this' 指针始终加载到 ECX 寄存器,并且是在调用任何实例方法(虚方法或非虚方法)时需要传递的第一个隐藏参数。这是因为 CLR 使用 **fastcall** 调用约定,该约定要求尽可能从寄存器 ECX 和 EDX 中使用前两个方法参数以快速访问。
- CLR 烧录方法代码的地址,并发出对该地址的调用。CLR 在方法编译阶段(包含调用站点的该方法)仅为每个调用站点执行一次此操作。CLR 使用用于方法调用的变量的类型的方法表,而不是变量指向的对象的方法表,来收集方法代码的地址。
下面显示了上述伪代码的 IA32 指令的近似等价指令。
mov ecx, esi ; assuming esi has Object Reference
call dword ptr ds:[567889h] ; call into the address where method code
resides
虚方法分派(方法调用)
虚方法只能是实例方法。这是因为虚方法分派机制用于根据变量指向的对象的类型而不是变量本身的类型来调用方法。由于我们需要某个类型的对象,因此在实例方法上使用虚方法分派机制是有意义的。
对于值类型,虚方法分派(调用)行为类似于非虚实例方法。这是因为值类型不能被继承,因此没有必要(由 CLR 强制执行)也没有办法(由 C# 等编译器强制执行)在值类型上定义多态行为(虚方法)。因此,C# 等语言不允许值类型(结构)定义虚方法。另外,在调用值类型上的方法时,不需要使用 callvirt IL 指令,因为在声明指令本身就为值类型变量创建并初始化了实例内存。因此,在调用方法之前检查值类型变量是否为 null 没有意义。但 MSIL 允许方法定义是虚的,并使用 callvirt IL 指令调用。但尽管如此,CLR 在生成机器码时,会将其优化为常规的实例方法调用。
虚方法分派机制仅在通过值类型变量调用 `System.Object` 类型中的虚方法时才适用于值类型。在这种情况下,如果调用值类型中未重写的方法,CLR 将在调用方法之前将值类型实例装箱。如果值类型重写了 `System.Object` 方法并使用值类型变量调用,那么 CLR 将直接调用方法,而无需任何装箱和虚分派。CLR 通过使用名为 **constrained <Type Token>** 的 IL 指令来实现这种运行时行为差异。`constrained` IL 指令检查由令牌表示的类型是否为值类型。如果是值类型,并且值类型通过 `callvirt` IL 指令实现了被调用的方法,那么它会简单地生成对该方法的直接调用指令。如果值类型未实现通过 `callvirt` IL 指令调用的方法,那么它将装箱值类型实例并发出对该方法的虚调用。
对于引用类型,虚方法基于类型变量指向的对象的类型表中对应的虚方法地址进行分派。
CLR 为虚方法调用将 `callvirt` IL 指令转换为以下机器码(伪代码):
- 将对象引用值与 null 进行比较
- 如果为 null,则抛出 `System.NullReference` 异常,否则继续
- 将对象引用的地址移动到 ECX 寄存器
- 从方法表中检索地址(对象引用指向的内存中的前 4 个字节的值是方法表的地址)+ 方法从方法表结构起始地址的相对偏移量。
- 调用步骤 4 中检索的地址指向的机器码。
下面显示了上述伪代码的 IA32 指令的近似等价指令。
mov ecx, esi ; move the Object Reference to ecx
cmp dword ptr [ecx], ecx ; try de-referencing ecx. If ecx is null, CLR
will catch the memory access violation and will
convert that to System.NullReferenceException
mov eax, dword ptr [ecx] ; move the Method Table structure address of the
type to eax
call dword ptr [eax + 40h] ; call into address where method code resides
请记住“非虚实例方法分派”部分的方法表结构内存布局。继承的虚方法地址存储的方法槽在派生类型重写虚方法时不会被复制。相反,如果虚方法在派生类中被重写,那么在最顶层父类型中的该槽将被重写在最底层派生类型中的相应虚方法地址替换。此技术允许 CLR 根据方法令牌保持方法在方法表结构中的偏移量相同。
CLR 为非虚实例方法调用将 `callvirt` IL 指令转换为以下机器码(伪代码):
- 将对象引用值与 null 进行比较
- 如果为 null,则抛出 `System.NullReference` 异常,否则继续
- 将对象引用的地址移动到 ECX 寄存器
- CLR 烧录方法代码的地址,并发出对该地址的调用。CLR 在方法编译阶段(包含调用站点的该方法)仅为每个调用站点执行一次此操作。CLR 使用用于方法调用的变量的类型的方法表,而不是变量指向的对象的方法表,来收集方法代码的地址。
下面显示了上述伪代码的 IA32 指令的近似等价指令。
mov ecx, esi ; move the Object Reference to ecx
cmp dword ptr [ecx], ecx ; try de-referencing ecx. If ecx is null, CLR
will catch the memory access violation and will
convert that to System.NullReferenceException
call dword ptr ds:[567889h] ; call into the address where method code resides
基于接口的方法分派(方法调用)
当通过接口类型的变量(该接口由该类型实现)在类型实例上调用方法时,会发生基于接口的方法分派。由于任何类型都可以以任何顺序实现多个接口,因此在通过类型实现的接口之一的变量调用该类型上的方法时,需要该类型实例的方法表。由于值类型实例没有方法表,因此在将其分配给接口变量之前必须先将其装箱,以便对接口变量的任何调用都可以使用装箱实例(对象引用)中的值类型的方法表。
装箱后,装箱值类型实例和引用类型实例上的基于接口的方法分派是相同的。`callvirt` IL 指令用于分派接口方法调用。CLR 将 `callvirt` IL 指令转换为以下机器码(伪代码):
- 将对象引用值与 null 进行比较
- 如果为 null,则抛出 `System.NullReference` 异常,否则继续
- 将对象引用移动到 ECX 寄存器
- 将对象引用的值(即类型的类型表结构地址)移动到 EAX 寄存器
- 检索类型表结构中偏移 12 字节的地址。此内存槽包含类型实现的接口在 AppDomain 范围内的 IOT 中的接口槽的起始地址。IOT 包含类型实现的每个接口的一个接口槽。这意味着同一个接口将有多个接口槽,该接口被 AppDomain 中加载的多个类型实现。此接口槽反过来包含类型方法表结构内的内存位置,这是该接口方法表方法的起始点。
- 从步骤 5 中检索的地址开始,找到与变量的接口类型对应的接口槽偏移量。此搜索基于接口 IID,该 IID 在进程中对于所有 AppDomain 中加载类型的不同接口类型都是唯一的。由于接口变量的接口类型偏移量对于实现该接口的不同具体类型可能不同,因此 CLR 必须在每次执行调用站点时在运行时计算此偏移量。
- 此偏移量位置中的地址包含变量接口类型在实例类型的类型表结构中的接口方法表的起始地址。此地址被移动到 EAX 寄存器。
- CLR 使用接口方法令牌并生成接口类型中方法的偏移量。发出一个调用指令到通过步骤 7 中获得的地址 + 本步开始时获得的该方法的偏移量计算出的地址。方法偏移量被烧录到在调用站点生成的机器码中。
下面显示了上述伪代码的 IA32 指令的近似等价指令。
mov ecx, esi ; move the Object Reference to ecx
cmp dword ptr [ecx], ecx ; try de-referencing ecx. If ecx is null, CLR
will catch the memory access violation and
will convert that to
System.NullReferenceException
mov eax, dword ptr [ecx] ; move the Method Table structure address of
the type to eax
mov eax, dword ptr [eax + 0ch] ; move the address of starting IOT slot for
this type into eax
... ; find the offset (02h) of the interface
starting from eax
mov eax, dword ptr [eax + 02h] ; move the address of IMT starting slot for
the interface into eax
call dword ptr [eax + 3h] ; call into address where method code resides
JIT 对基于接口的分派进行了一些优化。如果在给定调用站点上使用相同的类型进行方法分派,JIT 可以识别它并优化上述 8 个步骤,使其仅成为对方法地址的直接跳转。但这涉及维护调用计数器、检查特定调用次数、存储当前类型的类型表地址、存储当前类型的方法地址、将当前类型的类型表地址与传入类型的类型表地址进行比较等的开销。CLR 2.0 使用此技术来显着提高基于接口的方法调用的性能。但如果调用站点频繁提供不同的类型实例,这也会带来严重的性能损失。
委托
委托是一种特殊的引用类型。每个委托都代表一个 UDT(类)。委托 UDT 类型的 `Invoke` 方法的签名应与委托用于调用的方法签名匹配。委托 UDT 的 `Invoke` 方法(包括其异步版本 `BeginInvoke` 方法)的实现是在委托 UDT 实例化期间由 CLR 在运行时生成的。委托 UDT 构造函数接受两个参数:目标对象和目标方法的地址(方法代码驻留的地址),以便在目标对象上调用。目标对象可以是 null,在这种情况下,该方法被视为静态方法,并基于目标对象变量的类型进行调用。目标对象可以是引用类型实例或值类型实例。对于值类型实例,在将其传递给委托实例之前需要将其装箱。
基于委托的方法分派(方法调用)
但在托管世界中,我们如何获得目标方法的地址?MSIL 有两个操作码可以将类型方法的地址加载到堆栈上。它们是 **ldvirtftn** 和 **ldftn**。ldftn 将方法地址从方法令牌加载到堆栈上。方法的类型可以从方法令牌中检索,CLR 使用该令牌查找类型的类型表,并从类型的方法表的方法地址中获取方法地址。ldvirtftn 用于加载虚方法的地址。ldvirtftn 需要堆栈上的一个对象引用,它使用该对象引用来获取类型表,然后根据方法令牌确定方法地址。编译器(如 C#)在使用方法名称作为委托 UDT 构造函数的参数时,会根据该方法是虚方法、非虚方法还是静态方法来使用这两个 IL 指令之一。此检索和存储委托实例内方法地址的过程称为 **委托绑定**。这是执行委托实例方法时整个过程中开销最大的操作。一旦方法绑定到委托实例,CLR 就会编写 `Invoke` 方法体,该方法体非常高效,并具有以下机器码(伪代码)指令:
- 将目标对象引用复制到 ECX 寄存器
- 调用存储在委托实例内的目标方法地址。
下面显示了上述伪代码的 IA32 指令的近似等价指令。
mov ecx, [ecx + 0ch]
; ecx contains the delegate instance address and 12 bytes within the
delegate instance contains target Object Reference
call dword ptr ds:[567889h] ; call into the address where method
code resides
此外,CLR 在运行时创建的委托类型的 `Invoke` 方法使用 **jmp** IL 指令跳转到目标方法。`jmp` 指令只是从当前方法跳转到目标方法,而不清除堆栈。因此,在调用站点传递的参数可用于跳转到的方法。堆栈上的返回地址将是调用 `Invoke` 方法的原始调用者的返回地址。因此,当目标方法完成时,它通过传递 `Invoke` 方法直接返回给调用者。因此,一旦绑定,基于委托的方法分派的性能几乎等于虚方法调用的性能。
使用委托链的多重方法分派
委托实例可以组合在一起形成一个链,其中最后添加的实例将位于链的头部。当调用委托链中的委托实例时,它会将调用传递给链中的下一个委托实例。这种传递会一直持续到没有下一个委托实例为止。基于委托的方法调用从这个最终的委托实例(第一个添加到链中并且是链中的最后一个)开始,并逐级返回,直到委托实例的头部调用它包装的方法。
在执行委托链时,返回值和输出参数的值将由添加到链中的最后一个委托处理程序(以及链中的第一个委托,即链的头部)设置的值。
枚举
枚举是一种特殊类型的值类型。每个枚举类型包含一个实例字段来保存枚举的值,其数据类型与枚举本身的数据类型相同(在 C# 中默认为 `System.Int32`)。每个枚举还应包含一个或多个静态字面量值,表示枚举类型公开的命名数据常量。CLR 允许在枚举类型的命名数据常量与其底层数据类型之间进行显式转换。因此,如果一个枚举的类型是 `System.Int32`,那么可以通过类型转换将枚举类型的命名数据常量之一分配给 `System.Int32` 类型的变量,反之亦然。反向操作是危险的,需要谨慎使用。这是因为,如果一个不属于枚举类型公开的数据常量之一的整数值被分配给枚举变量(通过显式类型转换),CLR 不会抛出任何异常。相反,它只是将其分配给枚举实例的内部实例成员。在此转换之后,枚举实例包含无效值,这只是一个逻辑错误,可能导致程序产生不可预测的结果。
枚举类型使用的命名数据常量,在分配给变量或在表达式(如 switch case)中使用时,在编译时会被编译器直接替换为实际的常量值。这是因为 MSIL,因此 CLR 没有在运行时加载常量/默认值的机制。所有常量值都存储在元数据中,与相应的字段对应。编译器使用此元数据中存储的值,并在编译期间用实际值替换常量的名称。如果正在分发包含修改后的枚举数据常量的程序集,而客户端应用程序在没有针对修改后的程序集重新编译的情况下运行,这会产生一个有趣的副作用。在这种情况下,客户端应用程序在其代码中有一个硬编码的值 X,用于一个名为的枚举数据常量,而该值 X 在修改后的程序集中可能表示另一个名为的数据常量。这将导致程序的行为不可预测。
数组
数组是一类特殊的引用类型。对于程序中定义的每一种不同的值类型数组(包括 int、byte 等基本类型),CLR 会在运行时创建并维护一个引用类型,该类型将派生自 `System.Array` 引用类型。此合成数组类型有一个构造函数,该构造函数接受数组的大小作为一维数组或交错数组的参数,或者接受多维数组的维度大小。此构造函数通过 .NET 编程语言以不同的方式向程序公开。例如,C# 使用典型的 C/C++ 数组索引语法公开此构造函数,`int[] i = new int[10];`。对于所有引用类型的数组,CLR 会在运行时创建并维护一个名为 `System.Object[]` 的引用类型,该类型将派生自 `System.Array`。这是因为引用类型数组的所有元素都仅保存对象引用,对象引用的大小相同且内部内存表示相同。
一维数组和交错数组的元素根据它们的索引直接从运行时数组类型的实例的内存位置访问或修改。为此,CLR 提供了一种特殊的 IL 指令,名为 **ldelem** 和 **stelem**,它根据索引和堆栈上可用的数组对象引用来检索和修改数组元素。而多维数组元素通过对数组类型的合成运行时类型的方法调用来访问或修改。这使得交错数组比多维数组快得多,并且是使用数组的首选方式,如果需要多个维度的话。交错数组是 CLS 兼容的,并且被错误地记录为不兼容 CLS。此事实在 MSDN2 中得到承认。
泛型
泛型引用类型
泛型类型可以是开放类型,其中一些或所有类型参数尚未被非泛型类型或封闭泛型类型替换。泛型类型可以是封闭类型,其中所有类型参数都已替换为非泛型类型或封闭泛型类型。只有封闭的泛型类型才能实例化。原因很明显,因为泛型类型的方法内部会调用类型参数变量。因此,在不知道变量类型的情况下,CLR 无法为变量实例化类型。对于每个用值类型作为类型参数提供的封闭泛型类型,CLR 会在运行时创建一个新类型,并将其用于实例化和其他目的。对于给定泛型类型的所有封闭泛型类型,并且为类型参数提供了引用类型,CLR 会创建一个类型,其中提供引用类型的类型参数被替换为一个名为 `System.__Canon` 的特殊类型。让我们将此类型命名为 `Generic<T.System.__Canon>`。
对于给定泛型类型的每个封闭泛型类型,当类型参数为引用类型时,CLR 会创建一个具有类型参数替换为提供的引用类型的不同类型。此引用类型将是一个虚拟类型,用于在传递过程中提供类型安全性。此类型的 `EEClass` 包含指向 AppDomain 中加载的 `Generic<T.System.__Canon>` 类型的 `EEClass` 结构的指针。CLR 使用 `Generic<T.System.__Canon>` 类型的类型表来分派封闭泛型类型实例上的任何方法调用。顺便说一句,`EEClass` 是类型表(Method Table)的伴生结构,CLR 为 AppDomain 中加载的每种类型创建一个 `EEClass`。`EEClass` 包含给定类型的类型信息。类型表包含指向 `EEClass` 的指针。
public class DisplayClass : IDisplay
{
public void Display()
{
Console.WriteLine("From DisplayClass");
}
}
public struct DisplayStruct : IDisplay
{
public void Display()
{
Console.WriteLine("From DisplayStruct");
}
}
public class Test<T> where T : IDisplay
{
public T _field;
public void Show(T temp)
{
temp.Display();
}
}
public static void Main(string[] args)
{
// Reference Type as Type Parameter
//
Test<DisplayClass> objTestClass = new Test<DisplayClass>;
objTest.Show(new DisplayClass());
// Value Type as Type Parameter
//
Test<DisplayStruct> objTestStruct = new Test<DisplayStruct>;
objTestStrucr.Show(new DisplayStruct());
}
当上述代码使用 C# 编译器编译时,它会创建一个名为 **Test`1<(Test.IDisplay) T)** 的类型,该类型充当 CLR 在运行时将创建的类型的占位符。在运行时,CLR 将为封闭泛型类型 `Test<DisplayClass>` 创建一个名为 **Test.Test`1[[Test.DisplayClass, ConsoleApplication5]]** 的类型。前缀 **Test.** 是命名空间名称,**ConsoleApplication5** 是程序集名称。此类型是一个空类型,没有方法。在开放泛型类型 `Test<T>` 中定义的所有方法都定义在一个名为 **Test.Test`1[[System.__Canon, mscorlib]]** 的类中。
当如上代码片段所示调用 `objTestClass` 变量上的 `Show` 方法时,CLR 会在运行时调用由 `Test.Test`1[[System.__Canon, mscorlib]]` 类型定义的方法。这种将所有类型参数为引用类型的封闭泛型类型的方法定义保留在一个类型中的设计,可以减少代码膨胀。这种设计是可行的,因为在泛型类型的任何类型参数变量上的所有方法调用都只是通过已知的接口或基类进行的。因此,CLR 不需要为提供的每个引用类型重写方法 IL。此外,方法参数或涉及类型参数的返回值都将是对象引用,从内存布局和复制语义的角度来看,它们对于任何引用类型参数都是相同的。
而对于封闭泛型类型 `Test<DisplayStruct>`,CLR 创建一个非空类型 **Test.Test`1[[Test.DisplayStruct, ConsoleApplication5]]**。该类型具有开放泛型类型 `Test<T>` 中定义的所有方法。虽然在类型参数上的方法调用行为对于值类型参数和引用类型参数是相同的,但复制语义和内存布局是不同的。因此,如果开放泛型类型 `Test<T>` 中的方法返回一个类型参数变量,那么该方法应该返回精确的值类型实例。因此,不可能为从开放泛型类型构造的所有封闭泛型类型(其类型参数为值类型)提供一个通用基类。
可能出现一种情况,即一个开放泛型类型有多个类型参数,并且程序使用引用类型和值类型的混合类型来创建封闭泛型类型。在这种情况下,CLR 会为一组具有相同值类型组合以及任何引用类型组合的封闭泛型类型创建一个基本类型。
当 `Show` 方法通过类型参数变量(如上代码片段所示的 `temp.Display()`)调用 `Display` 方法时,C# 会发出对适当接口方法的 `callvirt`,`callvirt instance void Test.IDisplay::Display()`(或者如果类型参数提供了基类约束,则为对基类方法的 `callvirt`)。C# 发出的 `callvirt` IL 指令前面还会加上 `constrained` IL 指令。这是因为 C# 在编译时不知道类型参数的类型。`constrained` IL 指令允许 CLR 在运行时检查类型参数变量的类型,如果它是引用类型,则执行对该方法的虚分派。如果类型参数变量的类型是值类型,则分派方式如“虚方法分派(方法调用)”部分所述。
尽管类型参数的类型在运行时是已知的,但 CLR 不会修改方法的 IL。当类型参数是引用类型时,这很有意义,因为所有使用任何引用类型作为参数的封闭泛型类型都共享开放泛型类型方法的相同方法代码。但是,当使用值类型构建封闭泛型类型时,CLR 即使在这种情况下也不会修改封闭泛型类型方法的 IL 以进行非约束调用。这是我尚未找到答案的一个问题。
泛型值类型
CLR 以与泛型引用类型类似的方式处理泛型值类型,例如使用 `System.__Canon` 类型,使用受约束的虚方法调用等。