gitbook/深入拆解Java虚拟机/docs/11539.md

235 lines
15 KiB
Markdown
Raw Permalink Normal View History

2022-09-03 22:05:03 +08:00
# 04 | JVM是如何执行方法调用的
前不久在写代码的时候,我不小心踩到一个可变长参数的坑。你或许已经猜到了,它正是可变长参数方法的重载造成的。(注:官方文档建议避免重载可变长参数方法,见\[1\]的最后一段。)
我把踩坑的过程放在了文稿里,你可以点击查看。
```
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…的变长参数。
这里我想调用第一个方法,传入的参数为(null, 1)。也就是说声明为Object的形式参数所对应的实际参数为null而变长参数则对应1。
通常来说之所以不提倡可变长参数方法的重载是因为Java编译器可能无法决定应该调用哪个目标方法。
在这种情况下编译器会报错并且提示这个方法调用有二义性。然而Java编译器直接将我的方法调用识别为调用第二个方法这究竟是为什么呢
带着这个问题我们来看一看Java虚拟机是怎么识别目标方法的。
## 重载与重写
在Java程序里如果同一个类中出现多个名字相同并且参数类型相同的方法那么它无法通过编译。也就是说在正常情况下如果我们想要在同一个类中定义名字相同的方法那么它们的参数类型必须不同。这些方法之间的关系我们称之为重载。
```
小知识这个限制可以通过字节码工具绕开。也就是说在编译完成之后我们可以再向class文件中添加方法名和参数类型相同而返回类型不同的方法。当这种包括多个方法名相同、参数类型相同而返回类型不同的方法的类出现在Java编译器的用户类路径上时它是怎么确定需要调用哪个方法的呢当前版本的Java编译器会直接选取第一个方法名以及参数类型匹配的方法。并且它会根据所选取方法的返回类型来决定可不可以通过编译以及需不需要进行值转换等。
```
重载的方法在编译过程中即可完成识别。具体到每一个方法调用Java编译器会根据所传入参数的声明类型注意与实际类型区分来选取重载方法。选取的过程共分为三个阶段
1. 在不考虑对基本类型自动装拆箱auto-boxingauto-unboxing以及可变长参数的情况下选取重载方法
2. 如果在第1个阶段中没有找到适配的方法那么在允许自动装拆箱但不允许可变长参数的情况下选取重载方法
3. 如果在第2个阶段中没有找到适配的方法那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
如果Java编译器在同一个阶段中找到了多个适配的方法那么它会在其中选择一个最为贴切的而决定贴切程度的一个关键就是形式参数类型的继承关系。
在开头的例子中当传入null时它既可以匹配第一个方法中声明为Object的形式参数也可以匹配第二个方法中声明为String的形式参数。由于String是Object的子类因此Java编译器会认为第二个方法更为贴切。
除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载。
那么,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,那么这两个方法之间又是什么关系呢?
如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。
众所周知Java是一门面向对象的编程语言它的一个重要特性便是多态。而方法重写正是多态最重要的一种体现方式它允许子类在继承父类部分功能的同时拥有自己独特的行为。
打个比方如果你经常漫游那么你可能知道拨打10086会根据你当前所在地连接到当地的客服。重写调用也是如此它会根据调用者的动态类型来选取实际的目标方法。
## JVM的静态绑定和动态绑定
接下来我们来看看Java虚拟机是怎么识别方法的。
Java虚拟机识别方法的关键在于类名、方法名以及方法描述符method descriptor。前面两个就不做过多的解释了。至于方法描述符它是由方法的参数类型以及返回类型所构成。在同一个类中如果同时出现多个名字相同且描述符也相同的方法那么Java虚拟机会在类的验证阶段报错。
可以看到Java虚拟机与Java语言不同它并不限制名字与参数类型相同但返回类型不同的方法出现在同一个类中对于调用这些方法的字节码来说由于字节码所附带的方法描述符包含了返回类型因此Java虚拟机能够准确地识别目标方法。
Java虚拟机中关于方法重写的判定同样基于方法描述符。也就是说如果子类定义了与父类中非私有、非静态方法同名的方法那么只有当这两个方法的参数类型以及返回类型一致Java虚拟机才会判定为重写。
对于Java语言中重写而Java虚拟机中非重写的情况编译器会通过生成桥接方法\[2\]来实现Java中的重写语义。
由于对重载方法的区分在编译阶段已经完成我们可以认为Java虚拟机不存在重载这一概念。因此在某些文章中重载也被称为静态绑定static binding或者编译时多态compile-time polymorphism而重写则被称为动态绑定dynamic binding
这个说法在Java虚拟机语境下并非完全正确。这是因为某个类中的重载方法可能被它的子类所重写因此Java编译器会将所有对非私有实例方法的调用编译为需要动态绑定的类型。
确切地说Java虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
具体来说Java字节码中与调用相关的指令共有五种。
1. invokestatic用于调用静态方法。
2. invokespecial用于调用私有实例方法、构造器以及使用super关键字调用父类的实例方法或构造器和所实现接口的默认方法。
3. invokevirtual用于调用非私有实例方法。
4. invokeinterface用于调用接口方法。
5. invokedynamic用于调用动态方法。
由于invokedynamic指令较为复杂我将在后面的篇章中单独介绍。这里我们只讨论前四种。
我在文章中贴了一段代码,展示了编译生成这四种调用指令的情况。
```
interface 客户 {
boolean isVIP();
}
class 商户 {
public double 折后价格(double 原价, 客户 某客户) {
return 原价 * 0.8d;
}
}
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.8d;
}
}
```
在代码中“商户”类定义了一个成员方法叫做“折后价格”它将接收一个double类型的参数以及一个“客户”类型的参数。这里“客户”是一个接口它定义了一个接口方法叫“isVIP”。
我们还定义了另一个叫做“奸商”的类它继承了“商户”类并且重写了“折后价格”这个方法。如果客户是VIP那么它会被给到一个更低的折扣。
在这个方法中我们首先会调用“客户”接口的”isVIP“方法。该调用会被编译为invokeinterface指令。
如果客户是VIP那么我们会调用奸商类的一个名叫“价格歧视”的静态方法。该调用会被编译为invokestatic指令。如果客户不是VIP那么我们会通过super关键字调用父类的“折后价格”方法。该调用会被编译为invokespecial指令。
在静态方法“价格歧视”中我们会调用Random类的构造器。该调用会被编译为invokespecial指令。然后我们会以这个新建的Random对象为调用者调用Random类中的nextDouble方法。该调用会被编译为invokevirutal指令。
对于invokestatic以及invokespecial而言Java虚拟机能够直接识别具体的目标方法。
而对于invokevirtual以及invokeinterface而言在绝大部分情况下虚拟机需要在执行过程中根据调用者的动态类型来确定具体的目标方法。
唯一的例外在于如果虚拟机能够确定目标方法有且仅有一个比如说目标方法被标记为final\[3\]\[4\],那么它可以不通过动态类型,直接确定目标方法。
## 调用指令的符号引用
在编译过程中我们并不知道目标方法的具体内存地址。因此Java编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字以及目标方法的方法名和方法描述符。
符号引用存储在class文件的常量池之中。根据目标方法是否为接口方法这些引用可分为接口符号引用和非接口符号引用。我在文章中贴了一个例子利用“javap -v”打印某个类的常量池如果你感兴趣的话可以到文章中查看。
```
// 在奸商.class的常量池中#16为接口符号引用指向接口方法"客户.isVIP()"。而#22为非接口符号引用指向静态方法"奸商.价格歧视()"。
$ javap -v 奸商.class ...
Constant pool:
...
#16 = InterfaceMethodref #27.#29 // 客户.isVIP:()Z
...
#22 = Methodref #1.#33 // 奸商.价格歧视:()D
...
```
上一篇中我曾提到过在执行使用了符号引用的字节码前Java虚拟机需要解析这些符号引用并替换为实际引用。
对于非接口符号引用假定该符号引用所指向的类为C则Java虚拟机会按照如下步骤进行查找。
1. 在C中查找符合名字及描述符的方法。
2. 如果没有找到在C的父类中继续搜索直至Object类。
3. 如果没有找到在C所直接实现或间接实现的接口中搜索这一步搜索得到的目标方法必须是非私有、非静态的。并且如果目标方法在间接实现的接口中则需满足C与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法则任意返回其中一个。
从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。
对于接口符号引用假定该符号引用所指向的接口为I则Java虚拟机会按照如下步骤进行查找。
1. 在I中查找符合名字及描述符的方法。
2. 如果没有找到在Object类中的公有实例方法中搜索。
3. 如果没有找到则在I的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤3的要求一致。
经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。具体什么是方法表,我会在下一篇中做出解答。
## 总结与实践
今天我介绍了Java以及Java虚拟机是如何识别目标方法的。
在Java中方法存在重载以及重写的概念重载指的是方法名相同而参数类型不相同的方法之间的关系重写指的是方法名相同并且参数类型也相同的方法之间的关系。
Java虚拟机识别方法的方式略有不同除了方法名和参数类型之外它还会考虑返回类型。
在Java虚拟机中静态绑定指的是在解析时便能够直接识别目标方法的情况而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。由于Java编译器已经区分了重载的方法因此可以认为Java虚拟机中不存在重载。
在class文件中Java编译器会用符号引用指代目标方法。在执行调用指令前它所附带的符号引用需要被解析成实际引用。对于可以静态绑定的方法调用而言实际引用为目标方法的指针。对于需要动态绑定的方法调用而言实际引用为辅助动态绑定的信息。
在文中我曾提到Java的重写与Java虚拟机中的重写并不一致但是编译器会通过生成桥接方法来弥补。今天的实践环节我们来看一下两个生成桥接方法的例子。你可以通过“javap -v”来查看class文件所包含的方法。
1. 重写方法的返回类型不一致:
```
interface Customer {
boolean isVIP();
}
class Merchant {
public Number actionPrice(double price, Customer customer) {
...
}
}
class NaiveMerchant extends Merchant {
@Override
public Double actionPrice(double price, Customer customer) {
...
}
}
```
1. 范型参数类型造成的方法参数类型不一致:
```
interface Customer {
boolean isVIP();
}
class Merchant<T extends Customer> {
public double actionPrice(double price, T customer) {
...
}
}
class VIPOnlyMerchant extends Merchant<VIP> {
@Override
public double actionPrice(double price, VIP customer) {
...
}
}
```
\[1\] [https://docs.oracle.com/javase/8/docs/technotes/guides/language/varargs.html](https://docs.oracle.com/javase/8/docs/technotes/guides/language/varargs.html)
\[2\]
[https://docs.oracle.com/javase/tutorial/java/generics/bridgeMethods.html](https://docs.oracle.com/javase/tutorial/java/generics/bridgeMethods.html)
\[3\]
[https://wiki.openjdk.java.net/display/HotSpot/VirtualCalls](https://wiki.openjdk.java.net/display/HotSpot/VirtualCalls)
\[4\]
[https://wiki.openjdk.java.net/display/HotSpot/InterfaceCalls](https://wiki.openjdk.java.net/display/HotSpot/InterfaceCalls)