CLR Via C#个人笔记2 - 类与分配
大章4:类的根基
所有类都从System.Object派生
CLR要求所有类最终都从System.Object类派生。无论你的类写不写父类,都会继承自此。注意,通过反编译看构造比较难看出这一点,但是很重要。
System.Object的方法
public:
- Equals: 没啥好说,如果不重写默认就是判断值是否相等,==判断的是引用地址是否相等。String的比较特殊,微软重写了,所以”AAA” == “AAA”是会返回true的。
- GetHashCode: 返回对象的值得哈希码。用的少。
- ToString: 返回类型的完整名称(this.GetType().FullName)。经常重写。
- GetType: 获取类型,反射常用的。注意它是非虚,目的是不推荐修改。
protected:
- MemberwiseClone: 非虚方法。书里讲得不好,简单来说本方法就是浅拷贝。浅拷贝简单理解为只对类里的值类型字段进行逐位复制,而引用类型字段则拷贝引用。引用也新造,就是深拷贝了。
- Finalize: 在被GC前,会调用一次这个虚方法。
new Object()的整个流程
- 计算类型及其所有基类型(一直到System.Object)中定义所有的实例字段需要的字节数。堆上每个对象都需要一些overhead成员,包括“类型对象指针”和“同步索引块”,CLR利用这些成员管理对象,且这些overhead成员字节数会计入对象大小。
- 从托管堆中分配类型需要的字节数,从而分配对象内存,分配的所有字节都设为0。
- 初始化overhead成员:“类型对象指针”和“同步索引块”。
- 调用类型的实例构造器,传递入参。自动生成构造器代码调用父类的构造器,最终调用到System.Object的构造器,该构造器什么都不做简单返回。
new执行完了这些操作之后,返回指向新建对象的一个引用(或指针)。
这个对象在引用计数归0后会被CLR的GC清理掉。
类型转换
CLR随时都能知道对象的类型是什么,调用GetType方法可以知道确切类型。可以通过显式转换来转换:
1 | Object o = new Employee(); |
此外,我们常用as和is来进行类型转换。比较简单,不多写。
命名空间
就是namespace xxx
和using xxx;
,目的是提高代码可读性以及让程序员少打点字。
命名空间对相关的类型进行逻辑分组。
对于编译器,命名空间的作用就是为类型名称附加以句点分隔的符号,使名称变得更长,更可能具有唯一性。
CLR对命名空间一无所知,所以访问类型时,CLR需要知道类型的完整名称。那编译器是怎么实现的呢?如果你写的类再源代码、引用程序集中找不到,那它就把你using的namespace,比如System.IO.
放在类名前查看是否匹配,如果不匹配就再尝试System.Text.
,如此反复直到匹配。
遇到一个类在多个using里存在的时候,编译器会提示不明确引用的,补上前缀,或者using 别名 = 命名空间.类
然后后面用别名写这个类就行。这叫歧义性。
大章:方法运行流程⭐
解释类型、对象、线程栈和托管堆再运行时的相互关系,以及调用静态方法、实例方法和虚方法的区别。
原文是非常长的一套整体流程,写的很好,我简化一下搬下来。分成2块,下面拆解,但是在拆解前先引入一个模型。
先列一下整个流程参与的元素:
线程栈、托管堆⭐
序幕代码、栈帧、尾声代码
类型指针对象、类内部持有的父类类型引用字段
对象实例、Class类型对象、Type类型对象
非虚方法、虚方法⭐
实例字段,静态字段
类型对象指针指向的是类型,不是父类!
能对照着概念能模拟出一遍完整流程就行,非常重要。
先引入一个模型
线程栈和堆。 // TODO 具体栈和堆的我会在其它笔记单独写
一个进程可能有多个线程,每新开辟一个线程就给它分配1MB的栈。栈空间用于向方法传递实参,方法内部定义的局部变量也在栈上。栈从高位内存地址向低位内存地址构建。
普通方法的运行流程
预设有以下代码要进行(调用了M1):
1 | void M1(){ |
- M1方法开始执行,“序幕”代码(prologue)开始。
- M1序幕代码执行:分配局部变量name的内存,如图2。
- 然后调用M2方法,将局部变量name作为实参传递,这造成name局部变量中的地址被压入栈,同时还会将”返回地址“压入栈,如图3。//M2方法内部使用参数变量s标识栈位置
- M2方法开始,M2序幕代码执行:为局部变量length和tally分配内存,如图4。
- M2方法内部代码开始执行,最终return,使得CPU的指令指针被设置成栈中的返回地址,M2的栈帧unwind,恢复成图2的样子。
- 之后,M1继续执行M2调用之后的代码,M1的栈帧将准确反映M1需要的状态。
- 最终,M1会返回到他的调用者,同样是通过CPU指令指针设置成返回地址(图中未标记,正常情况下在
name(String)
上方),M1的栈帧unwind。 - 恢复成图1,继续执行原处
M1();
之后的后续代码。
总结一下,执行一个方法的整体流程就是:
- 执行“序幕”代码,也就是线程栈上分配局部变量的内存
- 内部还有方法的话把内部函数的参数和{返回地址}压入栈
- 内部方法执行完后(流程与本流程相同),CPU指令指针被设置成上面的{返回地址},栈帧展开恢复到遇到此方法之前
- 直到方法运行结束或者遇到return,CPU指令指针设置成本方法的返回地址,栈帧展开恢复到遇到本方法之前
类内调用方法的运行流程
比上面的情况更复杂点的方法。预设有以下代码要进行:
1 | // 有下面2个类: |
假设CLR已经加载到其中,托管堆已初始化,且已经创建一个线程(1MB栈空间也分配了)。
① 目前已经执行了一些代码,接下来要调用M3()
方法。如图4-6。
② 检查方法需要的所有类型是否被创建。
- JIT编译器将M3的IL代码转换成本机CPU指令时,会注意到M3内部引用的所有类型:Employee、Int32、Manager、String(因为
"Joe"
)。这时CLR要确认这些类型相关的程序集都已经加载,并为其创造一些数据结构来表示这些类。因为Int32和String类很常用,就当作已经在前面创建好了,这里只讨论Employee和Manager。 - 如图,堆上所有对象都包含2个额外成员,类型对象指针和同步索引块。定义类型时,在类型内部定义静态数据字段,为这些字段提供支援的字节在类型对象自身中分配。
③ 和上面普通方法的运行流程一样,分配局部变量。
- 当CLR确认方法需要的所有类型对象都已创建后,就在栈上分配局部变量的内存。
④ “序幕”代码处理结束。e = new Manager();
执行,在托管堆上创建Manager类型实例。
- 如图,它的类型对象指针指向自己的类型对象,Manager。
⑤ 下一句调用类内静态方法,e = Employee.Lookup("Joe");
。
- 当调用静态方法时,JIT会去找类型对象的方法表,找到静态方法对应的记录项,编译并执行该方法。
- 本方法Lookup假设是要去经理表查询数据库找到Joe,再new一个Manager返回Joe的信息。所以如图,在堆上会新建一个Manager对象,局部变量e持有它的引用。
⑥ 下一句调用类内非虚实例方法,year = e.GetYearsEmployed();
执行。
- 非虚方法执行流程:JIT会找到发出调用的变量(e)的申明类型(Employee)对应的类型对象,如果Employee类型没有定义正在调用的那个方法,JIT编译器会回溯父类层次结构(一直到Object类),沿途在每个类型中查找该方法。之所以能这样回溯,是因为每个对象都有一个字段引用了他的基类型(图中未展示)。
⑦ 下一句调用类内虚实例方法,e.GetProgressReport();
执行。
- 虚方法执行流程:JIT首先检查发出调用的变量(e),并跟随引用地址找到发出调用的对象(e引用的是叫“Joe”的Manager对象而不是Employee!)。然后,代码检查对象内部的“类型对象指针”成员,找到类型对象Manager里的方法,如果没有就同⑥一样回溯执行到可执行的类方法为止。
⑧ 方法体运行结束。回到前地址,栈帧展开。
- 在这里提一下一个理解上的要点,注意类型对象Manager和Employee都有自己的类型对象指针,这意味着,它们本质上也是对象“实例”,指向System.Type类型对象。System.Type类型对象自己也有指针,指向自己。
- 顺便一提,
System.Object.GetType()
方法返回存储在指定对象的“类型对象指针”成员中的地址,也就是指向对象的类型对象的指针。这样就可以判断系统中任何对象的真实类型。
总结一下:
流程太长全总结了也很难看懂,就提几个问题思考一下。
序幕代码都做了什么(2点)?
一个类型对象,都拥有哪些固定字段?
静态方法需要的内存是怎么分配的?
虚方法和非虚方法,寻找方法流程的主要不同点是什么(1点)?
答案上面都有,答不出来了就再仔细看一遍吧,自勉。