概述
執行引擎是Java虛擬機最核心的組成部分之一。“虛擬機”是一個相對于“物理機”的概念,這兩個機器都有代碼執行能力,其區別是物理機的執行引擎是直接建立在處理器、硬件、指令集和操作系統層面的,而虛擬機的執行引擎則是由自己實現的,因此可以自行指定指令集與執行引擎結構,并且能夠執行那些不被硬件直接支持的指令集格式。 在Java虛擬機規范中指定了虛擬機字節碼執行引擎的概念模型,這個概念模型成為各種虛擬機執行引擎的統一外觀(Facade)。在不同的虛擬機實現里面,執行引擎在執行Java代碼的時候可能會有解釋執行(通過解釋器執行)和編譯執行(通過即時編譯器產生本地代碼執行)兩種選擇,也可能兩者兼備,甚至還可能會包含幾個不同級別的編譯器執行引擎。但從外觀上看起來,所有的JVM的的執行引擎都是一致的:輸入的是字節碼文件,處理過程是字節碼解析的等效過程,輸出的是執行結果,下面主要從概念模型的角度來講解虛擬機的方法調用和字節碼執行。
運行時棧幀結構
棧幀(Stack Frame)是用于支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧(Virtual Machine Stack)的棧元素。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每個方法從調用開始至執行完成的過程,都對應著一個棧幀在虛擬機棧里面從入棧到出棧的過程。
每一個棧幀都包括了局部變量表、操作數棧、動態連接、方法返回地址和一些額外的附加信息。在編譯程序代碼的時候,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全確定了,并且寫入到方法表的Code屬性之中,因此一個棧幀需要分配多少內存,不會受到程序運行期變量數據的影響,而僅僅取決于具體的虛擬機實現。
一個線程中的方法調用的方法調用鏈可能會很長,很多方法都同時處于執行狀態。對于執行引擎來說,在活動線程中,只有位于棧頂的棧幀才是有效的,稱為當前棧幀(Current Stack Frame),與這個棧幀相關聯的方法稱為當前方法(Current Method)。執行引擎運行的所有字節碼指令都只針對當前棧幀進行操作,在概念模型上,典型的棧幀結構如圖所示:
局部變量表
局部變量表(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位虛擬機中的一致。
既然前面提到了JVM的數據類型,在此再簡單介紹一下它們。一個Slot可以存放32位以內的數據類型,Java中占用32位以內的數據類型有boolean、byte、char、short、int、float、reference和returnAddress8種類型。第7種reference類型表示對一個對象實例的引用,虛擬機規范既沒有說明它的長度,也沒有說明指出這種引用應有怎樣的結構。但一般來說,虛擬機實現至少都應當能通過這個引用做到兩點,一是從此引用中直接或間接地查找到對象在Java堆中的數據存放的起始地址索引,二是此引用中直接或間接地查找到對象所屬數據類型在方法區中的存儲的類型信息,否則無法實現Java語言規范中定義的語法約束。第8種即returnAddress類型目前已經很少見了,它是為字節碼指令jsr、jsr_w和ret服務的,指向了一條字節碼指令的地址,很古老的JVM曾經使用這幾條指令來實現異常處理,現在已經由異常表代替。
對于64位的數據類型,虛擬機會以高位對齊的方式為其分配兩個連續的Slot空間。Java語言中明確的(reference類型則可能是32位也可能是64位)64位的數據類型只有long和double兩種。值得一提的是,這里把long和double數據類型分割存儲的做法與“long和double的非原子性協定”中把一次long和double數據類型分割存儲為兩次32位讀寫的做法有些類似。不過,由于局部變量表建立在線程的堆棧上,是線程私有的數據,無論讀寫兩個連續的Slot是否為原子操作,都不會引起數據安全問題。
虛擬機通過索引定位的方式使用局部變量表,索引值的范圍是從0開始至局部變量表最大的Slot數量。如果訪問的是32位數據類型的變量,索引n就代表了使用第n個Slot,如果是64位數據類型的變量,則說明會同時使用n和n+1兩個Slot。對于兩個相鄰的共同存放在一個64位數據的兩個Slot,不允許采用任何方式單獨訪問其中的某一個,JVM規范中明確要求了如果遇到進行這種操作的字節碼序列,虛擬機應該在加載的校驗階段拋出異常。
在方法執行時,虛擬機就使用局部變量表完成參數值變量列表的傳遞過程的,如果執行的是實例方法,那局部變量表中第0位所以的Slot默認是用于傳遞方法所屬對象實例的引用,在方法中可以通過關鍵字this來訪問到這個隱藏的參數。其余參數則按照參數表順序排列,占用從1開始的局部變量Slot,參數表分配完畢后,再根據方法體內部定義的變量順序和作用域分配其余的Slot。
為了盡量節省棧空間,局部變量表中的Slot是可以重用的,方法體中定義的變量,其作用域并不一定會覆蓋整個方法體,如果,如果當前字節碼PC計數器的值已經超出了整個變量的作用域,那這個變量對應的Slot就可以交給其他變量使用。不過,這樣的設計除了節省棧幀空間之外,還會伴隨著一些額外的副作用,例如,在某些情況下,Slot的復用會直接影響到系統的垃圾收集行為。代碼中向內存中填充了64MB的數據,然后通知虛擬機進行垃圾回收,需要在虛擬機參數上加入“-verbose:gc”來觀察垃圾收集的過程。
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
//運行結果如下
[GC (System.gc()) 91791K->66264K(1256448K), 0.0275929 secs]
[Full GC (System.gc()) 66264K->66167K(1256448K), 0.0060836 secs]
沒有回收placeholder所占的內存能說得過去,因為在執行gc()的時候,變量placeholder還處于作用域之內,虛擬機自然不敢回收placeholder的內存。那我們把代碼修改一下,如下,placeholder的作用域被限制在了花括號之內,從邏輯上將,在執行gc的時候,placeholder已經不可能再被訪問了,但是執行了這段程序,會發現結果如下,還是有64MB的內存沒有被回收。
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
//運行結果如下
[GC (System.gc()) 91791K->66264K(1256448K), 0.0275929 secs]
[Full GC (System.gc()) 66264K->66167K(1256448K), 0.0060836 secs]
再改成如下代碼,運行后發現內存反而被正常回收了:
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
//運行結果如下
[GC (System.gc()) 91791K->752K(1256448K), 0.0009265 secs]
[Full GC (System.gc()) 752K->631K(1256448K), 0.0046714 secs]
之前的placeholder沒有被回收的根本原因是:局部變量表中的Slot是否存在有關于placeholder數組對象的引用。第一次修改中,代碼雖然已經離開了placeholder的作用域,但在此之后,沒有任何對局部變量表的讀寫操作,placeholder原本所占用的Slot還沒有被其他變量所復用,所以GCRoots一部分的變量表仍然保持著對它的關聯。這種關聯沒有被及時打斷,在絕大部分情況下影響都很輕微。但如果遇到一個方法,其后面的代碼有一些耗時很長的操作,而前面又定義了占用大量內存、實際上已經不再使用的變量,手動將其設置為null值(用來代替那句int a=0,把變量對應的局部變量表Slot清空)便不見得是一個絕對有意義的操作,這種操作可以作為你一種在極其特殊的情況(對象占用內存大、此方法的棧幀上時間不能被回收、方法嗲用次數達不到JIT的編譯條件)下的奇技來使用。
雖然在前面的代碼中,將對象賦值為null是有用的,但是不應當對賦null值的操作有過多的依賴,更沒有必要把它當做一個普遍的編碼規范來推廣。原因有兩點,從編碼的角度講,以恰當的變量作用域來控制變量回收時間才是最優雅的解決方法。更關鍵的是,從執行的角度講使用賦null值的操作來優化內存回收是建立在對字節碼執行引擎概念模型的理解之上的。在虛擬機使用解釋器執行時,通常與概念模型還比較接近,但經過JIT編譯器后,才是虛擬機執行代碼的主要方式,賦null值的操作在經過JIIT編譯優化后就會被消除掉,這時候將變量設置為null就沒有什么意義了。字節碼被編譯為本地代碼后,對GC Roots的枚舉也與解釋執行期間有巨大差別,以前面的例子來看,第二種方式在gc()執行時就可以正確的回收掉內存,無須寫成第三種方式。
關于局部變量表,還有一點可能會對實際開發產生影響,就是局部變量不像前面介紹的類變量那樣存在“準備階段”,我們已經知道類變量有兩次賦初始值的過程,一次在準備階段,賦予系統初始值;另一次在初始化階段,賦予程序員定義的初始值。因此,即使在初始化階段程序員沒有為類變量賦予值也沒有關系,類變量仍然具有一個確定的初始值。但局部變量就不一樣,如果一個局部變量定義了但沒有賦初始值就不能使用的,不要認為Java在任何情況下都存在整型變量默認為0,布爾值變量默認為false等這樣的情況,下面的代碼時無法被執行的,還好編譯器能在編譯期間檢查到這一點并提示,即使編譯器能通過或者手動生成字節碼的方式制造出下面的代碼,字節碼校驗的時候也會被虛擬機愛發現而導致加載失敗。
public static void main(String[] args) {
int a;
System.out.println(a);
}
操作數棧
操作數棧也常成為操作站,它是一個后入先出的棧。同局部變量表一樣,操作數棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks數據項中。操作數棧的每一個元素可以是任意的Java數據類型,包括long和double。32位數據類型所占用的棧容量為1,64位數據類型占用的棧容量為2。在方法執行的時候,操作數棧的深度都不會超過在max_stacks數據項中設定的最大值。
當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種字節碼指令往操作數棧中寫入和提取內容,也就是出棧入棧操作。例如,在做算術運算的時候是通過操作數棧來進行的,又或者在調用其他方法的時候是通過操作數棧來進行參數傳遞的。
操作數棧中元素的數據類型必須與字節碼指令的序列嚴格匹配,在編譯程序代碼的時候,編譯器要嚴格保證這一點,在類校驗階段的數據流分析中還要再次檢驗這一點。再以iadd指令為例,這個指令用于整型數加法,它在執行時,最接近棧頂的兩個元素的數據類型必須為int類型,不能出現一個long和一個float使用iadd命令相加的情況。
另外,在概念模型中,兩個棧幀作為虛擬機棧的元素,是完全相互獨立的。但是在大多數虛擬機的實現里偶讀會做一些優化處理,令兩個棧幀出現一部分重疊。讓下面的棧幀的部分操作數棧與上面棧幀的部分局部變量表重疊在一起。這樣在進行方法調用時就可以共用一部分數據,無須進行額外的參數復制傳遞。
JVM的解釋執行引擎稱為“基于棧的執行引擎”,其中所指的“棧”就是操作數棧。
動態鏈接
每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用的是為了支持方法調用過程中的動態鏈接。我們知道Class文件的常量池中存在大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用作為參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化為直接引用,這種轉化稱為靜態解析。另外一部分將在每一次運行期間轉化為直接引用,這部分為動態鏈接。關于這兩個轉化過程的詳細信息,將在下面進行闡述。
方法返回地址
當一個方法開始執行后,只有兩種方式可以退出這個方法:
- 第一種方式是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱為調用者),是否有返回值和返回值的類型將根據遇到何方法返回指令來決定,這種退出方法的方式稱為正常完成出口。
- 另一種退出方式是,在方法執行過程中遇到異常,并且這個異常沒有在方法體中得到處理,無論是JVM內部產生的異常,還是代碼中使用athrow字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者產生任何返回值的。
無論采用哪種方式退出,在方法退出之后,都需要回到方法被調用的位置,程序才能繼續執行,方法返回時可能需要在棧幀保存一些信息,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,調用者的PC計數器的值可以作為返回地址,棧幀中很可能會保存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會保存這部分信息。
方法退出的過程實際上就等于把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值(如果有的話)壓入調用者操作數棧中,調整PC計數器的值以指向方法調用指令后面的一條指令等。
方法調用
方法調用不等同于方法執行,方法調用階段唯一的任務就是確定被調用方法的版本,即調用哪一個方法,暫時還不涉及方法內部的具體運行過程。在程序運行時,運行方法調用是最普遍、最頻繁的操作,但前面已經講過,Class文件的編譯過程不包含傳統編譯中的連續步驟,一切方法調用在Class文件里面存儲的都是符號引用,而不是方法在實際運行時內存布局中的入口地址(相當于之前說的直接引用)。這個特性給Java帶來了更強大的動態擴展能力,但也使得Java方法調用過程變的相對復雜起來,需要在類加載期間,甚至到運行期間才能確定目標方法的直接引用。
解析
所有方法調用的目標方法在Class文件里面都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化為直接引用,這種解析能成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,并且這個方法的調用版本在運行期是不可改變的。換句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來。這類方法的調用稱為解析(Resolution)。
在Java語言中符合“編譯期可知,運行期不變”這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,后者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都不適合在類加載階段進行解析。
與之相應的是,在Java虛擬機里面提供了5條方法調用字節碼指令,如下:
- invokestatic:調用靜態方法
- invokespecial:調用實例構造器init方法、私有方法和父類方法
- involevirtual:調用所有的虛方法
- invokeinterface:調用接口方法,會在運行時再確定一個實現此接口的對象
- invokedynamic:在運行時動態解析出調用點限定符所引用的方法,然后再執行該方法,在此之前的4條調用指令,分派邏輯是固化在JVM內部的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。
只要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段中確定唯一的版本調用,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法4類,它們在類加載的時候就會把符號引用解析為該方法的直接引用。這些方法可以稱為非虛方法,與之相反,其他方法稱為虛方法(除去final方法)。
Java中的非虛方法除了使用了invokestatic、invokespecial調用的方法之外還有一種,就是被final修飾的方法,雖然final方法是使用invokevirtual指令來調用,但是由于它無法被覆蓋,沒有其他版本,所以也無須對方法接收者進行多態選擇,又或者說多態選擇的結果肯定是唯一的。在Java語言規范中明確說明了final方法是一種非虛方法。
解析調用一定是個靜態的過程,在編譯期間就完全確定,在類裝在的解析階段就會把涉及的符號全部轉變為可確定的直接引用,不會延遲到運行期再去完成。而分派(Dispatch)調用則可能是靜態的也可能是動態的,根據分派依據的宗量數可分為單分派和多分派。這兩類分派方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派4種分派組合情況,下面我們再看看虛擬機中的方法分派是如何進行的。
分派
眾所周知,Java是一門面向對象的語言,因為Java具備面向對象的3個基本特征:繼承、封裝、多態。這里講的分派調用過程將會揭示多態性特征的一些最基本的提現,如“重載”和“重寫”在JVM中是如何實現的,這里的實現當然不是語法上應該如何去寫,我們關心的依然是虛擬機如何確定正確的目標方法。
靜態分派
閱讀下面代碼:
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, man");
}
public void sayHello(Woman guy) {
System.out.println("hello, woman");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch staticDispatch = new StaticDispatch();
staticDispatch.sayHello(man);
staticDispatch.sayHello(woman);
}
}
這是在考察閱讀者對重載的理解程度,Human man = new Man();中,我們吧Human稱為變量的靜態類型,或者叫做外觀類型,后面的Man叫做變量的時機類型,靜態類型和實際類型在程序中都可可能發生一些變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變,并且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期才可確定,編譯器在編譯程序的時候并不知道一個對象的實際類型是什么。例如:
//實際類型變化
Human man = new Man();
man = new Woman();
//靜態類型
staticDispatch.sayHello((Man) man);
staticDispatch.sayHello((Woman) man);
main里執行了兩次sayHello()方法調用,在方法接收者已經確定是對象staticDispatch的前提下,使用哪個重載版本,就完全取決于傳入參數的數量和數據類型。代碼中刻意的定義了兩個靜態類型相同但實際類型不同的變量,但虛擬機(準確的說是編譯器)在重載的時候是通過參數的靜態類型而不是實際類型作為判定依據的。并且靜態類型是編譯期可知的。因此,在編譯階段,Javac編譯器會根據參數靜態類型決定使用哪個重載版本,所以選擇了sayHello(Human)作為調用目標,并把這個方法符號引用寫到main方法里的兩條invokevirtual指令的參數中。
所有依賴靜態類型來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型方法是方法重載。靜態分派發生在編譯階段,因此確定靜態分析的動作實際上不是由虛擬機來執行的。另外,編譯器雖然能確定出方案的重載版本,但在很多情況下這個重載版本并不是唯一的,往往能確定出方法的重載版本。產生這種模糊結論的原因是字面量不需要定義,所以字面量沒有顯示的靜態類型,它的靜態類型只能通過語言上的規則去理解和推斷。
動態分派
動態分派和多態性的另一個重要體現,重寫有著密切的關聯。
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
這個運行結果相信不會出乎任何人的意料,我們還是要知道虛擬機如何調用到相應方法的。這顯然不可能再根據靜態類型來決定,因為靜態類型同樣都是Human的兩個變量man和woman在調用sayHello()方法時執行了不同的行為,并且變量man在兩次調用中執行了不同的方法。導致這個現象的原因很明顯,是這兩個變量的時機類型不同,JVM是如何根據類型來分派執行版本的呢?我們使用javap命令輸出這段代碼的字節碼,從中尋找答案:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V
24: new #4 // class DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V
36: return
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句是方法調用指令,這兩條調用指令單從字節碼角度來看,無論是指令(都是invokevirutal)還是參數(都是常量池中第22項的常量,注釋顯示了這個常量是Human.sayhello的符號引用)完全一樣,但是這兩句指令最終執行的目標方法并不相同。原因就需要從invokevirtual指令的多態查找過程開始說起,invokevirtual指令的運行時解析過程大致可以分為以下幾個步驟:
- 找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C
- 如果在類型C中找到與常量中的描述符合簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.IllegalAccessError異常
- 否則,按照繼承關系從下往上依次對C的各個父類進行第2步的搜索和驗證過程
- 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
由于invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質,我們把這種運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。
單分派與多分派
方法的接收者與方法的參數統稱為方法的宗量,這個定義最早應該來源于《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 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()方法,這兩次調用的選擇結果在程序輸出中已經顯示的很清楚了。
我們來看看編譯階段編譯器的選擇過程,也就是靜態分派的過程。這時選擇目標方法的依據有兩點:一是靜態類型是Father還是Son,二是方法參數是QQ還是360.這次選擇結果的最終產物是產生了兩條invokevirtual指令,兩條指令的參數分別為常量池中指向Father.hardChoice(360)以及Father.hardChoice(QQ)方法的符號引用。因此是根據兩個宗量進行選擇,所以Java語言的靜態分析屬于多分派類型。
再看看運行階段虛擬機的權責,也就是動態分派的過程。在執行son.hardChoice(new QQ());這段代碼時,更準確的說,是在執行這句代碼所對應的invokevirtual指令時,由于編譯期已經決定目標方法的簽名必須為hardChoice(QQ),虛擬機此時不關心傳遞過來的參數到底是什么QQ,因為這時參數的靜態類型、實際類型都對方法的選擇不會構成任何影響,唯一可以影響虛擬機選擇的因素只有此方法的接受者的時機類型是Father還是Son。因為只有一個宗量作為選擇依據,所以Java語言的動態分派屬于單分派。
根據上面的結論,我們可以總結一句話:現在的Java語言是一門靜態多分派、動態單分派的語言。這個結論并不是恒久不變的,C#在3.0及之前版本與Java一樣是動態單分派語言,但是在C#4.0中引入了dynamic類型后,就可以很方便的實現動態多分派。
按照目前Java語言的發展趨勢,它并沒有直接變為動態語言的跡象,而是通過內置動態語言(如JavaScript)執行引擎的方式來滿足動態性的需求。但是JVM層面上并不是如此的,在JDK1.7中已經開始提供對動態語言的支持了,JDK1.7中新增的invokedynamic指令也成為了最復雜的一條方法調用的字節碼指令,稍后筆者將專門講解這個JDK1.7的新特性。
虛擬機動態分派的實現
前面介紹的分派過程,作為對虛擬機概念模型的解析基本上已經足夠了,它已經解決了虛擬機在分派中“會做什么”的這個問題,但是虛擬機“具體如何做到”,可能各種虛擬機實現會有差別。
由于動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要運行時在類方法元數據中搜索合適的目標方法,因此在虛擬機的時機實現中基于性能的考慮,大部分實現都不會真正的進行如此頻繁的搜索。而面對這種情況,最常用的穩定優化手段就是為類在方法區中建立一個虛方法表(Virtual Method Table,也叫itable,于此對應的,在invokeinterface執行時也會用到接口方法表,Interface Method Table,簡稱itable),使用虛方法表索引來代替元數據查找以提高性能。
虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表里面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會被替換為指向子類實現版本入口的地址。
Son重寫了來自Father的全部方法,因此Son的方法表沒有指向Father類型數據的箭頭。但是Son和Father都沒有重寫來自Object的方法,所以它們的方法表中所有從Object繼承來的方法都指向了Object的數據類型。
為了程序實現上的方便,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一樣的索引序號, 這樣當類型變換時,僅需求變更查找的方法表,就可以從不同的虛方法表中按索引轉換出所需的入口地址。
動態語言支持
JVM的字節碼指令集的數量從Sun公司的第一款JVM問世至JDK7來臨之前的十余年時間里,一直沒有發生任何變化。隨著JDK7的發布,字節碼指令集終于添加了一個新成員,invokedynamic指令。這條心增加的指令是JDK7實現“動態類型語言”支持而進行的改進之一,也是為JDK8可以順利實現Lambda表達式做技術準備。
動態類型語言
動態類型語言的關鍵特征是它的類型檢查的主題過程是在運行期而不是編譯期,滿足這個特性的語言有很多,包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tel等。相對于,在編譯期就進行類型檢查過程的語言(比如C++和Java等)就是最常用的靜態類型語言。
public static void main(String[] args) {
int[][][] a = new int[1][0][-1];
}
這段代碼時可以正常編譯的,但運行的時候會報NegativeArraySizeException異常。在JVM規范中明確規定了NegativeArraySizeException是一個運行時異常,通俗一點講,運行時異常就是只要代碼不運行到這一行就不會有問題。與運行時異常對應的就是連接時異常,即使會導致連接時異常的代碼放在一條無法執行到的分支路徑上,類加載時(Java的連接過程不在編譯階段,而在類加載階段)也照樣會跑出異常。
不過C語言會在編譯期報錯:
int main(void) {
int i[1][0][-1];//GCC拒絕編譯,報“size of array is negative”
return 0;
}
動態和靜態類型語言誰更先進呢?這個不會有確切的答案。
- 靜態類型語言在編譯期確定類型,最顯著的好處是編譯期可以提供嚴謹的類型檢查,這樣與類型相關的問題能在編碼的時候就及時被發現,利于穩定性以及代碼達到更大的規模。
- 動態類型語言在運行期確定類型,這可以為開發者提供更大的靈活性,某些靜態類型語言中需要大量臃腫代碼來實現的功能,由動態類型語言來實現可能更加清晰和簡潔,也就意味著開發效率的提升。
JDK1.7與動態類型
JDK1.7以前的字節碼指令集中,4條方法調用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一個參數都是被調用的方法的符號引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),方法的符號引用在編譯時產生,而動態類型語言只有在運行期才能確定接受者類型。這樣,在JVM上實現的動態類型語言就不得不使用其他方式,比如在編譯時留個占位符類型,運行時動態生成字節碼實現具體類型到占位符類型的適配來實現,這樣會讓動態類型語言實現的復雜度增加,也可能帶來額外的性能開銷。盡管可以利用一些方法讓這些開銷變小,但這種底層問題終究是應當在虛擬機層次上去解決才最合適,因此在JVM層面上提供動態類型的直接支持就稱為了JVM平臺的發展趨勢之一,這就是JDK1.7中invokedynamic指令以及java.lang.invoke包出現的技術背景。
java.lang.invoke包
JDK1.7實現了JSK-292,新加入的java.lang.invoke包就是JSR-292的一個重要組成部分,這個包的目的是在之前單純依靠符號引用來確定調用的目標方法這種方式以外,提供一種新的動態確定目標方法的機制,稱為MethodHandle。擁有MethodHandle之后,Java語言也可以擁有類似函數指針或者委托的方法別名的工具了。
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("sss");
}
private static MethodHandle getPrintlnMH(Object receiver) throws Throwable {
/*MethodType:代表方法類型,包含了方法的返回值methodType()的第一個參數和具體參數methodType()第二個及以后的參數。*/
MethodType mt = MethodType.methodType(void.class, String.class);
/*lookup()方法的作用是在指定類中查找符合給定的方法名稱、方法類型,并且符合調用權限的方法句柄
因為這里調用的是一個虛方法,按照Java語言的規則,方法第一個參數是隱式的,代表該方法的接收者,也即是this指向的對象,這個參數之前是放在參數列表中傳遞的,而現在提供了bindTo()方法來完成這件事情*/
return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}
}
實際上,getPrintlnMH()方法模擬了invokevirtual指令的執行過程,只不過它的分派邏輯并非固化在Class文件的字節碼上,而是通過一個具體方法來實現。而這個方法本身的返回值(MethodHandle對象),可以視作最終調用這個方法的一個“引用”。以此為基礎,有了MethodHandle就可以寫出類似下面的函數聲明:
void sort(List list, MethodHandle methodHandle)
僅僅站在Java的角度來看,MethodHandle的使用方法和效果與Reflection有眾多相似之處,但是,它們還有以下這些區別。
- 從本質上講,Reflection和MethodHandle機制都是在模擬方法調用,但Reflection是在模擬Java代碼層次的方法調用,而MethodHandle是在模擬字節碼層次的方法調用,在MethodHandles.lookup中的3個方法——findStatic()、findVirtual()、findSpecial()正是為了對應于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的設計目標是只為Java語言服務的,而MethodHandle則設計成可以服務于所有Java虛擬機之上的語言,其中也包括Java語言。
invokedynamic指令
從某種程度上講,invokedynamic指令與MethodHandle機制的作用是一樣的,都是為了解決原有4條“invoke*”指令方法分派規則固化在虛擬機之中的問題,把如何查找目標方法的決定權從虛擬機轉嫁到具體用戶代碼之中,讓用戶有更高的自由度。而且兩者的思路也是可類比的,可以把它們想象成為了達到同一個目的,一個采用上層Java代碼和API實現,另一個采用字節碼和Class中其他屬性、常量來完成。因此,如果理解了MethodHandle,那么理解invokedynamic指令也并不難。
每一處含有invokedynamic指令的位置都稱作“動態調用點”(Dynamic CallSite),這條指令的第一個參數不再是代表方法符號應用的CONSTANT_Methodref_info常量,而是變為JDK1.7新加入的CONSTANT_InvokeDynamic_info常量,從這個新常量中可以得到3項信息:引導方法、方法類型和名稱。引導方法是由固定的參數,并且返回值是java.long.invoke.CallSite對象,這個代表正要執行的目標方法調用。根據CONSTANT_InvokeDynamic_info常量中提供的信息,虛擬機可以找到并且執行引導方法,從而獲得一個CallSite對象,最終調用要執行的目標方法。
import java.lang.invoke.*;
public class InvokeDynamicTest {
public static void main(String[] args) throws Throwable {
INDY_BootstrapMethod().invokeExact("icfenix");
}
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 MethodHandles.lookup().findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
}
private static MethodHandle INDY_BootstrapMethod() throws Throwable {
CallSite cs = (CallSite) MH_BootstrapMethod().invokeWithArguments(MethodHandles.lookup(), "testMethod",
MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
return cs.dynamicInvoker();
}
}
這段代碼與前面的MethodHandleTest的作用基本上是一樣的,由于invokedynamic指令所面向的使用者并非是Java語言,而是其他Java虛擬機之上的動態語言,因此僅僅依靠Java語言的編譯器Javac沒有辦法生成invokedynamic指令的字節碼,曾經有一個java.dyn.InvokeDynamic的語法糖可以實現,后來被取消了,所以要使用Java語言來演示invokedynamic指令只能用一些變通的辦法。
掌握方法分派規則
invokedynamic指令與前面的“invoke*”指令的最大差別就是它的分派邏輯不是由虛擬機決定的,而是由程序員決定的。
在Java程序中,可以通過super關鍵字很方便的調用到父類的方法,但是如果要訪問祖類的方法呢?
在JDK1.7之前,使用純粹的Java語言很難處理這個問題,直接生成字節碼就很簡單,如使用ASM等字節碼工具,原因在于子類方法無法獲取一個實際類型是祖類的對象引用,而invokevirtual指令的分派邏輯就是按照方法接收者的時機類型進行分派,這個邏輯是固化在虛擬機中的,程序員無法改變。可以使用如下邏輯解決這個問題。
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
public class GrandTest {
static class GrandFather {
void thinking() {
System.out.println("GrandFather");
}
}
static class Father extends GrandFather {
void thinking() {
System.out.println("Father");
}
}
static class Son extends Father {
void thinking() {
System.out.println("Son");
try {
MethodType mt = MethodType.methodType(void.class);
Field IMPL_LOOKUP = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
IMPL_LOOKUP.setAccessible(true);
MethodHandles.Lookup lkp = (MethodHandles.Lookup) IMPL_LOOKUP.get(null);
MethodHandle h1 = lkp.findSpecial(GrandFather.class, "thinking", mt, GrandFather.class);
h1.invoke(this);
} catch (Throwable e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
new GrandTest.Son().thinking();
}
}