從虛擬機指令執行的角度分析JAVA中多態的實現原理


從虛擬機指令執行的角度分析JAVA中多態的實現原理

前幾天突然被一個“家伙”問了幾個問題,其中一個是:JAVA中的多態的實現原理是什么?

我一想,這肯定不是從語法的角度來闡釋多態吧,隱隱約約地記得是與Class文件格式中的方法表有關,但是不知道虛擬機在執行的時候,是如何選擇正確的方法來執行的了。so,趁着周末,把壓箱底的《深入理解Java虛擬機》拿出來,重新看了下第6、7、8章中的內容,梳理一下:從我們用開發工具(Intellij 或者Eclipse)寫的 .java 源程序,到經過javac 編譯成class字節碼文件,再到class字節碼文件被加載到虛擬機並最終根據虛擬機指令執行選擇出正確的(多態)方法執行的整個過程。

在討論的多態(一般叫運行時多態)的時候,不可避免地要和重載(Overload)進行對比,為什么呢?因為這涉及到一種方法調用方式----分派(分派這個名字來源於 深入理解Java虛擬機 第8章8.3.2節)

先從源代碼(語法)的角度看看二者的區別:

  • 重載(Overload)

  • 重寫(Override),或者叫運行時多態,這是本文主要要討論的內容。

先來看看重載,(代碼來源於書中)

public class StaticDispatch {
    static abstract class Human {}

    static class Man extends Human{}
    static class Woman extends Human{}

    public void sayHello(Human guy) {
	System.out.println("hello, guy");
    }
    public void sayHello(Man guy) {
	System.out.println("hello, gentleman");
    }
    public void sayHello(Woman guy) {
	System.out.println("hello, lady");
    }

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

	StaticDispatch sr = new StaticDispatch();
	sr.sayHello(man);//hello, guy
	sr.sayHello(woman);//hello, guy
    }
}

從語法的角度來聊一聊為什么上面的三個sayHello方法是重載的。方法之間是重載的要求這些方法具有:相同的簡單名稱 和 不同的特征簽名。

  • 方法的簡單名稱是:沒有類型和參數修飾的方法名稱。比如上面的sayHello方法的簡單名稱就是 字符串"sayHello"
  • 方法的特征簽名:可簡單粗暴地理解成方法的 參數類型、參數順序、參數個數。
    對於上面的三個sayHello方法而言,它們的簡單名稱是相同的,而參數類型不同(一個是Human 類型、一個是Woman類型、一個是Man類型),因此:它們是重載的。
    額外多補充一點:
    上面並沒有提到方法的返回值,因為方法的返回值並不屬於特征簽名。
    當你在編輯器,比如IDEA或者Eclipse 中寫了兩個 簡單名稱相同、特征簽名也相同、但是方法返回值不同的兩個方法時,會報編譯錯誤:“定義了兩個同名的方法”。但是,這兩個“同名的方法”是可以共存於同一個class文件中的。因為class文件格式規定了:描述符不完全一致的兩個方法可以共存於同一個class文件中。
    那什么是方法的描述符呢?
    每個人編寫代碼的時候,給方法定義一個方法、給方法取個名字、帶上參數……寫出來的方法的無窮無盡的,如何用一套統一的規則來描述這些寫出來的方法,就是方法描述符干的事情。

用描述符來描述方法時,先按照參數列表,后返回值的順序描述,參數列表按照參數的嚴格順序放在一組小括號內

從上面的描述符定義看,方法的描述符是包含了方法的返回值的。因此, 簡單名稱相同、特征簽名也相同、但是方法返回值不同的兩個方法可共存於同一個class文件中。
總結一下,討論一個方法是否“相同”,這里涉及到了三個概念:

  • 簡單名稱
  • 方法特征簽名
  • 方法描述符

我的理解,也許不准確:簡單名稱和特征簽名在語法層面 來判斷 編寫的兩個方法是否 是相同的;方法描述符在字節碼層面 來判斷 兩個方法是否是 相同的;方法描述符不僅包含了特征簽名、還包含了方法返回值。搞明白這三者的區別及作用就好了。
這個時候,你可能就有疑問了:既然簡單名稱相同、特征簽名也相同、但是方法返回值不同的兩個方法可共存於同一個class文件中,在jvm在執行代碼(這兩個方法)的時候怎么辦呢?其實不用擔心,在類加載的時候,有一個驗證階段,驗證階段包含了一個叫元數據驗證的過程,元數據驗證過程會驗證 加載到內存方法區里面的class字節碼是否符合方法重載的規則。因此,雖然這兩個方法共存於同一個class文件中,但這種不符合java語義(語言規范)的情形 最終在驗證階段會被檢查出來的。

再來看看重寫(Override)

public class DynamicDispatch {
    static abstract class Human{
	protected abstract void sayHello();
    }

    static class Man extends Human{
	@Override
	protected void sayHello() {
	    System.out.println("man say hello");
	}
    }

    static class Woman extends Human{
	@Override
	protected void sayHello() {
	    System.out.println("woman say hello");
	}
    }

    public static void main(String[] args) {
	Human man = new Man();
	Human woman = new Woman();
	man.sayHello();//man say hello
	woman.sayHello();//woman say hello
    }
}

在StaticDispatch.java 中,並不存在子類方法、父類方法。只有StaticDispatch.java的sayHello方法,即:sayHello 方法的接收者都是 StaticDispatch sr 對象,需要根據sayHello方法的參數類型來確定,具體執行下面這三個方法中的哪一個方法:

    public void sayHello(Human guy) {
	System.out.println("hello, guy");
    }
    public void sayHello(Man guy) {
	System.out.println("hello, gentleman");
    }
    public void sayHello(Woman guy) {
	System.out.println("hello, lady");
    }

而在DynamicDispatch.java中,首先有一個父類Human,它有一個sayHello方法,然后有兩個子類:Woman、Man,它們分別@Override 了父類中的sayHello方法,也就是說:子類重寫了父類中的方法。

上而就是從(源代碼)語法的角度 描述了一下 重載(Overload) 和 重寫(Override 或者叫運行時多態)的區別。程序要想執行,先要將源代碼編譯成字節碼文件。

接下來看一下,二者在字節碼文件上的不同

首先javac 命令將 StaticDispatch.java 和 DynamicDispatch.java編譯成 class文件,然后使用分別使用下面命令輸出這兩個文件字節碼的內容:

javap -verbose StaticDispatch


(圖一)

上面截取的是 StaticDispatch.java main方法中的方法表中的內容。方法表的結構 可參考書中第6.3.6小節的描述。

main方法中的代碼,經過編譯器編譯成字節碼指令后,存放在方法屬性表集合中的一個名為 "Code" 的屬性里面。

StaticDispatch 的main方法 字節碼的執行過程

上面的 序號26 和 序號31 紅色方框標出來的內容叫做:方法的符號引用,從而可以判斷:sr.sayHello(man);sr.sayHello(woman); 是由 invokevirtual指令執行的。

而且方法的符號引用都是:Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V

好,那咱就來看看,invokevirtual指令的具體執行過程,看它是如何將符號引用 解析到 具體的方法上的。

因為,覆蓋(Override)或者說運行時多態也是通過invokevirtual指令來選擇具體執行哪個方法的,因此:invokevirtual指令的解析過程 可以說是JAVA中實現多態的原理吧。

invokevirtual指令的解析過程大致分為以下幾個步驟:

1. 找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C
2. 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
3. 否則,按照繼承關系從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
4. 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。

因此,第一步,找到操作數棧頂的第一個元素所指的對象的實際類型,這個對象其實就是方法接收者的實際類型,它是StaticDispatch對象sr StaticDispatch sr = new StaticDispatch()

為什么是sr對象呢?比如對於序號26的invokevirtual指令,序號24、25行的兩條aload_3 和 aload_1字節碼指令 分別是把第四個引用類型的變量推送到棧頂,把第二個引用類型的變量推送到棧頂。而第四個引用類型的變量是StaticDispatch sr 對象;第二個引用類型的變量則是Man類的對象Human man = new Man()

第二步,根據常量 Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V尋找 StaticDispatch類中哪個方法的簡單名稱和描述符都與該常量相同。

常量Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V的簡單名稱是 'sayHello',描述符信息是:返回的類型為空,參數類型為Human,只有一個參數。

而在StaticDispatch.java中一共有三個不同的sayHello方法,它們的簡單名稱都是'sayHello',而描述符中的參數類型為'Human'類型的方法是:

    public void sayHello(Human guy) {
	System.out.println("hello, guy");
    }

因此,sr.sayHello(man);實際調用的方法就是上面的public void sayHello(Human guy)方法。

同樣地,sr.sayHello(woman);的方法接收者的實際類型是StaticDispatch對象sr,由序號31可知方法常量還是Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V ,因此,實際調用的方法還是public void sayHello(Human guy)

從這里可看出:對於重載(Overload)而言,它的方法接收者的類型是相同的,那調用哪個重載方法就取決於:傳入的參數類型、參數的數量等。而參數類型在編譯器生成字節碼的時候就已經確定了,比如上面的sayHello方法的參數類型都是Human(sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V

因此,sr.sayHello(man);sr.sayHello(woman);執行的是相同的方法public void sayHello(Human guy){}

接下來看看:覆蓋(Override),也即運行時多態的執行情況:

javap -verbose DynamicDispatch


(圖二)

上面截取的是DynamicDispatch.java的main方法的執行過程。從序號17和21 可知:man.sayHello();woman.sayHello();也都是由虛擬機指令invokevirtual指令執行的,並且調用的sayHello方法的符號引用都是Method org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V

那為什么最終執行的結果卻是:man.sayHello()輸出 'man say hello',而woman.sayHello()輸出'woman say hello'呢?

	man.sayHello();//man say hello
	woman.sayHello();//woman say hello

下面再來過一遍invokevirtual指令的執行過程。當虛擬機執行到man.sayHello()這條語句時,invokevirtual指令第一步:找到操作數棧頂的第一個元素,這個元素就是序號7 astore_1存進去的,它是一個Man類型的對象

接下來,第二步,在 Man 類中尋找與常量中描述符和簡單名稱都相符的方法,在這里常量是Method org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V,而Man 類中與該常量的描述符和簡單名稱都相符的方法,顯然就是 Man 類中的sayHello方法了。

於是invokevirtual指令就把 常量池中的類方法符號引用 解析 到了 具體的Man類的sayHello方法的直接引用上。

同理,類似地,在執行woman.sayHello()這條語句時,invokevirtual指令找到的操作數棧頂的第一個元素是由 指令15astore_2存儲進去的Woman類型的對象。於是,在Woman類中 尋找與常量池類方法的符號引用Method org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V都相符的方法,這個方法就是Woman類中的sayHello方法。

從上面的invokevirtual指令的執行過程看,語句man.sayHello();woman.sayHello(); 對應的類方法的符號引用是一樣的,都是org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V,但由於方法接受者的實際類型不同,一個是Man類型、另一個是Woman類型,因為最終執行的方法也就不一樣了。

文中涉及到的一些額外的概念:

  • 方法接收者:sr.sayHello(new Man()), sr 對象就是 sayHello方法的接收者
  • 常量:常量池中的常量,可參考常量池中的項目類型
  • 描述符:用來描述字段的數據類型,方法的參數列表和返回值,方法的參數列表指的是:方法有多少個參數、方法的參數是什么類型、參數的順序
  • 簡單名稱:沒有類型和參數修飾的方法或者字段名稱。比如說方法:public void m(String a){},那簡單名稱就是 m

總之,jvm在判斷具體執行哪個方法時,不僅要看方法的描述符(特征簽名),而且要看方法的接收者的實際類型。而在多態中,從上面的示例中可以看出:方法的接收者的類型是不同的
以上純個人理解,有些概念可能表述地不太嚴謹,若有錯誤,望指正,感激不盡。

寫完這篇文章,我抬頭望向窗外,天又黑了。目光緩緩移回到電腦屏幕上,一個技術人的追求到底是什么?我應該往哪個方向深入下去呢?后台、算法、ML、或者高大上的DL?

於是又想起了上一次的對話中那個人說的:關鍵是看你能不能持續地花時間把背后的原理搞清楚。

參考書籍:《深入理解JVM虛擬機》

原文:https://www.cnblogs.com/hapjin/p/9248525.html


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM