所有类型都从System.Object派生
System.Object的几个方法
运行时要求每个类型最终都从System.Object
类型派生。
System.Object
提供了4个public实例方法:
- Equals
- GetHashCode 当某个类型的对象要在哈希表集合汇总作为key使用,就应该重写这个方法
- ToString 默认返回类型的完整名称(
this.GetType().FullName
) - GetType 是一个非虚方法,这是为了避免类重写该方法
同时,System.Object
还有2个protected方法:
- MemberwiseClone 非虚方法,创建于this对象的实例完全一致的实例(浅复制),并返回新实例的引用
- Finalize 虚方法,在GC的时候,回收该实例之前,会被调用
new操作符
CLR要求所有对象都用new
操作符创建。当使用new
操作符时,CLR做了以下事情:
- 计算类型及其所有基类中定义的所有实例字段需要的字节数,除了实例字段以外,还要计算一些额外的成员(overhead成员),包括类型对象指针和同步块索引。
- 从托管堆中分配类型要求的字节数,从而分配对象的内存。
- 初始化对象的类型对象指针和同步块索引
- 调用类型的实例构造器(.ctor)
new执行了所有以上操作之后,返回一个引用。
类型转换
推荐使用is
和as
来判断类型转换是否可行,以及转换类型。
CLR允许将对象转换为它的实际类型或者它的任何基类类型。
当一个对象要转换成基类类型时,可以隐式转换,但是如果开发者想要把基类转成派生类,就一定要显式转换。
命名空间和程序集
命名空间的作用在于逻辑分组,类似于Java中的包名。
使用using
关键字可以让程序员少打很多字。当编辑器在查找一个类型的时候,会现在源代码或者引用的任何程序集(通过/reference
编辑器开关实现)中找指定名称的类型,找不到后就会把using
的命名空间加到类型名字前边。
CLR对命名空间一无所知,访问一个类型的时候,CLR需要知道的是类的完整名称(命名空间+类名)。
歧义
在不同的命名空间定义了同一个名称的类型,又在使用的时候分别using了这几个命名空间,那么使用这个类的时候就要打入全称。
命名空间别名
1 | using WintellectWidget = Wintellect.Widget; |
可以只引入命名空间中部分类,且赋予其别称。
命名空间与程序集的关系
同一个命名空间中的类型可能在不同程序集中实现,例如System.IO.FileStream
在MSCorLib.dll程序集中实现,而System.IO.FileSystemWatcher
类型在System.dll程序集中实现。
当然反过来,一个程序集可以定义多个命名空间。
查询api文档的时候能够查到类型所在的程序集。
运行时的相互关系
本节探讨类型、对象、线程栈和托管堆在运行时的相互关系,并解释静态方法、实例方法和虚方法的区别。
一个方法运行时线程栈的情况
这里以一个例子来做说明。
下图展示了已加载CLR的一个Windows进程。一个进程可能有多个线程,每个线程创建时会分配到1MB的空间。栈空间用于向方法传递实参,方法内部定义的局部函数变量也在栈上。栈从高位内存向低位内存构建。下图中的阴影部分是一些已执行过的代码使用的数据(可无视)。假定线程即将执行M1方法。
序幕(prologue)代码在方法开始做工作之前对其进行初始化,M1方法执行时,它的序幕代码在线程栈上分配局部变量name。之后栈上的情况如下图:
接着,M1调用M2方法,这需要把局部变量name作为实参传递——把name局部变量中的地址压入栈。除了实参之外,调用方法的时候还会把返回地址压入栈,以便在M2执行完之后继续M1的工作。如下图:
这之后M2的序幕代码运行,在线程栈中分配length
和tally
的内存,然后执行M2的内部代码。
执行完M2的内部代码之后,CPU的指令指针被设置成栈中的返回地址,M2的栈帧(可以简单地理解M2所占用的栈空间)被unwind(反绑,展开),恢复成4-2的样子。
然后M1继续执行。
栈帧 执行线程的过程中,进行的每个方法调用都会在调用栈中创建并压入一个StackFrame。
Explain the concept of a stack frame in a nutshellIf I remember correctly, the function return address is pushed onto the stack first, then the arguments and space for local variables. Together, they make the “frame,” although this is likely architecture-dependent. The processor knows how many bytes are in each frame and moves the stack pointer accordingly as frames are pushed and popped off the stack.
unwind意味着当一个方法执行完毕的时候把这个方法在栈上的数据都pop掉。
方法、虚方法、静态方法
假设有代码:
1 | internal class Employee |
现在我们要执行M3方法,来观察线程栈和托管堆的情况。
1 | void M3() |
初始状态如下图
JIT编译器在将M3的IL代码转成本机CPU指令时,会注意到M3内部饮用到的所有类型,CLR要确保定义了这些类型的程序集都已经被加载。
由于int32和string对象是很常用的,我们可以假定它们在执行M3之前已经被加载了。
下图中xxx类型对象在的位置应该是加载堆(Load Heap),不是GC堆。在M3执行之前就要保证这些类型对象都已经被加载好。
接下来开始执行M3,首先CLR会调用序幕代码,把局部变量初始化为null或0。注意这里是隐式初始化。如果后面的代码视图访问尚未显示初始化的局部变量,C#会报错:使用了未赋值的局部对象。
在运行时,CLR总是知道对象的类型是什么——通过调用GetType
就可以知道对象的确切类型。
图4-7中分配了局部变量。
之后调用了一个new
,造成了对象的生成,如图4-8。
接下来调用了静态方法,返回一个Manager对象。
然后调用了一个非虚方法GetYearsEmployed
。调用非虚实例方法时,JIT编译器找到与发出调用的那个类型(Employee持有了该对象,因故这里就是Employee)对应的类型对象,来找这个方法。如果在本类型对象中找不到该方法,就一路回溯基类,直到找到该方法。在该类型对象的方发表中查找该方法的记录项,对其进行JIT编译吗,然后运行,最后把结果返回到局部变量year中。
接下来是重点了,代码调用了一个虚实例方法GetProgressReport
。首先会在Manager对象的类型对象指针所指向的Manager类型对象中直接查找虚方法的新实现。
类型对象
类型对象的类型都是System.Type
。CLR开始在一个进程中运行时,会立即为MSCorLib.dll中定义的System.Type
类型创建一个特殊的类型对象。任何类型对象都是System.Type
的实例,因此上面例子提到的Employee
和Manager
类型对象的类型对象指针会指向Type
类型对象。
另外,这个特殊的Type
类型的类型对象的类型指针对象,指向自身。
我们调用GetType方法,返回的都是对象的真实类型。
用自己的话总结一下上面的道理
假设有一个类Person
1 | public class Person |
当我们new出一个person实例之后,内存里是这样的:
Person e指向了GC堆中的Person对象。Person对象中的类型对象指针指向了Person类型对象(Person类型对象如字面上理解,本身也是个对象,它的类型是Type)。Person类型对象又指向了Type类型对象,这是一个特殊的类型对象,它的类型对象指针指向自己。