大章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()的整个流程

  1. 计算类型及其所有基类型(一直到System.Object)中定义所有的实例字段需要的字节数。堆上每个对象都需要一些overhead成员,包括“类型对象指针”和“同步索引块”,CLR利用这些成员管理对象,且这些overhead成员字节数会计入对象大小。
  2. 从托管堆中分配类型需要的字节数,从而分配对象内存,分配的所有字节都设为0。
  3. 初始化overhead成员:“类型对象指针”和“同步索引块”。
  4. 调用类型的实例构造器,传递入参。自动生成构造器代码调用父类的构造器,最终调用到System.Object的构造器,该构造器什么都不做简单返回。

new执行完了这些操作之后,返回指向新建对象的一个引用(或指针)。
这个对象在引用计数归0后会被CLR的GC清理掉。

类型转换

CLR随时都能知道对象的类型是什么,调用GetType方法可以知道确切类型。可以通过显式转换来转换:

1
2
Object o = new Employee(); 
Employee e = (Employee) o;

此外,我们常用as和is来进行类型转换。比较简单,不多写。

命名空间

就是namespace xxxusing xxx;,目的是提高代码可读性以及让程序员少打点字。

命名空间对相关的类型进行逻辑分组。
对于编译器,命名空间的作用就是为类型名称附加以句点分隔的符号,使名称变得更长,更可能具有唯一性。

CLR对命名空间一无所知,所以访问类型时,CLR需要知道类型的完整名称。那编译器是怎么实现的呢?如果你写的类再源代码、引用程序集中找不到,那它就把你using的namespace,比如System.IO.放在类名前查看是否匹配,如果不匹配就再尝试System.Text.,如此反复直到匹配。
遇到一个类在多个using里存在的时候,编译器会提示不明确引用的,补上前缀,或者using 别名 = 命名空间.类然后后面用别名写这个类就行。这叫歧义性

大章:方法运行流程⭐

解释类型、对象、线程栈和托管堆再运行时的相互关系,以及调用静态方法、实例方法和虚方法的区别。
原文是非常长的一套整体流程,写的很好,我简化一下搬下来。分成2块,下面拆解,但是在拆解前先引入一个模型。
先列一下整个流程参与的元素:

  • 线程栈、托管堆⭐

  • 序幕代码、栈帧、尾声代码

  • 类型指针对象、类内部持有的父类类型引用字段

  • 对象实例、Class类型对象、Type类型对象

  • 非虚方法、虚方法⭐

  • 实例字段,静态字段

  • 类型对象指针指向的是类型,不是父类!

    能对照着概念能模拟出一遍完整流程就行,非常重要。

先引入一个模型

线程栈。 // TODO 具体栈和堆的我会在其它笔记单独写
一个进程可能有多个线程,每新开辟一个线程就给它分配1MB的栈。栈空间用于向方法传递实参,方法内部定义的局部变量也在栈上。栈从高位内存地址向低位内存地址构建。

普通方法的运行流程

预设有以下代码要进行(调用了M1):

1
2
3
4
5
6
7
8
9
10
11
12
13
void M1(){
String name = "Joe";
M2(name);
// do sth...
return;
}

void M2(String s){
Int32 length = s.Length;
Int32 tally;
// do sth...
return;
}
  1. M1方法开始执行,“序幕”代码(prologue)开始。
  2. M1序幕代码执行:分配局部变量name的内存,如图2。
  3. 然后调用M2方法,将局部变量name作为实参传递,这造成name局部变量中的地址被压入栈,同时还会将”返回地址“压入栈,如图3。//M2方法内部使用参数变量s标识栈位置
  4. M2方法开始,M2序幕代码执行:为局部变量length和tally分配内存,如图4。
  5. M2方法内部代码开始执行,最终return,使得CPU的指令指针被设置成栈中的返回地址,M2的栈帧unwind,恢复成图2的样子。
  6. 之后,M1继续执行M2调用之后的代码,M1的栈帧将准确反映M1需要的状态。
  7. 最终,M1会返回到他的调用者,同样是通过CPU指令指针设置成返回地址(图中未标记,正常情况下在name(String)上方),M1的栈帧unwind。
  8. 恢复成图1,继续执行原处M1();之后的后续代码。

总结一下,执行一个方法的整体流程就是:

  1. 执行“序幕”代码,也就是线程栈上分配局部变量的内存
  2. 内部还有方法的话把内部函数的参数{返回地址}压入栈
  3. 内部方法执行完后(流程与本流程相同),CPU指令指针被设置成上面的{返回地址},栈帧展开恢复到遇到此方法之前
  4. 直到方法运行结束或者遇到return,CPU指令指针设置成本方法的返回地址,栈帧展开恢复到遇到本方法之前

类内调用方法的运行流程

比上面的情况更复杂点的方法。预设有以下代码要进行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 有下面2个类:
public class Employee {
public Int32 GetYearsEmployed() {...}
public virtual String GetProgressReport() {...}
public static Employee Lookup(String name) {...}
}

public sealed class Manager : Employee {
public override String GetProgressReport() {...}
}

// 要执行下面的方法:
void M3(){
Employee e;
Int32 year;
e = new Manager();
e = Employee.Lookup("Joe");
year = e.GetYearsEmployed();
e.GetProgressReport();
}

假设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点)?

答案上面都有,答不出来了就再仔细看一遍吧,自勉。