「Java 路線」| 方法調用的本質(含重載與重寫區別)

點贊關注,再也不迷路,你的支持對我意義重大!前端

🔥 Hi,我是醜醜。本文 GitHub · Android-NoteBook 已收錄,這裏有 Android 進階成長路線筆記 & 博客,歡迎跟着彭醜醜一塊兒成長。(聯繫方式在 GitHub)java

前言

  • 對於習慣使用面向對象開發的工程師們來講,重載 & 重寫 這兩個概念應該不會陌生了。在中 / 低級別面試中,也經常會考察面試者對它們的理解(隱約記得當年在校招面試時遇到過);
  • 網上大多數資料 & 面經對這兩個概念的闡述,多數僅停留在討論二者在 表現上 的差別,讓讀者去被動地接受知識。在這篇文章裏,我將更有深度地理解重載 & 重寫的原理,應深刻理解Java 虛擬機執行引擎是如何進行方法調用的。請點贊,你的點贊和關注真的對我很是重要!

首先,嘗試寫出如下程序的輸出:git

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);
    }
}
複製代碼

程序輸出:github

Base - funcStatic - Object
Base - funcStatic - Object
Child - funcStatic - Object

Base - func - Object
Child - func - Object
Child - func - Object
複製代碼

程序輸出是否與你的預期一致呢?遇到困難了嗎,相信這篇文章必定能幫到你...面試

延伸文章

目錄


1. 靜態類型 & 實際類型

每個變量都有兩種類型:靜態類型(Static Type) & 實際類型(Actual Type)。例以下面代碼中,Base爲變量base的靜態類型,Child爲實際類型:ide

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)。對於方法來講,就是方法的入口地址。

下圖描述了方法符號引用的基本格式:

方法的符號引用

這個符號引用包含了變量的靜態類型(若是是變量的靜態類型與本類相同,不須要指明)、簡單方法名以及描述符(參數順序、參數類型和方法返回值)。經過這個符號引用,Java虛擬機就能夠翻譯出該方法的直接引用。可是,同一個符號引用,運行時翻譯出來的直接引用多是不一樣的,爲何會這樣呢?

  • 小結:

1. 方法調用的本質是根據方法的符號引用肯定方法的直接引用(入口地址)


3. 從符號引用到直接引用

爲何同一個符號引用,運行時翻譯出來的直接引用多是不一樣的? 這與使用的方法調用指令的處理過程有關,Java字節碼的方法調用指令一共有如下 5 種:

五種方法調用指令

其中,根據調用方法的版本是否在編譯期能夠肯定,(注意:只是版本,而不是入口地址,入口地址只能在運行時肯定)能夠將方法調用劃分爲靜態解析 & 動態分派兩種。

# 誤區(重要)#

《深刻理解Java虛擬機》中將方法調用分爲解析、靜態分派、動態分派三種,又根據宗量的數量引入了靜態多分派,動態單分派的概念。這些概念事實上過於字典化,也很容易讓讀者誤認爲靜態分派與動態分派是非此即彼的互斥關係。事實上,一個方法能夠同時重寫與重載 ,重載 & 重寫是方法調用的兩個階段,而不是兩個種類。

下面,我將介紹Java中方法選擇的三個步驟:

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指令須要將對象加載到操做數棧。例如:

一、源碼(實例構造器):
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
--------------------------------------------------------------------
二、源碼(父類方法):
super.func();

字節碼:
0: aload_0
1: invokespecial #2 // Method com/Base.func:()V
--------------------------------------------------------------------
三、源碼(私有方法):
funcPrivate();

字節碼:
0: aload_0
1: invokespecial #2 // Method funPrivate:()V
複製代碼

3.3 步驟三:動態分派(類使用時)

動態分派分爲invokevitrualinvokeinterfaceinvokedynamic,其中動態調用invokedynamic是 JDK 1.7 新增的指令,咱們單獨在另外一篇中解析。有些同窗可能會以爲方法不重寫不就只有一個版本了嗎?這個想法忽略了Java動態連接的特性,Java能夠從任何途徑加載一個class,除非解析的 5 種的狀況外,沒法保證方法不被重寫。

invokevirtual指令

虛擬機爲每一個類生成虛方法表vtable(virtual method table)的結構,類中聲明的方法的入口地址會按固定順序存放在虛方法表中;虛方法表還會繼承父類的虛方法表,順序與父類保持一致,子類新增的方法按順序添加到虛方法末尾(這以Java單繼承爲前提);若子類重寫父類方法,則重寫方法位置的入口地址修改成子類實現;

  • 1)類加載解析階段: 解析類的繼承關係,生成類的虛方法表 (包含了這個類型全部方法的入口地址)。舉個例子,有Class B繼承與Class A,並重寫了A中的方法:

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來支持虛方法的方法選擇。

參考資料

  • 《深刻理解Java虛擬機(第3版本)》(第8章)—— 周志明 著
  • 《深刻理解Android:Java虛擬機 ART》(第2章) —— 鄧凡平 著
  • 《深刻理解 JVM 字節碼》(第二、3章)—— 張亞 著

創做不易,你的「三連」是醜醜最大的動力,咱們下次見!

相關文章
相關標籤/搜索