本章我们来看一下,Java中字段和方法是如何参与重写的。
字段的重写
首先,需要明确一点,Java的字段永远不参与多态,当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会屏蔽父类的同名字段。我们来看一个简单地例子,该例子来自于《深入理解Java虚拟机》:
|
|
输出“This guy has $2”,可见调用的是Father的money字段,因为它是通过静态类型访问到的,我们把代码Father guy = new Son();
的“Father”称为变量的“静态类型(Static Type)”,或者叫“外观类型(Apparent Type)”,后面的“Son”则称为变量的“实际类型(Actual Type)”。后面通过代码(Son) guy)
把guy强转成Son类型,此时的静态类型就是Son,所以自然调用的就是Son的money字段了,输出4。所以,可以确认,Java的字段确实是不参与多态的。
再来看下最初的两行输出是因为何,首先两个类的构造函数中都有showMeTheMoney()函数,Son类在创建的时候,首先隐式调用Father的构造函数(跟主调调用super()
行为一样),而Father构造函数中对showMeTheMoney()的调用因为是虚方法调用,实际执行的版本是Son::showMeTheMoney()方法,所以输出都是“I am Son”。第一次输出是0,是因为当时子类Son的构造函数还没执行,它的money字段还是int类型的初始值0。
下面就来看下之前提到的虚方法调用,以及实际执行的版本是怎么回事。
方法的重写
先看一个小例子,同样来自于《深入理解Java虚拟机》:
|
|
这段代码很简单,就是Java语言的多态特性。那在JVM层到底是如何实现的呢,我们看一下截取的部分字节码:
|
|
16和20行的aload指令分别把之前创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,这两条指令单从字节码角度看,无论是指令还是参数都完全一样,但是这两句指令最终执行的目标方法并不相同。那就得来看下invokevirtual指令的执行流程了:
- 找到操作数栈顶的第一个元素所指的对象的实际类型,记作C。
- 如果在类型C中找到与常量池中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。
至此我们知道了方法重写的本质,再来看一个复杂点的例子:
|
|
在这个例子中,Son重写了Father的say2()方法,然后经过一系列方法的调用,其中还有对this、super关键字的调用,有些行为看起来会让人疑惑。下面对照输出的顺序,结合字节码,我们来详细了解一下整个调用过程。
首先基于之前重写的分析,理应调用Son::say1()方法,但是Son并没有重写say1()方法,按照继承关系往上找到Father::say1(),所以此时调用的正是Fathe的say1()方法,输出“this is father say1”。
在Father::say1()中执行了代码
this.getClass().getName()
,意在输出this指向的对象。在这里有一个关于this的知识点:如果执行的是实例方法(没有被static修饰的方法),那么局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。也就是说当调用father.say1();
的时候,默认传递了方法所属对象的实际类型Son的对象。所以说此时运行环境中Father::say1()方法中的this,指向的是main函数中声明的Son对象。所以输出“this -> cn.didadu.sample.jvm.methodInvoke.thisinFather.Son”。看下Father::say1()方法中的部分字节码:12311: aload_012: invokevirtual #5 // Method java/lang/Object.getClass:()Ljava/lang/Class;15: invokevirtual #6 // Method java/lang/Class.getName:()Ljava/lang/String;第11行,表示把第0位局部变量表的内容推入操作数栈顶,也就是是把this引用推入操作数栈顶,接着invokevirtual指令调用操作数栈顶指向对象的getClass()方法,第15行再调用其getName()方法,就是对应代码
this.getClass().getName()
接着调用say2()方法,其实这里省略了this引用,完整的调用写法应该是
this.say2()
,上面已经清楚地解释了当前this指向的是Son类型的对象,同时Son对象重写了say2()方法,所以调用栈进入了Son::say2(),从如下部分字节码也可以看出来:1226: aload_027: invokevirtual #8 // Method say2:()V同样是把第0位局部变量表的内容推入操作数栈顶,也就是是把this引用推入操作数栈顶,接着调用栈顶对象的say2()方法,自然输出“”this is son say2””。
接着输出“this -> cn.didadu.sample.jvm.methodInvoke.thisinFather.Son”,很好理解,因为此时的this一直是当初那个Son对象。
下面两行代码
super.getClass().getName()
和super.say3();
,从输出来看,super指向的是Son对象,但调用的确实Father::say3(),这两个输出为什么是矛盾的。我们来看下字节码:12345629: aload_030: invokespecial #5 // Method java/lang/Object.getClass:()Ljava/lang/Class;33: invokevirtual #6 // Method java/lang/Class.getName:()Ljava/lang/String;44: aload_045: invokespecial #9 // Method cn/didadu/sample/jvm/methodInvoke/thisinFather/Father.say3:()V这里使用了invokespecial指令,是在编译时就确定了方法调用的版本。
super.getClass()
在编译期确定了调用Object.getClass()方法,看一下Object.getClass()方法的注释:Returns the runtime class of this {@code Object}。也就是说,返回的是运行时对象的类型。这里很明显,运行时对象还是那个Son实例。所以输出“super -> cn.didadu.sample.jvm.methodInvoke.thisinFather.Son”。super.say3()
在编译期确定了调用Father::say3()方法,所以输出“this is father say3”。
至此,算是理清了字段和方法的重写。尤其是方法重写的过程,这也是模板方法模式得以运行的根本所在吧。