JVM方法调用
背景
Java文件在编译过程中不会进行传统编译的连接步骤,方法调用的目标方法以符号引用的方式存储在Class文件中,这种多态特性给Java带来了更灵活的扩展能力,但也使得方法调用变得相对复杂,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
重载与重写
在 Java 程序里,如果同一个类中出现多个名字相同,并且参数类型相同的方法,那么它无法通过编译。也就是说,在正常情况下,如果我们想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。这些方法之间的关系,我们称之为重载。
重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型来选取重载方法。选取的过程共分为三个阶段:
- 在不考虑对基本类型自动装拆箱,以及可变长参数的情况下选取重载方法
- 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法
- 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法
如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系(会选择继承体系的最后一个子类)
例子:
public class T2 {
public void m(Object o,Integer integer){
System.out.println("m1");
}
public void m(String s,Integer integer) {
System.out.println("m2");
}
/*
当传入 null 时,它既可以匹配第一个方法中声明为 Object 的形式参数,也可以匹配第二个方法中声明为 String 的形式参数。由于 String 是 Object 的子类,因此 Java 编译器会认为第二个方法更为贴切。
*/
public static void main(String[] args) {
T2 t2 = new T2();
t2.m(null,1);
// m2
}
}
除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载。
那么,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,那么这两个方法之间又是什么关系呢?
如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法(静态方法不能被重写,父类引用子类对象时,对象调用静态方法依然执行的是父类的静态方法)。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。
众所周知,Java 是一门面向对象的编程语言,它的一个重要特性便是多态。而方法重写,正是多态最重要的一种体现方式:它允许子类在继承父类部分功能的同时,拥有自己独特的行为。
JVM 的静态绑定和动态绑定
Java 虚拟机是怎么识别方法的?
Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。前面两个就不做过多的解释了。至于方法描述符,它是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错。
可以看到,JVM与 Java 语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此 Java 虚拟机能够准确地识别目标方法。
Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况
JVM提供了如下方法调用字节码指令:
- invokestatic:用于调用静态方法
- invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法
- invokevirtual:用于调用非私有实例方法
- invokeinterface:用于调用接口方法
- invokedynamic:用于调用动态方法
invokestatic
实例代码:
public class T1 {
public static void m() {
System.out.println("mm");
}
public static void main(String[] args) {
m();
}
}
通过命令行查看main方法字节码
//public static void main(java.lang.String[]);
// descriptor: ([Ljava/lang/String;)V
// flags: ACC_PUBLIC, ACC_STATIC
// Code:
// stack=0, locals=1, args_size=1
方法m是通过invokestatic指令调用的
// 0: invokestatic #5 // Method m:()V
// 3: return
invokespecial
实例代码:
public class T2 {
private int num;
public static void main(String[] args) {
T2 t2 = new T2();
}
}
查看Java字节码:
//public static void main(java.lang.String[]);
// descriptor: ([Ljava/lang/String;)V
// flags: ACC_PUBLIC, ACC_STATIC
// Code:
// stack=2, locals=2, args_size=1
// 0: new #2 // class jvm/D/T2
// 3: dup
可以发现实例构造器是通过invokespecial指令调用的。
// 4: invokespecial #3 // Method "<init>":()V
// 7: astore_1
// 8: return
通过invokestatic和invokespecial指令调用的方法,可以称为非虚方法,其余情况称为虚方法,不过有一个特例,即被final关键字修饰的方法,虽然使用invokevirtual指令调用,由于它无法被覆盖重写,所以也是一种非虚方法。
非虚方法的调用是一个静态的过程,由于目标方法只有一个确定的版本,所以在类加载的解析阶段就可以把符合引用解析为直接引用,
而虚方法的调用是一个分派的过程,有静态也有动态,可分为静态单分派、静态多分派、动态单分派和动态多分派。
静态分派
静态分派发生在代码的编译阶段
public class T3 {
interface Fruit{};
static class Apple implements Fruit {
}
static class WaterMelom implements Fruit {
}
public void sell(Fruit fruit) {
System.out.println("sell fruit");
}
public void sell(Apple apple) {
System.out.println("sell apple");
}
public void sell(WaterMelom waterMelom) {
System.out.println("sell watermelom");
}
public static void main(String[] args) {
Fruit apple = new Apple();
Fruit waterMelom = new WaterMelom();
T3 t3 = new T3();
t3.sell(apple);
t3.sell(waterMelom);
//sell fruit
//sell fruit
}
}
查看字节码:
// 26: invokevirtual #13 // Method sell:(Ljvm/D/T3$Fruit;)V
// 29: aload_3
// 30: aload_2
// 31: invokevirtual #13 // Method sell:(Ljvm/D/T3$Fruit;)V
通过字节码指令,可以发现两次sell方法都是通过invokevirtual指令进行调用,而且调用的是参数为Fruit类型的sell方法
Fruit apple = new Apple();
上面代码中,变量apple拥有两个类型,一个静态类型Fruit,一个实际类型Apple
静态类型在编译期间可知
在编译阶段,Java编译器会根据参数的静态类型决定调用哪个重载版本,但在有些情况下,重载的版本不是唯一的,这样只能选择一个“更加合适的版本”进行调用
动态分派
在运行期间根据参数的实际类型确定方法执行版本的过程称为动态分派,动态分派和多态性中的重写(override)有着紧密的联系。
Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而接口方法调用都会被编译成 invokeinterface 指令。这两种指令,均属于 Java 虚拟机中的虚方法调用
在绝大多数情况下,Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程我们称之为动态绑定。那么,相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时
实例:
public class T4 {
interface Car {
void run();
}
static class Audi implements Car {
@Override
public void run() {
System.out.println("奥迪");
}
}
static class BMW implements Car {
@Override
public void run() {
System.out.println("宝马");
}
}
public static void main(String[] args) {
Car audi = new Audi();
Car bmw = new BMW();
audi.run();
bmw.run();
//奥迪
//宝马
}
}
查看main方法字节码:
// Code:
// stack=2, locals=3, args_size=1
// 0: new #2 // class jvm/D/T4$Audi
// 3: dup
// 4: invokespecial #3 // Method jvm/D/T4$Audi."<init>":()V
// 7: astore_1
// 8: new #4 // class jvm/D/T4$BMW
// 11: dup
// 12: invokespecial #5 // Method jvm/D/T4$BMW."<init>":()V
// 15: astore_2
// 16: aload_1
// 17: invokeinterface #6, 1 // InterfaceMethod jvm/D/T4$Car.run:()V
// 22: aload_2
// 23: invokeinterface #6, 1 // InterfaceMethod jvm/D/T4$Car.run:()V
// 28: return
0-15行对应:
Car audi = new Audi();
Car bmw = new BMW();
在Java堆上申请内存空间和实例化对象
16-23对应:
audi.run();
bmw.run();
发现17和23行两条指令完全一样,但是最终执行的目标方法却不一样
其中指令的多态查找在发挥作用:
对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找
- 在 C 中查找符合名字及描述符的方法
- 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
- 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个
从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法
对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找
- 在 I 中查找符合名字及描述符的方法。
- 如果没有找到,在 Object 类中的公有实例方法中搜索。
- 如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。
所以上述两次invokeinterface指令将相同的符号引用解析成了不同对象的直接引用,这个过程就是Java语言中重写的本质
方法表
方法表JVM实现动态绑定的关键所在
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。
这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:
-
其一,子类方法表中包含父类方法表中的所有方法
-
其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。
方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。
在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
网友评论