「Java 路线」| 方法调用的本质(含重载与重写区别)

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 「Java 路线」| 方法调用的本质(含重载与重写区别)

前言


  • 对于习惯使用面向对象开发的工程师们来说,重载 & 重写 这两个概念应该不会陌生了。在中 / 低级别面试中,也常常会考察面试者对它们的理解(隐约记得当年在校招面试时遇到过);
  • 网上大多数资料 & 面经对这两个概念的阐述,多数仅停留在讨论两者在 表现上 的差异,让读者去被动地接受知识。在这篇文章里,我将更有深度地理解重载 & 重写的原理,应深入理解Java 虚拟机执行引擎是如何进行方法调用的。请点赞,你的点赞和关注真的对我非常重要!


首先,尝试写出以下程序的输出:


public class Base {
    public static void funcStatic(String str){
        System.out.println("Base - funcStatic - String");
    }
    public static void funcStatic(Object obj){
        System.out.println("Base - funcStatic - Object");
    }
    public void func(String str){
        System.out.println("Base - func - String");
    }
    public void func(Object obj){
        System.out.println("Base - func - Object");
    }
}
public class Child extends Base {
    public static void funcStatic(String str){
        System.out.println("Child - funcStatic - String");
    }
    public static void funcStatic(Object obj){
        System.out.println("Child - funcStatic - Object");
    }
    @Override
    public void func(String str){
        System.out.println("Child - func - String");
    }
    @Override
    public void func(Object obj){
        System.out.println("Child - func - Object");
    }
}
复制代码
public class Test{
    public static void main(String[] args){
        Object obj = new Object();
        Object str = new String();
        Base base = new Base();
        Base child1 = new Child();
        Child child2 = new Child();
        base.funcStatic(obj); // 正常编程中不应该用实例去调用静态方法
        child1.funcStatic(obj);
        child2.funcStatic(obj);
        base.func(str);
        child1.func(str);
        child2.func(str);
    }
}
复制代码


程序输出:


Base - funcStatic - Object
Base - funcStatic - Object
Child - funcStatic - Object
Base - func - Object
Child - func - Object
Child - func - Object
复制代码


程序输出是否与你的预期一致呢?遇到困难了吗,相信这篇文章一定能帮到你...


延伸文章



目录


image.png

1. 静态类型 & 实际类型


每一个变量都有两种类型:静态类型(Static Type) & 实际类型(Actual Type)。例如下面代码中,Base为变量base的静态类型,Child为实际类型:


Base base = new Child();
复制代码


两者的具体区别如下:


  • 静态类型:引用变量的类型,在编译期确定,无法改变
  • 实际类型:实例对象的类型,在编译期无法确定,需在运行期确定,可以改变


这里先谈到这里,后文会从字节码的角度理解继续讨论两个类型。


2. 方法调用的本质


这一节,我们来讨论Java中方法调用的本质。我们知道,Java前端编译的产物是字节码,与C/C++不同,前端编译过程中并没有链接步骤,字节码中所有的方法调用都是使用符号引用。举个例子:


- 源码:
public class Child extends Base {
    @Override
    void func() {
    }
    void test1(){
        func();
    }
    void test2(){
        super.func();
    }
}
- 字节码(javap -c Child.class):
Compiled from "Child.java"
public class com.Child extends com.Base {
  // 构造函数,默认调用父类构造函数
  public com.Child();
    Code:
       0: aload_0
       1: invokespecial #1 // Method com/Base."<init>":()V
       4: return
  void func();
    Code:
       0: return
  void test1();
    Code:
       0: aload_0
       // invokevirtual 调用实例方法
       1: invokevirtual #2 // Method func:()V
       4: return
  void test2();
    Code:
       0: aload_0
       // invokespecial 调用静态方法
       1: invokespecial #3 // Method com/Base.func:()V
       4: return
}
复制代码


上面的字节码中,invokespecialinvokevirtual都是方法调用的字节码指令,具体细节下文会详细解释。后面的#1 #2 #3表示符号引用在常量池中的索引号,根据这个索引号检索常量表,可以查到最终表示的是一个字符串字面量,例如func:()V,这个就是方法的符号引用。


为了方便理解字节码,javap反编译的字节码已经在注释中提示了最终表示的值,例如Method func:()V


符号引用(Symbolic References)是一个用来无歧义地标识一个实体(例如方法/字段)的字符串,在运行期它会翻译为直接引用(Direct Reference)。对于方法来说,就是方法的入口地址。


下图描述了方法符号引用的基本格式:

image.png


这个符号引用包含了变量的静态类型(如果是变量的静态类型与本类相同,不需要指明)、简单方法名以及描述符(参数顺序、参数类型和方法返回值)。通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。但是,同一个符号引用,运行时翻译出来的直接引用可能是不同的,为什么会这样呢?


  • 小结:1. 方法调用的本质是根据方法的符号引用确定方法的直接引用(入口地址)


3. 从符号引用到直接引用


为什么同一个符号引用,运行时翻译出来的直接引用可能是不同的? 这与使用的方法调

用指令的处理过程有关,Java字节码的方法调用指令一共有以下 5 种:


image.png

其中,根据调用方法的版本是否在编译期可以确定,(注意:只是版本,而不是入口地址,入口地址只能在运行时确定)可以将方法调用划分为静态解析 & 动态分派两种。


# 误区(重要)#

《深入理解Java虚拟机》中将方法调用分为解析、静态分派、动态分派三种,又根据宗量的数量引入了静态多分派,动态单分派的概念。这些概念事实上过于字典化,也很容易让读者误认为静态分派与动态分派是非此即彼的互斥关系。事实上,一个方法可以同时重写与重载 ,重载 & 重写是方法调用的两个阶段,而不是两个种类。


下面,我将介绍Java中方法选择的三个步骤:

image.png

3.1 步骤1:生成符号引用(编译时)


上一节我们提到过方法符号引用的基本格式,分为三个部分:


  • 变量的静态类型: 类的全限定名中将.替换为/,例如java.lang.Object对应java/lang/Object
  • 简单名称: 方法的名称,例如Object#toString()的简单名称为:toString
  • 描述符: 方法的参数列表和返回值,例如Object#toString()的描述符为()LJava/lang/String;


描述符的规则不是本文重点,这里便不再赘述了,若不了解可阅读延伸文章。这里我们用两段程序验证上述规则,这两段程序中我们考虑了重载 & 重写、静态 & 实例两个维度的因素:


程序一(重载 & 重写)
public class Base {
    public void func() {}
    public void func(int i){}
}
public class Child extends Base {
    @Override
    public void func() {}
    @Override
    public void func(int i){}
}
public class Test{
    public static void main(String[] args){
        Base base1 = new Base();
        Base child1 = new Child();
        Child child2 = new Child();
        base1.func();  // invokevirtual com.Base.func:():V
        child1.func(); // invokevirtual com.Base.func:():V
        child2.func(); // invokevirtual com.Child.func:():V
        base1.func(1);  // invokevirtual com.Base.func:(I):V
        child1.func(1); // invokevirtual com.Base.func:(I):V
        child2.func(1); // invokevirtual com.Child.func:(I):V
    }
}
复制代码


可以看到,符号引用中的类名确实是变量的静态类型,而不是变量的实际类型;方法名不用多说,方法描述符则选择重载方法中最合适的一个方法。这个例程很容易判断重载方法选择结果,具体选择规则其实更为复杂。


程序二(静态 & 实例)
public class Base {
    public static void func() {}
    public void func(int i){}
}
public class Child extends Base {
    public static void func() {}
    @Override
    public void func(int i){}
}
public class Test{
    public static void main(String[] args){
        Base base1 = new Base();
        Base child1 = new Child();
        Child child2 = new Child();
        符号引用与程序一相同,仅指令不同
        base1.func();  // invokestatic com.Base.func:():V
        child1.func(); // invokestatic com.Base.func:():V
        child2.func(); // invokestatic com.Child.func:():V
        base1.func(1);  // invokevirtual com.Base.func:(I):V
        child1.func(1); // invokevirtual com.Base.func:(I):V
        child2.func(1); // invokevirtual com.Child.func:(I):V
    }
}
复制代码


可以看到,static对符号引用没有影响,仅影响使用的指令(静态方法调用使用invokestatic)。而通过对象实例去调用静态方法是javac的语法糖,编译时会转换为使用变量的静态类型固化到符号引用中。


  • 小结:1. 方法的符号引用在编译期确定,并固化到字节码中方法调用指令的参数中

2. 是否有static修饰对符号引用没有影响,仅影响使用的字节码指令,对象实例去调用静态方法是javac的语法糖


3.2 步骤二:解析(类加载时)


为什么静态方法、私有实例方法、实例构造器、父类方法以及final修饰这五种方法(对应的关键字:  static、private、<init>、super、final)可以在编译期确定版本呢?因为无论运行时加载多少个类,这些方法都保证唯一的版本:


方法 原因
static 相同签名的子类方法会隐藏父类方法
private 只在本类可见
<init> 由编译器生成,源码无法编写
super Java是单继承,只有一个父类
final 禁止被重写


既然可以确定方法的版本,虚拟机在处理invokestaticinvokespecialinvokevirtual(final)时,就可以提前将符号引用转换为直接引用,不必延迟到方法调用时确定,具体来说,是在类加载的解析阶段完成转换的。


invokestatic 指令


  • 1)类加载解析阶段:根据符号引用中类名(如下例中java/lang/String变量的静态类型中),在对应的类中找到简单名称与描述符相符合的方法,如果找到则将符号引用转换为直接引用;否则,按照继承关系从下往上依次在各个父类中搜索
  • 2)调用阶段:符号引用已经转换为直接引用;调用invokestatic不需要将对象加载到操作数栈,只需要将所需要的参数入栈就可以执行invokestatic指令。例如:


源码:
String str = String.valueOf("1")
字节码:
0: iconst_1
1: invokestatic  #2 // Method java/lang/String.valueOf:(I)Ljava/lang/String;
4: astore_1
复制代码


invokespecial 指令


  • 1)类加载解析阶段:同invokestatic,也是从符号引用中的静态类型开始查找
  • 2)调用阶段:同invokestatic,符号引用已经转换为直接引用;、父类方法、私有实例方法这3种情况都是属于实例方法,所以调用invokespecial指令需要将对象加载到操作数栈。例如:


1、源码(实例构造器):
String str = new String();
字节码:
0: new           #2 // class java/lang/String
3: dup
4: invokespecial #3 // Method java/lang/String."<init>":()V
7: astore_1
--------------------------------------------------------------------
2、源码(父类方法):
super.func();
字节码:
0: aload_0
1: invokespecial #2 // Method com/Base.func:()V
--------------------------------------------------------------------
3、源码(私有方法):
funcPrivate();
字节码:
0: aload_0
1: invokespecial #2 // Method funPrivate:()V
复制代码


3.3 步骤三:动态分派(类使用时)


动态分派分为invokevitrualinvokeinterface 与  invokedynamic,其中动态调用invokedynamic是 JDK 1.7 新增的指令,我们单独在另一篇中解析。有些同学可能会觉得方法不重写不就只有一个版本了吗?这个想法忽略了Java动态链接的特性,Java可以从任何途径加载一个class,除非解析的 5 种的情况外,无法保证方法不被重写。


invokevirtual指令


虚拟机为每个类生成虚方法表vtable(virtual method table)的结构,类中声明的方法的入口地址会按固定顺序存放在虚方法表中;虚方法表还会继承父类的虚方法表,顺序与父类保持一致,子类新增的方法按顺序添加到虚方法末尾(这以Java单继承为前提);若子类重写父类方法,则重写方法位置的入口地址修改为子类实现;


  • 1)类加载解析阶段: 解析类的继承关系,生成类的虚方法表 (包含了这个类型所有方法的入口地址)。举个例子,有Class B继承与Class  A,并重写了A中的方法:


image.png

Object是所有类的父类,所有每个类的虚方法表头部都会包含Object的虚方法表。另外,B重写了A#printMe(),所以对应位置的入口地址方法被修改为B重写方法的入口地址。


需要注意的是,被finalstaticprivate修饰的方法不会出现在虚方法表中,因为这些方法无法被继承重写。


  • 2)调用阶段(动态分派): 解析阶段生成虚方法表后,每个方法在虚方法表中的索引是固定的,这是不会随着实际类型变化影响的。调用方法时,首先根据变量的实际类型获得对应的虚方法表(包含了这个类型所有方法的入口地址),然后根据索引找到方法的入口地址。


invokeinterface指令


接口方法的选择行为与类方法的选择行为略有区别,主要原因是Java接口是支持多继承的,就没办法像虚方法表那样直接继承父类的虚方法表。虚拟机提供了itable(interface method table)来支持多接口,itable由偏移量表offset table与方法表method table两部分组成。


当需要调用某个接口方法时,虚拟机会在offset table查找对应的method table,随后在该method table上查找方法。


3.4 性能对比


  • invokestatic & invokespecial可以直接调用方法入口地址,最快
  • invokevirtual通过编号在vtable中查找方法,次之
  • invokeinterface现在offset table中查找method table的偏移位置,随后在method table中查找接口方法的实现


4. 总结


  • 方法调用的本质是从符号引用转换到直接引用(方法入口地址)的过程,一共需要经过(编译时)生成符号引用、(类加载时)解析、(调用时)动态分派三个步骤
  • invokestatic & invokespecial指令在(类加载时)解析时根据静态类型完成转换
  • invokevirtual & invokeinterface在(调用时)根据实际类型,查找vtable & itable完成转换
  • 重载其实是编译器的语法特性与多态无关,对编译时符号引用生成有影响,在运行时已经没有影响了;重写是多态的基础,虚拟机通过vtable & itable来支持虚方法的方法选择。
目录
相关文章
|
16天前
|
Java
Java代码解释++i和i++的五个主要区别
本文介绍了前缀递增(++i)和后缀递增(i++)的区别。两者在独立语句中无差异,但在赋值表达式中,i++ 返回原值,++i 返回新值;在复杂表达式中计算顺序不同;在循环中虽结果相同但使用方式有别。最后通过 `Counter` 类模拟了两者的内部实现原理。
Java代码解释++i和i++的五个主要区别
|
24天前
|
Java
通过Java代码解释成员变量(实例变量)和局部变量的区别
本文通过一个Java示例,详细解释了成员变量(实例变量)和局部变量的区别。成员变量属于类的一部分,每个对象有独立的副本;局部变量则在方法或代码块内部声明,作用范围仅限于此。示例代码展示了如何在类中声明和使用这两种变量。
|
1月前
|
Java 编译器
在Java中,关于final、static关键字与方法的重写和继承【易错点】
在Java中,关于final、static关键字与方法的重写和继承【易错点】
21 5
|
1月前
|
Java
Java基础之 JDK8 HashMap 源码分析(中间写出与JDK7的区别)
这篇文章详细分析了Java中HashMap的源码,包括JDK8与JDK7的区别、构造函数、put和get方法的实现,以及位运算法的应用,并讨论了JDK8中的优化,如链表转红黑树的阈值和扩容机制。
23 1
|
1月前
|
Java 编译器 C语言
【一步一步了解Java系列】:探索Java基本类型与C语言的区别
【一步一步了解Java系列】:探索Java基本类型与C语言的区别
42 2
|
1月前
|
存储 缓存 Java
【用Java学习数据结构系列】HashMap与TreeMap的区别,以及Map与Set的关系
【用Java学习数据结构系列】HashMap与TreeMap的区别,以及Map与Set的关系
35 1
|
1月前
|
自然语言处理 Java 数据处理
Java IO流全解析:字节流和字符流的区别与联系!
Java IO流全解析:字节流和字符流的区别与联系!
76 1
|
1月前
|
缓存 安全 Java
Java中 final、finally、finalize 有什么区别?
本文详细阐述了Java中`final`、`finally`和`finalize`的区别:`final`用于修饰类、方法和变量以表示不可变性;`finally`是用于确保在`try-catch`结构中无论是否发生异常都能执行的代码块;而`finalize`是`Object`类的方法,用于在对象被垃圾回收前执行清理工作,但在JDK 9中已被标记为弃用。
30 0
Java中 final、finally、finalize 有什么区别?
|
27天前
|
Java
Java代码解释静态代理和动态代理的区别
### 静态代理与动态代理简介 **静态代理**:代理类在编译时已确定,目标对象和代理对象都实现同一接口。代理类包含对目标对象的引用,并在调用方法时添加额外操作。 **动态代理**:利用Java反射机制在运行时生成代理类,更加灵活。通过`Proxy`类和`InvocationHandler`接口实现,无需提前知道接口的具体实现细节。 示例代码展示了两种代理方式的实现,静态代理需要手动创建代理对象,而动态代理通过反射机制自动创建。
|
29天前
|
缓存 算法 Java
Java 中线程和纤程Fiber的区别是什么?
【10月更文挑战第14天】
67 0