JVM(十二):方法呼叫
JVM(十二):方法呼叫
在 JVM(七):JVM記憶體結構 中,我們說到了方法執行在何種記憶體結構上執行:Java 方法活動在虛擬機器棧中的棧幀上,棧幀的具體結構在記憶體結構中已經詳細講解過了,下面就讓我們來看一下 方法是如何呼叫的。
方法呼叫
首先,我們要明白一個基礎性概念:方法呼叫並不是方法執行。其只是確定該呼叫哪一個方法而已(多型的影響,選擇方法的不同版本)。並且因為 Java 呼叫的動態性,有些方法需要在類載入階段動態解析,這也為 JVM 解析符號引用成直接引用提供了難度。
解析
在 JVM(五):探究類載入過程-上 中,我們說過在類載入過程的解析階段, JVM 會將符號引用轉變為直接引用。但這需要方法在程式真正執行的之前就有一個 可確定 的版本,且這個版本在整個執行期間是不可更改的。這種在類載入階段就能解析出的方法呼叫被稱為 解析。
解析條件
滿足解析條件的方法在 Java 語言中有 靜態方法 和 私有方法 兩類。前者與類型別直接關聯,後者在外部無法被訪問。這種特性也使得其不可能通過繼承或別的方式來重寫其版本,因此適合在解析階段就進行處理。
在 JVM 內部與之相對應的就是invokestatic
和 invokespecial
兩種位元組碼指令。這兩種指令對應的就是 呼叫靜態方法 、呼叫例項構造器。 在執行這四類方法時,在類載入的解析階段就會將符號引用直接轉變為直接引用。
上述的這些方法也被稱為 非虛方法 。
滿足載入階段解析的還有一個特殊的方法為 final方法,因為其無法被覆蓋,也沒有其他版本,因此雖然其是用 invokevirtual
分派
分派則是在程式執行的期間才能確定呼叫哪個版本的方法。其也揭示了 過載 和 重寫 的本質。下面就讓我們來看一下其在 JVM 內是如何實現的。
靜態分派
// 程式碼參考自<<深入理解Java虛擬機器>> public class Demo { static class Human{ public void overWriteDemo(){ System.out.println("human overWriteDemo"); } } static class Man extends Human { @Override public void overWriteDemo(){ System.out.println("Man overWriteDemo"); } } static class Woman extends Human{ @Override public void overWriteDemo(){ System.out.println("Woman overWriteDemo"); } } public void sayHello(Human human){ System.out.println("human"); } public void sayHello(Man man){ System.out.println("Man"); } public void sayHello(Woman woman){ System.out.println("Woman"); } public static void main(String[] args) throws InterruptedException { Human man = new Man(); Human woman = new Woman(); Demo demo = new Demo(); demo.sayHello(man); demo.sayHello(woman); } }
首先各位讀者可以看一下以下程式碼輸出的是什麼。
讀者可以帶著您這邊的答案來看下一些概念。上面的 Human 我們稱之為變數的 靜態型別,或者叫做變數的 外觀型別 。後面的 Man 或 Women 我們則稱之為變數的 實際型別。
對應這兩種型別,變數的靜態型別是本身不變的,只有在使用的時候才可以對其進行改變使用,而且最終的靜態型別在編譯期就可知,如
demo.sayHello((Man)man);
demo.sayHello((Women)man);
這樣就可以在使用的時候,使用指定的型別,但其變數本身的屬性還是不會更改。
這種語法對應就是 Java 語言中的 過載 語法,虛擬機器是通過引數的 靜態型別 來確定該呼叫哪個版本的方法。
現在回到程式碼輸出結果上,第一次程式碼輸出的結果為
Human
Human
第二次程式碼輸出結果為:
Man
Women
動態分派
// 為了節約篇幅,前面的內容就不復制了,只是在呼叫的時候更改了一下程式碼,如下所示:
public static void main(String[] args) throws InterruptedException {
Human man = new Man();
Human woman = new Woman();
man.overWriteDemo();
woman.overWriteDemo();
man = new Woman();
man.overWriteDemo();
}
現在讀者再來看一下以上這段程式碼的輸出結果是什麼,帶著心中的答案我們再來看一下 動態分派 的概念。
通過javap
分析其位元組碼錶現,我們發現其呼叫的是invokevirtual
指令 , 而invokevirtual
在呼叫的時候,執行時解析步驟為以下方式:
- 找到元素找到的實際型別,記為 C
- 如果在型別 C 中找到簽名相符的方法 , 則進行許可權校驗。 如果通過 , 則返回其對應引用 , 如果沒通過 , 則丟擲異常;
- 在第二步中 , 如果沒找到相符的方法 , 則從 C 從下而上地對其父類進行查詢和驗證;
- 如果仍舊沒有找到 , 則丟擲異常 .
通過以上者多層的步驟推進 , 則可以找到實際呼叫的物件型別,從而決定呼叫哪個版本的方法。
實現方式
上面我們看到了動態多型的實現邏輯,現在我們來看一下在 JVM 內是如何實現這個過程的.
首先,我們看到該過程需要逐層搜尋以找到對應類。但是如果在實際呼叫過程中,這樣逐層的搜尋是會影響效能的。因此,面對這種情況,JVM 採用了虛方法表 的形式來實現。在虛方法表中儲存了每個方法的實際地址。
方法執行
Java 執行方法分為 解釋執行(通過直譯器執行) 和 編譯執行(通過編譯器編譯為原生代碼執行)兩種。
在本文中,我們著重講一下在解釋執行的過程中,JVM 內部是如何實現的。至於編譯執行,因為還涉及到 JIT 的優化等概念。放在後面的 編譯優化 章節再詳細敘說。
基於棧的執行模型
方法執行的過程其實就是在執行 Class 中位元組碼的指令集部分,而從 # JVM(六):探究類載入過程-下 中,我們已經看到指令集的大部分組成部分,都是零地址指令。那麼什麼是零地址指令呢,下面給大家舉個例子:
iconst_1
iconst_1
iadd
istore_0
以上這段程式碼就是一段零地址指令,其含義為計算1+1
的結果,具體操作過程為:
- 首先將 1 入棧
- 將另一個 1 入棧
- 將棧頂的兩個元素相加,並將計算結果重新入棧
- 將棧頂元素(結果),放入區域性變量表中 slot0 位置
基於暫存器的執行模型
而另外還有一種基於暫存器的執行方式,比如上面這段程式碼如果用暫存器的方式來實現就是如下所示:
mov eax,1
add eax,1
這段指令集的內容就是:
- 將 EAX 暫存器的值設為 1;
- add 指令將eax的值+1,然後結果就儲存在eax暫存器上。
執行模型對比
我們很難說清楚兩種設計理念哪個更加的好,因為在現如今,兩套指令集都在茁壯發展中,因此肯定都有各自的優點,接下來就讓我們看一下其各自的優點是什麼。
首先既然是基於棧的,那麼其最大的優點應該就是 可移植,因為暫存器是與硬體息息相關的,其會受到硬體暫存器的數量等原因限制;其次在棧結構中,大部分指令都是零地址的,那麼其第二個優點就是 程式碼更加的緊湊,而暫存器這樣的指令集還需要帶上多個引數;最後基於棧的模型,編譯器 實現起來也會更加簡單,因為所有的操作都是在棧上進行,不需要考慮記憶體空間分配的問題。
而對暫存器模型來說,其最大的優點就是 執行速度塊,因為基於棧來操作的話,同樣的操作,基於棧要比基於暫存器來說, 多出大量的指令內容,並且需要頻繁的 出棧入棧操作,也就是大量的記憶體訪問操作, 這都會影響指令執行的效率。而這也是所有主流物理機都使用基於暫存器來實現指令集的原因。
總結
在本文中,我們講述了方法呼叫和方式執行的過程,與# JVM(七):JVM記憶體結構 中方法執行時涉及到的記憶體結構相結合,這三者,使我們詳細理清了 Java 程式執行一個方法的全過程,希望讀者們以後在寫程式碼中,可以在腦海中清晰地明白這個過程。
文章在公眾號「iceWang」第一手更新,有興趣的朋友可以關注公眾號,第一時間看到筆者分享的各項知識點,謝謝!筆芯!
本系列文章主要借鑑自《深入分析 JavaWeb 技術內幕》和《深入理解 Java 虛擬機器-JVM 高階特性與最佳實踐》。