jdk原始碼解析(八)——Java虛擬機器位元組碼執行引擎
在前面我們瞭解了jvm執行時資料區,那個jvm圖中有執行引擎,那麼今天就解釋一下Java虛擬機器位元組碼執行引擎。
1 定義
Java虛擬機器位元組碼執行引擎是jvm最核心的組成部分之一,“虛擬機器” 是一個相對於 “物理機” 的概念,這兩種機器都有程式碼執行能力,其區別是物理機的執行引擎是直接建立在處理器、硬體、指令集和作業系統層面上的,而虛擬機器的執行引擎則是由自己實現的,因此可以自行制定指令集與執行引擎的結構體系,並且能夠執行哪些不被硬體直接支援的指令集格式。
在 Java 虛擬機器規範中制定了虛擬機器位元組碼執行引擎的概念模型,這個概念模型稱為各種虛擬機器執行引擎的統一外觀(Facade)。在不同的虛擬機器實現裡面,執行引擎在執行 Java 程式碼的時候可能會有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生代碼執行)兩種選擇,也可能兩者兼備,甚至還可能會包含幾個不同級別的編譯器執行引擎。
但從外觀上看起來,所有的 Java 虛擬機器的執行引擎都是一致的:輸入的是位元組碼檔案,處理過程是位元組碼解析的等效過程,輸出的是執行結果,下面將主要從概念模型的角度來講解虛擬機器的方法呼叫和位元組碼執行。
2 執行引擎的解釋和作用(有什麼用?什麼時候用?)
類載入器將位元組碼載入記憶體之後,執行引擎以Java 位元組碼指令為單元,讀取Java位元組碼。問題是,現在的java位元組碼機器是讀不懂的,因此還必須想辦法將位元組碼轉化成平臺相關的機器碼(也就是系統能識別的0和1)。這個過程可以由直譯器來執行,也可以有即時編譯器(JIT Compiler)來完成。如圖:
執行引擎的在全域性的作用
執行引擎內部包括如下:
總結:簡單來看jvm需要怎麼個流程如下圖:
3 執行時棧幀結構(與執行引擎什麼關係需要知道)
棧幀(Stack Frame)是用於支援虛擬機器進行方法呼叫和方法執行的資料結構,它是虛擬機器執行時資料區中的虛擬機器棧(Virtual Machine Stack)的棧元素。棧幀儲存了方法的區域性變量表、運算元棧、動態連線和方法返回地址等資訊。每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。
每一個棧幀都包括了局部變量表、運算元棧、動態連線、方法返回地址和一些額外的附加資訊。在編譯程式程式碼的時候,棧幀中需要多大的區域性變量表,多深的運算元棧都已經完全確定了,並且寫入到方法表的 Code 屬性之中,因此一個棧幀需要分配多少記憶體,不會受到程式執行期變數資料的影響,而僅僅取決於具體的虛擬機器實現。
一個執行緒中的方法呼叫鏈可能會很長,很多方法都同時處於執行狀態。對於執行引擎來說,在活動執行緒中,只有位於棧頂的棧幀才是有效的,稱為當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法稱為當前方法(Current Method)。執行引擎執行的所有位元組碼指令都只針對當前棧幀進行操作,在概念模型上,典型的棧幀結構如圖 8-1 所示。
接下來詳細講解一下棧幀中的區域性變量表、運算元棧、動態連線、方法返回地址等各個部分的作用和資料結構。
2.1 區域性變量表(在執行時資料區什麼位置,是什麼?有什麼用?)
區域性變量表(Local Variable Table) 是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。在 Java 程式編譯為 Class 檔案時,就在方法的 Code 屬性的 max_locals 資料項中確定了該方法所需要分配的區域性變量表的最大容量。
區域性變量表的容量以變數槽(Variable Slot,下稱 Slot)為最小單位,虛擬機器規範中並沒有明確指明一個 Slot 應占用的記憶體空間大小,只是很有導向性地說到每個 Slot 都應該能存放一個 boolean、byte、char、short、int、float、reference (注:Java 虛擬機器規範中沒有明確規定 reference 型別的長度,它的長度與實際使用 32 還是 64 位虛擬機器有關,如果是 64 位虛擬機器,還與是否開啟某些物件指標壓縮的優化有關,這裡暫且只取 32 位虛擬機器的 reference 長度)或 returnAddress 型別的資料,這 8 種資料型別,都可以使用 32 位或更小的實體記憶體來存放,但這種描述與明確指出 “每個 Slot 佔用 32 位長度的記憶體空間” 是有一些差別的,它允許 Slot 的長度可以隨著處理器、作業系統或虛擬機器的不同而傳送變化。只要保證即使在 64 位虛擬機器中使用了 64 位的實體記憶體空間去實現一個 Slot,虛擬機器仍要使用對齊和補白的手段讓 Slot 在外觀上看起來與 32 位虛擬機器中的一致。
既然前面提到了 Java 虛擬機器的資料型別,在此再簡單介紹一下它們。一個 Slot 可以存放一個 32 位以內的資料型別,Java 中佔用 32 位以內的資料型別有 boolean、byte、char、short、int、float、reference 和 returnAddress 8 種類型。前面 6 種不需要多加解釋,讀者可以按照 Java 語言中對應資料型別的概念去理解它們(僅是這樣理解而已,Java 語言與 Java 虛擬機器中的基本資料型別是存在本質差別的),而第 7 種 reference 型別表示對一個物件例項的引用,虛擬機器規範既沒有說明他的長度,也沒有明確指出這種引用應有怎樣的結構。但一般來說,虛擬機器實現至少都應當能通過這個引用做到兩點,一是從此引用中直接或間接地查詢到物件在 Java 堆中的資料存放的起始地址索引,二是此引用中直接或間接地查詢到物件所屬資料型別在方法區中的儲存的型別資訊,否則無法實現 Java 語言規範中定義的語法約束約束。第 8 種即 returnAddress 型別目前已經很少見了,它是為位元組碼指令 jsr、jsr_w 和 ret 服務的,指向了一條位元組碼指令的地址,很古老的 Java 虛擬機器曾經使用這幾條指令來實現異常處理,現在已經由異常表代替。
對於 64 位的資料型別,虛擬機器會以高位對齊的方式為其分配兩個連續的 Slot 空間。Java 語言中明確的(reference 型別則可能是 32 位也可能是 64 位)64 位的資料型別只有 long 和 double 兩種。值得一提的是,這裡把 long 和 double 資料型別分割儲存的做法與 “long 和 double 非原子性協定” 中把一次 long 和 double 資料型別讀寫分割為兩次 32 位讀寫的做法有些類似,讀者閱讀到 Java 記憶體模型時可以互相對比一下。不過,由於區域性變數建立線上程的堆疊上,是執行緒私有的資料,無論讀寫兩個連續的 Slot 是否為原子操作,都不會引起資料安全問題。
虛擬機器通過索引定位的方式使用區域性變量表,索引值的範圍是從 0 開始至區域性變量表最大的 Slot 數量。如果訪問的是 32 位資料型別的變數,索引 n 就代表了使用第 n 個 Slot,如果是 64 位資料型別的變數,則說明會同時使用 n 和 n+1 兩個 Slot。對於兩個相鄰的共同存放一個 64 位資料的兩個 Slot,不允許採用任何方式單獨訪問其中的某一個,Java 虛擬機器規範中明確要求瞭如果遇到進行這種操作的位元組碼序列,虛擬機器應該在類載入的校驗階段丟擲異常。
在方法執行時,虛擬機器是使用區域性變量表完成引數值到引數變數列表的傳遞過程的,如果執行的是例項方法(非 static 的方法),那區域性變量表中第 0 位索引的 Slot 預設是用於傳遞方法所屬物件例項的引用,在方法中可以通過關鍵字 “this” 來訪問到這個隱含的引數。其餘引數則按照引數表順序排列,佔用從 1 開始的區域性變數 Slot,引數表分配完畢後,再根據方法體內部定義的變數順序和作用域分配其餘的 Slot。
為了儘可能節省棧幀空間,區域性變數中的 Slot 是可以重用的,方法體中定義的變數,其作用域並不一定會覆蓋整個方法體,如果當前位元組碼 PC 計數器的值已經超出了某個變數的作用域,那這個變數對應的 Slot 就可以交給其他變數使用。不過,這樣的設計除了節省棧幀空間以外,還會伴隨一些額外的副作用,例如,在某些情況下,Slot 的複用會直接影響到系統的垃圾收集行為,請看程式碼清單。
package com.clazz;
/**
* jvm 引數設定為:-verbose:gc
* @author mch
*
*/
public class TestClzz {
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
}
上面的程式碼很簡單:即向記憶體填充了 64 MB 的資料,然後通知虛擬機器進行垃圾收集。我們在虛擬機器執行引數中加上“-verbose:gc” 來看看垃圾收集的過程,發現在 System.gc() 執行後並沒有回收這 64 MB 的記憶體,下面是執行的結果:
[GC 68516K->66144K(186880K), 0.0014354 secs]
[Full GC 66144K->66008K(186880K), 0.0127933 secs]
沒有回收 placeholder 所佔的記憶體能說得過去(full Gc回收之後的記憶體佔用66008k所以說明沒有被回收),因為在執行 System.gc() 時,變數 placeholder 還處於作用域之內,虛擬機器自然不敢回收 placeholder 的記憶體。那我們把程式碼修改一下,如下:
package com.clazz;
/**
* jvm 引數設定為:-verbose:gc
* @author mch
*
*/
public class TestClzz {
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
}
結果:
[GC 68516K->66120K(186880K), 0.0011906 secs]
[Full GC 66120K->66008K(186880K), 0.0093979 secs]
加入了花括號之後,placeholder 的作用域被限制在花括號之內,從程式碼邏輯上講,在執行 System.gc() 的時候,placeholder 已經不可能再被訪問了,但執行一下這段程式,會發現執行結果如下,還是有 64MB 的記憶體沒有被回收(gc之後使用記憶體為6608k說明記憶體未被回收),這又是為什麼呢?
在解釋為什麼之前,我們先對這段程式碼進行第二次修改,在呼叫 System.gc() 之前加入一行 “int a = 0;”,變成程式碼清單如下:
package com.clazz;
/**
* jvm 引數設定為:-verbose:gc
* @author mch
*
*/
public class TestClzz {
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a =0;
System.gc();
}
}
執行結果:
[GC 68516K->66176K(186880K), 0.0012137 secs]
[Full GC 66176K->472K(186880K), 0.0095775 secs]
這個修改看起來很莫名其妙,但執行一下程式,卻發現這次記憶體真的被正確回收了(gc之後使用記憶體變為472k明顯小於64m)。
在程式碼清單1 ~ 程式碼清單 3 中,placeholder 能否被回收的根本原因是:區域性變數中的 Slot 是否還存在關於 placeholder 陣列物件的引用。第一次修改中,程式碼雖然已經離開了 placeholder 的作用域,但在此之後,沒有任何區域性變量表的讀寫操作,placeholder 原本佔用的 Slot 還沒有被其他變數所複用,所以作為 GC Roots 一部分的區域性變量表仍然保持著對它的關聯。這種關聯沒有被及時打斷,在絕大部分情況下影響都很輕微。但如果遇到一個方法,其後面的程式碼有一些耗時很長的操作,而前面又定義了佔用了大量的記憶體、實際上已經不會再使用的變數,手動將其設定為 null 值(用來代替那句 int a=0,把變數對應的區域性變量表 Slot 清空)便不見得是一個絕對無意義的操作,這種操作可以作為一種在極特殊情形(物件佔用記憶體大、此方法的棧幀長時間不能被回收、方法呼叫次數達不到 JIT 的編譯條件)下的 “奇技” 來使用。Java 語言的一本著名書籍《Practical Java》中把 “不使用的物件應手動賦值為 null” 作為一條推薦的編碼規則。
雖然程式碼清單 1 ~ 程式碼清單 3 的程式碼示例說明了賦 null 值的操作在某些情況下確實是有用的,但筆者的觀點是不應當對賦 null 值的操作又過多的依賴,更沒有必要把它當做一個普遍的編碼規則來推廣。原因有兩點,從編碼角度講,以恰當的變數作用域來控制變量回收時間才是最優雅的解決方法,如程式碼清單 3 那樣的場景並不多見。更關鍵的是,從執行角度來將,使用賦 null 值的操作來優化記憶體回收是建立在對位元組碼執行引擎概念模型的理解之上的,而概念模型與實際執行過程是外部看起來等效,內部看上去則可以完全不同。在虛擬機器使用直譯器執行時,通常與概念模型還比較接近,但經過 JIT 編譯器後,才是虛擬機器執行程式碼的主要方式,賦 null 值的操作在經過 JIT 編譯優化後就會被消除掉,這時候將變數設定為 null 就是沒有意義的。位元組碼被編譯為原生代碼後,對 GC Roots 的列舉也與解釋執行時期有巨大差別,以前面例子來看,程式碼清單 2 在經過 JIT 編譯後,System.gc() 執行時就可以正確回收掉記憶體,無須寫成程式碼清單 3 的樣子。
關於區域性變量表,還有一點可能會對實際開發產生影響,就是區域性變數不像前面介紹的類變數那樣存在 “準備階段”。通過之前的講解,我們已經知道類變數有兩次賦初始值的過程,一次在準備階段,賦予系統初始化;另外一次在初始化階段,賦予程式設計師定義的初始值。因此,即使在初始化階段程式沒有為類變數賦值也沒有關係,類變數仍然具有一個確定的初始值。但區域性變數就不一樣,如果一個區域性變數定義了但沒有賦初始值是不能使用的,不要認為 Java 中任何情況下都存在諸如整型變數預設為 0,布林型變數預設為 false 等這樣的預設值。如程式碼清單 所示,
package com.clazz;
public class TestClzz {
public static void main(String[] args) {
int a;
System.out.println(a);
}
}
這段程式碼其實並不能執行,還好編譯器能在編譯期間就檢查到並提示這一點,即便編譯能通過或者手動生成位元組碼的方式製造出下面程式碼的效果,位元組碼校驗的時候也會被虛擬機發現而導致類載入失敗。
2.2 運算元棧
運算元棧(Operand Stack)也常稱為操作棧,它是一個後入先出(Last In First Out,LIFO)棧。同區域性變量表一樣,運算元棧的最大深度也在編譯的時候寫入到 Code 屬性的 max_stacks 資料項中。運算元棧的每一個元素可以是任意的 Java 資料型別,包括 long 和 double。32 位資料型別所佔的棧容量為 1,64 位資料型別所佔的棧容量為 2。在方法執行的任何時候,運算元棧的深度都不會超過在 max_stacks 資料項中設定的最大值。
當一個方法剛剛開始執行的時候,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令往運算元棧中寫入和提取內容,也就是出棧 / 入棧操作。例如,在做算術運算的時候是通過運算元棧來進行的,又或者再呼叫其他方法的時候是通過運算元棧來進行引數傳遞的。
舉個例子,整數加法的位元組碼指令 iadd 在執行的時候運算元棧中最接近棧頂的兩個元素已經存入了兩個 int 型的數值,當執行這個指令時,會將這兩個 int 值出棧並相加,然後將相加的結果入棧。
運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,在編譯程式程式碼的時候,編譯器要嚴格保證這一點,在類校驗階段的資料流分析中還要再次驗證這一點。再以上面的 iadd 指令為例,這個指令用於整型數加法,它執行時,最接近棧頂的兩個元素的資料型別必須為 int 型,不能出現一個 long 和一個 float 使用 iadd 命令相加的情況。
另外,在概念模型中,兩個棧幀作為虛擬機器棧的元素,是完全相互獨立的。但在大多虛擬機器的實現裡都會做一些優化處理,令兩個棧幀出現一部分重疊。讓下面棧幀的部分運算元棧與上面棧幀的部分區域性變量表重疊在一起,這樣在進行方法呼叫時就可以共用一部分資料,無須進行額外的引數複製傳遞,重疊的過程如圖 8-2 所示。
Java 虛擬機器的解釋執行引擎稱為 “基於棧的執行引擎”,其中所指的 “棧” 就是運算元棧。(基於棧的程式碼過程稍後在講解)
2.3 動態連結
每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線(Dynamic Linking)。通過前面的講解,我們知道 Class 檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用作為引數。這些符號引用一部分會在類載入階段或者第一次使用的時候就轉化為直接引用,這種轉化成為靜態解析。另外一部分將在每一次執行期間轉化為直接引用,這部分成為動態連線。(這裡兩個轉化過程稍後講解)
2.4 方法返回地址
當一個方法開始執行後,只有兩種方式可以退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法呼叫者(呼叫當前方法的方法稱為呼叫者),是否有返回值和返回值的型別將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocatino Completion)。
另外一種退出方式是,在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,無論是 Java 虛擬機器內部產生的異常,還是程式碼中使用 athrow 位元組碼指令產生的異常,只要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層呼叫者產生任何返回值的。
無論採用何種退出方式,在方法退出之後,都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,呼叫者的 PC 計數器的值可以作為返回地址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會儲存這部分資訊。
方法退出的過程實際上就等同於把當前棧幀出棧,因此退出時可能執行的操作又:恢復上層方法的區域性變量表和運算元棧,把返回值(如果有的話)壓入呼叫者棧幀的運算元棧中,調整 PC 計數器的值以指向方法呼叫指令後面的一條指令等。
2.5 附加資訊
虛擬機器規範允許具體的虛擬機器實現增加一些規範裡沒有描述的資訊到棧幀之中,例如與除錯相關的資訊,這部分資訊完全取決於具體的虛擬機器實現。在實際開發中,一般會把動態連線、方法返回地址與其他附加資訊全部歸為一類,稱為棧幀資訊。
4 方法呼叫
方法呼叫並不等同於方法執行,方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法),暫時還不涉及方法內部的具體執行過程。在程式執行時,進行方法呼叫是最普遍、最頻繁的操作,但前面已經講過,Class 檔案的編譯過程中不包含傳統編譯中的連線步驟,一切方法呼叫在 Class 檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體佈局中的入口地址(相當於之前說的直接引用)。這個特性給 Java 帶來了更強大的動態擴充套件能力,但也使得 Java 方法呼叫過程變得相對複雜起來,需要在類載入期間,甚至到執行期間才能確定目標方法的直接引用。
4.1 解析
繼續前面關於方法呼叫的話題,所有方法呼叫中的目標方法在 Class 檔案裡面都是一個常量池中的符號引用,在類載入的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。換句話說,呼叫目標在程式程式碼寫好、編譯器進行編譯時就必須確定下來。這類方法的呼叫稱為解析(Resolution)。
在 Java 語言中符合 “編譯期可知,執行期不可變” 這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與型別直接關聯,後者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類載入階段進行解析。
與之相對應的是,在 Java 虛擬機器裡面提供了 5 條方法呼叫位元組碼指令,分別如下。
第一條: invokestatic:呼叫靜態方法。
第二條:invokespecial:呼叫例項構造器 <init> 方法、私有方法和父類方法。
第三條:invokevirtual::呼叫所有的虛方法。
第四條:invokeinterface:呼叫介面方法,會在執行時再確定一個實現此介面的物件。
第五條:invokedynamic:先在執行時動態解析出呼叫點限定符所引用的方法,然後再執行該方法,再次之前的 4 條呼叫指令,分派邏輯是固化在 Java 虛擬機器內部的,而 invokedynamic 指令的分配邏輯是由使用者所設定的引導方法決定的。
只要能被 invokestatic 和 invokespecial 指令呼叫的方法,都可以在解析階段確定唯一的呼叫版本,符合這個條件的有靜態方法、私有方法、例項構造器、父類方法 4 類,它們在類載入的時候就會把符號引用解析為該方法的直接引用。這些方法可以稱為非虛方法,與之相反,其他方法稱為虛方法(除去 final 方法,後文會提到)。程式碼清單 8-5 演示了一個最常見的解析呼叫的例子,此樣例中,靜態方法 sayHello() 只可能屬於型別 StaticResolution,沒有任何手段可以覆蓋或隱藏這個方法。
package com.clazz;
/**
* 方法靜態解析演示
*
*/
public class StaticResolution {
public static void sayHello() {
System.out.println("hello world");
}
public static void main(String[] args) {
StaticResolution.sayHello();
}
}
使用 javap 命令檢視這段程式的位元組碼,會發現的確是通過 invokestatic 命令來呼叫 sayHello() 方法的。
Java 中的非虛方法除了使用 invokestatic、invokespecial 呼叫的方法之外還有一種,就是被 final 修飾的方法。雖然 final 方法是使用 invokevirtual 指令來呼叫的,但是由於它無法被覆蓋,沒有其他版本,所以也無須對方法接收者進行多型選擇,又或者說多型選擇的結果肯定是唯一的。在 Java 語言規範中明確說明了 final 方法是一種非虛方法。
解析呼叫一定是個靜態的過程,在編譯期間就完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變為可確定的直接引用,不會延遲到執行期再去完成。而分配(Dispatch)呼叫則可能是靜態的也可能是動態的,根據分派依據的宗量數可分為單分派和多分派。這兩類分派方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派 4 種分派組合情況。下面我們講解分派
4.2 分派
眾所周知,Java 是一門面向物件的程式語言,因為 Java 具備面向物件的 3 個基本特徵:繼承、封裝和多型。本節講解的分派呼叫過程將會揭示多型性特徵的一些最基本的體現,如 “過載” 和 “重寫” 在 Java 虛擬機器之中是如何實現的,這裡的實現當然不是語法上該如何寫,我們關心的依然是虛擬機器如何確定正確的目標方法。
4.2.1.靜態分派
在開始講解靜態分派(嚴格來說,Dispatch 這個詞一般不用再靜態環境中,英文技術文件的稱呼是 “Method Overload Resolution”,但國內的各種資料都普遍將這種行為翻譯成 “靜態分派”,特此說明)前,筆者準備了一段經常出現在面試題中的程式程式碼,讀者不妨先看一遍,想一下程式的輸出結果是什麼。後面我們的話題將圍繞這個類的方法來過載(Overload)程式碼,以分析虛擬機器和編譯器確定方法版本的過程。方法靜態分派如程式碼清單 8-6 所示。
package com.clazz;
/**
* 方法靜態分派演示
*
*/
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);
sr.sayHello(woman);
}
}
執行結果:
hello, guy!
hello, guy!
程式碼清單 中的程式碼實際上是在考驗閱讀者對過載的理解程度,相信對 Java 程式設計稍有經驗的程式設計師看完程式後都能得出正確的執行結果,但為什麼會選擇執行引數型別為 Human 的過載呢?在解決這個問題之前,我們先按如下程式碼定義兩個重要的的概念。
Human man = new Man();
我們把上面程式碼中的 “Human” 稱為變數的靜態型別(Static Type),或者叫做外觀型別(Apparent Type), 後面的 “Man” 則稱為變數的實際型別(Actual Type),靜態型別和實際型別在程式中都可以發生一些變化,區別是靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別是在編譯期可知的;而實際型別變化的結果在執行期才可確定,編譯器在編譯程式的時候並不知道一個物件的實際型別是什麼。例如下面的程式碼:
// 實際型別變化
Human man = new Man();
man = new Woman();
// 靜態型別變化
sr.sayHello((Man) man);
sr.sayHello((Woman) man);
解釋; 上面的實際型別變化——>將 new Man()改變成了 new Woman()那麼使用的時候 man 實際是new Woman()
上面的靜態型別變化(Man)man 實際是型別的強轉,所以靜態型別不是 Human了而是強轉之後的Man型別作為靜態型別傳遞過去了,(Woman)man 同理。
解釋了這兩個概念,再回到程式碼清單 8-6 的樣例程式碼中。main() 裡面的兩次 sayHello() 方法呼叫,在方法接收者已經確定是物件 “sr” 的前提下,使用哪個過載版本,就完全取決於傳入擦你數的數量和資料型別。程式碼中刻意地定義了兩個靜態型別相同但實際型別不同的變數,但虛擬機器(準確地說是編譯器)在過載時是通過引數的靜態型別而不是實際型別作為判定依據的。並且靜態型別是編譯期可知的,因此,在編譯階段,javac 編譯器會根據引數的靜態型別決定使用哪個過載版本,所以選擇了 sayHello(Human) 作為呼叫目標,並把這個方法的符號引用寫到 main() 方法裡的兩條 invokevirtual 指令的引數中。
所有依賴靜態型別來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用是方法過載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機器來執行的。另外,編譯器雖然能確定出方法的過載版本,但在很多情況下這個過載版本並不是 “唯一的”,往往只能確定一個 “更加適合的” 版本。這種模糊的結論在由 0 和 1 構成的計算機世界中算是比較 “稀罕” 的事情,產生這種模糊結論的主要原因是字面量不需要定義,所以字面量沒有顯式的靜態型別,它的靜態型別只能通過語言上的規則去理解和推斷。程式碼清單 8-7 演示了何為 “更加適合的” 版本。
package com.clazz;
import java.io.Serializable;
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(Character arg) {
System.out.println("hello Character");
}
public static void sayHello(char arg) {
System.out.println("hello char");
}
public static void sayHello(char... arg) {
System.out.println("hello char ...");
}
public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}
上面的程式碼執行後會輸出:hello char
這很好理解,'a' 是一個 char 型別的資料,自然會尋找引數型別為 char 的過載方法,如果註釋掉 sayHello(char arg) 方法,那輸出會變為:hello int
這時發生了一次自動型別轉換,'a' 除了可以代表一個字串,還可以代表數字 97(字元 'a' 的 Unicode 數值為十進位制數字 97),因此引數型別為 int 的過載也是合適的。我們繼續註釋掉 sayHello(int arg) 方法,那輸出會變為:hello long
這時發生了兩次自動型別轉換,'a' 轉型為整型 97 之後,進一步轉型為長整數 97L,匹配了引數型別為 long 的過載。筆者在程式碼中沒有寫其他的型別如 float、double 等的過載,不過實際上自動轉型還能繼續發生多次,按照 char -> int -> long -> float -> double 的順序轉型進行匹配。但不會匹配到 byte 和 short 型別的過載,因為 char 到 byte 或 short 的轉型是不安全的。我們繼續註釋掉 sayHello(long arg) 方法,那輸出會變為:hello Character
這時發生了一次自動裝箱,'a' 被包裝為它的封裝型別 java.lang.Character,所以匹配到了引數型別為 Character 的過載,繼續註釋掉 sayHello(Character arg) 方法,那輸出會變為:hello Serializable
這個輸出可能會讓人感覺摸不著頭腦,一個字元或數字與序列化有什麼關係?出現 hello Serializable,是因為 java.lang.Serializable 是 java.lang.Character 類實現的一個介面,當自動裝箱之後發現還是找不到裝箱類,但是找到了裝箱類實現了的介面型別,所以緊接著又發生一次自動轉型。char 可以轉型成 int,但是 Character 是絕對不會轉型為 Integer 的,它只能安全地轉型為它實現的介面或父類。Character 還實現了另外一個介面 java.lang.Comparable<Character>,如果同時出現兩個引數分別為 Serializable 和 Comparable<Character> 的過載方法,那它們在此時的優先順序是一樣的。編譯器無法確定要自動轉型為哪種型別,會提示型別模糊,拒絕編譯。程式必須在呼叫時顯式地指定字面量的靜態型別,如:sayHello((Comparable<Character>'a'),才能編譯通過。下面繼續註釋掉 sayHello(Serializable arg) 方法,輸出會變為:hello Object
這時是 char 裝箱後轉型為父類了,如果有多個父類,那將在繼承關係中從下往上開始搜尋,越接近上層的優先順序越低。即使方法呼叫傳入的才引數值為 null 時,這個規則仍然適用。我們把 sayHello(Object arg) 也註釋掉,輸出將會變為:hello char ...
7 個過載方法已經被註釋得只剩一個了,可見變長引數的過載優先順序是最低的,這時候字元 'a' 被當做了一個數組元素。筆者適用的是 char 型別的變長引數,讀者在驗證時還可以選擇 int 型別、Character 型別、Object 型別等變長引數過載來把上面的過程重新演示一遍。
程式碼清單 8-7 演示了編譯期間選擇靜態分派目標的過程,這個過程也是 Java 語言實現方法過載的本質。演示所用的這段程式屬於很極端的例子,除了用做面試題為難求職者以外,在實際工作中幾乎不能有實際用途。筆者拿來做演示僅僅是用於講解過載時目標方法選擇的過程,大部分情況下進行這樣極端的過載都可算是真正的 “關於茴香豆的茴有幾種寫法的研究”。無論對過載的認識有多麼深刻,一個合格的程式設計師都不應該再實際應用中寫出如此極端的過載程式碼。
另外還有一點讀者可能比較容易混淆:筆者講述的解析與分派這兩者之間的關係並不是二選一的排他關係,它們是不同層次上去篩選、確定目標方法的過程。例如,前面說過,靜態方法會在類載入期就進行解析,而靜態方法顯然也是可以擁有過載版本的,選擇過載版本的過程也是通過靜態分派完成的。
注意:好好看一下這一塊,能幫助我們更好的理解方法過載,方法過載也是有匹配優先順序的,最先是精確匹配,然後是向上轉型匹配,實現的介面,。。Object ,最後是可變引數,在確定方法版本的時候也會存在模糊匹配的情況。前提是不能精確匹配到。
4.2.2 動態分派
瞭解了靜態分派,我們接下來看一下動態分派的過程,它和動態性的另外一個重要體現(注:有一種觀點認為:因為過載是靜態的,重寫是動態的,所以只有重寫算是多型性的體現,過載不算多型。筆者認為這種整理沒有意義,概念僅僅是說明問題的一種工具而已)——重寫(Override)有著密切的關聯。我們還是用前面的 Man 和 Woman 一起 sayHello 的例子來講解動態分派,請看程式碼清單 中所示的程式碼。
package com.clazz;
/**
* 方法動態分派演示
*
*/
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();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
執行結果:
man say hello
woman say hello
woman say hello
這個執行結果相信不會出乎任何人的意料,對於習慣了面向物件思維的 Java 程式設計師會覺得這是完全理所當然的。現在的問題還是和前面的一樣,虛擬機器是如何知道要呼叫哪個方法的?
雖然這裡不可能再根據靜態型別來決定,因為靜態型別同樣都是 Human 的兩個變數 man 和 woman 在呼叫 sayHello() 方法時執行了不同的行為,並且變數 man 在兩次呼叫中執行了不同的方法。導致這個現象的原因很明顯,是這兩個變數的實際型別不同,Java 虛擬機器是如何根據實際型別來分派方法執行版本的呢?我們使用 javap 命令輸出這段程式碼的位元組碼,嘗試從中尋找答案,輸出結果如下圖 所示。
0 ~ 15 行的位元組碼是準備動作,作用是建立 man 和 woman 的記憶體空間、呼叫 Man 和 Woman 型別的例項構造器,將這兩個例項的引用存放在第 1、2 個區域性變量表 Slot 之中,這個動作也就對應了程式碼中的這兩句:
Human man = new Man();
Human woman = new Woman();
接下來的 16 ~ 21 句是關鍵部分、16、20 兩句分別把剛剛建立好的兩個物件的引用壓入到棧頂,這兩個物件是將要執行的sayHello() 方法的所有者,稱為接收者 (Receiver);17 和 21 句是方法呼叫指令,這兩條呼叫指令但從位元組碼角度來看,無論是指令(都是 invokevirtual)還是引數(都是常量池中第 22 項的常量,註釋顯示了這個常量是 Human.sayHello() 的符號引用)完全一樣的,但是這兩句指令最終執行的目標方法並不相同。原因就需要從 invokevirtual 指令的多型查詢過程開始說起,invokevirtual 指令的執行時解析過程大致分為以幾個步驟:
1、找到運算元棧頂的第一個元素所執行的物件的實際型別,記作 C。
2、如果在型別 C 中找到與常量中的描述符合簡單名稱都相符的方法,則進行訪問許可權校驗,如果通過則返回這個方法的直接引用,查詢過程結束;如果不通過,則返回
java.lang.IllegalAccessError 異常。
3、否則,按照繼承關係從下往上一次對 C 的各個父類進行第 2 步的搜尋和驗證過程。
4、如果始終沒有找到合適的方法,則丟擲 java.lang.AbstractMethodError 異常。
由於 invokevirtual 指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java 語言中方法重寫的本質。我們把這種在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。
4.2.3 單分派與多分派
方法的接收者與方法的引數統稱為方法的宗量,這個定義最早應該來源於《Java 與模式》一書。根據分派基於多少種宗量,可以將分派劃分為單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選中,多分派則根據多於一個宗量對目標方法進行選擇。
單分派和多分派的定義讀起來拗口,從字面上看也比較抽象,不過對照例項看就不難理解了。程式碼清單 8-10 中列舉了一個 Father 和 Son 一起來做出 “一個艱難的決定” 的例子。
package com.clazz;
/**
* 單分派、多分派演示
*
*/
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 harChoice(_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 harChoice(_360 arg) {
System.out.println("son choose 360");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.harChoice(new _360());
son.hardChoice(new QQ());
}
}
執行結果:
father choose 360
son choose qq
在 main 函式中呼叫了兩次 hardChoice() 方法,這兩次 hardChoice() 方法的選擇結果在程式輸出中已經顯示得很清楚了。
我們來看看編譯階段編譯器的選擇過程,也就是靜態分派的過程。這時選擇目標方法的依據有兩點: 一是靜態型別是 Father 還是 Son,而是方法引數是 QQ 還是 360。這次選擇結果的最終產物是產生了兩條 invokevirtual 指令,兩條指令的引數分別為常量池中指向 Father.hardChoice(360) 及 Father.hardChoice(QQ) 方法的符號引用。因為是根據兩個宗量進行選擇, 所以Java 語言的靜態分派屬於多分派型別。
再看看執行階段虛擬機器的選擇,也就是動態分派的過程。在執行 “son.hardChoice(new QQ())” 這句程式碼時,更準確地說,是在執行這句程式碼所對應的 invokevirtual 指令時,由於編譯期已經決定目標方法的簽名必須為 hardChoice(QQ),虛擬機器此時不會關心傳遞過來的引數 “QQ” 到底是 “騰訊QQ” 還是 “奇瑞QQ”,因為這時引數的靜態型別、實際型別都對方法的選擇不會構成任何影響,唯一可以影響虛擬機器選擇的因素只有此方法的接收者的實際型別是 Father 還是 Son。因為只有一個宗量作為選擇依據, 所以 Java 語言的動態分派屬於單分派型別。
根據上述論證的結果,我們可以總結依據:今天的 Java 語言是一門靜態多分派、動態單分派的語言。強調 “今天的 Java 語言” 是因為這個結論未必會恆久不變,C# 在 3.0 及之前的版本與 Java 一眼的動態單分派語言,但在 C# 4.0 中引入了 dynamic 型別後,就可以很方便地實現動態多分派。
按照目前 Java 語言的發展趨勢,它並沒有直接變為動態語言的跡象,而是通過內建動態語言(如 JavaScript)執行引擎的方式來滿足動態性的需求。但是 Java 虛擬機器層面上則不是如此,在 JDK 1.7 中實現的 JSR-292 裡面就已經開始提供對動態語言的支援了,JDK 1.7 中新增的 invokedynamic 指令也成為了最複雜的一條方法呼叫的位元組碼指令。
4.2.4 虛擬機器動態分派的實現
前面介紹的分派過程,作為對虛擬機器概念模型的解析基本上已經足夠了,它已經解決了虛擬機器在分派中 “會做什麼” 這個問題。但是虛擬機器 “具體是如何做到的”,可能各種虛擬機器的實現都會有些差別。
由於動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要執行時在類的方法元資料中搜索合適的目標方法,因此在虛擬機器的實際實現中基於效能的考慮,大部分實現都不會真正地進行如此頻繁的搜尋。面對這種情況,最常用的 “穩定優化” 手段就是為類在方法區中建立一個虛方法表(Virtual Method Table,也稱為 vtable,與此對應的,在 invokeinterface 執行時也會用到介面方法表——Interface Method Table,簡稱 itable),使用虛方法表索引來代替元資料查詢以提高效能。我們先看看但清單 8-10 所對應的虛方法表結構示例,如圖 8-3 所示。
虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的地址入口和父類相同方法的地址入口是一致的;都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。圖 8-3 中,Son 重寫了來自 Father 的全部方法,因此 Son 的方法表沒有指向 Father 型別資料的箭頭。但是 Son 和 Father 都沒有重寫來自 Object 的方法,所以它們的方法表中所有從 Object 繼承來的方法都指向了 Object 的資料型別。
為了程式實現上的方便,具有相同簽名的方法,在父類、子類的虛方法表中都應該具有一樣的索引序號,這樣當型別變換時,僅需要變更查詢的方法表,就可以從不同的虛方法表中按索引轉換出所需的入口地址。
方法表一般在類載入的連線階段進行初始化,準備了類的變數初始值後,虛擬機器會把該類的方法表也初始化完畢。
上文中筆者說方法表是分派呼叫的 “穩定優化” 手段,虛擬機器除了使用方法表之外,在條件允許的情況下,還會使用內聯快取(Inline Cache)和基於 “型別繼承關係分析”(Class Hierarchy Analysis,CHA)技術的守護內聯(Guarded Inlining)兩種非穩定的 “激進優化” 手段來獲得更高的效能。
4.3 動態型別語言支援
java 虛擬機器的位元組碼指令集的數量從 Sun 公司的第一款 Java 虛擬機器問世至 JDK 7 來臨之前的十餘年時間裡,一直沒有發生任何變化。隨著 JDK 7 的釋出,位元組碼指令集終於迎來了第一位新成員——invokedynamic 指令。這條新增加的指令是 JDK 7 實現 “動態型別語言” (Dynamically Typed Language)支援而進行的改進之一,也是為 JDK 8 可以順序實現 Lambda 表示式做技術準備。在本節中,我們將詳細講解 JDK 7 這項新特性出現的前因後果和它的深遠意義。
4.3.1 動態型別語言
在介紹 Java 虛擬機器的動態型別語言支援之前,我們要先弄明白動態型別語言是什麼?它與 Java 語言、Java 虛擬機器有什麼關係?瞭解 JDK 1.7 提供動態型別語言支援的技術背景,對理解這個語言特性是很有必要的。
什麼是動態型別語言(注意:動態型別語言與動態語言、弱型別語言並不是一個概念,需要區別對待)?動態型別語言的關鍵特徵是它的型別檢查的主體過程是在執行期而不是編譯期,滿足這個特徵的語言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk 和 Tcl 等。相對的,在編譯期就進行型別檢查過程的語言(如 C++ 和 Java 等)就是最常用的靜態型別語言。
4.3.2 JDK 1.7 與動態型別
目前確實已經有許多動態型別語言運行於 Java 虛擬機器之上了,如 Clojure、Groovy、Jython 和 JRuby 等,能夠在同一個虛擬機器上可以達到靜態型別語言的嚴謹性與動態型別語言的靈活性,這是一件很美妙的事情。
但遺憾的是,Java 虛擬機器層面對動態型別語言的支援一直都有所欠缺,主要表現在方法呼叫方面:JDK 1.7 以前的位元組碼指令集中,4 條方法呼叫指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一個引數都是被呼叫的方法的符號引用(CONSTANT_Methodref_info 或者 CONSTANT_InterfaceMethodref_info 常量),前面已經提到過,方法的符號引用在編譯時產生,而動態型別語言只有在執行期才能確定接收者型別。這樣,在 Java 虛擬機器上實現的動態型別語言就不得不使用其他方式(如編譯時留個佔位符型別,執行時動態生成位元組碼實現具體型別到佔位符型別的適配)來實現,這樣勢必讓動態型別語言實現的複雜度增加,也可能帶來額外的效能或者記憶體開銷。儘管可以利用一些辦法(如Call Site Caching)讓這些開銷儘量變小,但這種底層問題終歸是應當在虛擬機器層次上去解決才最合適,因此在 Java 虛擬機器層面上提供動態型別的直接支援就成為了 Java 平臺的發展趨勢之一,這就是 JDK 1.7(JSR-292)中invokedynamic 指令以及 java.lang.invoke 包出現的技術背景。
4.3.3 java.lang.invoke 包
JDK 1.7 實現了 JSR-292,新加入的 java.lang.invoke 包(注:這個包在很長一段時間裡稱為 java.dyn,也曾經短暫更名為 java.lang.mh,如果在其他資料上看到這兩個包名,可以把他們理解為 java.lang.invoke)就是 JSR-292 的一個重要組成部分,這個包的主要目的是在之前單純依靠符號引用來確定呼叫的目標方法這種方式以外,提供一種新的動態確定目標方法的機制,稱為 MethodHandle。這種表達方式也許不太好懂?那不妨把 MethodHandle 與 C/C++ 中的 Function Pointer,或者 C# 裡面的 Delegate 類比一下。舉個例子,如果我們要實現一個帶謂詞的排序函式,在 C/C++ 中常用的做法是把謂詞定義為函式,用函式指標把謂詞傳遞到排序方法,如下:
void sort(int list[], const int size, <strong>int (*compare)(int, int)</strong>)
但 Java 語言做不到這一點,即沒有辦法把一個函式作為引數進行傳遞。普遍的做法是設計一個帶有 compare() 方法的 Comparator 介面,以實現了這個介面的物件作為引數,例如 Collections.sort() 就是這樣定義的:
void sort(List list, Comparator c)
4.3.4 掌控方法分派規則
invokedynamic 指令與前面 4 條 “invoke*” 指令的最大差別就是它的分派邏輯不是由虛擬機器決定的,而是由程式設計師決定。在介紹 Java 虛擬機器語言支援的最後一個小結中,筆者通過一個簡單例子(如程式碼清單 8-14 所示),幫助讀者理解程式設計師在可以掌握方法分派規則之後,能做什麼以前無法做到的事情。
package com.clazz;
/**
* 掌控方法分派規則
* @author mch
*
*/
class GrandFather {
void thinking() {
System.out.println("i am grandfather");
}
}
class Father extends GrandFather {
void thinking() {
System.out.println("i am father");
}
}
class Son extends Father {
void thinking() {
// 請讀者在這裡填入適當的程式碼 (不能修改其他地方的程式碼)
// 實現呼叫祖父類的 thinking() 方法,列印 "i am grandfather"
}
}
在 Java 程式中,可以通過 “super” 關鍵字很方便地呼叫到父類中的方法,但如果要訪問祖類的方法呢?
在 JDK 1.7 之前,使用純粹的 Java 語言很難處理這個問題(直接生成位元組碼就很簡單,如使用 ASM 等位元組碼工具),原因是在 Son 類的 thinking() 方法中無法獲取一個實際型別是 GrandFather 的物件引用,而 invokevirtual 指令的分派邏輯就是按照方法接收者的實際型別進行分派,這個邏輯是固化在虛擬機器中的,程式設計師無法改變。在 JDK 1.7 中,可以使用程式碼清單 8-15 中的程式來解決這個問題。
package com.clazz;
import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
public class Test {
class GrandFather {
void thinking() {
System.out.println("i am grandfather");
}
}
class Father extends GrandFather {
void thinking() {
System.out.println("i am father");
}
}
class Son extends Father {
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
MethodHandle mh = lookup().findSpecial(GrandFather.class,
"thinking", mt, getClass());
mh.invokeExact(this);
} catch (Throwable e) {
}
}
}
public static void main(String[] args) throws Throwable {
(new Test().new Son()).thinking();
}
}
我列印的是:i am father (不知道為啥)
5 基於棧的位元組碼解釋執行引擎
虛擬機器是如何呼叫方法的內容已經講解完畢,從本節開始,我們來探討虛擬機器是如何執行方法中的位元組碼指令的。上文中提到過,許多 Java 虛擬機器的執行引擎在執行 Java 程式碼的時候都有解釋執行(通過直譯器執行)和編譯執行(通過即時編譯器產生原生代碼執行)兩種選擇,在下面,我們先來探討一下在解釋執行時,虛擬機器執行引擎是如何工作的。
5.1 解釋執行
Java 語言經常被人們定位為 “解釋執行” 的語言,在 Java 初生的 JDK 1.0 時代,這種定義還算是比較準確的,但當主流的虛擬機器中都包含了即時編譯器後,Class 檔案中的程式碼到底會被解釋執行還是編譯執行,就成了只有虛擬機器自己才能準確判斷的事情。再後來,Java 也發展處了可以直接生成原生代碼的編譯器【如 GCJ(GNU Compiler for the Java)】,而 C/C++ 語言也出現了通過直譯器執行的版本(如 CINT),這時候再籠統地說 “解釋執行”,對於整個 Java 語言來說就成了幾乎是沒有意義的概念,只有確定了談論物件是某種具體的 Java 實現版本和執行引擎執行模式時,談解釋執行還是貶義執行才會比較確切。
不論是解釋還是編譯,也不論是物理機還是虛擬機器,對於應用程式,機器都不可能如人那樣閱讀、理解,然後就獲得了執行能力。大部分的程式程式碼到物理機的目的碼或虛擬機器能執行的指令集之前,都需要經過圖 8-4 中的各個步驟。如果讀者對編譯原理的相關課程還有印象的話,很容易就會發現圖 8-4 中下面那條分支,就是傳統編譯原理中程式程式碼到目標機器程式碼的生成過程,而中間的那條分支,自然就是解釋執行的過程。
如今,基於物理機、Java 虛擬機器,或者非 Java 的其他高階語言虛擬機器(HLLVM)的語言,大多都會遵循這種基於現代經典編譯原理的思路,在執行前先對程式原始碼進行詞法分析和語法分析處理,把原始碼轉化為抽象語法樹(Abstract Syntax Tree,AST)。對於一門具體語言的實現來說,詞法分析、語法分析以至於後面的優化器和目的碼生成器都可以選擇獨立於執行引擎,形成一個完整意義的編譯器去實現,這類代表是 C/C++ 語言。也可以選擇把其中一部分步驟(如生成抽象語法樹之前的步驟)實現為一個半獨立的編譯器,這類代表是 Java 語言。又或者把這些步驟和執行引擎全部集中封裝在一個封閉的黑匣子之中,如大多數的 JavaScript 執行器。
Java 語言中,Javac 編譯器完成了程式程式碼經過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的位元組碼指令流的過程。因為這一部分動作是在 Java 虛擬機器之外進行的,而直譯器在虛擬機器的內部,所以 Java 程式的編譯就是半獨立的實現。
5.2 基於棧的指令集與基於暫存器的指令集
Java 編譯器輸出的指令流,基本上(注:使用 “基本上”,是因為部分位元組碼指令會帶有引數,而純粹基於棧的指令集架構中應當全部都是零地址指令,也即是都不存在顯式的引數。Java 這樣實現主要是考慮了程式碼的可校驗性)是一種基於棧的指令集架構(Instruction Set Architecture, ISA),指令流中的指令大部分都是零地址指令,它們依賴運算元棧進行工作。與之相對的另外一套常用的指令集架構是基於暫存器的指令集,最典型的就是 x86 的二地址指令集,說得通俗一些,就是現在我們主流 PC 機中直接支援的指令集架構,這些指令依賴暫存器進行工作。那麼,基於棧的指令集與基於暫存器的指令集這兩者之間有什麼不同呢?
舉個最簡單的例子,分別使用這兩種指令計算 “1+1” 的結果,基於棧的指令集會是這樣子的:
iconst_1
iconst_1
iadd
istore_0
兩條 iconst_1 指令連續把兩個常量 1 壓入棧後,iadd 指令把棧頂的兩個值出棧、相加,然後把結果放回棧頂,最後 istore_0 把棧頂的值放到區域性變量表的第 0 個 Slot 中。
如果基於暫存器,那程式可能會是這個樣子:
mov eax, 1
add eax, 1
mov 指令把 EAX 暫存器的值設為 1,然後 add 指令再把這個值加 1,結果就儲存在 EAX 暫存器裡面。
瞭解了基於棧的指令集與基於暫存器的指令集的區別後,讀者可能會有進一步的疑問,這兩套指令集誰更好一些呢?
應該這麼說,既然兩套指令集會同時並存和發展,那肯定是各有優勢的,如果有一套指令集全面優於另外一套的話,就不會存在選擇的問題了。
基於棧的指令集主要的有點就是可移植,暫存器由硬體直接提供(注:這裡說的是物理機器上的暫存器,也有基於暫存器的虛擬機器,如 Google Android 平臺的 Dalvik VM。即使是基於暫存器的虛擬機器,也希望把虛擬機器暫存器儘量對映到物理暫存器上以獲取儘可能高的效能),程式直接依賴硬體暫存器則不可避免地要受到硬體的約束。例如,現在 32 位 80x86 體系的處理器中提供了 8 個 32 位的暫存器,而 ARM 體系的 CPU(在當前的手機、PDA 中相當流行的一種處理器)則提供了 16 個 32 位的通用暫存器。如果使用棧架構的指令集,使用者程式不會直接使用這些暫存器,就可以由虛擬機器實現來自行決定把一些訪問最頻繁的資料(程式計數器、棧頂快取等)放到暫存器中以獲取儘量好的效能,這樣實現起來也更