CLR via C#(二)线程栈与托管堆

一、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的内存
image.png

接下来M1调用M2方法,需要将局部变量name作为实参传递。因此name局部变量中的地址需要被压入栈(在M2内部用s标识栈中的位置)
image.png

此外,调用方法还会将“返回地址”压入栈,用来在方法调用结束后返回原来的位置
image.png

接下来开始执行M2中的代码。首先在线程栈中为局部变量lengthtally分配内存
image.png

最终,M2抵达return语句,CPU的指令指针被设置为栈中的返回地址,M2栈帧展开,恢复成下图的样子
image.png

拓展:栈帧

(PS:下面的内容是我查了不同的资料后汇总出来的,不一定正确,如果有错误还请帮忙指正!)
上面的线程栈操作模型进行了一些简化,要想深入了解线程栈的操作流程,需要一些汇编基础。
所谓“栈帧”实际上是CPU中的两个寄存器EBP(帧指针)ESP(栈指针) 存储的两个地址所围成的一块线程栈上的区域。这块区域里面存储了当前正在执行的函数的参数、返回地址、局部变量等。
EBP用来保存正在运行的函数栈帧的开始地址,ESP用来保存正在运行的函数栈帧的结束地址。上面的函数执行过程中栈帧的变化过程如下:

初始时,先将M1的调用者函数的栈基址EBP指向的地址)压栈保存,并将EBPESP都指向这个位置
image.png

接下来将局部变量name、参数s压栈,ESP随之移动
image.png

接下来需要调用M2,首先要将M2的返回地址压栈(用来指明M2执行完成后接下来执行哪条指令)
image.png

然后需要将现在的EBP地址(也就是M1的栈基址)压栈,用来在M2调用完成后恢复EBP
image.png

EBP挪到与ESP相同的位置,此时算是正式进入了M2的栈帧
image.png

接下来将参数s压栈,局部变量lengthtally压栈
image.png

然后M2返回了,此时会将ESP挪动到EBP位置
image.png

因为此时EBP所指的内存空间中存放了M1的栈基址,所以EBP可以直接跳转到该位置
image.png

然后继续弹栈,ESP挪回M2的返回地址位置,继续执行M1的后续指令
image.png

三、托管堆

在程序运行过程中,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
image.png

JIT编译器将M3的IL代码转为本机指令时,会收集方法内部引用的所有类型(如EmployeeManagerStringInt32等这里不做展示),确认定义了这些类型的程序集都已经加载。然后利用程序集的元数据,在托管堆上创建一些数据结构来表示类型本身。
image.png

当CLR确认方法需要的所有类型对象都已经创建,M3的代码已经编译后,就允许线程执行M3的本机代码。

首先为局部变量分配线程栈空间,并设置默认值(null或0)
image.png

接下来代码构造了一个Manager对象,这会在托管堆上创建一个Manager类型的一个实例。它除了包含类型对象指针和同步块索引外,还包含Manager及其基类型定义的所有实例数据字段。当在托管堆上新建对象时,CLR会自动初始化内部的类型对象指针指向对应的类型对象。此外还会初始化同步块索引,并将所有实例字段设为null或0。接下来调用类型构造器,new 操作符会返回对象的内存地址。这个地址会被保存到变量e中。
image.png

下一行代码会调用Employee的静态方法LookUp()。在调用静态方法时,CLR会定位到定义静态方法的类型对象,然后JIT编译器在类型对象方发表中查找对应的方法的记录项,然后对方法进行即时编译(如果之前没编译过的话),然后再调用编译好的代码。

我们假设这个静态方法内部构造了一个新的Manager对象,并使用Joe这个参数进行了初始化。方法最终返回了该对象的地址。这个地址会保存到变量e中。
image.png

此时托管堆中会产生一个没有被引用的Manager对象。不过后续垃圾回收机制会自动释放该对象占用的内存。

再下一行代码调用了Employee的非虚实例方法GetYearsEmployed()。对于非虚的实例方法,JIT编译器会找到调用者的类型对象(这里是Employee类型对象)。如果在调用者的类型对象中没有找到该方法,则会进行向父类型对象回溯,一直回溯到Object。找到后就会进行即时编译。我们假设该方法返回了5。
image.png

接下来代码会调用Employee的虚实例方法GetProgressReport()。调用虚实例方法时,JIT首先检查发出调用的变量,根据地址来到发出调用的对象(这里是代表JoeManager对象)。然后根据对象的“类型对象指针”,找到其对应的类型对象。然后查找被调用的方法记录项,并对其进行即时编译。
image.png

这里调用的是Manager类型对象的GetProgressReport()方法。但如果在LookUp()方法中JoeEmployee而不是Manager,那么就会在内部构造一个Employee对象,这里调用的就会是Employee类型对象的GetProgressReport()方法。
image.png

最后,再用图来说明一下前面第一节讲的“类型对象指针”
image.png

四、参考资料

[1].函数栈帧·函数调用原理
[2].CPU眼里的:{函数括号} | 栈帧 | 堆栈 | 栈变量
[3].《VLR via C# 第四版》
[4].C#托管堆和垃圾回收

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
CLR via C# 中文版 第三版 (高清 PDF 全 带书签) 引用清清月儿说的话就是,此书不看,看遍千书也枉然。 地球人都知道此书的权威性。 .NET技术领域有两位世界级专家。      一位是Don Box。他以《Essential COM》确立了自己COM专家的地位,在.NET时代,Don Box又以《Essential .NET》(Volume I The Common Language Runtime)确立了自己.NET专家的地位。2002年,Microsoft将其招致麾下,成为.NET Architect,与Anders Hejlsberg一起研发并推出了Linq。由于Don Box的杰出贡献,Microsoft授予其“杰出工程师(Distinguished Engineer)”称号,目前从事声明式语言及工具的开发。可能是工作繁忙,以致没有后续著作问世。      另一位是Jeffrey Richter。Jeffrey Richter是.NET与Windows技术的咨询培训机构Wintellect的共同创立者(co-founder),在Windows领域早已是家喻户晓的世界级专家。从1999年起参与Microsoft .NET平台的研发,受Microsoft委托,为其开发人员提供技术咨询。在此过程中,诞生了《CLR via C#》。      这本书的第一版名为《Applied Microsoft .NET Framework Programming》,2002年出版,阐述的是.NET 1.0/1.1的相关内容。于2006年推出了针对 .NET 2.0的第版,书名改为《CLR via C#》。2010年2月,Jeffrey Richter又推出了针对Visual Studio 2010、.NET 4.0、C# 4.0的集大成之作《CLR via C#》第三版。      本书分为五个部分:      第一部分,CLR基础(CLR Basics),介绍CLR的执行模型,程序集概念,以及创建、打包、部署、管理程序集等。      第部分,设计类型(Designing Types),包括CLR类型基础,基础类型,方法,特性(Property),事件,泛型,接口等内容。      第三部分,基本类型(Essential Types),包括字符、字符串及文本的处理,枚举类型,数组,委托(Delegate),自定义属性(Attribute),可控制类型等。      第四部分,核心设施(Core Facilities),包括异常与状态管理,自动内存管理(垃圾收集),CLR托管与应用程序域(AppDomain),程序集加载与反射,运行时序列化等。      第五部分,线程(Threading),这是第三版新增加的内容,包括线程基础,计算密集的异步操作,I/O密集的异步操作,基本的线程同步构造,混合的线程同步构造等。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值