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執行字節碼的時候,或者叫程序運行的時候 才能知道要調用哪個方法)
Class 文件的常量池中存有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用作為參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化為直接引用,這種轉化稱為靜態解析。另一部分將在每次運行期間轉化為直接引用,稱為動態連接(動態分派)。棧幀是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區:虛擬機棧(不同於堆、方法區)中的內容,棧幀存儲了方法的局部變量表、操作數棧、動態連接、和方法返回地址等信息。這里所說的動態連接,就是:一個指向運行時常量池中該棧幀所屬方法的引用,虛擬機就是根據這個信息知道要調用哪個具體的方法。
直接引用有兩種方式來定位對象,句柄和直接指針。看下面的圖加深下理解:
虛擬機棧里面 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中多態的實現原理中提到的方法描述符與特征簽名的區別)
“雖然可以通過修改字節碼的方式,在同一個class字節碼文件里面可存在2個方法描述符相同的方法”表明:class 字節碼的描述能力是強於Java語言的,這也驗證了為什么可以將其他類型的語言(比如 動態類型)轉換成字節碼,從而運行在JVM上。只要class字節碼能有效地支持這種 動態類型 即可。
所以,虛擬機在執行 invokestatic 這條字節碼指令的時候,能夠根據sayHello:()V
方法描述符(符號引用) 來唯一確定調用的方法就是public static void sayHello() {System.out.println("hello world");}
-
序號7 是實例方法的調用(默認構造函數的調用)
-
序列12 是私有方法的調用
同理,由於私有方法不能被子類繼承,因此在同一個類里面也不能再定義一個與描述符
sayBye:()V
一樣的方法。
因此,上面四類方法的調用稱為 解析調用,對於這四類方法,它們的符號引用在 解析階段 就轉成了 直接引用。另外其實可以看出,解析調用的方法接收者是唯一確定的。
總結一下:在java語言中,重載的方法(overload),由於方法的描述符是唯一的。因此.java文件編譯成.class字節碼后,生成的方法符號引用也是唯一的,那么Code屬性表里面方法調用指令就能確定具體調用哪個方法,因而是解析調用。
下面再來看分派調用:
用重載和覆蓋來解釋分派調用,可參考從虛擬機指令執行的角度分析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方法執行。
再來解釋一下是如何確實選擇哪一個sayHello方法執行的?完整代碼是這篇文章中的StaticDispatch.java 。main方法中有一行語句:StaticDispatch sr = new StaticDispatch();
,因此 main 方法的棧幀中,局部變量表中存儲局部變量是sr,由於棧幀中還包含了動態連接信息,動態連接信息是:指向運行時常量池中該棧幀所屬方法的引用。對於這行語句sr.sayHello(man);
執行的時候,就會去字符串常量池中尋找sayHello方法的方法描述符。sayHello方法有一個名稱為man的參數,這個名為man的參數是由這條語句定義的Human man = new Man();
,可以看出:名為man的參數聲明的類型是Human,並且可從class字節碼文件中看出方法描述符的內容是sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V
,因此,就能根據方法描述符唯一確定調用的方法是public void sayHello(Human guy)
。
再啰嗦一句,這里分析的是方法重載(Overload)而不是方法覆蓋(Override),是通過方法描述符來唯一確定具體調用執行哪個方法,這與下面分析的動態分派中 通過invokevirtual 指令運行時解析 來確定執行哪個方法是有區別的。
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)
方法。
----2018.12.8 更新-----
當虛擬機執行某個方法時,會為這個方法創建棧幀,棧幀在 虛擬機運行進數據區 中的 虛擬機棧 中。棧幀包含四部分內容:局部變量表、操作數棧、動態連接、方法返回地址。局部變量表存儲我們在這個方法里面定義的局部變量,比如father這個局部變量。動態連接是:方法調用時 指向運行時常量池中 的方法的引用(其實就是符號引用)。比如在執行語句father.hardChoice(new _360());
時, invokevirtual指令的解析過程的第一步就是:根據動態連接信息,找到變量father的實際類型,在這個實際類型對應的類中找符合hardChoice(new _360())
的 方法符號引用( invokevirtual指令的解析過程的第二、三、四步)
總結一下:虛擬機具體在選擇哪個方法執行時:
根據在編譯成class字節碼文件后就確定了執行哪個方法----解析 or 分派
根據在方法是否由字節碼指令 invokevirtual 調用----解析 or 分派(分派調用是由 invokevirtual 指令執行的)
根據方法接收者的靜態類型 和 實際類型 ---- 動態分派 or 靜態分派
根據宗量個數來 確定具體執行哪個方法----多分派 or 單分派
但總感覺這樣划分有點絕對,不太准確。
構思了一個星期的文章,終於完成了。
參考文章:從虛擬機指令執行的角度分析JAVA中多態的實現原理
參考書籍:《深入理解java虛擬機》