“虛拟機”是一個相對于“實體機”的概念,實體機的執行引擎是直接建立在處理器、緩存、指令集和作業系統層面上的,而虛拟機的執行引擎則是由軟體自行實作的,可以不受實體條件制約地定制指令集與執行引擎的結構體系,能夠執行那些不被硬體直接支援的指令集格式。
在不同的虛拟機實作中,執行引擎在執行位元組碼的時候,通常會有解釋執行(通過解釋器執行)和編譯執行(通過即時編譯器産生本地代碼執行)兩種選擇,也可能兩者兼備,還可能會有同時包含幾個不同級别的即時編譯器一起工作的執行引擎。
Java虛拟機以方法作為最基本的執行單元,“棧幀”(stack frame)則是用于支援虛拟機進行方法調用和方法執行背後的資料結構。每一個方法從調用開始到執行結束的過程,都對應着一個棧幀在虛拟機棧裡面從入棧到出棧的過程。
一個線程中的方法調用鍊可能會很長,以Java程式的角度來看,同一時刻、同一條線程裡面,在調用堆棧的所有方法都同時處于執行狀态。而對于執行引擎來講,在活動線程中,隻有位于棧頂的方法才是在運作的, 隻有位于棧頂的棧幀才是生效的, 其被稱為“目前棧幀”(Current Stack Frame) ,與這個棧幀所關聯的方法被稱為“目前方法”(Current Method) 。執行引擎所運作的所有位元組碼指令都隻針對目前棧幀進行操作,在概念模型上,典型的棧幀結構如圖8-1所示。

2.1 局部變量表
局部變量表(Local Variables Table)是一組變量值的存儲空間,用于存放方法參數和方法内部定義的局部變量。
由于局部變量表是建立線上程堆棧中的,屬于線程私有的資料,無論讀寫兩個連續的變量槽是否為原子操作,都不會引起資料競争和線程安全問題。
當一個方法被調用時,Java虛拟機會使用局部變量表來完成參數值到參數變量清單的傳遞過程,即實參到形參的傳遞。
為了盡可能節省棧幀耗用的記憶體空間,局部變量表中的變量槽是可以重用的,方法體中定義的變量,其作用域并不一定會覆寫整個方法體,如果目前位元組碼PC計數器的值已經超出了某個變量的作用域,那這個變量對應的變量槽就可以交給其他變量來重用。
執行第一段代碼,沒有回收掉placeholder所占的記憶體是能說得過去,因為在執行System.gc()時,變量placeholder還處于作用域之内。
加入了花括号之後,placeholder的作用域被限制在花括号以内,從代碼邏輯上講,在執行System.gc()的時候,placeholder已經不可能再被通路了,但執行這段程式,還是有64MB的記憶體沒有被回收掉。
placeholder能否被回收的根本原因就是:局部變量表中的變量槽是否還存有關于placeholder數組對象的引用。第一次修改中,代碼雖然已經離開了placeholder的作用域,但在此之後,再沒有發生過任何對局部變量表的讀寫操作,placeholder原本所占用的變量槽還沒有被其他變量所複用,是以作為GC Roots一部分的局部變量表仍然保持着對它的關聯。
2.2 操作數棧
操作數棧(Operand Stack)是一個後入先出(Last In First Out,LIFO)棧。
當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種位元組碼指令往操作數棧中寫入和提取内容,也就是出棧和入棧操作。例如,整數加法的位元組碼指令iadd,在運作的時候要求操作數棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會把這兩個int值出棧并相加,然後将相加的結果重新入棧。
另外在概念模型中,兩個不同棧幀作為不同方法的虛拟機棧的元素,是完全互相獨立的。但是在大多虛拟機的實作裡都會進行一些優化處理,令兩個棧幀出現一部分重疊。讓下面棧幀的部分操作數棧與上面棧幀的部分局部變量表重疊在一起,這樣做不僅節約了一些空間,更重要的是在進行方法調用時就可以直接共用一部分資料,無須進行額外的參數複制傳遞了,重疊的過程如圖8-2所示。
2.3 動态連接配接
Class檔案的常量池中存有大量的符号引用,位元組碼中的方法調用指令就以常量池裡指向方法的符号引用作為參數。這些符号引用一部分會在類加載階段或者第一次使用的時候就被轉化為直接引用,這種轉化被稱為靜态解析。另外一部分将在每一次運作期間都轉化為直接引用,這部分就稱為動态連接配接。關于轉化的具體過程,将在第三節介紹。
2.4 方法傳回位址
當一個方法開始執行後,隻有兩種方式退出這個方法。
第一種方式是執行引擎遇到任意一個方法傳回的位元組碼指令,這種退出方法的方式稱為“正常調用完成”(Normal Method Invocation Completion) 。
另外一種退出方式是在方法執行的過程中遇到了異常,并且這個異常沒有在方法體内得到妥善處理。無論是Java虛拟機内部産生的異常,還是代碼中使用athrow位元組碼指令産生的異常,隻要在本方法的異常表中沒有搜尋到比對的異常處理器,就會導緻方法退出,這種退出方法的方式稱為“異常調用完成(Abrupt Method Invocation Completion) ”。 一個方法使用異常完成出口的方式退出,是不會給它的上層調用者提供任何傳回值的。
方法退出的過程實際上等同于把目前棧幀出棧,需要恢複上層方法的局部變量表和操作數棧,把傳回值(如果有的話) 壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令後面的一條指令等。
方法調用用來确定調用方法的版本,不涉及方法内部的具體運作過程。一切方法調用在Class檔案裡面存儲的都隻是符号引用,而不是方法在實際運作時記憶體布局中的入口位址(也就是之前說的直接引用) 。這個特性給Java帶來了更強大的動态擴充能力,但也使得Java方法調用過程變得相對複雜。
3.1 解析
在類加載的解析階段,會将其中一部分符号引用轉化為直接引用,但解析的前提條件是調用目标在程式代碼寫好、編譯器進行編譯那一刻就已經确定下來。
在Java語言中符合“編譯期可知,運作期不可變”這個要求的方法,主要有靜态方法和私有方法兩大類,前者與類型直接關聯,後者在外部不可被通路,這兩種方法各自的特點決定了它們都不可能通過繼承或别的方式重寫出其他版本。
在Java虛拟機支援以下5條方法調用位元組碼指令:
invokestatic。 用于調用靜态方法。
invokespecial。 用于調用執行個體構造器<init>()方法、私有方法和父類中的方法。
invokevirtual。 用于調用所有的虛方法。
invokeinterface。 用于調用接口方法,會在運作時再确定一個實作該接口的對象。
invokedynamic。 先在運作時動态解析出調用點限定符所引用的方法,然後再執行該方法。前面4條調用指令,分派邏輯都固化在Java虛拟機内部,而invokedynamic指令的分派邏輯是由使用者設定的引導方法來決定的。
隻要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段中确定唯一的調用版本。
在類加載的時候就可以把符号引用解析為該方法的直接引用。這些方法統稱為“非虛方法”(Non-Virtual Method),與之相反,其他方法就被稱為“虛方法”(Virtual Method) 。
3.2 分派
3.2.1 靜态分派
下面代碼中的“Human”稱為變量的“靜态類型”(Static Type),或者叫“外觀類型”(Apparent Type),後面的“Man”則被稱為變量的“實際類型”(Actual Type) 或者叫“運作時類型”(Runtime Type)。靜态類型是在編譯期可知的;而實際類型變化的結果在運作期才可确定,編譯器在編譯程式的時候并不知道一個對象的實際類型是什麼。
執行結果
虛拟機(或者準确地說是編譯器)在重載時是通過參數的靜态類型而不是實際類型作為判定依據的。由于靜态類型在編譯期可知,是以在編譯階段,Javac編譯器就根據參數的靜态類型決定了會使用哪個重載版本,是以選擇了sayHello(Human)作為調用目标,把這個方法的符号引用寫到main()方法裡的兩條invokevirtual指令的參數中。
所有依賴靜态類型來決定方法執行版本的分派動作,都稱為靜态分派。
3.2.2 動态分派
運作結果
在運作期根據實際類型确定方法執行版本的分派過程稱為動态分派。通過invokevirtual指令實作。
3.2.3 單分派與多分派
方法的接收者與方法的參數統稱為方法的宗量,單分派是根據一個宗量對目标方法進行選擇,多分派則是根據多于一個宗量對目标方法進行選擇。
編譯階段編譯器選擇目标方法時(靜态分派),有兩點依據:一是靜态類型是Father還是Son,二是方法參數是QQ還是360。最終會産生兩條invokevirtual指令,兩條指令的參數分别為常量池中指向Father::hardChoice(360)及Father::hardChoice(QQ)方法的符号引用。
動态分派時,在執行“son.hardChoice(new QQ())”這行代碼時,由于編譯期已經決定目标方法的簽名必須為hardChoice(QQ),虛拟機此時不會關心傳遞過來的參數“QQ”到底是“騰訊QQ”還是“奇瑞QQ”,因為這時候參數的靜态類型、實際類型都對方法的選擇不會構成任何影響,唯一可以影響虛拟機選擇的因素隻有該方法的接受者的實際類型是Father還是Son。
故而,Java語言是一門靜态多分派、動态單分派的語言。
3.2.4 虛拟機動态分派實作
動态分派是執行非常頻繁的動作,而且動态分派的方法版本選擇過程需要運作時在接收者類型的方法中繼資料中搜尋合适的目标方法, 一種基礎而且常見的優化手段是為類型在方法區中建立一個虛方法表(Virtual Method Table,也稱為vtable,與此對應的,在invokeinterface執行時也會用到接口方法表——Interface Method Table,簡稱itable), 用虛方法表索引來代替中繼資料查找以提高性能。
虛方法表中存放着各個方法的實際入口位址。如果某個方法在子類中沒有被重寫,那子類的虛方法表中的位址入口和父類相同方法的位址入口是一緻的,都指向父類的實作入口。如果子類中重寫了這個方法,子類虛方法表中的位址也會被替換為指向子類實作版本的入口位址。為了程式實作友善,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一樣的索引序号,這樣當類型變換時,僅需要變更查找的虛方法表,就可以從不同的虛方法表中按索引轉換出所需的入口位址。虛方法表一般在類加載的連接配接階段進行初始化,準備了類的變量初始值後,虛拟機會把該類的虛方法表也一同初始化完畢。
動态類型語言的關鍵特征是它的類型檢查的主體過程是在運作期而不是編譯期進行的,滿足這個特征的語言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、 Lisp、 Lua、 PHP、 Prolog、 Python、 Ruby、 Smalltalk、 Tcl, 等等。 那相對地, 在編譯期就進行類型檢查過程的語言, 譬如C++和Java等就是最常用的靜态類型語言。
4.1 Java與動态類型
JDK 7以前的位元組碼指令集中,4條方法調用指令(invokevirtual、 invokespecial、 invokestatic、invokeinterface) 的第一個參數都是被調用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),方法的符号引用在編譯時産生,而動态類型語言隻有在運作期才能确定方法的接收者。這樣,在Java虛拟機上實作的動态類型語言就不得不使用“曲線救國”的方式(如編譯時留個占位符類型,運作時動态生成位元組碼實作具體類型到占位符類型的适配)來實作,但這樣勢必會讓動态類型語言實作的複雜度增加,也會帶來額外的性能和記憶體開銷。
比如有如下代碼:
在動态類型語言下這樣的代碼是沒有問題,但由于在運作時arrays中的元素可以是任意類型,即使它們的類型中都有sayHello()方法,也肯定無法在編譯優化的時候就确定具體sayHello()的代碼在哪裡,編譯器隻能不停編譯它所遇見的每一個sayHello()方法,并緩存起來供執行時選擇、調用和内聯,如果arrays數組中不同類型的對象很多,就勢必會對内聯緩存産生很大的壓力,緩存的大小總是有限的,類型資訊的不确定性導緻了緩存内容不斷被失效和更新,先前優化過的方法也可能被不斷替換而無法重複使用。是以這種動态類型方法調用的底層問題終歸是應當在Java虛拟機層次上去解決才最合适。是以,在Java虛拟機層面上提供動态類型的直接支援就成為Java平台發展必須解決的問題,這也是invokedynamic指令以及java.lang.invoke包出現的技術背景。
4.2 java.lang.invoke 包
JDK 7時新加入的java.lang.invoke包主要目的是在之前單純依靠符号引用來确定調用的目标方法這條路之外,提供一種新的動态确定目标方法的機制,稱為“方法句柄”(Method Handle) 。
舉個例子,如果我們要實作一個帶謂詞(謂詞就是由外部傳入的排序時比較大小的動作)的排序函數,在C/C++中的常用做法是把謂詞定義為函數,用函數指針來把謂詞傳遞到排序方法,像這樣:
有了MethodHandle就可以寫出類似于C/C++那樣的函數聲明了:
如下為方法句柄執行個體代碼:
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是輕量級。
Reflection API的設計目标是隻為Java語言服務的, 而MethodHandle則設計為可服務于所有Java虛拟機之上的語言