Java的方法调用与重载、覆盖的本质
我们都知道,Java源代码需要经过前端编译器(Javac)编译后生成class字节码文件,才可以被JVM的类加载器加载;在class文件中,一句句java代码被解析成一个个对应的字节码指令,本文的主旨就是讨论方法调用的字节码指令以及方法重载与覆盖的本质。
在Class字节码指令中,方法的调用涉及到五条指令,他们又分为普通调用指令和动态调用指令:
- 普通调用指令:
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
- 动态调用指令
- invokedynamic:动态解析出需要调用的方法,然后执行
关于invokedynamic,这是java为了实现动态类型语言支持在JDK7中添加的动态调用指令,在本文中不做展开。
学过C++的同学应该都知道虚方法(通过virtual关键字来定义),涉及到虚方法,就不得不提几个概念:
静态链接(编译期间确定)
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接
动态链接(运行期间确定)
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
绑定机制
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
早期绑定 -->静态链接
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定 -->动态链接
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
介绍完这几个概念后,我们再回过来看虚方法与非虚方法:
-
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法(早期绑定)。
-
如果方法在编译期无法确定的调用版本,这个版本在运行时是可变的。这样的方法称为虚方法(晚期绑定)。
所以 如果在编译器就是可知的,也就是早期绑定,就是非虚方法;静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法;除去这些方法的其他方法都是虚方法。
再回到一开始列出的四个普通调用指令:
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
所以可以看出,其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
关于final修饰的方法,因为历史设计的原因,final方法是使用invokevitual指令来调用的,但是因为它无法被覆盖,没有其他版本的可能,在《Java语言规范》中明确定义了被final修饰的方法是一种非虚方法。
class Father {
public Father() {
System.out.println("father的构造器");
}
public static void showStatic(String str) {
System.out.println("father " + str);
}
public final void showFinal() {
System.out.println("father show final");
}
public void showCommon() {
System.out.println("father 普通方法");
}
}
interface MethodInterface {
void methodA();
}
public class Son extends Father {
public Son() {
//invokespecial
super();
}
public Son(int age) {
//invokespecial
this();
}
//不是重写的父类的静态方法,因为静态方法不能被重写!
public static void showStatic(String str) {
System.out.println("son " + str);
}
private void showPrivate(String str) {
System.out.println("son private" + str);
}
public void info() {
}
public void display(Father f) {
f.showCommon();
}
public void show() {
// 子类静态方法:invokestatic
showStatic("baidu.com");
// 显式调用父类静态方法:invokestatic
super.showStatic("good!");
// 私有方法:invokespecial
showPrivate("hello!");
// 显式调用父类方法:invokespecial
super.showCommon();
// 父类final方法:invokevirtual
// 因为此方法声明有final,不能被子类重写,
// 所以也认为此方法是非虚方法。
showFinal();
// 虚方法如下:
// invokevirtual
showCommon();
info();
MethodInterface in = null;
// 接口中的方法:invokeinterface
in.methodA();
}
}
Son的show()方法对应的字节码如下:
重载(OverLoad)与覆盖(OverWrite)
我们都知道,Java的多态体现在重载与覆盖,那么在JVM底层是如何知道什么时候该调用哪个版本(重载/覆盖)的方法呢?
在对重载进行讲解之前,我们先看一道很有意思的题目:
public class StaticDispatch {
public static void main(String[] args) {
Collection<?>[] collections =
{new HashSet<String>(), new ArrayList<String>(), new HashMap<String, String>().values()};
Demo Super = new Demo();
for(Collection<?> collection: collections) {
System.out.println(Super.getType(collection));
}
}
static class Demo {
public String getType(Collection<?> collection) {
return "collection";
}
public String getType(List<?> list) {
return "list";
}
public String getType(ArrayList<?> list) {
return "arrayList";
}
public String getType(Set<?> set) {
return "set";
}
public String getType(HashSet<?> set) {
return "hashSet";
}
}
}
这道题的输出是什么呢?
collection
collection
collection
为什么输出会是三个collection而不是精确匹配到每个集合呢?
我们都知道HashSet、ArrayList、HashMap都实现了Collection;在声明
Collection<?> collections = new HashSet<String>();
时,前者的Collection是静态类型,而HashSet是实际类型。静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅在使用时,变量本身的静态类型不会被改变,并且最终的静态类型在编译期是可知的,而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
简而言之,Collection在编译期是可知的,而HashSet在编译期不可知。
而getType方法是一个重载方法,具体调用哪个版本的getType是由传入的参数类型和数量来决定的;但是编译器在编译期就要确定具体调用的是哪个版本的getType方法,来确定方法调用的指令invokevirtual调用指向哪个符号引用,所以此时对于参数的类型选择是不能使用实际类型的,也就是HahSet;因为实际类型在编译期不可知,故只能使用静态类型,也就是Collection来确定调用的getType这个方法的唯一版本。
所有根据静态类型来决定方法执行版本的分派动作,都称为静态分派;重载就是静态分派的最好体现。
接下来我们来看一道与重写有关的题目:
public class DynamicDispatch {
static abstract class Super {
abstract void sayHello();
}
static class Sun1 extends Super {
@Override
void sayHello() {
System.out.println("Sun1");
}
}
static class Sun2 extends Super {
@Override
void sayHello() {
System.out.println("Sun2");
}
}
public static void main(String[] args) {
Super sun1 = new Sun1();
Super sun2 = new Sun2();
sun1.sayHello();
sun2.sayHello();
}
}
这道理相对来说就简单一些,相信读者都能给出正确的答案:
Sun1
Sun2
很明显此处则不是由静态类型来决定调用的方法版本,而是由真实的实际类型Sun1和Sun2来决定的,查看字节码可以得知:
在第17行和第21行的invokevirtual指令分别对应了两个sayHello,乍一看好像两条指令没有区别;所以我们还得先看看invokevirtual执行的具体解析过程:
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError 异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodsrror异常。
所以这也就是为什么会调用的是子类的方法了,因为实际类型是子类而不是父类。
所有根据实际类型来决定方法执行版本的分派动作,都称为动态分派;覆盖就是动态分派的最好体现。
现在的Java是一门静态多分派、动态单分派的语言。
所谓的单分派与多分派指的是在分派过程中依据方法的宗量的数量多少,宗量是方法的接收者与方法的参数统称。
当编译期进行方法版本选择时,也就是重载,编译期根据调用方法的变量的静态类型以及方法参数的静态类型来决定版本,这里涉及到了多个宗量,所以重载是静态多分派的。
当运行期进行方法版本选择时,也就是覆盖,虚拟机根据调用方法的变量的实际类型来决定版本,这里只涉及到一个宗量,所以覆盖是动态单分派的。
参考内容:
深入理解Java虚拟机(JVM高级特性与最佳实践)——周志明老师