深入理解java(方法调用)

JVM方法调用

重写与重载

重载

针对java编译器约定方法签名为方法名+参数列表,故此java中允许方法重载,即方法名相同,参数列表不同的方法。

但也会出现以下异常情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Main {
public static void main(String[] args) {
//调用第一个
test("a", new String[]{"a"});
//调用第二个
test("a", "b", new String[]{"a"});

//以下均无法通过编译
// test("a", "b");
// test("a", "b", "c", "d");
}
//第一个方法
public static void test(String a,String... c) {
System.out.println("echo first");
}

//第二个
public static void test(String a, String b, String... c) {
System.out.println("echo second");
}
}

java在编译阶段就能识别重载,识别方案如下:

  1. 优先选取不需要自动装箱拆箱的方法,和不需要可变长参数的方法;
  2. 考虑自动装箱,但不考虑可变长参数;
  3. 考虑自动装箱,考虑可变长参数;
  4. 如果在同一阶段由多个匹配的方法,会选择最贴切的方法(关键在于形参继承关系)。

如果子类也可以去重载父类的方法,他们的效果一样。

重写

java中允许方法重写,即子类可以重写父类的方法,但需要满足以下条件:

  1. 子类方法的访问权限不能低于父类方法;
  2. 子类方法的返回值类型不能大于父类方法;
  3. 子类方法的异常类型不能大于父类方法;
  4. 子类方法的方法名和参数列表必须与父类方法一致。
  5. 子类方法不能覆盖父类的private方法,因为private方法是私有的,子类无法访问。
  6. 子类方法不能覆盖父类的final方法,因为final方法是最终的,无法被覆盖。

关于静态字段

子类不能重写静态成员,因为静态不是对象的属性,是class类的成员。可以理解每一个class类都是一个单例的对象,故此静态成员是唯一的。

  • 在编译器眼里这是可以允许的,因为java编译器会将静态成员的调用转换为类名.静态成员,故此在编译阶段是可以通过的。
  • 运行阶段则不同,java虚拟机会根据对象的实际类型来调用方法,故此在运行阶段父类调用父类的静态成员。

    我们管以上场景叫子类对父类属性的隐藏。

JVM重写与重载判定

在JVM中,方法签名的判定由类名方法名``方法描述符共同判定。其中,方法描述符包括参数列表返回值。这与编译器的判定有所不同,编译器只关注方法名参数列表

  • 针对JVM重写而编译器不是重写的情况,编译器通过生成桥接方法的形式来实现语义重写。
  • 针对重载方法而言,其重载特性在编译时已决定,故此JVM不会再次判定。

静态绑定和动态帮定

针对以上重载和重写的场景,对于在编译时已决定的方法,如重载方法,编译器会在编译阶段就确定方法的调用。这种方法调用方式叫做静态绑定
但也有例外,如子类重写了父类的重载方法的场景,编译器无法确定方法的调用,故此在运行阶段才能确定方法的调用。这种方法调用方式叫做动态绑定。(包括大部分重写方法的调用)

具体来说,java在解析阶段就能判定要调用的方法会使用静态绑定(私有方法),反之则使用动态绑定(除私有方法以外)。

java的方法调用指令

java的方法调用指令主要有以下几种:

  1. invokestatic:调用静态方法
  2. invokespecial:调用私有方法,构造方法,父类方法
  3. invokevirtual:非私有实例方法调用
  4. invokeinterface:调用接口方法
  5. invokedynamic:动态调用方法
    对于JVM而言,invokestaticinvokespecial静态绑定,因为他们可以被虚拟机在解析阶段确定调用的方法。其它场景则需要根据JVM上下文环境判断当前类型。

符号引用与实际引用替换

针对上文讲过静态绑定以外的场景,编译器会将方法的符号引用放在常量池中,而不是直接指向方法的内存地址。这种方式叫做符号引用。因此在编译器编译方法之后,所有的方法并不指向某一块内存地址,而是指向一个符号引用(目标所在类,方法名称,方法描述符)。
方法符号引用包括接口符号引,非接口符号引用用,以及他们在解析阶段如何查找实际引用;

  • 接口符号引用
    1. 在接口中找到符合签名的方法。
    2. 在 Object 类中的公有实例方法中搜索。
    3. 在接口的超接口中搜索。
  • 非接口符号引用(假定该符号引用所指向的类为 C)
    1. 在 C 中找到符合签名的方法。
    2. 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
    3. 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。

虚方法

上文提到静态绑定,包含invokestaticinvokespecial两种方法调用指令。这两种方法调用指令在编译阶段就能确定调用的方法,故此在运行阶段不需要再次判定。
而针对invokevertualinvokeinterface两种方法调用指令即为虚方法调用,属于动态绑定

虚方法则使用了一种空间换时间的策略,JVM会为每个类生成一个方法表(在类接在准备阶段创建),用于存储类的方法信息。
方法表中存储了方法的实际地址,以及方法的访问权限等信息。
在调用虚方法时,JVM会根据对象的实际类型来查找方法表,找到对应的方法地址,然后调用该方法。

关于方法表

  • 方法表是一个二维数组,第一维是类的实际类型,第二维是方法的实际地址。
  • 方法表的生成是在类加载的准备阶段,即为每个类生成一个方法表。
  • 子类会拥有父类方法表的所有方法,对于同签名的方法子类的方法表会覆盖父类的方法表对应索引值的方法。
  • 由于子类会拥有所有父类的非私有方法,因此对应索引值位置的方法和父类会相同。

关于方法调用

  • 虚方法绑定
    • 解析阶段,根据类型,获取到虚方法对应类型的索引值。
    • 替换符号引用为当前方法表的索引值。(静态绑定则是直接替换为方法地址)
  • 虚方法调用
    • 根据对象的实际类型,获取到方法表。
    • 根据方法表的索引值,获取到方法地址。
    • 调用方法。

内联缓存

针对虚方法调用,JVM会使用内联缓存来提高方法调用的效率。(解决虚方法调用需要多次解引用操作的性能问题)。

简单说,内联缓存是一种空间换时间的策略。

  • JVM会将方法调用的结果缓存起来,下次调用时直接使用缓存的结果,而不需要再次查找方法表。
  • 缓存无法命中则退化为普通的虚方法调用。

内联缓存的调用场景

  • 单态:方法调用的接收者只有一个类型。
    • 缓存单个类型,当触发这个类型的调用,直接调用缓存的方法地址。
  • 多态:方法调用的接收者有两个或数个类型。
    • 缓存多个类型,当触发这些类型的调用,直接调用缓存的方法地址。
  • 超多太:方法调用的接收者有多个类型。
    • 同上

JVM为了节约内存占用,且绝大部分方法都是单态调用,因而只使用了单态缓存。

  • 在二态场景下,AB两个类型调用相同的方法。在最坏的场景下(即AB交替调用),JVM无法使用内联缓存,并在原有虚方法调用基础上额外增加了替换原有缓存的写操作。(但这次写在下一次调用也不会被命中)
    • 在上述场景下,JAVA选择抛弃内联缓存,而使用普通的虚方法调用(需要达到一定阈值)。
  • 在超多态场景下,JVM会使用分支预测来提高方法调用的效率(软件层全局表和硬件分支预测)。

深入理解java(方法调用)
https://andrewjiao.github.io/2022/03/14/深入理解JAVA/深入理解java(二)/
作者
Andrew_Jiao
发布于
2022年3月14日
许可协议