bboyjing's blog

Java中方法与字段的重写

  本章我们来看一下,Java中字段和方法是如何参与重写的。

字段的重写

  首先,需要明确一点,Java的字段永远不参与多态,当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会屏蔽父类的同名字段。我们来看一个简单地例子,该例子来自于《深入理解Java虚拟机》:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class FieldHashNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, I have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
@Override
public void showMeTheMoney() {
System.out.println("I am Son, I have $" + money);
}
}
public static void main(String[] args) {
Father guy = new Son();
// 通过静态类型访问到了父类中的money,输出2
System.out.println("This guy has $" + guy.money);
// 将静态类型强转成Son,访问的就是子类中的money,输出4
System.out.println("This guy has $" + ((Son) guy).money);
}
}
# 输出如下
I am Son, I have $0
I am Son, I have $4
This guy has $2
This guy has $4

  输出“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虚拟机》:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
}
}
// out put
man say hello
woman say hello

  这段代码很简单,就是Java语言的多态特性。那在JVM层到底是如何实现的呢,我们看一下截取的部分字节码:

1
2
3
4
16: aload_1
17: invokevirtual #6 // Method cn/didadu/sample/jvm/methodInvoke/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method cn/didadu/sample/jvm/methodInvoke/DynamicDispatch$Human.sayHello:()V

16和20行的aload指令分别把之前创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,这两条指令单从字节码角度看,无论是指令还是参数都完全一样,但是这两句指令最终执行的目标方法并不相同。那就得来看下invokevirtual指令的执行流程了:

  1. 找到操作数栈顶的第一个元素所指的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量池中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。

  至此我们知道了方法重写的本质,再来看一个复杂点的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class Father {
public void say1() {
System.out.println("this is father say1");
// 这里的this获取的是真正的调用方,main方法里声明的Son对象
System.out.println("this -> " + this.getClass().getName());
say2();
}
public void say2() {
System.out.println("this is father say2");
}
public void say3() {
System.out.println("this is father say3");
}
}
public class Son extends Father {
@Override
public void say2() {
System.out.println("this is son say2");
System.out.println("this -> " + this.getClass().getName());
// super对象的引用是Son,并不是Father
System.out.println("super -> " + super.getClass().getName());
// 但是这里调用的却是Father的say方法
super.say3();
}
@Override
public void say3() {
System.out.println("this is son say3");
}
}
public class Test {
/**
* 运行的时候影响虚拟机选择的因素只有该方法的接受者的实际类型是Father还是Son,因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
*
* @param args
*/
public static void main(String[] args) {
Father father = new Son();
father.say1();
}
}
# 输出
this is father say1
this -> cn.didadu.sample.jvm.methodInvoke.thisinFather.Son
this is son say2
this -> cn.didadu.sample.jvm.methodInvoke.thisinFather.Son
super -> cn.didadu.sample.jvm.methodInvoke.thisinFather.Son
this is father say3

在这个例子中,Son重写了Father的say2()方法,然后经过一系列方法的调用,其中还有对this、super关键字的调用,有些行为看起来会让人疑惑。下面对照输出的顺序,结合字节码,我们来详细了解一下整个调用过程。

  1. 首先基于之前重写的分析,理应调用Son::say1()方法,但是Son并没有重写say1()方法,按照继承关系往上找到Father::say1(),所以此时调用的正是Fathe的say1()方法,输出“this is father say1”。

  2. 在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()方法中的部分字节码:

    1
    2
    3
    11: aload_0
    12: 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()

  3. 接着调用say2()方法,其实这里省略了this引用,完整的调用写法应该是this.say2(),上面已经清楚地解释了当前this指向的是Son类型的对象,同时Son对象重写了say2()方法,所以调用栈进入了Son::say2(),从如下部分字节码也可以看出来:

    1
    2
    26: aload_0
    27: invokevirtual #8 // Method say2:()V

    同样是把第0位局部变量表的内容推入操作数栈顶,也就是是把this引用推入操作数栈顶,接着调用栈顶对象的say2()方法,自然输出“”this is son say2””。

  4. 接着输出“this -> cn.didadu.sample.jvm.methodInvoke.thisinFather.Son”,很好理解,因为此时的this一直是当初那个Son对象。

  5. 下面两行代码super.getClass().getName()super.say3();,从输出来看,super指向的是Son对象,但调用的确实Father::say3(),这两个输出为什么是矛盾的。我们来看下字节码:

    1
    2
    3
    4
    5
    6
    29: aload_0
    30: invokespecial #5 // Method java/lang/Object.getClass:()Ljava/lang/Class;
    33: invokevirtual #6 // Method java/lang/Class.getName:()Ljava/lang/String;
    44: aload_0
    45: 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”。

至此,算是理清了字段和方法的重写。尤其是方法重写的过程,这也是模板方法模式得以运行的根本所在吧。