【JVM系列3】方法重載和方法重寫原理分析,看完這篇終於完全搞懂了

前言

JVM執行字節碼指令是基於棧的架構,就是說全部的操做數都必須先入棧,而後再根據須要出棧進行操做計算,再把結果進行入棧,這個流程和基於寄存器的架構是有本質區別的,而基於寄存器架構來實現,在不一樣的機器上可能會沒法作到徹底兼容,這也是Java會選擇基於棧的設計的緣由之一。java

思考

咱們思考下,當咱們調用一個方法時,參數是怎麼傳遞的,返回值又是怎麼保存的,一個方法調用以後又是如何繼續下一個方法調用的呢?調用過程當中確定會存儲一些方法的參數和返回值等信息,這些信息存儲在哪裏呢?segmentfault

JVM系列文章1中咱們提到了,每次調用一個方法就會產生一個棧幀,因此咱們確定能夠想到棧幀就存儲了全部調用過程當中須要使用到的數據。如今就讓咱們深刻的去了解一下Java虛擬機棧中的棧幀吧。數組

棧幀

當咱們調用一個方法的時候,就會產生一個棧幀,當一個方法調用完成時,它所對應的棧幀將被銷燬,不管這種完成是正常的仍是忽然的(拋出一個未捕獲的異常)。安全

每一個棧幀中包括局部變量表(Local Variables)、操做數棧(Operand Stack)、動態連接(Dynamic Linking)、方法返回地址(Return Address)和額外的附加信息。架構

在給定的線程當中,永遠只有一個棧幀是活動的,因此活動的棧幀又稱之爲當前棧幀,而其對應的方法則稱之爲當前方法,定義了當前方法的類則稱之爲當前類。當一個方法調用結束時,其對應的棧幀也會被丟棄。jvm

局部變量表(Local Variables)

局部變量表是以數組的形式存儲的,並且當前棧幀的方法所須要分配的最大長度是在編譯時就肯定了。局部變量表經過index來尋址,變量從index[0]開始傳遞。ide

局部變量表的數組中,每個位置能夠保存一個32位的數據類型:boolean、byte、char、short、int、float、reference或returnAddress類型的值。而對於64位的數據類型long和double則須要兩個位置來存儲,可是由於局部變量表是屬於線程私有的,因此雖然被分割爲2個變量存儲,依然不用擔憂會出現安全性問題。佈局

對於64位的數據類型,假如其佔用了數組中的index[n]和index[n+1]兩個位置,那麼不容許單獨訪問其中的某一個位置,Java虛擬機規範中規定,若是出現一個64位的數據被單獨訪問某一部分時,則在類加載機制中的校驗階段就應該拋出異常。post

Java虛擬機在方法調用時使用局部變量進行傳遞參數。在類方法(static方法)調用中,全部參數都以從局部變量中的index[0]開始進行參數傳遞。而在實例方法調用上,index[0]固定用來傳遞方法所屬於的對象實例,其他全部參數則在從局部變量表內index[1]的位置開始進行傳遞。spa

注意:局部變量表中的變量不能夠直接使用,如須要使用的話,必須經過相關指令將其加載至操做數棧中做爲操做數才能使用

操做數棧(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
    調用虛方法

瞭解了字節碼指令的大概意思,接下來就讓咱們來演示一下主要的幾個執行流程:

  • 一、代碼編譯以後大體獲得以下的一個Java虛擬機棧,注意這時候操做數棧都是空的(pc寄存器的值在這裏暫不考慮 ,實際上調用指令的過程,pc寄存器是會一直髮生變化的)

    在這裏插入圖片描述

  • 二、執行iconst_1和iconst_2兩個指令,也就是從本地變量中把整型1和2兩個數字壓入操做數棧內:

    在這裏插入圖片描述

  • 三、執行invokestatic指令,調用add方法,會再次建立一個新的棧幀入棧,而且會將參數a和b存入add棧幀中的本地變量表

    在這裏插入圖片描述

  • 四、add棧幀中調用iconst_3指令,從本地變量中將整型3壓入操做數棧

    在這裏插入圖片描述

  • 五、add棧幀中調用istore_0,表示將當前的棧頂元素存入局部變量表index[0]的位置,也就是賦值給a。

    在這裏插入圖片描述

  • 六、調用iload_0和iload_1,將局部變量表中index[0]和index[1]兩個位置的變量壓入操做數棧

    在這裏插入圖片描述

  • 七、最後執行iadd指令:將3和2彈出棧後將兩個數相加,獲得5,並將獲得的結果5從新壓入棧內

    在這裏插入圖片描述

    八、執行istore_2指令,將當前棧頂元素彈出存入局部變量表index[2]的位置,並再次調用iload_2從局部變量表內將index[2]位置的數據壓入操做數棧內

    在這裏插入圖片描述

  • 九、最後執行ireturn命令將結果5返回main棧幀,此時棧幀add被銷燬,回到main棧幀繼續後續執行

    在這裏插入圖片描述

    方法的調用大體就是不斷的入棧和出棧的過程,上述的過程省略了不少細節,只關注了大體流程便可,實際調用比圖中要複雜的多。

方法調用分析

咱們知道,Java是一種面嚮對象語言,支持多態,而多態的體現形式就是方法重載和方法重寫,那麼Java虛擬機又是如何確認咱們應該調用哪個方法的呢?

方法調用指令

首先,咱們來看一下方法的字節碼調用指令,在Java中,提供了4種字節碼指令來調用方法(jdk1.7以前):

  • 一、invokestatic:調用靜態方法
  • 二、invokespecial:調用實例構造器方法,私有方法,父類方法
  • 三、invokevirtual:調用全部的虛方法
  • 四、invokeinterface:調用接口方法(運行時會肯定一個實現了接口的對象)

注意:在JDK1.7開始,Java新增了一個指令invokedynamic,這個是爲了實現「動態類型語言」而引入的,在這裏咱們暫不討論

方法解析

在類加載機制中的解析階段,主要作的事情就是將符號引用轉爲直接引用,可是,對方法的調用而言,有一個前提,那就是在方法真正運行以前就能夠惟一肯定具體要調用哪個方法,並且這個方法在運行期間是不可變的。只有知足這個前提的方法纔會在解析階段直接被替換爲直接引用,不然只能等到運行時才能最終肯定。

非虛方法

在Java語言中,知足「編譯器可知,運行期不可變」這個前提的方法,被稱之爲非虛方法。非虛方法在類加載機制中的解析階段就能夠直接將符號引用轉化爲直接引用。非虛方法有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主要是按照以下步驟進行方法選擇的:

  • 一、找到當前操做數棧中的方法接收者(調用者),記下來,好比叫Caller
  • 二、而後在類型Caller中去找方法,若是找到方法簽名一致的方法,則中止搜索,開始對方法校驗,校驗經過直接調用,校驗不經過,直接拋IllegalAccessError異常
  • 三、若是在Caller中沒有找到方法簽名一致的方法,則往上找父類,以此類推,直到找到爲止,若是到頂了還沒找到匹配的方法,則拋出AbstractMethodError異常

動態分派

上面的方法重寫例子中,在運行期間才能根據實際類型來肯定方法的執行版本的分派過程就稱之爲動態分派。

單分派與多分派

上面方法重載的第1個示例中,是一個靜態分派過程,靜態分配過程當中Java虛擬機選擇目標方法有兩點:

  • 一、靜態類型
  • 二、方法參數
    也就是用到了2個宗量來進行分派,因此是一個靜態多分派的過程。

而上面方法重寫的例子中,由於方法簽名是固定的,也就是參數是固定的,那麼就只有一個宗量-靜態類型,能最終肯定方法的調用,因此屬於動態單分派。

因此能夠得出對Java而言:Java是一門靜態多分派,動態單分派語言

總結

本文主要介紹了一下Java虛擬機中,方法的執行流程以及方法執行過程當中時,Java虛擬機棧中的內存佈局,並從字節碼的角度詮釋了Java虛擬機是如何針對方法重載和方法重寫來作出最終調用方法的選擇的。

下一篇,將會介紹Java對象在內存中的佈局,以及堆這種做爲全部線程共享的的內存區域中具體又是如何存儲對象的。

相關文章
相關標籤/搜索