可变长参数方法的重载可能会造成一些坑(官方文档建议避免重载可变长参数方法),下面是一个可变长参数的例子:
void invoke(Object obj, Object... args) { ... }
void invoke(String s, Object obj, Object... args) { ... }
invoke(null, 1); // 调用第二个 invoke 方法
invoke(null, 1, 2); // 调用第二个 invoke 方法
invoke(null, new Object[]{1}); // 只有手动绕开可变长参数的语法糖,
// 才能调用第一个 invoke 方法
某个API定义了两个同名的重载方法,其中第一个接收一个Object,以及声明为Object…的变长参数;而第二个则接收一个String,一个Object,以及声明为Object…的变长参数。
之所以不提倡可变长参数方法的重载,是因为Java编译器可能无法决定应该调用哪个目标方法。。
在这种情况下编译器会报错,并且提示这个方法调用有二义性,然而Java编译器直接将方法调用识别为调用第二个方法,这是为什么呢?下面来看一下JVM是如何识别目标方法的。
重载与重写
Java程序中如果同一个类中出现多个名字相同并且参数类型相同的方法那么将无法编译成功,就是说在正常情况下如果想要在同一个类中定义名字相同的方法,参数类型必须相同,这些方法之间的关系称之为重载。
重载的方法在编译过程中即可完成识别。具体到每一个方法调用Java编译器会根据传入参数的声明类型(注意与实际类型区分)来选取重载方法,选取的过程分为三个阶段:
- 不考虑对基本类型自动装拆箱,以及可变长参数的情况下选取重载方法
- 如果在第1个阶段中没有找到适配的方法,允许在自动装拆箱但不允许可变长参数情况下选取重载方法
- 如果在第2个阶段中没有找到适配的方法,允许在自动装拆箱以及可变长参数情况下选取重载方法
如果Java编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,决定贴切程度的一个关键就是形式参数类型的继承关系。
开头的例子中传入null时,可以匹配到Object和String,由于String是Object子类,因此Java编译器会认为第二个方法更为贴切。
除了同一个类中的方法,重载也可以作用域这个类所继承而来的方法。就是说如果子类定义了与父类中非私有方法同名的方法,而且这个两个方法参数类型不同,在子类中这两个方法同样构成了重载。
那么如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同那么这两个方法之间又是什么关系呢???
如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法,如果这两个方法都不是静态的且不是私用的,那么子类的方法重写了父类中的方法。
众所周知,Java是一门面向对象的编程语言,一个重要的特性便是多态。方法重写正是多态最重要的一种体现方法:允许子类在继承父类部分功能同时,拥有自己独特的行为。。。
JVM静态绑定和动态绑定
接下来看看JVM是如何识别方法的。
Java虚拟机识别方法关键在于类名、方法名以及方法描述符(method descriptor)。前面两个不做过多解释。至于方法描述符,是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个相同且描述符也相同的方法那么JVM会在类的验证阶段报错。。。
Java虚拟机和Java语言不同,它并不限制名字与参数类型相同,但是返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说由于字节码所附带的方法描述符包含了返回类型,因此Java虚拟机能够转却识别目标方法。。。
JVM虚拟机中关于方法重写的判定同样基于方法描述符。就是说如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java虚拟机才会判定为重写。。。
对于Java语言中重写而Java虚拟机中非重写的情况,编译器会通过生成桥接方法来实现Java中的重写语义。。
在这里,重载也可以被译为静态绑定,重写则被称为动态绑定。
再进一步确切的说:JVM的静态绑定指的是在解析的时候能够识别目标方法的情况,而动态绑定指的是在运行过程中根据调用的动态类型来识别目标方法的情况。
具体来说,Java字节码中与调用相关的指令共有五种:
- invokestatic:用于调用静态方法
- invokespecial:用于调用私有实例方法、构造器、以及使用super关键字调用父类的实例方法或构造器和所实现接口的默认方法
- invokevirtual:用于调用非私有实例方法
- invokeinterface:用于调用接口方法
- invokedynamic:用于调用动态方法
由于第五种比较复杂,这里只简单介绍一个前四种:
interface 客户{
boolean isVIP();
}
public class 商户 {
public double 折后价格(double 原价, 客户 某客户){
return 原价 * 0.8f;
}
}
class 奸商 extends 商户{
@Override
public double 折后价格(double 原价, 客户 某客户) {
if (某客户.isVIP()) { //invokeinterface
return 原价 * 价格歧视(); //invokestatic
} else {
return super.折后价格(原价, 某客户); //invokespecial
}
}
public static double 价格歧视() {
//将客户城市作为随机数生成器的种子
return new Random() //invokespecial
.nextDouble() //invokevirtual
+ 0.8f;
}
}
调用指令的符号引用
在编译过程中,我们并不知道目标方法的具体内存地址。因此Java编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。。。
符号引用存储在class文件的常量池之中。先判断是否为接口,然后用不同的符号去引用。
对于非接口符号引用,假定该符号引用所指向的类为C,则Java虚拟机会按照如下步骤进行查找。
- 在C中查找符合名字及描述符的方法
- 如果没有找到,在C的父类中继续搜索,直至Object类
- 如果没有找到,在C所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且如果目标方法在间接实现的接口中,则需满足C与该接口之间没有其他符合条件的目标方法,如果有多个符合条件的目标方法,则任意返回其中一个。
从这个解析算法可以看出:静态方法可以通过子类来调用。此外子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。
对于接口符号引用,假定该符号引用所指向的接口我I,则Java虚拟机会按照如下步骤进行查找:
- 在I中查找符合名字及描述符的方法
- 如果没有找到,在Object类中的公有实例方法中搜索
- 如果没有找到,则在I的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤3的要求一致。
经过上述的解析步骤之后:符号引用会被解析成实际引用。静态绑定实际引用是一个指向方法的指针,动态绑定是一个方法表的索引。具体什么是方法表,下一篇文章会做具体介绍。。。