執行引擎是Java虛擬機最核心的組成部分之一。「虛擬機」是一個相對於「物理機」的概念 ,這兩種機器都有代碼執行能力,其區別是物理機的執行引擎是直接創建在處理器、硬件、指令集和操做系統層面上的,而虛擬機的執行引擎則是由本身實現的,所以能夠自行制定指令集與執行引擎的結構體系,而且可以執行那些不被硬件直接支持的指令集格式。java
在Java虛擬機規範中制定了虛擬機字節碼執行引擎的概念模型,這個概念模型成爲各類虛擬機執行引擎的統一外觀(Facade )。在不一樣的虛擬機實現裏面,執行引擎在執行Java代碼的時候可能會有解釋執行(經過解釋器執行)和編譯執行(經過即時編譯器產生本地代碼執行)兩種選擇 , 也可能二者兼備,甚至還可能會包含幾個不一樣級別的編譯器執行引擎。 但從外觀上看起來,全部的Java虛擬機的執行引擎都是一致的:輸入的是字節碼文件,處理過程是字節碼解析的等效過程,輸出的是執行結果,本章將主要從概念模型的角度來說解虛擬機的方法調用和字節碼執行。程序員
有一些虛擬機(如Sun Classic VM ) 的內部只存在解釋器,只能解釋執行,而另一些虛擬機(如BEA JRockit) 的內部只存在即時編譯器,只能編譯執行。面試
棧幀( Stack Frame ) 是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧( Virtual Machine Stack ) 的棧元素。棧幀存儲了方法的局部變量表、操做數棧、動態鏈接和方法返回地址等信息。每個方法從調用開始至執行完成的過程 ,都對應着一個棧幀在虛擬機棧裏面從入棧到出棧的過程。編程
每個棧幀都包括了局部變量表、操做數棧、動態鏈接、方法返回地址和一些額外的附加信息。在編譯程序代碼的時候,棧幀中須要多大的局部變量表,多深的操做數棧都已經徹底肯定了,而且寫入到方法表的Code屬性之中,所以一個棧幀須要分配多少內存,不會受到程序運行期變量數據的影響,而僅僅取決於具體的虛擬機實現。c#
一個線程中的方法調用鏈可能會很長,不少方法都同時處於執行狀態。對於執行引擎來講,在活動線程中,只有位於棧頂的棧幀纔是有效的,稱爲當前棧幀( Current Stack Frame ) , 與這個棧幀相關聯的方法稱爲當前方法( Current Method )。執行引擎運行的全部字節碼指令都只針對當前棧幀進行操做,在概念模型上,典型的棧幀結構如圖8-1所示。數組
接下來詳細講解一下棧幀的局部變量表、操做數棧、動態鏈接、方法返回地址等各個部分的做用和數據結構。緩存
局部變量表(Local Variable Table)是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。在Java程序編譯爲Class文件時 ,就在方法的Code屬性的max_locals數據項中肯定了該方法所須要分配的局部變量表的最大容量。安全
局部變量表的容量以變量槽( Variable Slot,下稱Slot) 爲最小單位,虛擬機規範中並無明確指明一個Slot應占用的內存空間大小,只是頗有導向性地說到每一個Slot都應該能存放一 個boolean、byte、char、short、int、float、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的複用會直接影響到系統的垃圾收集行爲,請看代碼清單8-1〜代碼清單8-3的3個演示。
代碼清單8 - 1 局部變量表Slot複用對垃圾收集的影響之一
public static void main(String[] args)() { byte[] placeholder = new byte[64 * 1024 * 1024]; System.gc(); }
代碼清單8-1中的代碼很簡單,即向內存填充了64MB的數據 ,而後通知虛擬機進行垃圾收集。咱們在虛擬機運行參數中加上「-verbose : gc」來看看垃圾收集的過程,發如今 System.gc() 運行後並無回收這64MB的內存,下面是運行的結果:
[GC 66846K->65824K (125632K ) ,0.0032678 secs] [Full GC 65824K-> 65746K (125632K) ,0.0064131 secs]
沒有回收placeholder所佔的內存能說得過去,由於在執行Systemgc() 時 ,變量 placeholder還處於做用域以內,虛擬機天然不敢回收placeholder的內存。那咱們把代碼修改一下 ,變成代碼清單8-2中的樣子。
代碼清單8 - 2 局部變量表Slot複用對垃圾收集的影響之二
public static void main(String[] args)() { { byte[] placeholder = new byte[64 * 1024 * 1024]; } System.gc(); }
加入了花括號以後,placeholder的做用域被限制在花括號以內,從代碼邏輯上講,在執行System.gc() 的時候,placeholder已經不可能再被訪問了,但執行一下這段程序,會發現運行結果以下,仍是有64MB的內存沒有被回收,這又是爲何呢?
在解釋爲何以前,咱們先對這段代碼進行第二次修改,在調用System.gc() 以前加入 —行「int a=0;」 , 變成代碼清單8-3的樣子。
代碼清單8 - 3 局部變量表Slot複用對垃圾收集的影響之三
public static void main(String[] args)() { { byte[] placeholder = new byte[64 * 1024 * 1024]; } int a = 0; System.gc(); }
這個修改看起來莫名其妙,但運行一下程序,卻發現此次內存真的被正確回收了。
[GC 66401K-> 65778K (125632K ) ,0.0035471 secs] [Full GC 65778K->218K (125632K) ,0.0140596 secs]
在代碼清單8-1〜代碼清單8-3中 ,placeholder可否被回收的根本緣由是:局部變量表中的Slot是否還存有關於placeholder數組對象的引用。第一次修改中,代碼雖然已經離開了placeholder的做用域,但在此以後,沒有任何對局部變量表的讀寫操做,placeholder本來所佔用的Slot尚未被其餘變量所複用,因此做爲GC Roots —部分的局部變量表仍然保持着對它的關聯。這種關聯沒有被及時打斷,在絕大部分狀況下影響都很輕微。但若是遇到一個方法 ,其後面的代碼有一些耗時很長的操做,而前面又定義了佔用了大量內存、實際上已經不會再使用的變量,手動將其設置爲null值(用來代替那句int a=0, 把變量對應的局部變量表 Slot清 空 )便不見得是一個絕對無心義的操做,這種操做能夠做爲一種在極特殊情形(對象佔用內存大、此方法的棧幀長時間不能被回收、方法調用次數達不到JIT的編譯條件)下的「奇技」來使用。Java語言的一本很是著名的書籍《 Practical Java》中把「不使用的對象應手動賦值爲null」做爲一條推薦的編碼規則,可是並無解釋具體的緣由,很長時間以內都有讀者對這條規則感到疑惑。
雖然代碼清單8-1〜代碼清單8-3的代碼示例說明了賦null值的操做在某些狀況下確實是有用的 ,但筆者的觀點是不該當對賦null值的操做有過多的依賴,更沒有必要把它當作一個廣泛的編碼規則來推廣。緣由有兩點,從編碼角度講,以恰當的變量做用域來控制變量回收時間纔是最優雅的解決方法,如代碼清單8-3那樣的場景並很少見。更關鍵的是,從執行角度講 ,使用賦null值的操做來優化內存回收是創建在對字節碼執行引擎概念模型的理解之上的 ,在第6章介紹完字節碼後,筆者專門增長了一個6.5節「公有設計、私有實現」來強調概念模型與實際執行過程是外部看起來等效,內部看上去則能夠徹底不一樣。在虛擬機使用解釋器執行時 ,一般與概念模型還比較接近,但通過JIT編譯器後 ,纔是虛擬機執行代碼的主要方式 ,賦null值的操做在通過JIT編譯優化後就會被消除掉,這時候將變量設置爲null就是沒有意義的。字節碼被編譯爲本地代碼後,對GC Roots的枚舉也與解釋執行時期有巨大差異,之前面例子來看,代碼清單8-2在通過JIT編譯後, System.gc() 執行時就能夠正確地回收掉內存 ,無須寫成代碼清單8-3的樣子。
關於局部變量表,還有一點可能會對實際開發產生影響,就是局部變量不像前面介紹的類變量那樣存在「準備階段」。經過第7章的講解,咱們已經知道類變量有兩次賦初始值的過程 ,一次在準備階段,賦予系統初始值;另一次在初始化階段,賦予程序員定義的初始值。所以 ,即便在初始化階段程序員沒有爲類變量賦值也沒有關係,類變量仍然具備一個肯定的初始值。但局部變量就不同,若是一個局部變量定義了但沒有賦初始值是不能使用的,不要認爲Java中任何狀況下都存在諸如整型變量默認爲0 ,布爾型變量默認爲false等這樣的默認值。如代碼清單8-4所 示 ,這段代碼其實並不能運行,還好編譯器能在編譯期間就檢查到並提示這一點,即使編譯能經過或者手動生成字節碼的方式製造出下面代碼的效果,字節碼校驗的時候也會被虛擬機發現而致使類加載失敗。
代碼清單8 - 4 未賦值的局部變量
public static void main(String[] args) { int a; System.out.println(a); }
注:Java虛擬機規範中沒有明確規定reference類型的長度,它的長度與實際使用32仍是64位虛 擬機有關,若是是64位虛擬機,還與是否開啓某些對象指針壓縮的優化有關,這裏暫且只取32位虛擬機的reference長度。
操做數棧( 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虛擬機的解釋執行引擎稱爲「基於棧的執行引擎」,其中所指的「棧」就是操做數棧。
每一個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程當中的動態鏈接(Dynamic Linking)。經過第6章的講解,咱們知道Class文件的常量池中存有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用做爲參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化爲直接引用 ,這種轉化稱爲靜態解析。另一部分將在每一次運行期間轉化爲直接引用,這部分稱爲動態鏈接。關於這兩個轉化過程的詳細信息,將在8.3節中詳細講解。
當一個方法開始執行後,只有兩種方式能夠退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱爲調用者),是否有返回值和返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式稱爲正常完成出口(Normal Method Invocation Completion ) 。
另一種退出方式是,在方法執行過程當中遇到了異常,而且這個異常沒有在方法體內獲得處理,不管是Java虛擬機內部產生的異常,仍是代碼中使用athrow字節碼指令產生的異 常 ,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會致使方法退出,這種退出方法的方式稱爲異常完成出口( Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者產生任何返回值的。
不管採用何種退出方式,在方法退出以後,都須要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能須要在棧幀中保存一些信息,用來幫助恢復它的上層方法的執行狀態。通常來講,方法正常退出時,調用者的PC計數器的值能夠做爲返回地址,棧幀中極可能會保存這個計數器值。而方法異常退出時,返回地址是要經過異常處理器表來肯定的,棧幀中通常不會保存這部分信息。
方法退出的過程實際上就等同於把當前棧幀出棧,所以退出時可能執行的操做有:恢復上層方法的局部變量表和操做數棧,把返回值(若是有的話)壓入調用者棧幀的操做數棧中 ,調整PC計數器的值以指向方法調用指令後面的一條指令等。
虛擬機規範容許具體的虛擬機實現增長一些規範裏沒有描述的信息到棧幀之中,例如與調試相關的信息,這部分信息徹底取決於具體的虛擬機實現,這裏再也不詳述。在實際開發中 ,通常會把動態鏈接、方法返回地址與其餘附加信息所有歸爲一類,稱爲棧幀信息。
方法調用並不等同於方法執行,方法調用階段惟一的任務就是肯定被調用方法的版本(即調用哪個方法),暫時還不涉及方法內部的具體運行過程。在程序運行時,進行方法調用是最廣泛、最頻繁的操做,但前面已經講過,Class文件的編譯過程當中不包含傳統編譯中的鏈接步驟,一 切方法調用在Class文件裏面存儲的都只是符號引用,而不是方法在實際運行時內存佈局中的入口地址(至關於以前說的直接引用)。這個特性給Java帶來了更強大的動態擴展能力,但也使得Java方法調用過程變得相對複雜起來,須要在類加載期間,甚至到運行期間才能肯定目標方法的直接引用。
繼續前面關於方法調用的話題,全部方法調用中的目標方法在Class文件裏面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化爲直接引用,這種解析能成立的前提是:方法在程序真正運行以前就有一個肯定的調用版本,而且這個方法的調用版本在運行期是不可改變的。換句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須肯定下來。這類方法的調用稱爲解析(Resolution)。
在Java語言中符合「編譯器可知,運行期不可變」這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,後者在外部不可被訪問,這兩種方法各個的特色決定了它們都不可能經過繼承或別的方式重寫其餘版本,所以它們都適合在類加載階段進行解析。
與之相對應的是,在Java虛擬機裏面提供了5條方法調用字節碼指令,分別以下。
只要能被invokestatic和invokespecial指令調用的方法,均可以在解析階段中肯定惟一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法4類 ,它們在類加載的時候就會把符號引用解析爲該方法的直接引用。這些方法能夠稱爲非虛方法,與之相反 ,其餘方法稱爲虛方法(除去final方法 ,後文會提到)。代碼清單8-5演示了一個最多見的解析調用的例子,此樣例中,靜態方法sayHello() 只可能屬於類型StaticResolution , 沒有任何手段能夠覆蓋或隱藏這個方法。
代碼清單8 - 5 方法靜態解析演示
/** * 方法靜態解析演示 * * @author zzm */ 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種分派組合狀況,下面咱們再看看虛擬機中的方法分派是如何進行的。
衆所周知,Java是一門面向對象的程序語言,由於Java具有面向對象的3個基本特徵:繼承、封裝和多態。本節講解的分派調用過程將會揭示多態性特徵的一些最基本的體現, 如「重載」和「重寫」在Java虛擬機之中是如何實現的,這裏的實現固然不是語法上該如何寫, 咱們關心的依然是虛擬機如何肯定正確的目標方法。
在開始講解靜態分派前 ,筆者準備了一段常常出如今面試題中的程序代碼,讀者不妨先看一遍,想一下程序的輸出結果是什麼。後面咱們的話題將圍繞這個類的方法來重載(Overload)代碼,以分析虛擬機和編譯器肯定方法版本的過程。方法靜態分派如代碼清單8-6所示。
代碼清單8 - 6 方法靜態分派演示
package org.fenixsoft.polymorphic; /** * 方法靜態分派演示 * @author zzm */ 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!
代碼清單8-6中的代碼其實是在考驗閱讀者對重載的理解程度,相信對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);
解釋了這兩個概念,再回到代碼清單8-6的樣例代碼中。main()裏面的兩次sayHello() 方法調用,在方法接收者已經肯定是對象「sr」的前提下,使用哪一個重載版本,就徹底取決於傳入參數的數量和數據類型。代碼中刻意地定義了兩個靜態類型相同但實際類型不一樣的變量,但虛擬機(準確地說是編譯器)在重載時是經過參數的靜態類型而不是實際類型做爲斷定依據的。而且靜態類型是編譯期可知的,所以 ,在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪一個重載版本,因此選擇了sayHello(Human) 做爲調用目標, 並把這個方法的符號引用寫到main() 方法裏的兩條invokevirtual指令的中 。
全部依賴靜態類型來定位方法執行版本的分派動做稱爲靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,所以肯定靜態分派的動做實際上不是由虛擬機來執行的。另外 ,編譯器雖然能肯定出方法的重載版本,但在不少狀況下這個重載版本並不 是「惟一的」 ,每每只能肯定一個「更加合適的」版本。這種模糊的結論在由0和1構成的計算機世界中算是比較「稀罕」 的事情 ,產生這種模糊結論的主要緣由是字面量不須要定義,因此字面量沒有顯式的靜態類型,它的靜態類型只能經過語言上的規則去理解和推斷。代碼清單8- 7演示了何爲「更加合適的」版本。
代碼清單8 - 7 重載方法匹配優先級
package org.fenixsoft.polymorphic; 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類型等的變長參數重載來把上面的過程從新演示一遍。但要注意的是,有一些在單個參數中能成立的自動轉型,如char轉型爲int ,在變長參數中是不成立的。
代碼清單8-7演示了編譯期間選擇靜態分派目標的過程,這個過程也是Java語言實現方法重載的本質。演示所用的這段程序屬於很極端的例子,除了用作面試題爲難求職者之外,在 實際工做中幾乎不可能有實際用途。筆者拿來作演示僅僅是用於講解重載時目標方法選擇的過程 ,大部分狀況下進行這樣極端的重載均可算是真正的「關於茴香豆的茴有幾種寫法的研究」。不管對重載的認識有多麼深入,一個合格的程序員都不該該在實際應用中寫出如此極端的重載代碼。
另外還有一點讀者可能比較容易混淆:筆者講述的解析與分派這二者之間的關係並非二選一的排他關係,它們是在不一樣層次上去篩選、肯定目標方法的過程。例如,前面說過, 靜態方法會在類加載期就進行解析,而靜態方法顯然也是能夠擁有重載版本的,選擇重載版本的過程也是經過靜態分派完成的。
瞭解了靜態分派,咱們接下來看一下動態分派的過程,它和多態性的另一個重要體現——-重寫(Override)有着很密切的關聯。咱們仍是用前面的Man和Woman一塊兒sayHello的例子來說解動態分派,請看代碼清單8-8中所示的代碼。
代碼清單8 - 8 方法動態分派演示
package org.fenixsoft.polymorphic; /** * 方法動態分派演示 * @author zzm */ 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命令輸出這段代碼的字節碼,嘗試從中尋找答案,輸出結果如代碼清單8-9所示。
代碼清單8-9 main() 方法的字節碼
0 〜15行的字節碼是準備動做,做用是創建man和woman的內存空間、調用Man和Woman 類型的實例構造器,將這兩個實例的引用存放在第一、2個局部變量表Slot之中 ,這個動做也就對應了代碼中的這兩句:
Human man=new Man(); Human woman=new Woman();
接下來的16〜21句是關鍵部分,1六、20兩句分別把剛剛建立的兩個對象的引用壓到棧頂 ,這兩個對象是將要執行的sayHello()方法的全部者,稱爲接收者( Receiver ) ; 17和21句是方法調用指令,這兩條調用指令單從字節碼角度來看,不管是指令(都是invokevirtual) 仍是參數(都是常量池中第22項的常量,註釋顯示了這個常量是Human.sayHello()的符號引用)徹底同樣的,可是這兩句指令最終執行的目標方法並不相同。緣由就須要從invokevirtual指令的多態查找過程開始提及,invokevirtual指令的運行時解析過程大體分爲如下幾個步驟:
因爲invokevirtual指令執行的第一步就是在運行期肯定接收者的實際類型,因此兩次調用中的invokevirtual指令把常量池中的類方法符號引用解析到了不一樣的直接引用上,這個過程就是Java語言中方法重寫的本質。咱們把這種在運行期根據實際類型肯定方法執行版本的分派過程稱爲動態分派。
方法的接收者與方法的參數統稱爲方法的宗量,這個定義最先應該來源於《.丨ava與模 式》一書。根據分派基於多少種宗量,能夠將分派劃分爲單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。
單分派和多分派的定義讀起來拗口,從字面上看也比較抽象,不過對照着實例看就不難理解了。代碼清單8-10中列舉了一個Father和Son—起來作出「一個艱難的決定」的例子。
代碼清單8 - 10 單分派和多分派
/** * 單分派、多分派演示 * @author zzm */ 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 choose 360"); } } public static void main(String[] args) { Father father = new Father(); Father son = new Son(); father.hardChoice(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語言的動態分派屬於單分派類型。
根據上述論證的結果,咱們能夠總結一句:今天(直至還未發佈的Java1.8 )的Java語言是一門靜態多分派、動態單分派的語言。強調「今天的Java語言」是由於這個結論未必會恆久不變 ,C#在3.0及以前的版本與Java—樣是動態單分派語言,但在C#4.0中引入了dynamic類型後 ,就能夠很方便地實現動態多分派。
按照目前Java語言的發展趨勢,它並無直接變爲動態語言的跡象,而是經過內置動態語言(如JavaScript)執行引擎的方式來知足動態性的需求。可是Java虛擬機層面上則不是如此 ,在JDK 1.7中實現的JSR-292裏面就已經開始提供對動態語言的支持了, JDK 1.7中新增的invokedymmic指令也成爲了最複雜的一條方法調用的字節碼指令,稍後筆者將專門講解這個JDK 1.7的新特性。
前面介紹的分派過程,做爲對虛擬機概念模型的解析基本上已經足夠了,它已經解決了虛擬機在分派中「會作什麼」這個問題。可是虛擬機「具體是如何作到的」,可能各類虛擬機的實現都會有些差異。
因爲動態分派是很是頻繁的動做,並且動態分派的方法版本選擇過程須要運行時在類的方法元數據中搜索合適的目標方法,所以在虛擬機的實際實現中基於性能的考慮,大部分實現都不會真正地進行如此頻繁的搜索。面對這種狀況,最經常使用的「穩定優化」手段就是爲類在方法區中創建一個虛方法表(Vritual Method Table,也稱爲vtable,與此對應的,在invokeinterface執行時也會用到接口方法表——Inteface 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 Mining ) 兩種非穩定的「激進優化」手段來得到更高的性能,關於這兩種優化技術的原理和運做過程,讀者能夠參考本書第11章中的相關內容。
Java虛擬機的字節碼指令集的數量從Sun公司的第一款Java虛擬機問世至JDK 7來臨以前的十餘年時間裏,一直沒有發生任何變化。隨着JDK 7的發佈,字節碼指令集終於迎來了第一位新成員—— invokedynamic指令。這條新增長的指令是JDK 7實現「動態類型語言」 (Dynamically Typed Language ) 支持而進行的改進之一,也是爲JDK 8能夠順利實現Lambda表達式作技術準備。在本節中,咱們將詳細講解JDK 7這項新特性出現的來龍去脈和它的深遠意義。
在介紹Java虛擬機的動態類型語言支持以前,咱們要先弄明白動態類型語言是什麼?它與Java語言、Java虛擬機有什麼關係?瞭解JDK 1.7提供動態類型語言支持的技術背景,對理解這個語言特性是頗有必要的。
什麼是動態類型語言? 動態類型語言的關鍵特徵是它的類型檢查的主體過程是在運行期而不是編譯期,知足這個特徵的語言有不少,經常使用的包括:APL、Clojure、Erlang、 Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。相對的 ,在編譯期就進行類型檢查過程的語言(如C++和Java等 )就是最經常使用的靜態類型語言。
以爲上面定義過於概念化?那咱們不妨經過兩個例子以最淺顯的方式來講明什麼是「在編譯期/運行期進行」和什麼是「類型檢查」。首先看下面這段簡單的Java代碼 ,它是否能正常編譯和運行?
public static void main (String[]args ){ int[][][] array=new int[1][0][-1]; }
這段代碼可以正常編譯,但運行的時候會報NegativeArraySizeException異常。在Java虛擬機規範中明確規定了NegativeArraySizeException是運行時異常 ,通俗一點來講,運行時異常就是隻要代碼不運行到這一行就不會有問題。與運行時異常相對應的是鏈接時異常, 例如很常見的NoClassDefFoundError便屬於鏈接時異常,即便會致使鏈接時異常的代碼放在一條沒法執行到的分支路徑上,類加載時(Java的鏈接過程不在編譯階段,而在類加載階段)也照樣會拋出異常。
不過,在C語言中,含義相同的代碼會在編譯期報錯:
int main (void ) { int i[1][0][-1] ;//GCC拒絕編譯,報'size of array is negative' return 0; }
由此看來,一門語言的哪種檢查行爲要在運行期進行,哪種檢查要在編譯期進行並無必然的因果邏輯關係,關鍵是語言規範中人爲規定的。再舉一個例子來解釋「類型檢查」 ,例以下面這一句很是簡單的代碼:
obj.println("hello world");
雖然每一個人都能看懂這行代碼要作什麼,但對於計算機來講,這一行代碼「沒頭沒尾」是沒法執行的,它須要一個具體的上下文才有討論的意義。
如今假設這行代碼是在Java語言中,而且變量obj的靜態類型爲java.io.PrintStream ,那變量obj的實際類型就必須是PrintStream的子類(實現了PrintStream接口的類)纔是合法的。不然 ,哪怕obj屬於一個確實有用println(String)方法,但與PrintStream接口沒有繼承關係,代碼依然不可能運行— 由於類型檢查不合法。
可是相同的代碼在ECMAScript (JavaScript)中狀況則不同,不管obj具體是何種類型 ,只要這種類型的定義中確實包含有println ( String ) 方 法 ,那方法調用使可成功。
這種差異產生的緣由是Java語言在編譯期間已將pnntln( String )方法完整的符號引用(本例中爲一個CONSTANT_InterfaceMethodref_info常量)生成出來,做爲方法調用指令的參數存儲到Class文件中,例以下面這段代碼:
invokevirtual#4 ;//Method java/io/PrintStream.println:(Ljava/lang/String ;)V
這個符號引用包含了此方法定義在哪一個具體類型之中、方法的名字以及參數順序、參數類型和方法返回值等信息,經過這個符號引用,虛擬機能夠翻譯出這個方法的直接引用。而在ECMAScript等動態類型語言中,變量obj自己是沒有類型的,變量obj的值才具備類型,編譯時最多隻能肯定方法名稱、參數、返回植這些信息,而不會去肯定古法所在的具體類型(即方法接收者不固定)。「變量無類型而變量值纔有類型」這個特色也是動態類型語言的一個重要特徵。
瞭解了動態和靜態類型語言的區別後,也許讀者的下一個問題就是動態、靜態類型語言二者誰更好,或者誰更加先進?這種比較不會有確切答案,由於它們都有本身的優勢,選擇哪一種語言是須要通過權衡的。靜態類型語言在編譯期肯定類型,最顯著的好處是編譯器能夠提供嚴謹的類型檢查,這樣與類型相關的問題能在編碼的時候就及時發現,利於穩定性及代碼達到更大規模。而動態類型語言在運行期肯定類型,這能夠爲開發人員提供更大的靈活性 ,某些在靜態類型語言中需用大量「臃腫」代碼來實現的功能,由動態類型語言來實現可能 會更加清晰和簡潔,清晰和簡潔一般也就意味着開發效率的提高。
回到本節的主題,來看看Java語言、虛擬機與動態類型語言之間有什麼關係。Java虛擬機毫無疑問是Java語言的運行平臺,但它的使命並不只限於此,早在1997年出版的《Java虛擬機規範》中就規劃了這樣一個願景:「在將來,咱們會對Java虛擬機進行適當的擴展,以便更好地支持其餘語言運行於Java虛擬機之上」。而目前確實已經有許多動態類型語言運行於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包出現的技術背景。
JDK1 .7實現了JSR-292,新加入的java.lang.invoke包就是JSR-292的一個重要組成部分 , 這個包的主要目的是在以前單純依靠符號引用來肯定調用的目標方法這種方式之外,提供一種新的動態肯定目標方法的機制,稱爲MethodHandle。這種表達方式也許不太好懂?那不妨把MethodHandle與C/C++中的Function Pointer,或者C#裏面的Delegate類比一下。舉個例子, 若是咱們要實現一個帶謂詞的排序函數,在C/C++中經常使用的作法是把謂詞定義爲函數,用函數指針把謂詞傳遞到排序方法,以下 :
void sort(int list[],const int size,int(*compare)(int,int))
但Java語言作不到這一點,即沒有辦法單獨地把一個函數做爲參數進行傳遞。廣泛的作法是設計一個帶有compare()方法的Comparator接口 ,以實現了這個接口的對象做爲參數, 例如Collections.sort() 就是這樣定義的:
void sort (List list,Comparator c)
不過,在擁有Method Handle以後,Java語言也能夠擁有相似於函數指針或者委託的方法別名的工具了。代碼清單8-11演示了MethodHandle的基本用途,不管obj是何種類型(臨時定義的ClassA抑或是實現PrintStream接口的實現類System.out) ,均可以正確地調用到println()方法。
代碼清單8-11 MethodHandle演示
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodType; /** * JSR 292 MethodHandle基礎用法演示 * @author zzm */ public class MethodHandleTest { static class ClassA { public void println(String s) { System.out.println(s); } } public static void main(String[] args) throws Throwable { Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA(); // 不管obj最終是哪一個實現類,下面這句都能正確調用到println方法。 getPrintlnMH(obj).invokeExact("icyfenix"); } private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable { // MethodType:表明「方法類型」,包含了方法的返回值(methodType()的第一個參數)和具體參數(methodType()第二個及之後的參數)。 MethodType mt = MethodType.methodType(void.class, String.class); // lookup()方法來自於MethodHandles.lookup,這句的做用是在指定類中查找符合給定的方法名稱、方法類型,而且符合調用權限的方法句柄。 // 由於這裏調用的是一個虛方法,按照Java語言的規則,方法第一個參數是隱式的,表明該方法的接收者,也便是this指向的對象,這個參數之前是放在參數列表中進行傳遞,如今提供了bindTo()方法來完成這件事情。 return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver); } }
實際上,方法getPrintlnMH()中模擬了invokevirtual指令的執行過程,只不過它的分派邏輯並不是固化在Class文件的字節碼上,而是經過一個具體方法來實現。而這個方法自己的返回值(MethodHandle對象),能夠視爲對最終調用方法的一個「引用」。以此爲基礎,有了MethodHandle就能夠寫出相似於下面這樣的函數聲明:
void sort (List list,MethodHandle compare)
從上面的例子能夠看出,使用MethodHandle並無什麼困難,不過看完它的用法以後, 讀者大概就會產生疑問,相同的事情,用反射不是早就能夠實現了嗎?
確實 ,僅站在Java語言的角度來看,MethodHandle的使用方法和效果與Reflection有衆多類似之處,不過,它們仍是有如下這些區別:
從本質上講,Reflection和MethodHandle機制都是在模擬方法調用,但Reflection是在模擬Java代碼層次的方法調用,而MethodHandle是在模擬字節碼層次的方法調用。在 MethodHandles.lookup中的3個方法——findStatic ( ) 、 fmdVirtual ( ) 、 fmdSpecial ( ) 正是爲了對應於invokestatic、 invokevirtual 、invokeinterface和invokespecial這幾條字節碼指令的執行權限校驗行爲,而這些底層細節在使用Reflection API時是不須要關心的。
Reflection中的java.lang.reflect.Method對象遠比MethodHandle機制中的 java.lang.invoke.MethodHandle對象所包含的信息多。前者是方法在Java一端的全面映像,包含了方法的簽名、描述符以及方法屬性表中各類屬性的Java端表示方式,還包含執行權限等的運行期信息。然後者僅僅包含與執行該方法相關的信息。用通俗的話來說,Reflection是重量級 ,而MethodHandle是輕量級。
因爲MethodHandle是對字節碼的方法指令調用的模擬,因此理論上虛擬機在這方面作的各類優化(如方法內聯),在MethodHandle上也應當能夠採用相似思路去支持(但目前實現還不完善)。而經過反射去調用方法則不行。
MethodHandle與Reflection除了上面列舉的區別外,最關鍵的一點還在於去掉前面討論施加的前提「僅站在Java語言的角度來看」 : Reflection API的設計目標是隻爲Java語言服務的, 而MethodHandle則設計成可服務於全部Java虛擬機之上的語言,其中也包括Java語言。
本節一開始就提到了JDK 1.7爲了更好地支持動態類型語言,引入了第5條方法調用的字節碼指令invokedynamic,以後一直沒有再提到它,甚至把代碼清單8-11中使用MethodHandle的示例代碼反編譯後也不會看見invokedynamic的身影,它的應用之處在哪裏呢?
在某種程度上,invokedynamic指令與MethodHandle機制的做用是同樣的,都是爲了解決原有4條「invoke*」指令方法分派規則固化在虛擬機之中的問題,把如何查找目標方法的決定權從虛擬機轉嫁到具體用戶代碼之中,讓用戶(包含其餘語言的設計者)有更高的自由度。 並且 ,它們二者的思路也是可類比的,能夠把它們想象成爲了達成同一個目的,一個採用上層Java代碼和API來實現,另外一個用字節碼和Class中其餘屬性、常量來完成。所以,若是理解了前面的MethodHandle例 子 ,那麼理解invokedynamic指令也並不困難。
每一處含有invokedynamic指令的位置都稱作「動態調用點」 ( Dynamic Call Site ) , 這條指令的第一個參數再也不是表明方法符號引用的CONSTANT_Methodref_info常量 ,而變爲JDK 1.7新加入的CONSTANT_InvokeDynamic_info常量,從這個新常量中能夠獲得3項信息:引導
方法(Bootstrap Method,此方法存放在新增的BootstrapMethods屬性中)、方法類型 ( MethodType ) 和名稱。引導方法是有固定的參數,而且返回A是java.langinvoke.CallSite對象 ,這個表明真正要執行的目標方法調用。根據CONSTANT_InvokeDynamic_info常量中提供的信息,虛擬機能夠找到而且執行引導方法,從而得到一個CallSite對象,最終調用要執行的目標方法。咱們仍是舉一個實際的例子來解釋這個過程,如代碼清單8-12所示。
代碼清單8-12 invokedynamic指令演示
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.CallSite; import java.lang.invoke.ConstantCallSite; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; public class InvokeDynamicTest { public static void main(String[] args) throws Throwable { INDY_BootstrapMethod().invokeExact("icyfenix"); } public static void testMethod(String s) { System.out.println("hello String:" + s); } public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws Throwable { return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt)); } private static MethodType MT_BootstrapMethod() { return MethodType .fromMethodDescriptorString( "(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;", null); } private static MethodHandle MH_BootstrapMethod() throws Throwable { return lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod()); } private static MethodHandle INDY_BootstrapMethod() throws Throwable { CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(lookup(), "testMethod", MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null)); return cs.dynamicInvoker(); } }
這段代碼與前面MethodHandleTest的做用基本上是同樣的,雖然筆者沒有加以註釋,可是閱讀起來應當不困難。本書前面提到過,因爲invokedynamic指令所面向的使用者並不是Java語言 ,而是其餘Java虛擬機之上的動態語言,所以僅依靠Java語言的編譯器Javac沒有辦法生成帶有invokedynamic指令的字節碼(曾經有一個java.dyn.InvokeDymmic的語法糖能夠實現, 但後來被取消了),因此要使用Java語言來演示invokedynamic指令只能用一些變通的辦法。John Rose (Da Vinci Machine Project的Leader)編寫了一個把程序的字節碼轉換爲使用 invokedynamic的簡單工具INDY來完成這件事情,咱們要使用這個工具來產生最終要的字節碼 ,所以這個示例代碼中的方法名稱不能隨意改動,更不能把幾個方法合併到一塊兒寫,由於它們是要被INDY工具讀取的。
把上面代碼編譯、再使用INDY轉換後從新生成的字節碼如代碼清單8-13所示 (結果使用javap輸出 ,因版面緣由,精簡了許多無關的內容)。
從main()方法的字節碼可見,本來的方法調用指令已經替換爲invokedynamic,它的參數爲第123項常量(第二個值爲0的參數在HotSpot中用不到,與invokeinterface指令那個值爲0的參數同樣都是佔位的)。
2 :invokedynamic#123 ,0//InvokeDynamic#0 :testMethod :(Ljava/lang/String ; )V
從常量池中可見,第123項常量顯示「#123=InvokeDynamic#0 : #121」說明它是一項 CONSTANT_InvokeDynamic_info類型常量,常量值中前面的「#0」表明引導方法取BootstrapMethods屬性表的第0項 (javap沒備列出屬性表的具體內容,不過示例中僅有一個引導方法,即BootstrapMethod() ) , 然後面的「#121」表明引用第121項類型爲 CONSTANT_NameAndType_info的常量,從這個常量中能夠獲取方法名稱和描述符,即後面輸出的「testMethod : ( Ljava/lang/String ; ) V’。
再看一下BootstrapMethod() ,這個方法Java源碼中沒有,是INDY產生的,可是它的字節碼很容易讀懂,所奏邏輯就是調用MethodHandles $Lookup的findStatic ( )方 法 ,產生testMethod ( ) 方法的MethodHandle,而後用它建立一個ConstantCallSite對象。最後,這個對象返回給invokedynamic指令實現對testMethod ( ) 方法的調用,invokedynamic指令的調用過程到此就宣告完成了。
invokedynamic指令與前面4條「invoke*」指令的最大差異就是它的分派邏輯不是由虛擬機決定的 ,而是由程序員決定。在介紹Java虛擬機動態語言支持的最後一個小結中,筆者經過一個簡單例子(如代碼清單8-14所 示 ),幫助讀者理解程序員在能夠掌控方法分派規則以後 ,能作什麼之前沒法作到的事情。
代碼清單8 - 14 方法調用問題
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之 前有沒有辦法解決這個問題。
在JDK 1.7以前,使用純粹的Java語言很難處理這個問題(直接生成字節碼就很簡單,如使用ASM等字節碼工具),緣由是在Son類的thinking() 方法中沒法獲取一個實際類型是GrandFather的對象引用,而invokevirtual指令的分派邏輯就是按照方法接收者的實際類型進行分配,這個邏輯是固化在虛擬機中的,程序員沒法改變。在JDK 1.7中,能夠使用代碼清單8- 15中的程序來解決這個問題。
代碼清單8-15 使用MethodHandle來解決相關問題
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodType; 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.invoke(this); } catch (Throwable e) { } } } public static void main(String[] args) { (new Test().new Son()).thinking(); } }
運行結果:
i'm father
許多Java虛擬機的執行引擎在執行Java代碼的時候都有解釋執行(經過解釋器執行)和編譯執行(經過即時編譯器產生本地代碼執行)兩種選擇,在本章中,咱們先來探討一下在解釋執行時,虛擬機執行引擎是如何工做的。
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執行器。
圖8-4 編譯過程
Java語言中 ,Javac編譯器完成了程序代碼通過詞法分析、語法分析到抽象語法樹,再遍歷語法樹生成線性的字節碼指令流的過程。由於這一部分動做是在Java虛擬機以外進行的, 而解釋器在虛擬機的內部,因此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寄存器裏面。
瞭解了基於棧的指令集與基於寄存器的指令集的區別後,讀者可能會有進一步的疑問, 這兩套指令集誰更好一些呢?
應該這麼說,既然兩套指令集會同時並存和發展,那確定是各有優點的,若是有一套指令集全面優於另一套的話,就不會存在選擇的問題了。
基於棧的指令集主要的優勢就是可移植,寄存器由硬件直接提供,程序直接依賴這些硬件寄存器則不可避免地要受到硬件的約束。例如 ,如今32位80x86體系的處理器中提供了8 個32位的寄存器,而ARM體系的CPU ( 在當前的手機、PDA中至關流行的一種處理器)則提供了16個32位的通用寄存器。若是使用棧架構的指令集,用戶程序不會直接使用這些寄存器 ,就能夠由虛擬機實現來自行決定把一些訪問最頻繁的數據(程序計數器、棧頂緩存等) 放到寄存器中以獲取儘可能好的性能,這樣實現起來也更加簡單一些。棧架構的指令集還有一 些其餘的優勢,如代碼相對更加緊湊(字節碼中每一個字節就對應一條指令,而多地址指令集中還須要存放參數)、編譯器實現更加簡單(不須要考慮空間分配的問題,所需空間都在棧上操做 ) 等。
棧架構指令集的主要缺點是執行速度相對來講會稍慢一些。全部主流物理機的指令集都是寄存器架構也從側面印證了這一點。
雖然棧架構指令集的代碼很是緊湊,可是完成相同功能所需的指令數量通常會比寄存器架構多,由於出棧、入棧操做自己就產生了至關多的指令數量。更重要的是 ,棧實如今內存之中 ,頻繁的棧訪問也就意味着頻繁的內存訪問,相對於處理器來講,內存始終是執行速度的瓶頸。儘管虛擬機能夠採起棧頂緩存的手段,把最經常使用的操做映射到寄存器中避免直接內存訪問 ,但這也只能是優化措施而不是解決本質問題的方法。 因爲指令數量和內存訪問的緣由 ,因此致使了棧架構指令集的執行速度會相對較慢。
注:
部分字節碼指令會帶有參數,而純粹基於棧的指令集架構中應當所有都是零地址指令,也就是都不存在顯式的參數。Java這樣實現主要是考慮了代碼的可校驗性。
這裏說的是物理機器上的寄存器,也有基於寄存器的虛擬機,如Google Android平臺的 Dalvik VM。即便是基於寄存器的虛擬機,也但願把虛擬機寄存器儘可能映射到物理寄存器上以獲取儘量高的性能。
初步的理論知識已經講解過了,本節準備了一段Java代碼 ,看看在虛擬機中實際是如何執行的。前面曾經舉過一個計算「 1+1」的例子,這樣的算術題目顯然太過簡單了,筆者準備了四則運算的例子,請看代碼清單8-16。
從Java語言的角度來看,這段代碼沒有任何解釋的必要,能夠直接使用javap命令看看它的字節碼指令,如代碼清單8-17所示。
javap提示這段代碼須要深度爲2的操做數棧和4個Slot的局部變量空間,筆者根據這些信息畫了圖8-5〜圖8-11共7張圖,用它們來描述代碼清單8-17執行過程當中的代碼、操做數棧和局部變量表的變化狀況。
上面的執行過程僅僅是一種概念模型,虛擬機最終會對執行過程作一些優化來提升性能 ,實際的運做過程不必定徹底符合概念模型的描述……更準確地說,實際狀況會和上面描述的概念模型差距很是大,這種差距產生的緣由是虛擬機中解析器和即時編譯器都會對輸入的字節碼進行優化,例如 ,在HotSpot虛擬機中,有不少以「fast_」開頭的非標準字節碼指令用於合併、替換輸入的字節碼以提高解釋執行性能,而即時編譯的優化手段更加花樣繁多。
不過 ,咱們從這段程序的執行中也能夠看出棧結構指令集的通常運行過程,整個運算過程的中間變量都以操做數棧的出棧、入棧爲信息交換途徑,符合咱們在前面分析的特色。