从字节码看重载和重写
重载和重写
Java作为面向对象(OOP)的语言,其中之一的特性就是多态(polymorphic)。而对于多态在Java上主要体现就是“重载”和“重写”。
稍有Java常识的人便会知道“重载”和“重写”的区别。
重载,方法名相同,参数类型或者参数个数不同,返回可以修改也可以不变。
重写,方法名相同,参数和返回都必须相同,但方法中实现可以不同,也可以说方法签名不变,方法核心可以改变。
举个栗子
以下是重载和重写简单的栗子,可以生食。
代码1 重载
public class Forest {
static class Animal {}
static class Cat extends Animal{}
static class Bird extends Animal{}
public void animalSound(Cat cat) {
System.out.println("cat is sounding ");
}
public void animalSound(Bird bird) {
System.out.println("bird is sounding ");
}
public static void main(String[] args) {
Forest forest = new Forest();
Cat cat = new Cat();
Bird bird = new Bird();
forest.animalSound(cat);
forest.animalSound(bird);
}
}
//结果
// cat is sounding
// bird is sounding
定义三个类分别是 Animal
, Cat
, Bird
, 根据不同的入参重写 animalSound()
这个方法, 使之表现出不同版本。
代码2 重写
public class Forest {
static abstract class Animal {
public abstract void animalSound();
}
static class Cat extends Animal {
@Override
public void animalSound() { System.out.println("cat is sounding "); }
}
static class Bird extends Animal {
@Override
public void animalSound() {System.out.println("bird is sounding ");}
}
public static void main(String[] args) {
Animal cat = new Cat();
cat.animalSound();
Animal bird = new Bird();
bird.animalSound();
}
}
//结果
//cat is sounding
//bird is sounding
定义Animal父类,Cat和Bird都继承Animal类,分别在Dog类和Bird类中重写继承自Animal重animalSound方法,使之具备不同特性。
最简单的问题
从代码1和代码2中我们很容易就可以看出执行的结果。此时,我们不免往深入思考一点点,得出本文最重要也是最本质的问题。
1. 重载示例代码Main方法中,在forest对象调用animalSound方法时,它如何从多个重载方法中识别匹配上我们想要调用的目标方法的呢?
2. 重写示例代码Main方法中,在forest对象调用animalSound方法时,它又如何从对个重写方法中识别匹配上我们想要的调用的目标方法的呢?
3. 重载和重写两者对象调用方法的方式一样么?
从字节码上分析
对代码1使用javap -verbose -c Forest
生成以下字节码,
代码2-1 重载字节码
Warning: Binary file Forest contains com.helix.about.OverloadDemo.Forest
Classfile /Users/helix/IdeaProjects/java10/target/classes/com/helix/about/OverloadDemo/Forest.class
Last modified Apr 15, 2018; size 1513 bytes
MD5 checksum fdb78190178b0b189a9e20acad2fe128
Compiled from "Forest.java"
public class com.helix.about.OverloadDemo.Forest
minor version: 0
major version: 54
flags: ACC_PUBLIC, ACC_SUPER
// 常量池
Constant pool:
#1 = Methodref #15.#44 // java/lang/Object."<init>":()V
#2 = Fieldref #45.#46 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #47 // animal is sounding
#4 = Methodref #48.#49 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = String #50 // cat is sounding
#6 = String #51 // bird is sounding
#7 = Class #52 // com/helix/about/OverloadDemo/Forest
#8 = Methodref #7.#44 // com/helix/about/OverloadDemo/Forest."<init>":()V
#9 = Class #53 // com/helix/about/OverloadDemo/Forest$Cat
#10 = Methodref #9.#44 // com/helix/about/OverloadDemo/Forest$Cat."<init>":()V
#11 = Class #54 // com/helix/about/OverloadDemo/Forest$Bird
#12 = Methodref #11.#44 // com/helix/about/OverloadDemo/Forest$Bird."<init>":()V
#13 = Methodref #7.#55 // com/helix/about/OverloadDemo/Forest.animalSound:(Lcom/helix/about/OverloadDemo/Forest$Cat;)V
#14 = Methodref #7.#56 // com/helix/about/OverloadDemo/Forest.animalSound:(Lcom/helix/about/OverloadDemo/Forest$Bird;)V
#15 = Class #57 // java/lang/Object
#16 = Utf8 Bird
#17 = Utf8 InnerClasses
#18 = Utf8 Cat
#19 = Class #58 // com/helix/about/OverloadDemo/Forest$Animal
#20 = Utf8 Animal
.....
{
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
// 实例化
0: new #7 // class com/helix/about/OverloadDemo/Forest
3: dup
// 调用Forest构造,(调用对象的构造函数,因为方法调用会弹出参数,因此需要上面的dup指令,保证在调用构造函数之后栈顶上还是对象的引用,很多种情况下dup指令都是为这个目的而存在的)
4: invokespecial #8 // Method "<init>":()V
// 从操作数栈顶弹出对象引用,然后保存到索引为1的本地变量中
7: astore_1
// 实例化
8: new #9 // class com/helix/about/OverloadDemo/Forest$Cat
11: dup
// 调用Cat构造 从常量池中获取方法符号引用(#10)
12: invokespecial #10 // Method com/helix/about/OverloadDemo/Forest$Cat."<init>":()V
// 从操作数栈弹出cat对象引用,然后保存到索引为2的本地变量中
15: astore_2
// 实例化
16: new #11 // class com/helix/about/OverloadDemo/Forest$Bird
19: dup
// 调用Bird构造 从常量池中获取方法符号引用(#12)
20: invokespecial #12 // Method com/helix/about/OverloadDemo/Forest$Bird."<init>":()V
// 从操作数栈弹出bird对象引用,然后保存到索引为3的本地变量中
23: astore_3
// 将本地变量表中索引1位置的对象引用压如操作数栈
24: aload_1
// 将本地变量表中索引2位置的对象引用压如操作数栈
25: aload_2
// 从下标为13的常量池中获得类方法符号解析到cat对象的直接引用
26: invokevirtual #13 // Method animalSound:(Lcom/helix/about/OverloadDemo/Forest$Cat;)V
// 将本地变量表中索引1位置的对象引用压如操作数栈
29: aload_1
// 将本地变量表中索引3位置的对象引用压如操作数栈
30: aload_3
// 获取操作数栈顶元素所指向的对象的实际类型,即从下标为14的常量池中获得类方法符号解析到bird对象的直接引用
31: invokevirtual #14 // Method animalSound:(Lcom/helix/about/OverloadDemo/Forest$Bird;)V
34: return
LineNumberTable:
line 26: 0
line 27: 8
line 28: 16
line 29: 24
line 30: 29
line 31: 34
LocalVariableTable:
Start Length Slot Name Signature
0 35 0 args [Ljava/lang/String;
8 27 1 forest Lcom/helix/about/OverloadDemo/Forest;
16 19 2 cat Lcom/helix/about/OverloadDemo/Forest$Cat;
24 11 3 bird Lcom/helix/about/OverloadDemo/Forest$Bird;
}
而对应对象如何识别调用的重载方法问题,在《深入理解Java虚拟机》第8章,分派中这样写道:
代码中刻意地定义了两个静态类型相同、实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据。并且静态类型是编译期可知的,所以在编译阶段,Javac编译器就根据参数类型决定使用哪个重载版本。
......
所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
我们通过Cat cat = new Cat();
实例化cat对象 , cat变量的静态类型和动态类型都是Cat(使用new Cat(),默认静态类型为Cat类型)。
我们通过Bird bird = new Bird();
实例化bird对象 , bird变量的静态类型和动态类型都是Bird(使用new Bird(),默认静态类型为Bird类型)。
从字节码注释中可以看出。
第26行, 26: invokevirtual #13 // Method animalSound:(Lcom/helix/about/OverloadDemo/Forest$Cat;)V
,下标为13的常量池中的类方法的符号引用Method animalSound:(Lcom/helix/about/OverloadDemo/Forest$Cat;)V
包含方法的签名(方法名,方法参数等),将其解析成栈顶元素所指向的对象的实际类型。
第31行,31: invokevirtual #14 // Method animalSound:(Lcom/helix/about/OverloadDemo/Forest$Bird;)V
, 下标为31的常量池中的类方法的符号引用(Lcom/helix/about/OverloadDemo/Forest$Bird;)V
,将其解析成栈顶元素所指向的对象的实际类型。
当然,此时并不能说明重载时是通过参数的静态类型而不是实际类型作为判定依据
。我们将调用重载的方法换成以下代码:
Forest forest = new Forest();
Animal cat = new Cat();
Animal bird = new Bird();
forest.animalSound(cat);
forest.animalSound(bird);
// 结果
//animal is sounding
//animal is sounding
此时,
我们通过Animal cat = new Cat();
实例化cat对象 , 此时Animal为cat对象的静态类型,Cat为cat对象的实际类型(使用new Cat(),默认静态类型为Cat类型)。
我们通过Animal bird = new Bird();
实例化bird对象 , 此时Animal为变量bird的静态类型,Bird为bird对象的实际类型(使用new Bird(),默认静态类型为Bird类型)。
为什么只调用了Animal类型的重载了呢?
对应字节码如下:
代码2-2 修改静态类型后调用重载产生的字节码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #7 // class com/helix/about/OverloadDemo/Forest
3: dup
4: invokespecial #8 // Method "<init>":()V
7: astore_1
8: new #9 // class com/helix/about/OverloadDemo/Forest$Cat
11: dup
12: invokespecial #10 // Method com/helix/about/OverloadDemo/Forest$Cat."<init>":()V
15: astore_2
16: new #11 // class com/helix/about/OverloadDemo/Forest$Bird
19: dup
20: invokespecial #12 // Method com/helix/about/OverloadDemo/Forest$Bird."<init>":()V
23: astore_3
24: aload_1
25: aload_2
26: invokevirtual #13 // Method animalSound:(Lcom/helix/about/OverloadDemo/Forest$Animal;)V
29: aload_1
30: aload_3
31: invokevirtual #13 // Method animalSound:(Lcom/helix/about/OverloadDemo/Forest$Animal;)V
34: return
此时我们发现,cat和bird在调用重载方法对应在字节码第26行和第31行,看出从常量池中取出的方法的符号引用都是(Lcom/helix/about/OverloadDemo/Forest$Animal;)V
。
如上可以得出:
在编译期间,编译器在重载时通过参数的静态类型而不是实际类型作为判定依据。
我们继续通过javap -verbose -c Forest
命令生成的重写字节码。 如下所示,
代码2-3 重写字节码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class com/helix/about/OverrideDemo/Forest$Cat
3: dup
4: invokespecial #3 // Method com/helix/about/OverrideDemo/Forest$Cat."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method com/helix/about/OverrideDemo/Forest$Animal.animalSound:()V
12: new #5 // class com/helix/about/OverrideDemo/Forest$Bird
15: dup
16: invokespecial #6 // Method com/helix/about/OverrideDemo/Forest$Bird."<init>":()V
19: astore_2
20: aload_2
21: invokevirtual #4 // Method com/helix/about/OverrideDemo/Forest$Animal.animalSound:()V
24: return
第8行aload_1
和第20行 aload_2
正好分别对应着cat和bird在实例化后对象引用压到操作数栈顶,下一步就是执行invokevirtual
指令,也就是执行Animal.animalSound
方法,但最终执行的目标并不相同,那么这个时候cat和bird对象究竟是如何知道所执行的重写方法的呢?
我们不知道,编译器也不知道,所以编译期间无法判断重写方法的版本。
其实问题的关键点在于invokevirtual
指令,对于invokevirtual指令的运行时解析会做以下事情:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型;
- 如果在该类型(子类)中找到与常量中的描述符和简单名称都相同的方法,进行权限访问,通过则返回这个方法的引用;如果权限访问不通过,则返回java.lang.IllegalAccessError错误。
- 如果没有找到签名相同的方法,对该类型的父类进行第二步的搜索和验证;
- 如果没有找到,则返回java.lang.AbstractMethodError.
由于invokevirtual指令在第一步就确定了在运行期间接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上这个就是Java语言中重写的本质。而这种在运行期间根据实际类型确定方法执行版本的分派被称为动态分派。
总结
- 重载,在编译期间,编译器通过参数的静态类型来确定重载目标方法的版本,通过静态类型来确定重载目标方法的分派被称作静态分派。
- 重写,在运行期间通过
invokevirtual
指令,将常量池中的方法的符号引用解析成栈顶元素不同直接引用,通过运行期实际类型的分派来确定执行目标方法的版本被称作动态分派。
参考
- [深入理解Java虚拟机-第8章]
- [JVM系列2—字节码指令]