亲宝软件园·资讯

展开

Java和JVM重载识别、重写方法

JavaEdge.  人气:1

可变长参数方法的重载造成的。

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定义了两个同名重载方法:

想调用第一个方法,传参(null, 1),即声明为Object的形式参数所对应的实际参数为null,而变长参数则对应1。
之所以不提倡可变长参数方法重载,是因为Java编译器可能无法决定应该调用哪个目标方法。
这种情况下,编译器会报错,并且提示这方法调用有二义性。然而,Java编译器直接将我的方法调用识别为调用第二个方法,这究竟是为什么呢?

Java虚拟机是怎么识别目标方法的?

2.重载与重写

同一类中出现多个:

这限制可通过字节码工具绕开,编译完成后,可再向class文件中添加方法名和参数类型相同,而返回类型不同的方法。当这种包括多个方法名相同、参数类型相同,而返回类型不同的方法的类,出现在Java编译器的用户类路径上时,它是怎么确定需要调用哪个方法的呢?
当前版本的Java编译器会直接选取第一个方法名以及参数类型匹配的方法。并且,它会根据所选取方法的返回类型来决定可不可以通过编译,以及需不需要进行值转换等。
重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:

如Java编译器在同一阶段中找到多个适配方法,那它会在其中选择一个最为贴切,贴切程度关键就是形式参数类型的继承关系。

传入null时,它既可匹配第一个方法中声明为Object的形式参数,也可匹配第二个方法中声明为String的形式参数。由于String是Object的子类,因此Java编译器会认为第二个方法更贴切。
除同一个类中的方法,重载也可作用于这个类所继承而来的方法。如子类定义了与父类中非私有方法同名的方法,且这两个方法的参数类型不同,那在子类中,这两个方法同样构成重载。

若子类定义与父类中非private方法的同名方法,且这两方法参数类型相同,那这俩方法间啥关系:

Java的方法重写是多态的体现:允许子类在继承父类部分功能同时,拥有自己独特行为。
重写调用会根据调用者的动态类型选取实际的目标方法。

3.JVM的静态绑定和动态绑定

Java虚拟机识别方法的关键在于类名、方法名及方法描述符(method descriptor)。
方法描述符由方法的参数类型及返回类型构成。
同一类中,如同时出现多个名字相同且描述符相同的方法,那Java虚拟机会在类的验证阶段报错。
Java虚拟机与Java语言不同,它不限制名字与参数类型相同,但返回类型不同的方法出现在同一类,对调用这些方法的字节码,由于字节码所附带的方法描述符包含了返回类型,因此Java虚拟机能够准确识别目标方法。

JVM方法重写判定同样基于方法描述符。
如子类定义了与父类中非私有、非静态方法同名的方法,则仅当这俩方法的参数类型及返回类型一致,JVM才会判定为重写。

对Java中重写而Java虚拟机中非重写的情况,编译器会通过生成桥接方法[2]实现Java的重写语义。

由于对重载方法的区分在编译阶段已完成,可认为JVM不存在重载概念。因此,某些文章将

这说法在JVM语境下并非完全正确,因为某类中的重载方法可能被它的子类重写,因此JVM 会将所有对非私有实例方法的调用编译为需要动态绑定的类型。

JVM的:

Java字节码中与调用相关的指令有:

编译生成这四种调用指令的情况。

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”。

“奸商”类这个方法,首先调用客户#isVIP,该调用会被编译为invokeinterface指令

在静态方法“价格歧视”会调用Random类的构造器。该调用会被编译为invokespecial指令。然后以这个新建Random对象为调用者,调用Random类中的nextDouble方法。该调用会被编译为invokevirutal指令。

对于invokestatic以及invokespecial而言,Java虚拟机能够直接识别具体的目标方法。

而对于invokevirtual以及invokeinterface而言,在绝大部分情况下,虚拟机需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法。

如虚拟机能确定目标方法有且仅有一个,比如说目标方法被标记为final[3][4],它可不通过动态类型,直接确定目标方法。

4.调用指令的符号引用

编译过程中,我们并不知目标方法的具体内存地址。因此,Java编译器会暂时用符号引表示该目标方法。
这符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。

符号引用存储在class文件的常量池。根据目标方法是否为接口方法,这些引用可分为:

// 在奸商.class的常量池中,#16为接口符号引用,指向接口方法"客户.isVIP()"。#22为非接口符号引用,指向静态方法"奸商.价格歧视()"。
$ javap -v 奸商.class ...
Constant pool:
...
  #16 = InterfaceMethodref #27.#29        // 客户.isVIP:()Z
...
  #22 = Methodref          #1.#33         // 奸商.价格歧视:()D
...

执行使用了符号引用的字节码前,JVM需解析这些【符号引用】并替换为【实际引用】。

对【非接口符号引用】,假定该【符号引用】所指向的类为C,则JVM按如下步骤查找:

所以static方法也可通过子类来调用。子类的static方法会隐藏(这不是重写)父类中的同名、同描述符的静态方法。

对于接口符号引用,假定该符号引用所指向的接口为I,则Java虚拟机会按照如下步骤进行查找。

经过上述解析步骤后,符号引用会被解析成实际引用:

5.总结与实践

文介绍了Java以及Java虚拟机是如何识别目标方法的。

在Java方法的:

JVM识别方法的方式除了方法名和参数类型,还有返回类型。

JVM的:

在class文件中,Java编译器会用符号引用指代目标方法。在执行调用指令前,它所附带的符号引用需要被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用为目标方法的指针。对于需要动态绑定的方法调用而言,实际引用为辅助动态绑定的信息。

Java的重写与Java虚拟机中的重写并不一致,但编译器会通过生成桥接方法来弥补。

参考:

[1] https://docs.oracle.com/javase/8/docs/technotes/guides/language/varargs.html
[2] https://docs.oracle.com/javase/tutorial/java/generics/bridgeMethods.html
[3] https://wiki.openjdk.java.net/display/HotSpot/VirtualCalls
[4] https://wiki.openjdk.java.net/display/HotSpot/InterfaceCalls

加载全部内容

相关教程
猜你喜欢
用户评论