http://www.zhlmmc.com (收藏,设为首页)
岂能尽如人意,但求无愧我心 (手机请访问 http://3g.dlog.cn/zhlmmc)
上一篇:感性与理性 下一篇:腐败的中国学术界

从编译的角度看对象

2008年3月8日(Saturday) 06点38分 作者: 虫虫 天气: 心情: 一般
这篇文章去年就写了,也贴出来了。最近看完了《Java Puzzlers》,发现里面提到的Classy的问题都能用我文中提到的内容去解释,于是对文章重新整理和修改,贴于此。

从编译的角度看对象

Java语言描述

by zhlmmc

 

前言

我刚开始学Java的时候总觉得面向对象很神秘,摸不透。后来学习编译的时候,发现如果从底下往上看,透过对象直接看汇编,看内存,一切都很清楚了。我这篇文章不是写给编程初学者看的,我假设

<[if !supportLists]>1.       你已经至少熟悉一种面向对象的语言(比如Java)并能熟练运用<[endif]>

<[if !supportLists]>2.       你对编译和操作系统的原理有基本的认识(起码知道函数调用栈,堆是什么吧)<[endif]>

面向对象有三大特征,封装(encapsulation),继承(inheritance),多态(polymorphism),我将从内存和编译的角度来着重分析继承和多态,解释这些现象背后的原理,也就是继承和多态是如何实现的。封装性也会附带讨论一些。首先要声明的是,我所讲的内容都是基于我所查阅的资料和我个人的理解,跟事实情况可能有出入。我以Java为例子来解释面向对象的特性,并不是说Java就是我说的那样去实现。实现的方法不同,但并不影响理解。

 

第一节:类和对象,到底是什么?

计算机里的任何东西到了最后都是0101,类和对象也不例外。我们没有必要从机器码的层面来考察对象(也无法考察),先来看看类和对象在内存里究竟是什么。类定义好了以后先被编译,然后执行的时候被装入内存,在内存中的表示如下:

 

<[if gte vml 1]> <[if !vml]><[endif]><[if gte mso 9]>

1:类和对象的内存结构

这个内存结构相当重要,面向对象特性的实现就靠它了。每个Class都有一个Class Descriptor记录了这个类的所有信息,包括静态字段(static fields),静态方法入口(static method list pointer),动态字段(dynamic field),动态方法入口(dynamic method list pointer),父类指针(super class pointer)。Class Descriptor有可能是放在.text段中的,不过考虑到static field也是可写的,我认为放在heap里面比较合理。所有的方法都是可执行代码,所以会被编译到代码段中。Descriptor并不包含任何函数的代码只包含指针指向属于这个类的函数列表。这个函数列表也不包含函数的代码而是列出了所有函数的代码段地址。程序执行的时候会先找到这个函数列表,然后通过[offset]找到特定函数的地址,然后跳到那个地址去执行代码。为了解释清楚,我这里用了一个Class A来做例子。你可以把图和代码对照着看一下帮助理解。

public class A {

    public static String NAME = "A";

    public static long MAX = 10L;

   

    public static void a(){

      

    }

   

    public static void b(){

      

    }

   

    public String str;

    public int i;

   

    public A(){

       str = "test";

       i = 1;

    }

   

    public void c(){

      

    }

   

    public void d(){

      

    }

}

Class Descriptor保存了静态字段的定义和值,但是只保存了动态字段的定义。动态字段的值在类的实例中而不是类中。字段和方法一样,也是通过[offset]来访问的。图1中的“method list”省略了一些隐藏的方法,比如static initializationconstructor等等。

 

       类分析完了,下面来看看对象(也称为实例,Object)。类是模板,对象就是用这个模板创造出来的实体。所有static的东西都是属于类的,所以对象不必关心。对象里面保存了动态字段和类指针(class pointer)。这里不太确定的是动态方法入口。既然类描述符里面已经了有动态方法入口了,那么通过class pointer先找到class descriptor,然后就知道动态方法的入口了。的确是这样,不过出于性能考虑我认为对象也会保存一份动态方法入口的地址。还有一点要说明的是,所有相同类型的对象在堆上所占的空间是固定的。不理解?仔细想想吧!

 

       都说Java里面没有指针,其实是有的,只不过功能被弱化了,改称reference。首先可以肯定的是对象是放在堆上而不是栈上,如果没有没有指针的话程序运行的时候怎么拿到对象的数据呢?Java里面新建一个对象,和C里面malloc一块内存是差不多的,在堆上申请一块空间,返回一个指向这块空间的指针,保存在栈上。图1中右下角显示了栈上的一个指针,指向新生成的类“A”的对象“a”。我后面的讨论把reference或者指针都称作指针。

 

第二节:继承是如何实现的?

继承可以说是面向对象的精华之所在,有点玄。从我学Java那天起,我受到的教育是新建一个对象的时候,所有他的父类都会被创建一遍,所以子类可以调用父类的字段和方法(我们先不谈privatepublic之类,后文再解释)。这个说法其实是不确切的。我估计当时老师是为了方便我们理解才这么说的。严格来说,一个对象被创建完以后,就只有它自己,没有他的爸爸,更没有爷爷。祖祖辈辈能被继承的东西都融合到一个对象里面了,就是被创建的那个。先来看一段代码:

class S{

    int a = 1;

    int f(){

       return a;

    }

}

 

class A extends S{

   

}

 

class B extends S{

    int b = 10;

    int g(){

       return b;

    }

}

 

class C extends B{

    int a = 2;

    int f(){

       return a + b;

    }

}

如果为上面的每个类都生成一个实例的话,它们的内存表示是这样的:

<[if gte vml 1]> <[if !vml]><[endif]><[if gte mso 9]>

2:子类对象和父类对象的内存结构

从上面的图中我们可以发现,字段是不能覆盖的,也就是如果子类声明了一个和父类中同名的字段,那么这两个字段在子类的对象中都存在。比如图2中的类C中的“C_a”和“a”。其实它们的名字都是“a”, 由于字段不能覆盖,在内存中肯定是通过前缀或者别的方法区别开来。我这里用类名做前缀,看起来方便一点。和字段不一样,方法是可以覆盖的,如果子类的方法 和父类中的某个方法的签名(方法名,参数列表)一样的话,子类的方法的地址会替代父类方法的地址出现在子类的动态方法列表中。比如图2中类C的方法列表中没有“f”方法,只有“C_f”。你应该明白,其实这两个方法都叫“f”, 只不过方法的实际地址不一样,我这里也用类名做前缀区别开来。为什么方法能覆盖而字段不能呢?直观的来说,字段就像是长相,继承下来就不能改了,而方法就 像是思想,能用前人的,也能用自己创新的。虽然这个比喻有点牵强,暂且凑或吧,详细的以后再说。在创建对象的时候,程序会先找父类,为父类的字段先初始 化,父类也会找父类的父类,直到Object,这是一个递归的过程。这样创建的对象就包括了所有父类的字段(并且初始化完毕),而且是按顺序排列的,Object的 字段在最前面。这个顺序很重要!后文会有解释。整个对象的创建是发生在运行的时候,而动态方法列表的创建是发生在编译阶段。创建对象的时候只是添加了一个 指针,指向早已存在的方法列表的地址(这个东西是和类一起被装载进来的,所以一定先于对象存在)。我无法清楚的解释编译的时候是如何生成方法列表的,这个 问题很复杂。编译的时候有完整的代码树,想干啥都行。大体来说,父类一定是先于子类编译的,在生成子类方法列表的时候,先copy父类的,然后和父类的方法比较一下,添加新的方法,覆盖相同签名的方法。值得一提的是,和动态字段一样,方法列表也是有顺序的,父类的方法一定在前面。Static的方法比较特殊,它们不能被覆盖,why?请允许我再次卖个关子。

 

       继承的本质就是这样了,虽然有很多问题暂时无法解释。现在基本能够解释为什么子类能调用父类的方法,为什么父类不能调用子类的方法,为什么子类能用父类的字段等等。原因很简单啊,因为子类把父类的东西统统复制了一遍,而父类根本不知道子类的存在。

 

第三节:构造函数究竟干了些什么?

大家都知道,对象是由构造函数创建的,那其中的过程是什么样的呢?写一段代码,单步跟踪执行一下就全都出来了。例如下面一段代码:

class G{

    int x = 0; // sentence 1

}

class T extends G{

    int x = 21;   //  sentence 2

    int y = 1;    //  sentence 3

    public T(){

       super();   //  sentence 4

       x = 22;    //  sentence 5

    }

    public static void main(String[] args) {

        G g = new G();    //  sentence 6

        T t = new T();    //  sentence 7

        G t2 = new T();   //  sentence 8

        System.out.println(g.x);    //  sentence 9

        System.out.println(t.x);    //  sentence 10

        System.out.println(t2.x);   //  sentence 11

}

}

运行结果如下:

0

22

0

根据第三节中讲到的继承的原理,我们来跟踪一下程序每一步的执行。首先被装载的是Class T,因为main函数在T里面。Class G此时不会被装载因为T没有用到任何G的东西(根据不同的实现,G也有可能现在就被装载)。第一个被执行的是sentence 6,此时JVM还不知道有G这个class,所以它会去classpath里面找,找到以后把G装载进来,这样我们就有了Gclass descriptor。然后去找G的构造函数,由于我们没有定义自己的构造函数,所以编译器用默认构造函数替代了,这样G的默认构造函数就被调用。在调用G的构造函数之前JVM先根据G的定义在堆上面申请一块空间,空间的大小符合G的要求。然后告诉构造函数在这块空间上构建G的对象。那这个默认构造函数做了些什么呢?第一件事就是找父类(因为父类的字段必须得先构造),这里就是Object了。如果你单步跟踪的话你会发现跳到Object里面去了。在Object里面做了什么呢?Object一个字段都没有,又跳回来了,执行sentence 1,创建一个int字段,也就是在给定的堆空间,给定的[offset]的地方写入一个值,0。到这里,G的构造就完成了,返回刚刚分配的堆空间地址给gg是在栈上的。

注:关于对象的创建,我这里其实讲的不是很确切。JVM在创建一个对象的时候会根据这个对象的Class Descriptor在堆上申请一块空间,满足这个类的要求。因为所有字段信息都是可以从类的定义直接拿到的,JVM会根据类的定义把从Object开 始的所有父类还有自己的字段都初始化为默认值,并且这些字段是按顺序排列的(也就是父类的字段在前)。这些都发生在调用构造函数之前。构造函数中的字段初 始化部分只不过是把字段的值设为用户定义的值,而不是创建这些字段。我之所以把对象的创建解释成“构造函数创建了对象的字段”是为了方便理解“为什么这些 字段是按顺序排列的”。我们用单步跟踪可以很容易的观察到父类的构造函数总是会先被调用,如果字段是在构造函数中被创建的话,就很容易理解为什么字段是按 顺序排列的了。

 

       下面执行sentence 7。类似的,JVM去找T,发现T已经被装载了,那就直接拿来用。原本还要装载G的,由于前面已经装载过了,所以这里就不用了。我们自己定义了构造函数,所以就不用默认的了。单步跟踪的话会发现程序跳到了sentence 4,而不是去找父类的构造函数。为什么呢?因为sentence 4就是调用父类构造函数啊!试着把这句话注释掉,重新执行,嘿嘿,有趣的事情发生了,程序不是先执行sentence 5,而是跳到父类构造函数去了。由此可见,如果我们没有显示的调用父类构造函数的话,编译器会在T构造函数的第一句,为我们自动加“super()”。好了,我们接着执行。这里又有一个有趣的事情,sentence 4执行完毕以后,不是执行sentence 5,而是sentence 2!然后是sentence 3,最后才是sentence 5。从结果来看,很容易解释,因为sentence 23是声明成员变量,而sentence 5是对成员变量赋值,声明当然要在赋值前面。我们可以这么理解,Java在编译的时候,总会生成一个基本构造函数,这个基本构造函数包含了成员变量的声明语句。而一个完整的构造函数事实上有三部分组成,第一部分调用父类的构造函数,第二部分调用基本构造函数,第三部分就是我们写的构造函数代码了。这三个部分是严格安顺序执行的。这样T就构造好了。

 

       接下来来执行sentence 8,构造的过程和sentence 7完全一样。唯一不一样的就是最后的那个指针t2。其实计算机里面的指针都是一样的(32位机就是4byte的一块空间,保存了一个地址),这一点也可以从Java里面类型的强行转换看出来,如果不一样的话,强行转换就不好实现了。所有的不一样都是编译时造成的,编译时会检查指针的类型,不符合要求的会报错,以保证程序的正确性。换句话说,如果你能通过种种手段骗过编译器,让Integer指针指向一个String也是没有问题的。至少这么做对C没有任何问题,Java可能有问题,因为Java有运行时类型检查。这里Sentence 8返回的是一个T的指针,而t2的类型是G,因为GT的父类,根据Java的规则,父类的指针可以指向子类对象,所以这里没有问题。

 

       到这里为止,所有对象构造完毕。栈上多了三个指针,堆里面多了三个对象。值得一提的是两个x,虽然它们名字相同,但是子类的x不会覆盖父类的x,这个我们在第二节已经提到过了(别急,我会在下一节解释为什么)。可以想象到的是,编译好以后,这两个x的名字一定是不一样的或者说根本不存在什么名字,只有[offset]。现在内存中的情况差不多是这样的

<[if gte vml 1]> <[if !vml]><[endif]><[if gte mso 9]>

3Class GClass T的对象内存结构

       下面来分析一下输出的结果,第一个0一定没有什么问题。问题出在sentence 1011上。我们已经知道T有两个x,我们暂且称他们为G_x(图中标为x)T_x。先看sentence 5,这里的x是哪个x?根据就近原则(专业术语叫做shadowing,这个是规定,没有什么为什么,就这样设计了),T要用x首先用的是T_x,所以这里的xT_x。根据输出的结果,我们可以看出,sentence 10xT_xsentence 11xG_x。为了解释这个问题,我们得先解释以前留下的一个问题:为什么对象的构造一定要严格按照父类在先,子类在后的顺序?(注意,我前面解释过Java是如何实现“让父类字段在先,子类在后”的,我现在要解释的是Java为什么要这么做)在编译父类的时候,它并不知道子类的存在,所以父类一定只知道G_x。我们假设G_x在父类对象中的offset0。那么父类要用x就是去offset0的地方去找。在sentence 11中,虽然t2指向的实际上是T的实例,但是编译器并不知道。编译器是无法帮你去分析代码来判断对象的真实类型的。我这个例子看起来好像编译器可以知道t2的真实类型,但是想象一下,如果t2不是new出来的,而是从方法参数传入的,编译器怎么才能知道t2的真实类型呢?不可能,所以编译器只知道t2的类型是G。现在编译器要用x,根据G的定义知道在 offset0的地方有个x,于是就用它了。让我们试想一下,如果对象的创建不按顺序来的话,这里offset0的地方就不知道是什么东西了。所以对象的创建顺序保证了父类声明的成员变量在子类和父类中的offset是一致的,这样父类指针在指向子类对象的时候能够正常的工作。而且也保证了子类在引用父类字段的时候,父类字段已经初始化完毕。其实这也解释了为什么构造函数中“super()”的调用必须是第一句。这可是面向对象的一大特点啊,和后面要讲的多态有异曲同工之处。理解了这个以后,上面的输出结果就很容易理解了。

 

第四节:动态绑定三部曲

现在轮到我们的重头戏出场了,动态绑定。动态绑定又称为多态,是面向对象一个非常重要的元素,很多设计模式都是建立在动态绑定的基础上的。我将分三个部分来讲这个问题,第一部分是字段的绑定,第二部分是动态方法绑定的实现原理,第三部分谈谈动态绑定相关的优化。

<[if !supportLists]>1.       字段的绑定<[endif]>

“字段绑定”这个说法是我自己想出来的,用来描述父类和子类有相同名字的字段时的情况。其实在上一节“构造函数到底干了些什么”中已经提到了一些。先来看下面的一段代码:

class P{

    int a = 1;

    public void f(){

       System.out.println(a);

    }

}

class Q extends P{

    int a = 2;    //  sentence 1

    public static void main(String[] args) {

       new P().f();

       new Q().f();

    }

}

先不谈执行结果,我们来看看Java是如何编译这段代码的。我们在前几节已经提到过,父类一定在子类之前编译,所以在P编译的时候JavaQ一无所知。所以在编译方法f的时候这个a一定是P里面的a。如果不考虑中间代码,我们假设Java直接编译成汇编代码,那么方法f里面一定会用到变量a的地址,比如说在offset0的地方。编译好以后,方法f的汇编代码就定下来了,是死的。由于Q没有覆盖方法f,所以QP调用的是同一个f,也就是执行的同一段汇编。根据继承的原理,PQoffset0的地方都有一个a且它们的值为1,所以这段代码的输出是

1

1

这也解释了为什么对象实例化一定要按顺序进行:方法编译好以后就是死的,必须在运行的时候保证方法中用到的成员变量出现在编译时候的那个地址,否则子类就没法执行父类的方法了!这样的话字段绑定就遵循以下两个基本原则:

<[if !supportLists]>1.         父类的方法或者父类的指针只会引用父类中定义的字段<[endif]>

<[if !supportLists]>2.         子类覆盖父类的方法,或者子类自定义的方法,或者子类指针直接引用,优先使用子类自己定义的的字段(就近原则,上一节中有说明)。<[endif]>

你一定在想,如果字段可以覆盖的话,情况不就简单了吗?非也!这正好和我前面遗留的问题“为什么字段不能被覆盖”是一样的。我们先想象一下,如果字段可以被覆盖的情况是怎样的。首先子类和父类的同名字段必须是相同类型,而且子类的字段必须和父类的字段放在相同的[offset]。这些限制的原因我们前面都解释过了。这样一来子类和父类的同名字段实际上是同一个,也就是父类声明的那个。也就是说子类的那个声明语句没有起到任何作用。比如我们例子中的“int a = 2;”,这句话相当于“a = 2;”。这样的“覆盖”实际上是父类覆盖了子类,这明显不符合逻辑。我在接下来的“动态方法绑定的原理”中还会讲到这个问题。

 

<[if !supportLists]>2.       动态方法绑定的原理<[endif]>

我前面讲得是“字段的绑定”,而我现在要讲的是“动态方法的绑定”,差别在于“动态”。方法之所以能实现“动态绑定”是因为有“method list”帮忙。按照第二节中讲的继承的本质,我们可以知道子类的方法是可以覆盖父类的方法的(不同于字段)。这也是“Polymorphism”实现的关键。其实方法的覆盖很简单,只要把method list中方法的代码地址改掉就可以了。所以,子类的method list不再指向父类的方法。需要注意的是staticfinalprivate的方法不能被覆盖,我后面会解释的。下面来分析方法绑定和执行的过程。方法调用的基本过程是这样的,object.method()这句调用会先去找到“object”,然后找到“object”的method list,然后根据函数“method”的offset找到函数“method”的代码段地址。请看下面的代码:

class G{

    int x = 1;

    int m1(){

       return x + m2();

    }

    int m2(){

       return 1;

    }  

    public static void main(String[] args){

        T t = new T();

       G g = new T();

       System.out.println(t.m1()); //  sentence 1

       System.out.println(g.m1()); //  sentence 2

       System.out.println(g.m2()); //  sentence 3

    }

}

class T extends G{

    int x = 21;

    int m2(){

       return x;