C#、类型基础
前言
前面我介绍了C#多线程和异步编程的相关内容。本篇文章将介绍C#中,使用类型和CRL时需要掌握的基础知识。包括CLR工作流程、类型转换等内容,最后还会解释类型、对象、线程栈和托管堆在运行时的关系。
传动门:
C#、多线程
C#、异步编程
CLR
在说后面的内容之前,我们要简单介绍一下CLR。全称是 Common Language Runtime,翻译过来就是公共语言运行库。CLR是.NET框架的核心组件,负责管理代码的执行,提供内存管理、线程管理、异常处理、垃圾回收、安全性等服务。
CLR的工作流程
- 编译:C#代码被编译为中间语言(IL)和元数据,存储在程序集(Assembly)中。
- 加载:CLR加载程序集,读取IL代码和元数据。
- JIT编译:CLR通过即时编译器(JIT,Just-In-Time)将IL代码转换为机器码。
- 执行:CLR执行编译后的机器码,管理内存、线程和异常。
CLR将C#代码编译为中间语言(IL),所以CLR是支持跨语言的,支持多种 .NET 语言(如 C#、VB.NET、F#),并允许它们互操作,因为它们最终都被编译为相同的 IL 代码。
CLR的核心功能
- 代码执行:CLR负责将C#等.NET语言编译的中间语言(IL,Intermediate 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 管理,还有经常用的List<T>,都是托管资源。
非托管资源,需要手动释放的资源,比如数据库连接、网络请求、读取文件等等。如果不手动释放,会造成内存的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永远不会抛出异常,如果对象引用的是null,is操作符总是返回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方法要查找数据库来查询Tom,Tom并且还是公司的一名经理,所以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编译(通常是在第一次调用该方法时编译,后续就不需要)。如果已经编译好了那就直接用。