前言

本文翻译自BotR中的一篇,原文链接 Type Loader Design ,可以帮助我们了解CLR的类型加载机制(注意是Type类型,而不是Class类),文中涉及到术语或者容易混淆的地方,我有在随后的括号里列出原文和解释。如有翻译不正确的地方,欢迎指正。

文章内容偏底层,有很多琐碎的概念,后面有机会我会一一写文章介绍。

类型加载器设计

作者: Ladi Prosek - 2007

翻译:几秋 (https://home.cnblogs.com/u/netry/)

介绍

在一个基于类的(class-based)的面向对象系统中,类型(type)是一个模板,它描述了单个实例将包含的数据、将提供的功能。如果不首先定义对象的类型,就不可能创建对象1。如果两个对象是同一个类型的实例,就可以说它们是同一个类型;事实上(即使)两个对象定义了完全相同的成员,它们可能也没有任何关联。

上面一段可以用来描述一个典型的C++系统。CLR必不可少的一个附加功能是完整的运行时类型信息的可用性。为了“管理”托管代码并提供类型安全的环境,运行时必须在任何时候都要能知道任意对象的类型。这种类型信息必须是不用大量计算就可以很容易地得到,因为类型标识查询被认为是相当频繁的(例如,任何类型转换都涉及到查询对象的类型标识,以验证转换是安全并且可以执行的)。

此性能要求排除了所有的字典查找方法,我们只剩下以下架构



图一 抽象宏观的对象设计

除了实际的实例数据之外,每个对象还包含一个类型id的指针, 指向表示该类型的结构。这个概念和C++虚表(v-table)指针相似,但是这个结构(我们现在称为类型,后文会更精确地定义它),它包含的不仅仅是一个虚表,对于实例,它必须包含关于层次结构的信息(即继承关系),以便能够回答“is-a”的包含问题。

1C# 3.0引入的匿名类型,允许你不用显示引用一个类型就可以定义对象 - 只需直接列出其字段即可,不要让这愚弄你,实际上编译器在幕后为你创建了一个类型。

1.1 相关阅读

[1] Martin Abadi, Luca Cardelli, A Theory of Objects, ISBN

978-0387947754

[2] Andrew Kennedy (@andrewjkennedy), Don Syme (@dsyme), [Design and Implementation of Generics for the .NET Common Language Runtime][generics-design]

[generics-design]: http://research.microsoft.com/apps/pubs/default.aspx?id=64031

[3] ECMA Standard for the Common Language Infrastructure (CLI)

1.2 设计目标

类型加载器(type loader)有时也称为类加载器(class loader,常见于各种Java八股文),这种说法严格来说是不正确的,因为类(class)只是类型(type)的子集 - 即引用类型,类型加载器也会加载值类型,它的最终目的是构建表示要求它加载的类型的数据结构。以下是加载器应具有的属性:

  • 快速类型查找 (通过[module, token] 或者 [assembly, name] 查找).
  • 优化的内存布局已实现良好的工作集大小、缓存命中率和 JIT编译后的代码性能。
  • 类型安全 - 不加载格式错误的类型,并抛出一个 TypeLoadException 异常。
  • 并发性 - 在多线程环境中具有良好的可扩展性。

2 类型加载器架构

加载器的入口点(entry-points,可以理解为公开的方法)数量相对较少。尽管每个入口点的签名略有不同,但是它们都有相同的语义。它们采用以元数据 token或者name字符串为形式的类型/成员名称,token的作用域(模块或者程序集),以及一些附加信息如标志;然后以句柄(handle)的形式返回已加载的实体。

在JIT过程中,通常会有调用很多次类型加载器。思考下面的代码:

object CreateClass()
{
return new MyClass();
}

在它IL代码里,MyClass被一个元数据token所引用。为了生成一个对 JIT_New(它是真正完成实例化的函数) 帮助方法的调用指令,JIT会要求类型加载器去加载这个类型并返回一个句柄。然后这个句柄将作为一个立即数(immediate value)直接嵌入到JIT编译后的代码中。类型和成员通常是在JIT过程中被解析和加载的,而不是在运行时阶段,它还解释了像这样的代码有时容易引起混淆的行为:

object CreateClass()
{
try {
return new MyClass();
} catch (TypeLoadException) {
return null;
}
}

如果MyClass加载失败,例如,因为它应该在另一个程序集中定义,并且在最新的版本中被意外删除了,所以此代码仍将抛出TypeLoadException。这里异常不会被捕获的原因是这段代码根本没有执行!这个异常发生在JIT的过程中,只能在调用了CreateClass并使它JIT完成的方法里被捕获。此外,由于内联(inlining)的存在,触发JIT的时机有时并不是那么明显,因此用户不应该期待和依赖于这种不确定的行为。

关键数据结构

CLR中最通用的类型名称是TypeHandle,它是一个的抽象实体,封装了指向一个MethodTable(表示“普通的”类型像System.Object 或者 List<string>)或者一个TypeDesc(表示 byref、指针、函数指针、数组,以及泛型变量)的指针。它构成了一个类型的标识,因为当且仅当两个句柄表示同一类型时,它们才是相等的。为了节省空间, TypeHandle通过设置指针的第二低位为 1(即 (ptr|2))来表示它指向的TypeDesc,而不是用额外的标志。TypeDesc是“抽象的”,并且有如下的继承体系:

图2 TypeDesc体系

TypeDesc

抽象类型描述符。具体的描述符类型由标志确定。

TypeVarTypeDesc

表示一个类型变量,即 List<T>或者Array.Sort<T>中的T(参见下文关于泛型的部分)。类型变量不会在多个类型或者方法间共享,因此每个变量有且只有一个所有者。

FnPtrTypeDesc

表示一个函数指针,实质上是一个引用返回类型和参数的变长type handle列表,这个描述符不太常见,因为C#不支持函数指针。然后托管C++会使用它们。

ParamTypeDesc

这个描述符表示一个byref和指针类型,byref是refout这两个C#关键字应用到方法参数3的结果,而指针类型是非托管的指针,指向unsafe C#和托管C++中使用的数据。

ArrayTypeDesc

表示数组类型. 派生自ParamTypeDesc,因为数组也由单个参数(其元素的类型)参数化。这与参数数量可变的泛型实例化相反。

MethodTable

这是目前为止运行时的最重要的数据结构,它表示所有不属于上述类别的类型(它包括基本类型,开放(open)或闭合(closed)的泛型类型)。它包含了所有关于类型需要快速查找的信息,像它的父类型,实现的接口,和虚表。

EEClass

为了提高工作集和缓存利用率,MethodTable 的数据被分为“热”和“冷”两种结构。MethodTable 本身只存储程序在稳定状态(steady state)下所需的“热”数据;而EEClass存储通常只在类型加载、JIT 编译或者反射中需要的“冷”数据。每个MethodTable 指向一个 EEClass.

此外,EEClass是被泛型共享的,多个泛型MethodTable可以指向同一个EEClass。这种共享对可以存储在 EEClass 上的数据增加了额外的约束。

MethodDesc

顾名思义,此结构用来描述方法。它实际上有几种变体,它们有相应的 MethodDesc子类型,但是大多数都超出了本文的讨论范围。这里只需说其中一个叫做InstantiatedMethodDesc的子类型,它在泛型中扮演了重要角色。更多信息请参考Method Descriptor Design

FieldDesc

MethodDesc相似 , 此结构用来描述字段。除了某些 COM 互操作场景,EE(EEClass中的EE,即Execution Engine,执行引擎)根本不在乎属性和事件,因为它们最终归结为方法和字段,只有编译器和反射才能生成和理解它们,以便提供语法糖之类的体验。

2这对调试很有用,如果一个TypeHandle的值是以2, 6, A, 或者E结尾,那么它就是不是MethodTable,为了成功地观察到TypeDesc,必须清除额外的位。

3注意refout之间的区别仅在于参数属性,就类型系统而言,它们都是相同的类型。

2.1 加载级别

当类型加载器被要求加载一个指定的类型时,例如通过一个typedef/typeref/typespec的token和一个Module,它不会一次性做完所有的工作,而是分阶段完成加载;这是因为一个类型经常会依赖其它的类型,如果在能被其它类型引用之前就完全加载,将导致无限递归和死锁,思考下面的代码:

class A<T> : C<B<T>>
{ } class B<T> : C<A<T>>
{ } class C<T>
{ }

上面的类型都是有效的,显然 AB相互依赖。

加载器首先会创建表示这个类型的一些结构,然后使用无需加载其它类型就可得到的数据来初始化它们。当“没有依赖”的工作完成,这些结构就可以被其它地方所引用,通常是通过将指向它们的指针粘贴到其它结构中。之后,加载器以增量步骤进行,用越来越多的信息填充这些结构,直到类型完全加载完成。在上面的例子中,首先AB的基类会近似于不包括其它类型,然后才会被真正的类型所替代。

所谓的加载加载级别,就是用来描述这些半加载状态( half-loaded),从 CLASS_LOAD_BEGIN开始,到CLASS_LOADED结束,中间还有一些中间级别。在 classloadlevel.h源文件里,每个级别都有丰富且有用的注释。注意,虽然类型可以保存到NGEN镜像中,但表示的结构不能简单地映射或者写入内存,然后不做额外“恢复”工作就使用。一个类型是来自于NGEN镜像并且需要“恢复”,这一信息它的加载级别也可以感知到。

更多关于加载级别的解释请看Design and Implementation of Generics for the .NET Common Language Runtime

2.2 泛型

在没有泛型的世界里,一切都很美好,每个人都很开心,因为每一个普通类型(不是由 TypeDesc 所表示的类型)都有一个 MethodTable指向他关联的EEClass,这个EEClass又指回它的MethodTable,该类型的所有实例都包含一个指向MethodTable,作为偏移量为0处的第一个字段,即在被视为参考值的地址上。为了节省空间,由该类型声明的MethodDesc表示方法,被组织在EEClass指向的块链表中4

图3 具有非泛型方法的非泛型类型

4当然,当托管代码运行时,它不会通过在这些块中查找方法来调用它们,调用一个方法是很“热”的操作,正常只需要访问MethodTable中的信息。

2.2.1 术语

泛型形式参数(Generic Parameter)

一个能被其它类型替换的占位符;如 List<T>中的T。有时也称作形式类型参数(formal type parameter)。一个泛型形式参数有一个名字和一个可选的泛型约束。

泛型实际参数(Generic Argument)

一个替换泛型形式参数的具体类型;如List<int>中的int。注意,一个泛型形式参数也可以被用作泛型实际参数。思考下面的代码:

List<T> GetList<T>()
{
return new List<T>();
}

这个方法有一个泛型形式参数T,被用作泛型列表的泛型实际参数。

泛型约束

泛型形式参数对其潜在的泛型实际参数的可选要求。不满足要求的类型不能替换形式参数,这是类型加载器强制的。有下面三种泛型约束:

  1. 特殊约束

    • 引用类型约束 —— 泛型实际参数必须是一个引用类型(相对应的是一个值类型)。在C#里,用class表示这个约束。
    public class A<T> where T : class
    • 值类型约束 —— 泛型实际参数必须是一个除System.Nullable<T>之外的值类型。C#里使用struct这个关键字。
    public class A<T> where T : struct
    • 默认构造函数约束 —— 泛型实际参数必须有个公开的无参构造函数。C#里用new()表示。
    public class A<T> where T : new()
  2. 基类约束 —— 泛型实际参数必须派生自(或者直接就是)给定的非接口类型。显然最多只能有一个引用类型作为基类约束。

     public class A<T> where T : EventArgs
  3. 接口实现约束 —— 泛型实际参数必须实现(或者直接就是)给定的接口类型。可以同时有多个接口约束。

     public class A<T> where T : ICloneable, IComparable<T>

上面的约束可以被一个显式AND组合起来,即一个泛型形式参数可以约束要派生自一个给定的类,实现几个接口,并且还要有默认的构造函数。声明类型的所有泛型参数都可以用来表示约束,从而在参数之间引入相互依赖关系,例如:

public class A<S, T, U>
where S : T
where T : IList<U> {
void f<V>(V v) where V : S {}
}

实例(Instantiation)

一组泛型实际参数,用来替换泛型类型或者方法中的泛型形式参数。每一个加载的泛型和方法都有它的实例。

典型实例(Typical Instantiation)

一个实例仅仅包含类型或者方法自己的类型参数,且和声明参数一样的顺序。每个泛型类型和方法只存在一个典型实例。通常当我们提到开放泛型类型(Open generic type)时,就是指它的典型实例,例如:

public class A<S, T, U> {}

C#会把typeof(A<,,>)编译为一个ldtoken A\'3,让运行时加载S , T , U实例化的 A`3

规范实例(Canonical Instantiation)

一个所有泛型实际参数都是System.__Canon的实例。System.__Canon是一个定义在mscorlib中的内部类型,其任务只是为了作为规范,并且与其它可能用作泛型实际参数的类型不同。带有规范实例的类型/方法被用作所有实例的代表,并携带所有实例共享的信息。由于System.__Canon显然不能满足泛型形参上可能携带的任何约束,因此约束检查对于System.__Canon是特例,会忽略了这些行为。

2.2.2 共享

随着泛型的出现,运行时加载的类型数量变得更多了,虽然不同实例的泛型(如List<string> and List<object>)是不同的类型,它们都有自己的MethodTable,事实证明,他们有大量信息可以共享。这种共享对内存足迹(memory footprint)有积极的影响,因此也会提高性能。



图4 带有非泛型方法的泛型类型 - 共享EEClass

当前所有包含引用类型的实例都共享同一个EEClass 和 它的 MethodDesc。这是可行的,因为所有的引用类型大小都一样 —— 4或者8个字节。因此所有这些类型的布局都是相同的。上图为List<object>List<string>阐明了这点。规范的 MethodTable在第一个引用类型实例被加载之前就自动创建,它包含了热数据,但不是特定于像非虚的方法槽(non-virtual slots)或者RemotableMethodInfo实例。仅包含值类型的实例是不共享的,并且每个这种实例化的类型都有自己的未共享EEClass

目前为止已加载泛型类型的MethodTable,会被缓存到一个属于它们加载器模块(loader module)的哈希表中5。在一个新的实例构造之前,首先会查询这个哈希表,确保不会有两个多种多个 MethodTable实例表示同一个类型。

更多关于泛型共享的信息请看Design and Implementation of Generics for the .NET Common Language Runtime

5从NGEN镜像加载的类型,事情会变得有点复杂。

【译】CLR类型加载器设计的更多相关文章

  1. 手撸Spring框架,设计与实现资源加载器,从Spring.xml解析和注册Bean对象

    作者:小傅哥 博客:https://bugstack.cn 沉淀.分享.成长,让自己和他人都能有所收获! 一.前言 你写的代码,能接的住产品加需求吗? 接,是能接的,接几次也行,哪怕就一个类一片的 i ...

  2. JS框架设计之加载器所在路径的探知一模块加载系统

    1.要加载一个模块,我们需要一个URL作为加载地址,一个script作为加载媒介,但用户在require是都用ID,我们需要一个将ID转换为URL的方法,思路很简单,强加个约定,URL的合成规则是为: ...

  3. Loader加载器

    今天学到了这个Loader,浅谈一下自己的看法: 1.定义 Loader是一个加载器,可以用来它访问数据,可以看做访问数据的机器(好比挖掘机).装再器从android3.0开始引进,它使得在activ ...

  4. Webpack模块加载器

    一.介绍 Webpack是德国开发者 Tobias Koppers 开发的模块加载器,它能把所有的资源文件(JS.JSX.CSS.CoffeeScript.Less.Sass.Image等)都作为模块 ...

  5. 《你必须知道的.NET》读书实践:一个基于OO的万能加载器的实现

    此篇已收录至<你必须知道的.Net>读书笔记目录贴,点击访问该目录可以获取更多内容. 一.关于万能加载器 简而言之,就是孝顺的小王想开发一个万能程序,可以一键式打开常见的计算机资料,例如: ...

  6. jvm(1)类的加载(三)(线程上下文加载器)

    简介: 类加载器从 JDK 1.0 就出现了,最初是为了满足 Java Applet 的需要而开发出来的. Java Applet 需要从远程下载 Java 类文件到浏览器中并执行. 现在类加载器在 ...

  7. How Tomcat Works 读书笔记 八 加载器 上

    Java的类加载器 具体资料见 http://blog.csdn.net/dlf123321/article/details/39957175 http://blog.csdn.net/dlf1233 ...

  8. JVM类加载(4)—加载器

    定义: 虚拟机设计团队把类加载阶段中“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类.实现这个动作的代码模块称之为“类加载器 ...

  9. <JVM中篇:字节码与类的加载篇>04-再谈类的加载器

    笔记来源:尚硅谷JVM全套教程,百万播放,全网巅峰(宋红康详解java虚拟机) 同步更新:https://gitee.com/vectorx/NOTE_JVM https://codechina.cs ...

随机推荐

  1. 编写可维护的webpack配置

    为什么要构建配置抽离成npm包 通用性 业务开发者无需挂住配置 统一团队构建脚本 可维护性 构建配置合理的拆分 README文档, chan 构建配置管理的可选方案 通过多个配置管理不同环境的构建, ...

  2. java,捕获和抛出异常

    package Exrro; public class Test { //ctrl + alt + T快速生成异常捕捉 public static void main(String[] args) { ...

  3. Note -「Dsu On Tree」学习笔记

    前置芝士 树连剖分及其思想,以及优化时间复杂度的原理. 讲个笑话这个东西其实和 Dsu(并查集)没什么关系. 算法本身 Dsu On Tree,一下简称 DOT,常用于解决子树间的信息合并问题. 其实 ...

  4. C++ 加速(卡常)技巧【超级 快读、快写】

    C++ \texttt{C++} C++ 加速技巧 快读快写 快读 inline int read() { int x = 0, w = 0; char ch = 0; while (!isdigit ...

  5. python3学习笔记之字符串

    字符串 1.一个个字符组成的有序的序列,是字符的集合: 2.使用单引号.双引号.三引号引住的字符序列 3.字符串是不可变对象 4.python3起,字符串就是Unicode类型: 字符串特殊举例: 不 ...

  6. 关于2022年3月9日之后Typora登录不了--已解决

    p.s.今天是2022.7.27,软件版本:13.6.1 (以下所有方法,亲自尝试后整理出的) 报错信息: This beta version of typora is expired, please ...

  7. AI全流程开发难题破解之钥

    摘要:通过对ModelArts.盘古大模型.ModelBox产品技术的解读,帮助开发者更好的了解AI开发生产线. 本文分享自华为云社区<[大厂内参]第16期:华为云AI开发生产线,破解AI全流程 ...

  8. 面试突击69:TCP 可靠吗?为什么?

    相比于 UDP 来说,TCP 的主要特性是三个:有连接.可靠.面向数据流.所谓的"有连接"指的是 TCP 中的连接管理机制,也就是著名的三次握手和四次挥手,就像打电话一样,想要正常 ...

  9. Odoo14 设置Binary字段默认值

    1 # Odoo 中的附件也就是Binary字段都是经过特殊处理的 2 # 首先是上传的时候会进行base64编码后再上传到服务器 3 # 服务器进行压缩存放在odoo文件仓库中 4 # 每个odoo ...

  10. 【原创】Python 懂车帝口碑爬虫

    本文所有教程及源码.软件仅为技术研究.不涉及计算机信息系统功能的删除.修改.增加.干扰,更不会影响计算机信息系统的正常运行.不得将代码用于非法用途,如侵立删! 懂车帝综合口碑 需求 操作环境 win1 ...