JAVA方法調用中的解析與分派 本文算是《深入理解JVM》的讀書筆記,參考書中的相關代碼示例,從位元組碼指令角度看看解析與分派的區別。 方法調用,其實就是要回答一個問題:JVM在執行一個方法的時候,它是如何找到這個方法的? 找一個方法,就需要知道 所謂的 地址。這個地址,從不同的層次看,對它的稱呼也不 ...
JAVA方法調用中的解析與分派
本文算是《深入理解JVM》的讀書筆記,參考書中的相關代碼示例,從位元組碼指令角度看看解析與分派的區別。
方法調用,其實就是要回答一個問題:JVM在執行一個方法的時候,它是如何找到這個方法的?
找一個方法,就需要知道 所謂的 地址。這個地址,從不同的層次看,對它的稱呼也不同。從編譯器javac的角度看,我稱之為符號引用;從jvm虛擬機角度看,稱之為直接引用。或者說從class位元組碼角度看,將這個地址稱之為符號引用;當將class位元組碼載入到記憶體(方法區)中後,稱之為直接引用。當然,這是我個人的理解,也許不正確。
從符號引用如何變成直接引用的?
在回答這個問題之前,先看看符號引用是什麼?它是怎麼來的?為什麼需要它?直接引用又是什麼?最後,符號引用是怎麼轉化成直接引用的。
符號引用是什麼?
根據定義:符號引用屬於編譯原理方面的概念,包括了下麵三類常量:
- 類和介面的全限定名
- 欄位的名稱和描述符
- 方法的名稱和描述符
拋開定義,舉個例子來說明:工程師寫的一個JAVA程式如下:
package org.hapjin.dynamic; /** * Created by Administrator on 2018/7/26. */ public class SymbolicTest { private int m; public void test(){} }
源代碼經過javac編譯後生成的class文件,這個class文件當然也是按規定的格式組織的,即class文件格式。使用WinHex打開如下,然後來找一找 類的全限定名,在class文件中的哪個地方。
如上圖,藍色陰影區域(紅色方框)區域中標出了:SymbolicTest.java 這個類的全限定名:!Lorg/hapjin/dynamic/SymbolicTest
,而這就是一個符號引用。這樣就明白了符號引用是怎麼來的了。
為什麼需要符號引用?
符號引用其實是從位元組碼角度來標識類、方法、欄位。位元組碼只有載入到記憶體中才能運行,載入到記憶體中,就是記憶體定址了。
在class文件中不會保存各個方法、欄位的最終記憶體佈局信息,因此這些欄位、方法的符號引用不經過運行期轉換的話無法得到真正的記憶體入口地址,也就無直接被虛擬機使用。
那這個運行期轉換,到底是在類的生命周期的哪個階段進行的轉換?是在載入階段、還是在連接階段、還是在初始化階段、還是在使用階段?這個後面再分析。
直接引用是什麼?
JAVA虛擬機運行時數據區 分為很多部分:
其中有一個叫做方法區,它用於存儲已被虛擬機載入的類信息、常量、靜態變數……比如說,類的介面的全限定名、方法的名稱和描述符 這些都是類信息。因此,是被載入到方法區存儲。
那前面已經提到,類的介面的全限定名、方法的名稱和描述符 都是符號引用,當被載入到記憶體的方法區之後,就變成了直接引用(這樣說,有點絕對,因為 有些方法需要等到jvm執行位元組碼的時候,或者叫程式運行的時候 才能知道要調用哪個方法)
直接引用有兩種方式來定位對象,句柄和直接指針。看下麵的圖加深下理解:
虛擬機棧裡面 reference 可以理解成直接引用,換句話說,直接引用 存儲 在虛擬機棧中(並不是說,其它地方就不能存儲直接引用了,因為我也不知道其他地方能不能存儲直接引用,比如 static 類型的對象的直接引用)。
從這裡也可以映證一點:在記憶體分配與回收過程中,判斷對象是否可達的可達性分析演算法中:可作為GC roots 的對象有:虛擬機棧中引用的對象。
對符號引用和直接引用有了一定認識之後,最後來看看:符號引用是如何變成直接引用的?先來看張圖:
類從被載到虛擬機記憶體,到卸載出記憶體為止,整個生命周期如上圖。那有些 符號引用轉化成直接引用,是不是也發生在上面某個階段呢?
其實就是根據 在哪個階段 符號引用 轉化成直接引用,將方法調用分成:解析調用 與 分派調用。
在類載入的解析階段,會將一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程式真正運行之前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可改變的。
換句話說,調用目標在程式代碼寫好、編譯器進行編譯時就必須確定下來,這類方法的調用稱為解析
只要能被 invokestatic 和 invokespecial 指令調用的方法,都可以在解析階段中確定唯一的調用版本,符合這個條件的方法有:靜態方法、私有方法、實例構造器、父類方法 4類。
下麵來看下,這四類方法 調用的位元組碼指令和符號引用是啥?
public class StaticResolution {
public static void sayHello() {
System.out.println("hello world");
}
private void sayBye() {
System.out.println("bye");
}
public static void main(String[] args) {
StaticResolution.sayHello();//靜態方法調用
StaticResolution sr = new StaticResolution();
sr.sayBye();//私有方法調用
}
}
使用javap -v StaticResolution
對class文件反編譯,查看main方法的內容如下:
序號0 是靜態方法的調用
這個靜態方法的描述符 是
sayHello:()V
,由於靜態方法是與類相關的,不能在一個類裡面再定義一個與描述符sayHello:()V
一樣的方法,不然編譯期就會提示“重名的方法”錯誤。(雖然可以通過修改位元組碼的方式,在同一個class位元組碼文件裡面可存在2個方法描述符相同的方法,但是在類載入的驗證階段,就會驗證失敗,具體可參考從虛擬機指令執行的角度分析JAVA中多態的實現原理中提到的方法描述符與特征簽名的區別)所以,虛擬機在執行 invokestatic 這條位元組碼指令的時候,能夠根據
sayHello:()V
方法描述符(符號引用) 來唯一確定調用的方法就是public static void sayHello() {System.out.println("hello world");}
序號7 是實例方法的調用(預設構造函數的調用)
序列12 是私有方法的調用
同理,由於私有方法不能被子類繼承,因此在同一個類裡面也不能再定義一個與描述符
sayBye:()V
一樣的方法。
因此,上面四類方法的調用稱為 解析調用,對於這四類方法,它們的符號引用在 解析階段 就轉成了 直接引用。另外其實可以看出,解析調用的方法接收者是唯一確定的。
下麵再來看分派調用:
用重載和覆蓋來解釋分派調用,可參考從虛擬機指令執行的角度分析JAVA中多態的實現原理 。後面的講解也以這篇參考文章中的 圖一 和 圖二 進行說明。
分派調用分成兩類:靜態分派和動態分派。其中,重載屬於靜態分派、方法覆蓋屬於動態分派。下麵來解釋一下為什麼?
在分派中,涉及到一個概念:叫實際類型 和 靜態類型。比如下麵的語句:
Human man = new Man();
Human woman = new Woman();
等式左邊叫靜態類型,等式右邊是實際類型。比如 man 這個引用,它的靜態類型是Human,實際類型是Man;woman這個引用,靜態類型是Human,實際類型是Woman
從參考文章的圖一和圖二中看出:sayHello方法的調用都是由 invokevirtual 指令執行的。我想,這也是解析與分派的一個區別吧 ,就是分派調用是由 invokevirtual 指令來執行。
那靜態分派調用 和 動態分派調用的區別在哪兒呢?
靜態分派
靜態分派方法的調用(方法重載)如下:
sr.sayHello(man);//hello, guy sr.sayHello(woman);//hello, guy
man引用和woman引用的靜態類型都是Human,因此方法重載是根據 引用的靜態類型來選擇相應的方法執行的,也就是說:上面兩條語句中的
sayHello(Human )
方法的參數類型都是Human,結果就是選擇了參數類型為 Human 的 sayHello方法執行。【這樣也是不是可以說:對於 方法參數 這種類型的引用,只有靜態類型,沒有實際類型?】Human man = new Man();// man 是“語句類型的引用” public void sayHello(Human human){}//human 是 sayHello方法的參數,稱之為 參數類型 的引用
動態分派
動態分派方法調用(方法覆蓋)的代碼如下:
Human man = new Man(); Human woman = new Woman(); man.sayHello();//man say hello woman.sayHello();//woman say hello
由上面可知:變數man引用的動態類型是Man,變數woman引用的動態類型是Woman,方法的執行是根據引用的 實際類型來選擇相應的方法執行的。結果就是分別選擇了 Man類的sayHello方法 和 Woman類的sayHello方法執行。
當然了,靜態分派與動態分派的具體執行過程的差異也可以由參考文章窺出端倪。
至此,解析與分派就介紹完了。
最後,書中使用QQ和_360 的示例,談到了JAVA語言的靜態分派屬於多分派類型;動態分派屬於單分派類型。趁著前面對分派的分析,記錄一下我的理解:
首先,它是根據宗量的個數來區分單分派與多分派的。那宗量是什麼呢?宗量可理解成:引用的靜態類型、實際類型、方法的接收者。看代碼:
public class Dispatch {
static class QQ{}
static class _360{}
public static class Father{
public void hardChoice(QQ arg)
{
System.out.println("father choose qq");
}
public void hardChoice(_360 arg)
{
System.out.println("father choose 360");
}
}
public static class Son extends Father{
public void hardChoice(QQ arg)
{
System.out.println("son choose qq");
}
public void hardChoice(_360 arg)
{
System.out.println("son chooes 360");
}
}
public static class Son2 extends Father{
public void hardChoice(QQ arg)
{
System.out.println("son2 choose qq");
}
public void hardChoice(_360 arg)
{
System.out.println("son2 chooes 360");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
Father son2 = new Son2();
father.hardChoice(new _360());//father choose 360
son.hardChoice(new QQ());//son choose qq
son2.hardChoice(new QQ());//son2 choose qq
son2.hardChoice(new _360());//son2 chooes 360
}
}
javap -v Dispatch 反編譯出來class位元組碼文件的main方法如下:
其中下麵這兩句方法調用的符號引用是一樣的,都是:org/hapjin/dynamic/Dispatch$Father.hardChoice:(Lorg/hapjin/dynamic/Dispatch$QQ;)V
son.hardChoice(new QQ());//son choose qq
son2.hardChoice(new QQ());//son2 choose qq
既然這兩個方法調用的符號引用是一樣,但是它們最終輸出了不同的值。說明,虛擬機在執行的時候,選擇了不同的方法來執行。而變數son 和 son2 的靜態類型都是Father,但是son的實際類型是 類Son,son2的實際類型是 類Son2。(變數son和son2 都是它們各自方法的接收者)
而書中說:“因為這裡參數的靜態類型、實際類型都對方法的選擇不會構成任何影響”,其實在編譯出class位元組碼文件的時候,方法的參數的類型就已經確定了,在這個示例中都是 類QQ,那當然不能構成影響了,但我總覺得這種說法有點勉強,導致費解。
動態分派不僅要看方法接收者的實際類型,也是要看方法的參數類型的,只是編譯成class文件的時候方法的參數類型就已經確定了而已。其實也不用管,只需要明白 invokevirtual 指令解析過程的大致步驟就能區分,方法在運行時到底是調用哪個方法了。
invokevirtual指令的解析過程大致分為以下幾個步驟:
1. 找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C
2. 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.IllegalAccessError異常。
3. 否則,按照繼承關係從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
4. 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
而這兩句方法調用的符號引用也是一樣的,都是:org/hapjin/dynamic/Dispatch$Father.hardChoice:(Lorg/hapjin/dynamic/Dispatch$_360;)V
father.hardChoice(new _360());//father choose 360
son2.hardChoice(new _360());//son2 chooes 360
但是,這兩句的執行結果也不一樣,根據invokevirtual指令的解析過程可知:
father.hardChoice(new _360());
語句操作數棧頂的第一個元素所指的對象的實際類型是Father。
son2.hardChoice(new _360());
語句操作數棧頂的第一個元素所指的對象的實際類型是Son2。
所以它們一個執行的是Father類中的hardChoice(_360 arg)
,一個執行的是Son2類中的hardChoice(_360 arg)
方法。
總結一下:虛擬機具體在選擇哪個方法執行時:
根據在編譯成class位元組碼文件後就確定了執行哪個方法----解析 or 分派
根據在方法是否由位元組碼指令 invokevirtual 調用----解析 or 分派(分派調用是由 invokevirtual 指令執行的)
根據方法接收者的靜態類型 和 實際類型 ---- 動態分派 or 靜態分派
根據宗量個數來 確定具體執行哪個方法----多分派 or 單分派
但總感覺這樣劃分有點絕對,不太準確。
構思了一個星期的文章,終於完成了。
參考文章:從虛擬機指令執行的角度分析JAVA中多態的實現原理
參考書籍:《深入理解java虛擬機》
原文:https://www.cnblogs.com/hapjin/p/9374269.html