关于java:JVM系列3方法重载和方法重写原理分析看完这篇终于彻底搞懂了

6次阅读

共计 9926 个字符,预计需要花费 25 分钟才能阅读完成。

前言

JVM 执行字节码指令是基于栈的架构,就是说所有的操作数都必须先入栈,而后再依据须要出栈进行操作计算,再把后果进行入栈,这个流程和基于寄存器的架构是有本质区别的,而基于寄存器架构来实现,在不同的机器上可能会无奈做到齐全兼容,这也是 Java 会抉择基于栈的设计的起因之一。

思考

咱们思考下,当咱们调用一个办法时,参数是怎么传递的,返回值又是怎么保留的,一个办法调用之后又是如何持续下一个办法调用的呢?调用过程中必定会存储一些办法的参数和返回值等信息,这些信息存储在哪里呢?

JVM 系列文章 1 中咱们提到了,每次调用一个办法就会产生一个栈帧,所以咱们必定能够想到栈帧就存储了所有调用过程中须要应用到的数据。当初就让咱们深刻的去理解一下 Java 虚拟机栈中的栈帧吧。

栈帧

当咱们调用一个办法的时候,就会产生一个栈帧,当一个办法调用实现时,它所对应的栈帧将被销毁,无论这种实现是失常的还是忽然的(抛出一个未捕捉的异样)。

每个栈帧中包含局部变量表 (Local Variables)、操作数栈(Operand Stack)、动静链接(Dynamic Linking)、办法返回地址(Return Address) 和额定的附加信息。

在给定的线程当中,永远只有一个栈帧是流动的,所以流动的栈帧又称之为以后栈帧,而其对应的办法则称之为以后办法,定义了以后办法的类则称之为以后类。当一个办法调用完结时,其对应的栈帧也会被抛弃。

局部变量表(Local Variables)

局部变量表是以数组的模式存储的,而且以后栈帧的办法所须要调配的最大长度是在编译时就确定了。局部变量表通过 index 来寻址,变量从 index[0]开始传递。

局部变量表的数组中,每一个地位能够保留一个 32 位的数据类型:boolean、byte、char、short、int、float、reference 或 returnAddress 类型的值。而对于 64 位的数据类型 long 和 double 则须要两个地位来存储,然而因为局部变量表是属于线程公有的,所以尽管被宰割为 2 个变量存储,仍然不必放心会呈现安全性问题。

对于 64 位的数据类型,如果其占用了数组中的 index[n]和 index[n+1]两个地位,那么不容许独自拜访其中的某一个地位,Java 虚拟机标准中规定,如果呈现一个 64 位的数据被独自拜访某一部分时,则在类加载机制中的校验阶段就应该抛出异样。

Java 虚拟机在办法调用时应用局部变量进行传递参数。在类办法 (static 办法) 调用中,所有参数都以从局部变量中的 index[0]开始进行参数传递。而在实例办法调用上,index[0]固定用来传递办法所属于的对象实例 ,其余所有参数则在从局部变量表内 index[1] 的地位开始进行传递。

留神:局部变量表中的变量不能够间接应用,如须要应用的话,必须通过相干指令将其加载至操作数栈中作为操作数能力应用

操作数栈(Operand Stacks)

操作数栈,在上下文语义清晰时,也能够称之为操作栈(Operand Stack),是一个后进先出 (Last In First Out,LIFO) 栈,同局部变量表一样,操作数栈的最大深度也是在编译时就确定的。

操作数栈在刚被创立时 (也就是办法刚被执行的时候) 是空的,而后在执行办法的过程中,通过虚拟机指令将常量 / 值从局部变量表或字段加载到操作数栈中,而后对其进行操作,并将操作后果压入栈内。

操作数堆栈上的每个条目都能够保留任何 Java 虚拟机类型的值,包含 long 或 double 类型的值。

留神:咱们必须以适宜其类型的形式对操作数堆栈中的值进行操作。例如,不可能将两个 int 类型的值压入栈后将其视为 long 类型,也不可能将两个 float 类型值压入栈内后应用 iadd 指令将其增加

动静连贯(Dynamic Linking)

每个栈帧都蕴含一个指向运行时常量池中该栈帧所属办法的援用,持有这个援用是为了反对办法调用过程中的动静连贯。

在 Class 文件中的常量池中存有大量的符号援用,字节码中的办法调用指令就以常量池中指向办法的符号援用作为参数,这些符号援用一部分会在类加载阶段或者第一次应用的时候就转化为间接援用,这种就称为动态解析。而另外一部分则会在每一次运行期间才会转化为间接援用,这部分就称为动静连贯。

办法返回地址

当一个办法开始执行后,只有两种形式能够退出:一种是遇到办法返回的字节码指令;一种是遇见异样,并且这个异样没有在办法体内失去解决。

失常退出(Normal Method Invocation Completion)

如果对以后办法的调用失常实现,则可能会向调用办法返回一个值。当被调用的办法执行其中一个返回指令时,返回指令的抉择必须与被返回值的类型相匹配(如果有的话)。

办法失常退出时,以后栈帧通过将调用者的 pc 程序计数器适当的并跳过以后的调用指令来复原调用程序的状态,包含它的局部变量表和操作数堆栈。而后持续在调用办法的栈帧来执行后续流程,如果有返回值的话则须要将返回值压入操作数栈。

异样终止(Abrupt Method Invocation Completion)

如果在办法中执行 Java 虚拟机指令导致 Java 虚拟机抛出异样,并且该异样没有在办法中解决,那么办法调用会忽然完结,因为异样导致的办法忽然完结永远不会有返回值返回给它的调用者。

其余附加信息

这一部分具体要看虚拟机产商是如何实现的,虚拟机标准并没有对这部分进行形容。

办法调用流程演示

下面的概念听起来有点形象,上面咱们就通过一个简略的例子来演示一下办法的执行流程。

package com.zwx.jvm;

public class JVMDemo {public static void main(String[] args) {int sum = add(1, 2);
        print(sum);
    }

    public static int add(int a, int b) {
        a = 3;
        int result = a + b;
        return result;
    }

    public static void print(int num) {System.out.println(num);
    }
}
复制代码

要想理解 Java 虚拟机的执行流程,那么咱们必须要对类进行编译,失去字节码文件,执行如下命令

javap -c xxxxxxJVMDemo.class >1.txt
复制代码

将 JVMDemo.class 生成的字节码指令输入到 1.txt 文件中,而后关上,看到如下字节码指令:

Compiled from "JVMDemo.java"
public class com.zwx.jvm.JVMDemo {public com.zwx.jvm.JVMDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: iconst_1
       1: iconst_2
       2: invokestatic  #2                  // Method add:(II)I
       5: istore_1
       6: iload_1
       7: invokestatic  #3                  // Method print:(I)V
      10: return

  public static int add(int, int);
    Code:
       0: iconst_3
       1: istore_0
       2: iload_0
       3: iload_1
       4: iadd
       5: istore_2
       6: iload_2
       7: ireturn

  public static void print(int);
    Code:
       0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: iload_0
       4: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
       7: return
}
复制代码

如果是第一次接触可能指令看不太懂,然而大抵的类构造还是很清晰的,咱们先来对用到的字节码指令大抵阐明一下:

  • iconst_i
    示意将整型数字 i 压入 操作数栈 ,留神,这里 i 的返回只有 -1~5,如果不在这个范畴会采纳其余指令,如当 int 取值范畴是[-128,127] 时,会采纳 bipush 指令。
  • invokestatic
    示意调用一个静态方法
  • istore_n
    这里示意将一个整型数字存入 局部变量表 的索引 n 地位,因为 局部变量表 是通过一个数组模式来存储变量的
  • iload_n
    示意将局部变量地位 n 的变量压入操作数栈
  • ireturn
    将以后办法的后果返回到上一个栈帧
  • invokevirtual
    调用 虚办法

理解了字节码指令的大略意思,接下来就让咱们来演示一下次要的几个执行流程:

  • 1、代码编译之后大抵失去如下的一个 Java 虚拟机栈, 留神这时候操作数栈都是空的(pc 寄存器的值在这里暂不思考,实际上调用指令的过程,pc 寄存器是会始终发生变化的)

  • 2、执行 iconst_1 和 iconst_2 两个指令,也就是从本地变量中把整型 1 和 2 两个数字压入操作数栈内:

  • 3、执行 invokestatic 指令,调用 add 办法,会再次创立一个新的栈帧入栈,并且会将参数 a 和 b 存入 add 栈帧中的本地变量表

  • 4、add 栈帧中调用 iconst_3 指令,从本地变量中将整型 3 压入操作数栈

  • 5、add 栈帧中调用 istore_0,示意将以后的栈顶元素存入局部变量表 index[0]的地位,也就是赋值给 a。

  • 6、调用 iload_0 和 iload_1,将局部变量表中 index[0]和 index[1]两个地位的变量压入操作数栈

  • 7、最初执行 iadd 指令:将 3 和 2 弹出栈后将两个数相加,失去 5,并将失去的后果 5 从新压入栈内

    8、执行 istore_2 指令,将以后栈顶元素弹出存入局部变量表 index[2]的地位,并再次调用 iload_2 从局部变量表内将 index[2]地位的数据压入操作数栈内

  • 9、最初执行 ireturn 命令将后果 5 返回 main 栈帧,此时栈帧 add 被销毁,回到 main 栈帧持续后续执行

    办法的调用大抵就是一直的入栈和出栈的过程,上述的过程省略了很多细节,只关注了大抵流程即可,理论调用比图中要简单的多。

办法调用剖析

咱们晓得,Java 是一种面向对象语言,反对多态,而多态的体现模式就是办法重载和办法重写,那么 Java 虚拟机又是如何确认咱们应该调用哪一个办法的呢?

办法调用指令

首先,咱们来看一下办法的字节码调用指令,在 Java 中,提供了 4 种字节码指令来调用办法(jdk1.7 之前):

  • 1、invokestatic:调用静态方法
  • 2、invokespecial:调用实例结构器办法,公有办法,父类办法
  • 3、invokevirtual:调用所有的 虚办法
  • 4、invokeinterface:调用接口办法(运行时会确定一个实现了接口的对象)

留神:在 JDK1.7 开始,Java 新增了一个指令 invokedynamic,这个是为了实现“动静类型语言”而引入的,在这里咱们暂不探讨

办法解析

在类加载机制中的解析阶段,次要做的事件就是将符号援用转为间接援用,然而,对办法的调用而言,有一个前提,那就是在办法真正运行之前就能够惟一确定具体要调用哪一个办法,而且这个办法在运行期间是不可变的。只有满足这个前提的办法才会在解析阶段间接被替换为间接援用,否则只能等到运行时能力最终确定。

非虚办法

在 Java 语言中,满足“编译器可知,运行期不可变”这个前提的办法,被称之为非虚办法。非虚办法在类加载机制中的解析阶段就能够间接将符号援用转化为间接援用。非虚办法有 4 种:

  • 1、静态方法
  • 2、公有办法
  • 3、实例结构器办法
  • 4、父类办法(通过 super.xxx 调用,因为 Java 是单继承,只有一个父类,所以能够确定办法的惟一)

除了非虚办法之外的非 final 办法就被称之为虚办法,虚办法须要运行时能力确定真正调用哪一个办法。Java 语言标准中明确指出,final 办法是一种非虚办法,然而 final 又属于比拟非凡的存在,因为final 办法和其余非虚办法调用的字节码指令不一样

晓得了虚办法的类型,再联合下面的办法的调用指令,咱们能够晓得,虚办法就是通过字节码指令 invokestatic 和 invokespecial 调用的,而final 办法又是一个例外,final 办法是通过字节码指令 invokevirtual 调用的,然而因为 final 办法的个性就是不可被重写,无奈笼罩,所以必然是惟一的,尽管调用指令不同,然而仍然属于非虚办法的领域。

办法重载

先来看一个办法重载的例子:

package com.zwx.jvm.overload;

public class OverloadDemo {static class Human {}
    static class Man extends Human { }
    static class WoMan extends Human { }

    public void hello(Human human) {System.out.println("Hi,Human");
    }

    public void hello(Man man) {System.out.println("Hi,Man");
    }

    public void hello(WoMan woMan) {System.out.println("Hi,Women");
    }

    public static void main(String[] args) {OverloadDemo overloadDemo = new OverloadDemo();
        Human man = new Man();
        Human woman = new WoMan();

        overloadDemo.hello(man);
        overloadDemo.hello(woman);
    }
}
复制代码

输入后果为:

Hi,Human
Hi,Human
复制代码

这里,Java 虚拟机为什么会抉择参数为 Human 的办法来进行调用呢?

在解释这个问题之前,咱们先来介绍一个概念:宗量

宗量

办法的接收者 (调用者) 和办法参数统称为宗量。而最终决定办法的分派就是基于宗量来抉择的,故而依据基于多少种宗量来抉择办法又能够分为:

  • 单分派:依据 1 个宗量对办法进行抉择
  • 多分派:依据 1 个以上的宗量对办法进行抉择

晓得了办法的分派是基于宗量来进行的,那咱们再回到下面的例子中就很好了解了。

overloadDemo.hello(man);
复制代码

这句代码中 overloadDemo 示意接收者,man 示意参数,而接收者是确定惟一的,就是 overloadDemo 实例,所以决定调用哪个办法的只有参数 (包含参数类型和个数和程序) 这一个宗量。咱们再看看参数类型:

Human man = new Man();
复制代码

这句话中,Human 称之为变量的动态类型,而 Man 则称之为变量的理论类型,而 Java 虚拟机在确认重载办法时是基于参数的动态类型来作为判断根据的,故而最终实际上不论你左边 new 的对象是哪个,调用的都是参数类型为 Human 的办法。

动态分派

所有依赖变量的动态类型来定位办法执行的分派动作就称之为动态分派。动态分派最典型的利用就是办法重载。

办法重载在编译期就能确定办法的惟一,不过尽管如此,然而在有些状况下,这个重载版本不是惟一的,甚至是有点含糊的。产生这个起因就是因为字面量并不需要定义,所以字面量就没有明天类型,比方咱们间接调用一个办法:xxx.xxx(‘1’),这个字面量 1 就是含糊的,并没有对应动态类型。咱们再来看一个例子:

package com.zwx.jvm.overload;

import java.io.Serializable;

public class OverloadDemo2 {public static void hello(Object a){System.out.println("Hello,Object");
    }
    public static void hello(double a){System.out.println("Hello,double");
    }
    public static void hello(Double a){System.out.println("Hello,Double");
    }
    public static void hello(float a){System.out.println("Hello,float");
    }
    public static void hello(long a){System.out.println("Hello,long");
    }
    public static void hello(int a){System.out.println("Hello,int");
    }
    public static void hello(Character a){System.out.println("Hello,Character");
    }
    public static void hello(char a){System.out.println("Hello,char");
    }
    public static void hello(char ...a){System.out.println("Hello,chars");
    }
    public static void hello(Serializable a){System.out.println("Hello,Serializable");
    }

    public static void main(String[] args) {OverloadDemo2.hello('1');
    }
}
复制代码

这里的输入后果是

Hello,char
复制代码

而后如果把该办法正文掉,就会输入:

Hello,int
复制代码

再把 int 办法正文掉,那么会顺次依照如下程序进行办法调用输入:

char->int->long->float->double->Character->Serializable->Object->chars
复制代码

能够看到,多参数的优先级最低,之所以会输入 Serializable 是因为包装类 Character 实现了 Serializable 接口,留神示例中 double 的包装类 Double,并不会被执行。

办法重写

咱们把下面第 1 个例子批改一下:

package com.zwx.jvm.override;

public class OverrideDemo {
    static class Human {public void hello(Human human) {System.out.println("Hi,Human");
        }
    }

    static class Man extends Human {
        @Override
        public void hello(Human human) {System.out.println("Hi,Man");
        }
    }

    static class WoMan extends Human {
        @Override
        public void hello(Human human) {System.out.println("Hi,Women");
        }
    }

    public static void main(String[] args) {Human man = new Man();
        Human woman = new WoMan();

        man.hello(man);
        man.hello(woman);
        woman.hello(woman);
        woman.hello(man);
    }
}
复制代码

输入后果为:

Hi,Man
Hi,Man
Hi,Women
Hi,Women
复制代码

这里动态类型都是 Human,然而却输入了两种后果,所以必定不是依照动态类型来分派办法了,而从后果来看应该是依照了调用者的理论类型来进行的判断。

执行 javap 命令把类转换成字节码:

Compiled from "OverrideDemo.java"
public class com.zwx.jvm.override.OverrideDemo {public com.zwx.jvm.override.OverrideDemo();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class com/zwx/jvm/override/OverrideDemo$Man
       3: dup
       4: invokespecial #3                  // Method com/zwx/jvm/override/OverrideDemo$Man."<init>":()V
       7: astore_1
       8: new           #4                  // class com/zwx/jvm/override/OverrideDemo$WoMan
      11: dup
      12: invokespecial #5                  // Method com/zwx/jvm/override/OverrideDemo$WoMan."<init>":()V
      15: astore_2
      16: aload_1
      17: aload_1
      18: invokevirtual #6                  // Method com/zwx/jvm/override/OverrideDemo$Human.hello:(Lcom/zwx/jvm/override/OverrideDemo$Human;)V
      21: aload_1
      22: aload_2
      23: invokevirtual #6                  // Method com/zwx/jvm/override/OverrideDemo$Human.hello:(Lcom/zwx/jvm/override/OverrideDemo$Human;)V
      26: aload_2
      27: aload_2
      28: invokevirtual #6                  // Method com/zwx/jvm/override/OverrideDemo$Human.hello:(Lcom/zwx/jvm/override/OverrideDemo$Human;)V
      31: aload_2
      32: aload_1
      33: invokevirtual #6                  // Method com/zwx/jvm/override/OverrideDemo$Human.hello:(Lcom/zwx/jvm/override/OverrideDemo$Human;)V
      36: return
}
复制代码

咱们能够发现这里的办法调用应用了指令 invokevirtual 来调用,因为依据下面的分类能够判断,hello 办法均是 虚办法

main 办法大略解释一下,

main 办法中,第 7 行 (Code 列序号) 和第 15 行是别离把 Man 对象实例和 Women 对象实例存入局部变量变的 index[1]和 index[2]两个地位,而后 16,17 两行,21,22 两行,26,27 两行,31,32 两行别离是把须要用到的办法调用者和参数压入操作数栈,而后调用 invokevirtual 指令调用办法

所以下面最要害的就是 invokevirtual 指令到底是如何工作的呢?invokevirtual 次要是依照如下步骤进行办法抉择的:

  • 1、找到以后操作数栈中的办法接收者(调用者),记下来,比方叫 Caller
  • 2、而后在类型 Caller 中去找办法,如果找到办法签名统一的办法,则进行搜寻,开始对办法校验,校验通过间接调用,校验不通过,间接抛 IllegalAccessError 异样
  • 3、如果在 Caller 中没有找到办法签名统一的办法,则往上找父类,以此类推,直到找到为止,如果到顶了还没找到匹配的办法,则抛出 AbstractMethodError 异样

动静分派

下面的办法重写例子中,在运行期间能力依据理论类型来确定办法的执行版本的分派过程就称之为动静分派。

单分派与多分派

下面办法重载的第 1 个示例中,是一个动态分派过程,动态调配过程中 Java 虚拟机抉择指标办法有两点:

  • 1、动态类型
  • 2、办法参数
    也就是用到了 2 个宗量来进行分派,所以是一个 动态多分派 的过程。

而下面办法重写的例子中,因为办法签名是固定的,也就是参数是固定的,那么就只有一个宗量 - 动态类型,能最终确定办法的调用,所以属于动静单分派。

所以能够得出对 Java 而言:Java 是一门动态多分派,动静单分派语言

总结

本文次要介绍了一下 Java 虚拟机中,办法的执行流程以及办法执行过程中时,Java 虚拟机栈中的内存布局,并从字节码的角度诠释了 Java 虚拟机是如何针对办法重载和办法重写来做出最终调用办法的抉择的。

下一篇,将会介绍 Java 对象在内存中的布局,以及堆这种作为所有线程共享的的内存区域中具体又是如何存储对象的。

请关注我,和一起学习提高

正文完
 0