运行时要求每个类型最终都要从System.Object
派生,它提供了如下几个基本方法:
方法名 | 说明 |
---|---|
Equals() |
虚方法。两个对象具有相同的值,就返回true |
GetHashCode() |
虚方法。返回对象的哈希码 |
ToString() |
虚方法。默认返回类型的完整名称 |
GetType() |
非虚方法。指出对象是什么类型 |
MemberwiseClone() |
非虚方法。创建该类型的新实例,且其实例字段与this 的实例字段完全一致 |
Finalize() |
虚方法。在对象内存被回收之前会被调用 |
CLR要求所有的对象都用new
操作符创建。比如:
Apple apple = new Apple();
在这期间,new
操作符其实做了以下几件事情:
System.Object
的构造器,并返回。new
执行完这些操作后,会返回指向新建对象的一个引用。
上面有几个名词需要解释一下:
实例字段:指非静态字段,是属于对象的。与之相对的静态字段是属于类的。
类型对象指针:每个对象都是一个类型的实例,而每个类型都由一个Type
类型的实例来表示。类型对象指针就是指向该Type
实例的指针。当然,Type
类型对象本身也是一个类型对象的实例,它的类型对象指针指向了它自己。
同步块索引:可以简单理解为一个指向“同步块”的指针,拥有这个同步块的对象可以支持线程同步。
线程创建时会分配1MB
的栈。栈空间用来向方法传递实参,方法内部定义的局部变量也存在栈上。栈从高位内存地址向低位内存地址构建。
假如线程要调用下面的M1
方法:
void M1()
{
string name = "Joe";
M2(name);
// ...
return;
}
void M2(string s)
{
Int32 length = s.Length;
Int32 tally;
// ...
return;
}
首先执行第一句代码,需要在线程栈上分配局部变量name
的内存
接下来M1
调用M2
方法,需要将局部变量name
作为实参传递。因此name
局部变量中的地址需要被压入栈(在M2
内部用s
标识栈中的位置)
此外,调用方法还会将“返回地址”压入栈,用来在方法调用结束后返回原来的位置
接下来开始执行M2
中的代码。首先在线程栈中为局部变量length
和tally
分配内存
最终,M2
抵达return
语句,CPU的指令指针被设置为栈中的返回地址,M2
的栈帧展开,恢复成下图的样子
(PS:下面的内容是我查了不同的资料后汇总出来的,不一定正确,如果有错误还请帮忙指正!)
上面的线程栈操作模型进行了一些简化,要想深入了解线程栈的操作流程,需要一些汇编基础。
所谓“栈帧”实际上是CPU中的两个寄存器EBP(帧指针) 和 ESP(栈指针) 存储的两个地址所围成的一块线程栈上的区域。这块区域里面存储了当前正在执行的函数的参数、返回地址、局部变量等。
EBP
用来保存正在运行的函数栈帧的开始地址,ESP
用来保存正在运行的函数栈帧的结束地址。上面的函数执行过程中栈帧的变化过程如下:
初始时,先将M1
的调用者函数的栈基址
(EBP
指向的地址)压栈保存,并将EBP
和ESP
都指向这个位置
接下来需要调用M2
,首先要将M2
的返回地址压栈(用来指明M2
执行完成后接下来执行哪条指令)
然后需要将现在的EBP
地址(也就是M1
的栈基址)压栈,用来在M2
调用完成后恢复EBP
将EBP
挪到与ESP
相同的位置,此时算是正式进入了M2
的栈帧
因为此时EBP
所指的内存空间中存放了M1
的栈基址,所以EBP
可以直接跳转到该位置
然后继续弹栈,ESP
挪回M2的返回地址
位置,继续执行M1
的后续指令
在程序运行过程中,CLR还会维护一个用于管理引用类型的堆,即托管堆
。在进程初始化时,由CLR划出一个地址空间区域作为托管堆。当区域被非垃圾对象填满后,CLR会分配更多的区域,直到整个进程地址空间(受进程的虚拟地址空间限制,32位进程最多分配1.5GB,而64位最多可分配8TB)被填满。
接下来以下面这段代码为例,讲解托管堆与线程栈以及类型、对象在运行时的相互关系
class Manager:Employee
{
public override string GetProgressReport(){...}
}
class Employee
{
public Int32 GetYearsEmployed(){...}
public virtual string GetProgressReport(){...}
public static Employee LookUp(string name){...}
}
void M3()
{
Employee e;
Int32 year;
e = new Manager();
e = Employee.LookUp("Joe");
year = e.GetYearsEmployed();
e.GetProgressReport();
}
首先,线程栈和托管堆的初始状态如下(线程已执行过一些代码,马上要执行M3
)
JIT编译器将M3
的IL代码转为本机指令时,会收集方法内部引用的所有类型(如Employee
、Manager
。String
、Int32
等这里不做展示),确认定义了这些类型的程序集都已经加载。然后利用程序集的元数据,在托管堆上创建一些数据结构来表示类型本身。
当CLR确认方法需要的所有类型对象都已经创建,M3
的代码已经编译后,就允许线程执行M3
的本机代码。
接下来代码构造了一个Manager
对象,这会在托管堆上创建一个Manager
类型的一个实例。它除了包含类型对象指针和同步块索引外,还包含Manager
及其基类型定义的所有实例数据字段。当在托管堆上新建对象时,CLR会自动初始化内部的类型对象指针
指向对应的类型对象。此外还会初始化同步块索引
,并将所有实例字段设为null或0。接下来调用类型构造器,new 操作符会返回对象的内存地址。这个地址会被保存到变量e
中。
下一行代码会调用Employee
的静态方法LookUp()
。在调用静态方法时,CLR会定位到定义静态方法的类型对象,然后JIT
编译器在类型对象方发表中查找对应的方法的记录项,然后对方法进行即时编译(如果之前没编译过的话),然后再调用编译好的代码。
我们假设这个静态方法内部构造了一个新的Manager
对象,并使用Joe
这个参数进行了初始化。方法最终返回了该对象的地址。这个地址会保存到变量e
中。
此时托管堆中会产生一个没有被引用的Manager
对象。不过后续垃圾回收机制会自动释放该对象占用的内存。
再下一行代码调用了Employee
的非虚实例方法GetYearsEmployed()
。对于非虚的实例方法,JIT
编译器会找到调用者的类型对象(这里是Employee
类型对象)。如果在调用者的类型对象中没有找到该方法,则会进行向父类型对象回溯,一直回溯到Object
。找到后就会进行即时编译。我们假设该方法返回了5。
接下来代码会调用Employee
的虚实例方法GetProgressReport()
。调用虚实例方法时,JIT
首先检查发出调用的变量,根据地址来到发出调用的对象(这里是代表Joe
的Manager
对象)。然后根据对象的“类型对象指针”,找到其对应的类型对象。然后查找被调用的方法记录项,并对其进行即时编译。
这里调用的是Manager
类型对象的GetProgressReport()
方法。但如果在LookUp()
方法中Joe
是Employee
而不是Manager
,那么就会在内部构造一个Employee
对象,这里调用的就会是Employee
类型对象的GetProgressReport()
方法。
[1].函数栈帧·函数调用原理
[2].CPU眼里的:{函数括号} | 栈帧 | 堆栈 | 栈变量
[3].《VLR via C# 第四版》
[4].C#托管堆和垃圾回收