c#中有许多的内置类型,int是最常用的数据类型之一。它代表一个4个字节的有符号整数。对于下面的这代码却可以通过c#编译器的语法检查。
int i = int.Parse("123");
string num = 100.ToString();
生成的IL指令如下:
仔细查看可发现call指令调用的是System.Int32的方法。为什么一个四字节的int型整数会与一个复杂的System.Int32结构类型挂上关系呢?答案其实并不复杂。
CLR为每种内置的数据类型准备了一张“方法表”,其中列出了此类型所有的方法。如下图是所示。在程序运行时,CLR会查表完成调用过程。
上图内存布局图,其中代表整数值的内存单元与System.Int32类型方法表之间的逻辑关系不是在程序运行时建立的,而是在编译时确定的。对于用户自定义的类型,道理是类似的,如c#中的结构,在程序运行时,同样存在一张对应的方法表供CLR调用。与System.Int32这种内置数据类型不一样,用户自定义类型的相关信息存放在用户程序集元数据中,CLR在装入用户程序集时根据这些元数据信息创建相应的方法表。创建好之后用法和内置类型的用法没什么两样。
引用类型变量的内存布局
引用类型变量用于引用一个生存于托管堆中的对象,其作用类似于一个类型安全的对象指针。通过引用类型变量可以设置它所应用对象的字段或属性和调用它的方法。CLR是如何做到这一点?
与值类型情况相似,每种引用类型也有一个方法表。下图为引用类型变量的内存布局图。
如图所示,每个引用类型都有一个方法表,此方法表中包含了类中定义的所有静态字段和静态、实例方法。而使用new关键字创建的实例对象中存放有此类的实例字段。同时拥有一个指针指向此类所对应的方法表。
一个类可以创建多个对象,这些实例对象共享同一个方法表。
上图中引用类型变量1和2分别引用两个对象,这两个对象都属于引用类型1,所以它们共享同一个方法表
引用类型变量3和4引用同一个对象,此对象属于引用类型2,它有一个指针指向引用类型2的方法表。
特别注意
类所定义的实例字段与方法是分离的,不管是静态方法还是动态方法,都统一放置在方法表中,但是字段分两个地方存放,静态字段存放在方法表中,实力字段存放在对象中。
这个对象模型很好的解释了面向对象中“类的静态字段被此类创建的所有对象共享”这一现象。
另外要注意一点,引用变量生存于线程堆栈中,而对象生存于托管堆中。
每个对象还有一个“同步块索引”,它其实是CLR维护的一个“同步块表”的索引,在多线程运行环境下,CLR可用这些同步块来同步多个线程对此对象的访问。
也正是由于类型方法表的存在,c#不允许一个引用变量像c++的指针那样随意转换类型,从而避免了对引用变量的误用,也限制了其所能进行的操作。正因如此,我们才说引用变量是一种“类型安全”的对象指针。
方法的JIT编译原理
弄清楚了值类型与引用类型的变量的内存布局之后,就能明白CLR对方法代码的动态编译原理。示例代码如下:
class MyClassExample
{
public void f()
{
}
}
class Program
{
static void Main(string[] args)
{
MyClassExample obj = new MyClassExample();
obj.f();
}
}
根据前面的介绍,我们知道CLR运行此程序时,会自动为MyClassExample创建一个方法表,在此方法表中保存了MyClassExample类的所有静态字段和方法,每个字段和方法在表中占用一行。如下图:
因为MyClassExample类没有定义静态字段,所以方法表中没有字段的对应行,但由于CLR规定所有的类型都必须是Object的子类,所以方法表中出现了Object类的四个虚方法,方法表的最下方的两行是MyClassExample类的f方法和构造函数。
注意:
子类的方法表中只包含基类定义的虚方法,基类的其他方法不会出现在子类的方法表中。
当程序第一次运行时,CLR负责从程序集元数据中提取MyClassExample类型信息,创建MyClassExample方法表,每个方法表项都对应一个“方法桩”(Method Stub),其内容为调用CLR中JIT编译器的call指令。
当某个方法被第一次调用时,CLR查类型方法表找到对应的方法表项,取出其方法桩,发现其中是一条调用JIT编译器的call指令,CLR先从程序集中提取出此方法所对应的IL指令代码,将其传送给JIT编译器,由JIT编译器将这些IL指令代码即时编译成可以在当前CPU上执行的本地代码,即时编译工作完成后,CLR将此方法的本地代码缓存起来,并将此方法对应之方法桩内容由原先的调用JIT编译器的call指令改为无条件跳转的jmp指令,跳转地址就是JIT编译器编译完成的方法本地代码首地址。修改完方法桩后,CLR执行方法的本地代码。
当第二次调用方法时,由于它的本地代码已缓存,而且对应的方法桩也修改为一条跳转指令jmp,CLR就可以直接执行本地代码,不再需要JIT编译器重新编译了。
由于CLR缓存了方法的本地代码,所以对方法的第二次调用比第一次要快得多。这就是CLR的即时编译原理。