一、System.Object
运行时要求每个类型最终都要从System.Object
派生,它提供了如下几个基本方法:
方法名 | 说明 |
---|---|
Equals() | 虚方法。两个对象具有相同的值,就返回true |
GetHashCode() | 虚方法。返回对象的哈希码 |
ToString() | 虚方法。默认返回类型的完整名称 |
GetType() | 非虚方法。指出对象是什么类型 |
MemberwiseClone() | 非虚方法。创建该类型的新实例,且其实例字段与this 的实例字段完全一致 |
Finalize() | 虚方法。在对象内存被回收之前会被调用 |
CLR要求所有的对象都用new
操作符创建。比如:
Apple apple = new Apple();
在这期间,new
操作符其实做了以下几件事情:
- 计算类型及其所有基类型中定义的所有实例字段所需的字节数。此外,还需要加上一些额外的成员所占的字节数,包括:类型对象指针、同步块索引,还有一个未公开的内部字段(用于计算对象实例的哈希值)。
- 从托管堆中分配计算好的字节数的内存,并且所有的二进制位都初始化为0。
- 初始化对象的“类型对象指针”和“同步块索引”。
- 调用类型的构造器,传递指定的实参。每个类型的构造器都负责初始化自己的实例字段。最终会调用到
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
都指向这个位置
接下来将局部变量name
、参数s
压栈,ESP
随之移动
接下来需要调用M2
,首先要将M2
的返回地址压栈(用来指明M2
执行完成后接下来执行哪条指令)
然后需要将现在的EBP
地址(也就是M1
的栈基址)压栈,用来在M2
调用完成后恢复EBP
将EBP
挪到与ESP
相同的位置,此时算是正式进入了M2
的栈帧
接下来将参数s
压栈,局部变量length
和tally
压栈
然后M2
返回了,此时会将ESP
挪动到EBP
位置
因为此时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
的本机代码。
首先为局部变量分配线程栈空间,并设置默认值(null或0)
接下来代码构造了一个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#托管堆和垃圾回收