代码改变世界

4.4 运行时的相互联系

2011-12-22 16:36  iRead  阅读(291)  评论(0编辑  收藏  举报

  本节将解释类型、对象、线程栈和托管堆在运行时的相互关系。此外,还将解释调用静态方法、实例方法和虚方法的区别。首先从一些计算机基础知识开始。

  虽然下面要讨论的东西不是CLR特有的,但掌握了这些只是之后,就有了一个良好的理论基础。接着,就可以修改我们的讨论,介绍CLR特有的内容。

  图4-2展示了已记载了CLR的一个Microsoft Windows进程。在这个进程中,可能存在多个线程。一个线程创建时,会分配到一个1MB大小的栈。这个栈的空间用于向方法传递实参,并用于方法内部定义的局部变量。图4-2展示了一个线程的栈内存(右侧)。栈是从高位内存地址向低位内存地址构建的。在图中,线程已执行了一些代码,它的栈上已经有一些数据了(显示成栈顶的阴影区域)。现在,假定线程执行的代码要调用M1方法。

                       

  图4-2 一个线程的栈,当前准备调用M1方法

  在一个最基本的方法中,应包含一些“序幕”(prologue)代码,负责在方法开始做它的工作之前对其进行初始化。另外,还应包含一些“尾声”(epilogue)代码,负责在方法完成工作之后对其进行清理,然后才返回至调用者。M1方法开始执行时,它的“序幕”代码在线程栈上分配局部变量name的内存,如图4-3所示。

 

         图4-3 在线程栈上分配M1方法定义的局部变量

  然后,M1调用M2方法,将局部变量name作为一个实参来传递。这造成name局部变量中的地址被压入栈(参见图4-4)。在M2方法内部,将使用名为s的参数变量来标识栈位置(注意,有的CPU架构通过寄存器来传递实参以提升性能,但这个区别对于当前的讨论来说不重要)。另外,调用一个方法时,还会将一个“返回地址”压入栈。被调用的方法在结束之后,应该返回到这个位置(同样参见图4-4)。

         图4-4 M1调用M2时,将实参和返回地址压入线程栈

  M2方法开始执行时,它的“序幕”代码在线程栈中为局部变量length和tally分配内存,如图4-5所示。然后,M2方法内部的代码开始执行。最终,M2抵达它的return语句,造成CPU的指令指针被设置成栈中的返回地址,而且M2的栈帧会展开(unwind),使之看起来类似于图4-3。之后,M1将将继续执行在M2调用之后的代码,M1的栈帧将准确反映M1需要的状态。

 

         图4-5 在线程栈上分配M2的局部变量

  最终,M1会返回到它的调用者。这同样是通过将CPU的指令指针设置成返回地址来实现的(这个返回地址在图中未显示,但它应该刚好在栈中的name实参上方),而且M1的栈帧会展开(unwind),使之看起来类似与图4-2.之后,调用了M1的方法会继续执行在M1调用之后的代码,那个方法的栈帧将准确反映它需要的状态。

  现在,让我们围绕CLR来调整一下讨论。假定有以下两个类定义:

         internal class Employee {

                   public Int32 GetYearsEmployed(){…}

                   public virtual String GenProgressReport{…}

                   public static Employee Lookup(String name){…}

         }

         internal sealed class Manager : Employee {

                   public override String GenProgressReport(){…}

         }

我们的Windows进程已启动,CLR已加载到其中,托管堆已初始化,而且已创建一个线程(连同它的1MB的栈空间)。该线程已执行了一些代码,现在马上就要调用M3方法。图4-6展示了目前的状况。M3方法包含的代码演示了CLR是如何工作的。平时不会写这样的代码,因为它们没有做什么真正有用的事情。

 

         图4-6 CLR已加载到一个进程中,它的堆已初始化,一个线程栈已经创建,现在马上要调用M3方法

  当JIT编译器将M3的IL代码转换成本地CPU指令时,会注意到M3内部引用的所有类型:Employee,Int32,Manager以及String(因为”Joe”)。在这个时候,CLR要确保定义了这些类型的所有程序集都已加载。然后,利用程序集的元数据,CLR提取与这些类型有关的信息,并创建一些数据结构来表示类型本身。图4-7展示了为Employee和Manager类型对象使用的数据结构。由于这个线程在调用M3之前已经执行了一些代码,所以不妨假定Int32和String类型对象已经创建好了(这是极有可能的,因为它们都是很常用的类型),所以图中没有显示它们。

 

         图4-7 Employee和Manager类型对象会在M3被调用时创建

  让我们花点时间来讨论一下这些类型对象。本章前面讲过,对上的所有对象都包含两个额外的成员:类型对象指针(type object pointer)和同步块索引(sync block index)。如图所示,Employee和Manager类型对象都有这两个成员。定义一个类型时,可以在类型的内部定义静态数据字段。为这些静态数据字段提供支援的直接是在类型对象自身中分配的。在每个类型对象中,最后都包含一个方法表。在方法表中,类型中定义的每个方法都有一个对应的记录项。我们已经在第1章讨论过这个方法表。由于Employee类型定义了3个方法(GetYearsEmployed,GenProgressReport和Lookup),所以Employee的方法表中有3个记录项。Manager类型只定义了1个方法(GenProgressReport的一个重写),所以Manager的方法表中只有1个记录项。

  现在,当CLR确定方法需要的所有类型对象都以创建,而且M3的代码已经编译之后,就允许线程开始执行M3的本地代码。M3的“序幕”代码执行时,必须在线程栈中为局部变量分配内存,如图4-8所示。顺便说一句,作为方法的“序幕”代码的一部分,CLR会自动将所有局部变量初始化为null或0(零)。然而,如果试图从一个尚未显示初始化的局部变量读取数据,C#会报告错误消息:使用了未赋值的局部变量

 

         图4-8 在线程栈上分配M3的局部变量

  然后,M3执行它的代码来构造一个Manager对象。这造成在托管堆中创建Manager类型的一个实例(也就是一个Manager对象),如图4-9所示。可以看出,和所有对象一样,Manager对象也有一个类型对象指针和同步块索引。该对象还包含必要的字节来容纳Manager类型定义的所有实例数据字段,以及容纳由Manager的任何基类(本例就是Employee和Object)定义的所有实例字段。任何时候在堆上新建一个对象,CLR都会自动初始化内部类型对象指针成员,让它引用与对象对应的类型对象(本例就是Manager类型对象)。此外,CLR会先初始化同步块索引,并将对象的所有实例字段设为null或0(零),在调用类型的构造器(它本质上是可能修改某些实例数据字段的一个方法)。new操作符会返回Manager对象的内存地址,该地址保存在变量e中(e在线程栈上)。

 

         图4-9 分配并初始化一个Manager对象

  M3的下一行代码调用Employee的静态方法Lookup。调用一个静态方法时,CLR会定位于静态方法的类型对应的类型对象。然后,JIT编译器在类型对象的方法表中查找与被调用的方法对应的记录项,对方法进行JIT编译(如果需要的话),在调用JIT编译的代码。就本例来说,假定Employee的Lookup方法要查询一个数据库来查找Joe。另外,假定数据库指出Joe是公司的一名经理,所以在内部,Lookup方法在堆上构造一个新的Manager对象,用Joe的信息初始化它,然后返回该对象的地址。这个地址保存到局部变量e中。这个操作的结果如图4-10所示。

 

         图4-10 Employee的静态方法Lookup为Joe分配并初始化一个Manager对象

  注意,e不再引用第一个Manager对象。事实上,由于没有变量引用这个对象,所以它是将来进行垃圾回收的主要目标。垃圾回收机制会自动回收(释放)这个对象占用的内存。

  M3的下一行代码调用Employee的非虚实例方法GetYearsEmployed。调用一个非虚实例方法时,JIT编译器会找到与“发出调用的那个变量(e)的类型(Employee)”对应的类型对象(Employee类型对象)。本例中,变量e被定义成一个Employee。如果Employee类型没有定义正在调用的那个方法,JIT编译器会回溯类层次结构(一直回溯到Object),并在沿途的每个类型中查找该方法。之所以能这样回溯,是因为每个类型对象都有一个字段引用了它的基类型,这个信息在图中没有显示。

  然后,JIT编译器在类型对象的方法表中查找引用了被调用方法的记录项,对方法进行JIT编译(如果需要的话),再调用JIT编译的代码。就本例来说,假定Employee的GetYearsEmployed方法返回5,因为Joe已被公司雇佣了5年。这个整数保存在局部变量year中。这个操作的结果如图4-11所示。 

         图4-11 Employee的非虚实例方法GetYearsEmployed在调用后返回5

  M3的下一行代码调用Employee的虚实例方法GenProgressReport。调用一个虚实例方法时,JIT编译器要在方法中生成一些额外的代码;方法每次调用时,都会执行这些代码。这些代码首先检查发出调用的变量,然后跟随地址来到发出调用的对象。在本例中,变量e引用的是代表”Joe”的一个Manager对象。然后,代码检查对象内部的“类型对象指针”成员,这个成员指向对象的实际类型。然后,代码在类型对象的方法表中查找引用了被调用方法的记录项,对方法进行JIT编译(如果需要的话),再调用JIT编译过的代码。就本例来说,由于e引用一个Manager对象,所以会调用Manager的GenProgressReport实现。这个操作的结果如图4-12所示。 

         图4-12 调用Employee的虚方法GenProgressReport,最终执行的是Manager重写的这个方法

  注意,如果Employee的Lookup方法发现Joe只是一个Employee,而不是一个Manager,Lookup会在内部构造一个Employee对象,它的类型对象指针将引用Employee类型对象。这样一来,最终执行的就是Employee的GenProgressReport实现,而不是Manager的GenProgressReport实现。

  至此,我们已经讨论了源代码、IL和JIT编译的代码之间的关系。还讨论了线程栈、实参、局部变量以及这些实参和变量如何引用托管堆上的对象。我们还知道对象中包含一个指针,它指向对象的类型对象(类型对象中包含静态字段和方法表)。我们还讨论了JIT编译器如何决定静态方法、非虚实例方法以及虚实例方法的调用方式。理解这一切之后,可以深刻认识CLR的工作方式。以后在构建、设计和实现类型、组件以及应用程序时,这些知识会带来很大帮助。在结束本章之前,让我们更深入地探讨一下CLR内部发生的事情。

  注意,Employee和Manager类型对象都包含“类型对象指针”成员。这是由于类型对象本质上也是对象。CLR创建类型对象时,必须初始化这些成员。初始化成什么呢?CLR开始在一个进程中运行时,会立即为MSCorLib.dll中定义的System.Type类型创建一个特殊的类型对象。Employee和Manager类型对象都是该类型的“实例”。因此,它们的类型对象指针成员会初始化成对System.Type类型对象的引用,如图4-13所示。 

         图4-13 Employee和Manager类型对象是System.Type类型的实例

  当然,System.Type类型对象本身也是一个对象,内部也有一个“类型对象指针”成员。那么这个指针指向的是什么呢?它指向它本身,因为System.Type类型对象本身是一个类型对象的“实例”。现在,我们总算理解了CLR的整个类型系统及其工作方式。顺便说一句,System.Object的GetType方法返回的是存储在指定对象的“类型对象指针”成员中的地址。换言之,GetType方法返回的是指向对象的类型对象的一个指针。这样一来,就勊判断系统中任何对象(包括类型对象本身)的真实类型。