前言前面我介绍了C#多线程和异步编程的相关内容。本篇文章将介绍C#中使用类型和CRL时需要掌握的基础知识。包括CLR工作流程、类型转换等内容最后还会解释类型、对象、线程栈和托管堆在运行时的关系。传动门C#、多线程C#、异步编程CLR在说后面的内容之前我们要简单介绍一下CLR。全称是 Common Language Runtime翻译过来就是公共语言运行库。CLR是.NET框架的核心组件负责管理代码的执行提供内存管理、线程管理、异常处理、垃圾回收、安全性等服务。CLR的工作流程编译C#代码被编译为中间语言IL和元数据存储在程序集Assembly中。加载CLR加载程序集读取IL代码和元数据。JIT编译CLR通过即时编译器JITJust-In-Time将IL代码转换为机器码。执行CLR执行编译后的机器码管理内存、线程和异常。CLR将C#代码编译为中间语言IL所以CLR是支持跨语言的支持多种 .NET 语言如 C#、VB.NET、F#并允许它们互操作因为它们最终都被编译为相同的 IL 代码。CLR的核心功能代码执行CLR负责将C#等.NET语言编译的中间语言ILIntermediate Language代码转换为机器码并执行。内存管理CLR自动管理内存分配和回收通过垃圾回收机制GC释放不再使用的对象。线程管理CLR提供多线程支持管理线程的创建、调度和同步。异常处理CLR提供统一的异常处理机制支持跨语言异常处理。类型安全CLR确保代码的类型安全防止非法类型转换和内存访问。等等总结下来就是CLR将我们写的C#代码编译并执行为计算机可以运行的机器码同时管理应用程序的运行环境包括内存管理、线程管理、异常处理等内容。但是上面说的CRL是支持跨语言不支持跨平台的只能在Windows上运行。现在我们说的.NET5/6/7/8...。都是支持跨平台可以在Linux、macOS上运行。所以微软后面为了支持跨平台提出了CoreCLR。CoreCLR相较于CLR其实基础功能是没有变的包括内存、内存管理等。主要区别是CoreCLR支持跨平台同时还支持现代应用场景如云原生、微服务、容器化等等。所有类型都从System.Object派生运行时就是CLR要求每个类型最终都是从System.Object类型派生。也就是说下面两个类型完全一致。public class Test { } //显式继承System.Object public class Test: System.Object { }由于所有类型最终都从System.Object类型派生所以每个类型的每个对象都保证了一组最基本的方法。如下表所示方法说明Equals比较当前对象与指定对象是否相等。引用相等性检查即比较两个对象是否引用同一内存地址可以通过在自定义类中重写此方法以实现值相等性比较。GetHashCode返回对象的哈希码用于支持哈希表基于对象的内存地址生成哈希码。如果重写了 Equals 方法通常也需要重写 GetHashCode以确保相等的对象具有相同的哈希码。ToString返回对象的字符串表示形式默认返回对象的完全限定类型名称例如 Namespace.ClassName通常需要重写此方法以返回更有意义的字符串表示。GetType返回当前对象的运行时类型通常返回的 Type 对象可以用于反射获取类型信息如属性、方法等。但需要注意的一点是这是一个非虚方法不能重写。我们平时定义一个对象后会通过new来创建该对象比如以下方式Test test new Test();这也是CLR要求创建对象的方式。那么new到底做了哪些事呢下面详细介绍一下计算类型以及所有基类型一直到System.Object中定义的所有实例字段需要的字节数。托管堆上每个对象都需要一些额外成员包括类型对象指针和同步块索引。CRL利用这些成员管理对象。额外成员的字节数要计入对象大小。从托管堆中分配类型要求的字节数从而分配对象的内存分配的所有字节都设为0。初始化对象的 类型对象指针 和 同步块索引。调用类型的实例构造器构造器其实就是构造函数如果没有显式构造函数会默认创建一个无参构造函数每个类型的构造器都负责初始化该类型的实例字段最终会调用到System.Object的构造器该构造器什么都不做简单地返回。new执行了所有这些操作之后返回指向新建对象一个引用就是我们常说的指针。在前面的代码中该指针保存到变量test中后者具有Test类型。顺便说一句没有new操作符对应的delete操作符也就是说没有办法显式释放为对象分配的内存。CLR采用垃圾回收机制也就是GC能自动检测到一个对象不再被使用或访问并自动释放该对象的内存。GC后面会单独再做介绍。这里简单说下GC就是常说的垃圾回收主要回收一些托管资源比如一个普通的类它的对象会存放在托管堆中、string Name 也是一个托管资源因为 string 本质上也是一个引用类型由 GC 管理还有经常用的ListT都是托管资源。非托管资源需要手动释放的资源比如数据库连接、网络请求、读取文件等等。如果不手动释放会造成内存的OOM内存溢出。类型转换CLR最重要的特性之一就是类型安全。在运行时CLR总是知道对象的类型是什么。调用GetType方法就可以知道对象的确切类型。我们开发人员有时候需要将对象从一种类型转换成另一种类型。CLR允许将对象转换成它的实际类型或者它的任何基类型向基类型转换被认为是一种安全的隐式转换。然而将对象转换为它的某个派生类型时C#要求开发人员只能进行显示转换因为这种转换可能在运行时失败。public void T1() { //隐式转换 Object o new Employee(); //显式转换 Employee e (Employee)Object; }但有的时候代码编译的时候没有问题在运行时转换类型会报错比如下面的代码public void T2() { var now DateTime.Now; PromoteEmployee(now ); } public void PromoteEmployee(Object o) { Employee e (Employee )o; ...... }正常我们肯定不会写这种代码但是可能也会出现类似的情况。上述代码编译肯定没问题但是在运行的时候肯定会抛异常针对这种情况C#使用is和as操作符来转型。is操作符is操作符用于检查对象是否兼容于指定类型返回布尔值true或false。注意is永远不会抛出异常如果对象引用的是nullis操作符总是返回false因为没有可检查其类型的对象。具体用法如下所示public void T3() { Object o new Object(); if(o is Employee) { Employee e (Employee)o; ...... } }as操作符有了is为啥又有as了呢这个区别我们留着下面再说。as操作符用于返回对同一个对象的非null引用。跟is不同的是as要么返回该对象的引用要么返回null。具体用法如下所示public void T4() { Employee e o as Employee(); if(e ! null) { ...... } }as操作符的工作方式与强制类型转换一样只是它永远不会抛出异常。相反如果对象不能转换则返回null。主要区别is操作符is在CLR中实际检查了两次对象类型。首先会核实是否兼容于指定类型如果返回true则在if内部中进行类型转换时CLR会再次核实是否引用了指定类型。这是因为CLR必须遍历继承层次结构用每个基类型去核对指定的类型。as操作符as在CLR中只检查一次对象类型只会核实是否兼容于指定类型。如果是那就返回对象的引用如果不是就返回null。总结来说is相较于as增加了安全性但相对于as性能更低一些。运行时的相互关系在介绍类型、对象、线程栈、托管堆在运行时的相互关系前我们先说一下线程栈和托管堆。这也是我们经常听到的。托管堆托管堆是CLR管理的一块内存区域用于存储引用类型对象比如类的实例、数组等等。目前大家只需要知道是存储什么类型的对象就行具体后面在GC垃圾回收的文章中再详细介绍托管堆。每个.NET应用程序都有自己的托管堆。线程栈线程栈是操作系统为每个线程分配的一块内存区域用于存储线程执行过程中的局部变量、方法调用信息和执行上下文。线程栈是线程私有的每个线程都有自己的栈空间。线程创建时会分配1MB的栈栈空间用于向方法传递实参方法内部定义的局部变量也在栈上。托管堆与线程栈的区别内容托管堆线程栈存储内容引用类型对象如类实例、数组局部变量、方法调用信息内存管理由垃圾回收器GC管理自动管理方法结束时释放分配速度相对线程栈较慢需要查找可用内存非常快生命周期由垃圾回收器决定与方法调用绑定线程私有否托管堆由所有线程共享是类型和对象类型是数据的抽象表示定义了数据的结构、行为和约束。总体来说就是引用类型和值类型。对象就是类型的实例。下面来具体介绍一下类型、对象、线程栈、托管堆在运行时的相互关系。下面展示一段C#代码代码没有任何实际意义来介绍线程栈和运行时的关系。public void M1() { string name Tom; M2(name); ...... return; } public void M2(string s) { int len s.Length; ...... return; }根据上面的代码Windows会加载一个CLR的程序进程。我们知道一个进程可能有多个线程。线程创建的时候会分配一个1MB的栈如下图所示栈是从高位内存地址向低位内存地址构建。上述图中的线程栈有背景颜色的部分表示已经被一部分数据占用了也有栈初始化时的一些内容。在执行到方法时栈会初始化创建prologue序幕代码和 epilogue尾声代码主要作用是可以返回到调用者代码继续往下执行。其中prologue和epilogue代码在方法执行完毕后会对进行清理。第一步在执行到M1方法时它的prologue代码在线程栈上分配局部变量name的内存如图所示第二步M1调用M2方法将局部变量name作为实参传递。这造成那么局部变量中的地址被压入栈。M2方法内部使用参数变量s标识栈位置。最后还会将返回地址压入栈被调用的方法在结束之后应返回至该位置。如下图所示第三步M2方法开始执行同理它的prologue代码在线程栈中为局部变量len如果还有其他变量分配内存。然后M2方法内部的代码开始执行最终会抵达return处此时CPU的指针被设置成栈中的返回地址。就是会返回到调用M2之前M1之后的代码然后M1继续执行。如下图所示上面说的相关类型都是int、string那如果是对象呢线程栈上又是怎么记录的。说到对象肯定离不开托管堆下面来一起说一下。假设有下面两个类public class Employee { public int GetYearsEmployed() { ...... } public virtual string GetProgressReport() { ...... } public static Employee Lookup(string name) { ...... } } public class Manager : Employee { public override string GetProgressReport() { } }跟上面M1和M2一样在Windows上已经启动一个进程CLR已经加载托管堆也初始化完成。这是有个请求创建了一个线程包括1MB的栈空间该线程要调用M3方法public void M3() { Employee e; int year; e new Manager(); e Employee.Lookup(Tom); year e.GetYearsEmployed(); e.GetProgressReport(); ...... return; }第一步JIT编译器将M3的IL代码转换成本机CPU指令时会注意到M3内部引用的所有类型包括Employee、int、Manager等等。CLR要确认定义的这些类型的所有程序集已经加载完成。然后利用程序集的元数据就是这些类型的有关信息提取相关类型的信息创建一些数据结构来表示类型本身。如下图所示上面我们说过堆上所有对象都包含两个额外成员类型对象指针 和 同步块索引同时每个类型对象最后还包含一个方法表。在方法表中类型定义的每个方法都有对应的记录项。这里要说一下类型对象类型对象包含该类型的元数据如字段、方法、属性等它是类型的描述信息通常存储在方法表中并在类首次被访问时初始化。不会被GC回收掉第二步现在要正式的调用M3方法线程也已经创建好了如下所示作为prologue序幕代码CLR自动将所有局部变量初始化为null或者0。第三步M3方法内部new了一个Manager对象这时会在托管堆中创建一个Manager类型的实例。如下图所示可以看出和所有对象一样Manager对象也有类型对象指针和同步块索引。该对象还包含必要的字节来容纳Manager类型定义的所有实例数据字段包括父类Employee和Object定义的所有实例字段。任何时候在堆上新建对象CLR都自动初始化内部的 类型对象指针 成员来引用和对象对应的类型对象。在new之后返回Manager对象的内存地址并将该地址保存到变量e中。e在线程栈上这里总结一下类型对象和对象类型对象 在类型首次使用时创建且在整个进程中共享也存储在托管堆中不会被GC掉。对象 在每次new时都会创建并存储在托管堆中等到GC 回收时销毁。第四步M3的下一行代码调用Employee的静态方法Lookup。调用静态方法时CLR会定位与定义静态方法的类型对应的类型对象。然后JIT编译器在类型对象的方法表中查找与被调用方法对应的记录项对方法进行JIT编译如果需要的话再调用JIT编译好的代码。上面的例子中假定Lookup方法要查找数据库来查询TomTom并且还是公司的一名经理所以Lookup内部代码还包含Manager对象的构造用于查询Tom然后返回该对象的地址。该地址保存到局部变量e中如下图所示注意e不再是第一个Manager对象。事实上由于第一个Manager对象没有变量引用该对象所以它是未来垃圾回收的主要目标 。第五步M3代码继续执行下一行代码调用Employee的实例方法GetYearsEmployed。调用实例方法时JIT编译器会找到与 发出调用的那个变量的类型对应的类型对象。此时该类型对象是Employee。诶不对不应该是Manager类型对象吗这是因为Manager类型对象中的方法表没有对应的GetYearsEmployed实例方法。JIT编译器会回溯类的层次结构一直回溯到Object并在沿途的每个类型中查找该方法。之所以这样回溯是因为每个类型对象都有一个字段引用了它的基类型也就是继承的父类只是在图中没有显示这里大家知道就可以了。同理JIT编译器在类型对象的方法表中找到了方法的记录项对方法进行JIT编译如果需要的话再调用编译好的JIT代码。假设Tom在公司工作了10年那么返回10并赋值给栈中的year变量。如下图所示第六步M3代码继续执行下一行代码调用Employee的虚方法GetProgressReport。调用虚方法时JIT编译器要在方法中生成一些额外代码方法每次调用都会执行这些代码。这些代码首先检查发出调用的变量并跟随地址来到发出调出的对象。变量e引用的是Manager对象然后代码检查出对象内部的 类型对象指针 该指针指向对象的实际类型。最后代码在实际类型对象的方法表中找到对应的记录项。同理对方法进行JIT编译如果需要的话再调用JIT编译好的代码。如下图所示注意如果变量e引用的是Employee那么最终执行的是Employee的GetProgressReport方法。总结CLR主要就是管理我们发布的应用程序包括内存管理、线程管理、异常处理、垃圾回收等等。CLR创建线程时会在内存中分配1MB的栈。线程栈和运行时CLR的关系。线程栈中记录方法中定义的值类型变量还包括方法的返回地址、方法入参、当前方法的执行状态、寄存器值执行上下文等等信息。其中如果变量是引用类型还会记录指针指向托管堆中类的实例对象。当 .NET 程序启动时CLR 会在进程的虚拟地址空间中申请一块内存作为托管堆的初始区域。不同的操作系统初始化内存大小不一样需要注意的是负责托管堆的内存是根据运行时动态变化的具体等后面介绍GC的时候再说。托管堆中存放了所有引用类型的类型对象和实例对象这个上面也说过了。大家可能对上面说的JIT编译的代码有点疑惑这里简单说一下。在托管堆中类型对象末尾会记录该类中所有的方法项。这里的方法项里面不包含代码的只是一个引用地址指向具体的代码地址。然后通过该地址找打具体的代码然后JIT编译通常是在第一次调用该方法时编译后续就不需要。如果已经编译好了那就直接用。