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

2.1 區域變數表
區域變數表(Local Variables Table)是一組變數值的存盤空間,用于存放方法引數和方法內部定義的區域變數,
由于區域變數表是建立在執行緒堆疊中的,屬于執行緒私有的資料,無論讀寫兩個連續的變數槽是否為原子操作,都不會引起資料競爭和執行緒安全問題,
當一個方法被呼叫時,Java虛擬機會使用區域變數表來完成引數值到引數變數串列的傳遞程序,即實參到形參的傳遞,
為了盡可能節省堆疊幀耗用的記憶體空間,區域變數表中的變數槽是可以重用的,方法體中定義的變數,其作用域并不一定會覆寫整個方法體,如果當前位元組碼PC計數器的值已經超出了某個變數的作用域,那這個變數對應的變數槽就可以交給其他變數來重用,
public static void main(String[] args)() { byte[] placeholder = new byte[64 * 1024 * 1024]; System.gc(); }
public static void main(String[] args)() { { byte[] placeholder = new byte[64 * 1024 * 1024]; } System.gc(); }
執行第一段代碼,沒有回收掉placeholder所占的記憶體是能說得過去,因為在執行System.gc()時,變數placeholder還處于作用域之內,
加入了花括號之后,placeholder的作用域被限制在花括號以內,從代碼邏輯上講,在執行System.gc()的時候,placeholder已經不可能再被訪問了,但執行這段程式,還是有64MB的記憶體沒有被回收掉,
public static void main(String[] args)() { { byte[] placeholder = new byte[64 * 1024 * 1024]; }
int a = 0; System.gc(); }
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計數器的值以指向方法呼叫指令后面的一條指令等,
3. 方法呼叫
方法呼叫用來確定呼叫方法的版本,不涉及方法內部的具體運行程序,一切方法呼叫在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),靜態型別是在編譯期可知的;而實際型別變化的結果在運行期才可確定,編譯器在編譯程式的時候并不知道一個物件的實際型別是什么,
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!
虛擬機(或者準確地說是編譯器)在多載時是通過引數的靜態型別而不是實際型別作為判定依據的,由于靜態型別在編譯期可知,所以在編譯階段,Javac編譯器就根據引數的靜態型別決定了會使用哪個多載版本,因此選擇了sayHello(Human)作為呼叫目標,把這個方法的符號參考寫到main()方法里的兩條invokevirtual指令的引數中,
所有依賴靜態型別來決定方法執行版本的分派動作,都稱為靜態分派,
3.2.2 動態分派
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
在運行期根據實際型別確定方法執行版本的分派程序稱為動態分派,通過invokevirtual指令實作,
3.2.3 單分派與多分派
方法的接收者與方法的引數統稱為方法的宗量,單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多于一個宗量對目標方法進行選擇,
/** * 單分派、 多分派演示 * @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
編譯階段編譯器選擇目標方法時(靜態分派),有兩點依據:一是靜態型別是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), 用虛方法表索引來代替元資料查找以提高性能,
虛方法表中存放著各個方法的實際入口地址,如果某個方法在子類中沒有被重寫,那子類的虛方法表中的地址入口和父類相同方法的地址入口是一致的,都指向父類的實作入口,如果子類中重寫了這個方法,子類虛方法表中的地址也會被替換為指向子類實作版本的入口地址,為了程式實作方便,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一樣的索引序號,這樣當型別變換時,僅需要變更查找的虛方法表,就可以從不同的虛方法表中按索引轉換出所需的入口地址,虛方法表一般在類加載的連接階段進行初始化,準備了類的變數初始值后,虛擬機會把該類的虛方法表也一同初始化完畢,
4. 動態型別語言支持
動態型別語言的關鍵特征是它的型別檢查的主體程序是在運行期而不是編譯期進行的,滿足這個特征的語言有很多,常用的包括: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虛擬機上實作的動態型別語言就不得不使用“曲線救國”的方式(如編譯時留個占位符型別,運行時動態生成位元組碼實作具體型別到占位符型別的適配)來實作,但這樣勢必會讓動態型別語言實作的復雜度增加,也會帶來額外的性能和記憶體開銷,
比如有如下代碼:
var arrays = {"abc", new ObjectX(), 123, Dog, Cat, Car..}
for(item in arrays){
item.sayHello();
}
在動態型別語言下這樣的代碼是沒有問題,但由于在運行時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++中的常用做法是把謂詞定義為函式,用函式指標來把謂詞傳遞到排序方法,像這樣:
void sort(int list[], const int size, int (*compare)(int, int))
有了MethodHandle就可以寫出類似于C/C++那樣的函式宣告了:
void sort(List list, MethodHandle compare)
如下為方法句柄實體代碼:
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); } }
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虛擬機之上的語言
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/299287.html
標籤:其他
