虛擬機字節碼執行引擎

1.前言

  以前對虛擬機的加載機制進行了描述:這裏,本章主要對虛擬機的運行機制進行記錄說明。html

  虛擬機區別於物理機就在於運行方面,物理機的執行引擎是直接創建在處理器、硬件、指令集和操做系統層次上的,虛擬機的執行引擎是本身實現的,能夠自行定製指令集。因此JVM能夠進行跨平臺。java

2.棧幀的結構

  在最先的文章中介紹了JVM的內存佈局:這裏。文章中提到了棧和程序計數器是線程私有的。棧幀是虛擬機進行方法調用和方法執行的數據結構,是棧的元素。每一個方法都對應一個棧幀,方法的執行過程就是在棧中的棧幀入棧到出棧的過程。python

  上圖能夠看出一個線程一個棧,一個棧中包含若干棧幀,棧幀對應一個具體的方法,主要包含四塊內容:局部變量表、操做棧、動態連接、返回地址。最上面的棧纔是當前要執行的有效棧,稱爲當前棧幀,關聯的方法就是當前方法。緩存

2.1 局部變量表

  局部變量表是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。在編譯時,就在方法的Code屬性中的max_locals數據項定義了該方法須要分配的局部變量表的最大容量。安全

  這個表以變量槽爲單位——slot。虛擬機規範中沒有規定一個slot的具體大小,可是說每一個slot都應該存放一個boolean、byte、char、short、int、float、reference或returnAddress類型的數據。這8種數據類型均可以使用32位的物理內存存放,但不意味着slot就是32位。數據結構

  對於64位的數據類型long、double,虛擬機會以高位對齊的方式爲其分配兩個連續的slot空間。這種方式與"long和double的非原子性協定"中把long和double數據類型讀寫分割成兩次32位讀寫的作法相似(這也就是爲何long和double的賦值操做不是原子性的緣由)。不過slot是分配在棧上的,因此在一個線程內,不會有線程安全問題。函數

  虛擬機經過索引來定位使用局部變量表,範圍從0~N(slot的數量),n就表示定位局部變量表的第n個slot的值,64位的會使用兩個定位n和n+1,且不容許單獨訪問一個,校驗環節會拋出異常。第0個slot是保留的,表示方法所屬的實例的引用,表明this。從1~N就是真正的變量了,包括參數,後面就是按順序的方法體內的變量。工具

  爲了節省空間,局部變量表中的slot是能夠重用的,好比if條件句中定義的變量,出了這個結構就再也不使用,這個時候就能夠被複用。不過這種設計會帶來額外的反作用,會影響垃圾回收的行爲。即便定義在代碼塊中的變量再也不被後續使用,垃圾回收也不會回收這部份內容,哪怕再也不使用,由於:局部變量表中的slot還存在與其相關的引用。因此當被其餘變量複用的時候,垃圾回收纔會判斷須要進行回收。因此若是遇到一個方法,後面代碼有一段很耗時的操做,前面又佔用了大量內存,後續又沒有使用,那麼將其手動設置成null就有意義了,這樣纔會被垃圾回收掉。這個就是《Practical Java》中提到的「不使用的對象應手動賦值爲null"。固然不必強行賦值成null,由於代碼被編譯過程可能會發生優化,賦值不賦值爲null可能最終結果與代碼自己書寫的無關。佈局

  另外,局部變量和以前說的類變量不一樣,類變量要通過準備階段賦予零值,再進行初始化階段賦予真正的值。局部變量沒有初始值,不賦值是不能使用的,不要想固然不賦值就是零值。性能

2.2 操做數棧

  這是一個後入先出的LIFO棧。通局部變量表同樣,操做數棧的最大深度在編譯的時候寫入到Code屬性的max_stacks數據項中。操做數棧的每個元素能夠是任意的Java數據類型,包括long和double。32位數據類型所佔的棧容量爲1,64位數據類型所佔的棧容量爲2。在任什麼時候候,操做數棧的深度都不會超過設置的最大值。

  當一個方法剛剛開始執行的時候,這個方法的操做數棧是空的,執行過程當中,各類字節碼指令往操做數棧中寫入和提取內容,出棧和入棧操做。舉個例子,作加法:iadd命令,會取出棧中最頂上的兩個元素,執行相加,而後將結果放入棧中。

  操做數棧元素的數據類型必須與字節碼指令的序列嚴格匹配,在編譯程序代碼的時候,編譯器要嚴格保證這一點,在類的校驗階段也須要再次驗證。

  此外,概念模型中,兩個棧幀做爲虛擬機的元素是徹底獨立的,可是實現上會進行優化,棧幀會出現重疊。這樣就能夠共用一部分數據,無需進行額外的參數傳遞。

2.3 動態鏈接

  每一個棧幀都包含了一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用的目的是爲了支持方法調用過程當中的動態鏈接。以前類加載那文中介紹過,Class文件的常量池中有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用做爲參數。這些符號引用有一部分會在類加載階段翻譯成直接引用,這種稱爲靜態解析。另外一部分在運行期間纔會轉化成直接引用,這個就是動態鏈接。後續會對這一塊作詳細的說明。

2.4 方法返回地址

  執行一個方法有兩種退出方式:

    1.遇到返回字節碼指令,可能會有返回值給上層方法進行調用,或者沒有返回值。這種退出方法的方式稱爲正常完成出口。

    2.遇到異常,沒有被處理,就會致使方法退出,這種方式被稱爲異常完成出口,不會給它的上層調用者產生任何返回值。

  不管哪一種退出,退出以後都要返回方法被調用的位置,程序才能繼續執行,返回時可能須要在棧幀保存一些信息,用來幫助恢復它的上層方法的執行狀態。通常來講,正常退出時,PC計數器的值能夠做爲返回地址,棧幀可能會保存這個計數器值。異常退出時,返回地址是要經過異常處理器表來肯定的,棧中通常不會保存這部分信息。

  方法退出等同於棧幀出棧,因此退出時可能執行的操做有:恢復上層方法的局部變量表和操做數棧,把返回值壓入調用棧幀的操做數棧中,調整PC計數器的值以指向方法調用指令後面的一個指令等。

3 方法調用

  這裏就詳細說下動態鏈接的緣由和過程了。方法調用不等於方法執行,這個階段的目的就是肯定調用的方法是哪一個。前面說過,編譯階段不包含鏈接步驟,都是符號引用,類加載過程會轉變部分爲直接引用,可是仍是有一些是沒法肯定,只有在運行時才能肯定的。

3.1 解析

  類加載的解析階段會將部分符號引用替換成直接引用,成功的前提在於:在運行以前就能肯定調用的版本,而且這個版本在運行期間是不可改變的。符合的方法有靜態方法和私有方法兩種,前者與類型直接關聯,後者只能在內部訪問。

  JVM提供了5條方法調用字節碼指令:

    1.invokestatic:調用靜態方法

    2.invokespecial:調用構造器方法<init>、私有方法、父類方法

    3.invokevirtual:調用全部虛方法

    4.invokeinterface:調用接口方法,會在運行時再肯定一個實現此接口的對象。

    5.invokedynamic:先在運行時動態解析出調用點限定符所引用的方法,而後再執行該方法。

  invokestatic和invokespecial的方法,能夠在解析過程當中肯定,符合條件的有靜態方法,私有方法、實例構造器、父類方法,這些方法稱爲非虛方法,其餘的都是虛方法(除了final)。而invokedynamic又更加特殊,其餘指令的邏輯是固化在虛擬機內部的,這個指令的邏輯由用戶設置的引導方法決定。final方法雖然是由invokevirtual指令來調用,可是因爲其沒法被覆蓋,沒有其餘版本,因此結果惟一,是一種非虛方法。

  將符號引用轉化成直接引用按照階段分,能夠劃分紅靜態分派和動態分派,按數量又有單分派和多分派。

3.2 分派

   Java的三個基本特徵:繼承、封裝、多態。這裏介紹的方法調用過程將描述一下多態的特徵,如重載和重寫時如何實現的,關注的是虛擬機是如何找到正確的執行目標。

3.2.1 靜態分派

  這裏有個重載的理解題。Man和Woman都繼承了Human,Test類有3個重載方法say(Human)、say(Man)、say(Woman)。若是建立兩個對象Human man = new Man(),和Human woman = new Woman()。而後執行Test.say(man)和Test.say(woman),會觸發哪一個方法呢?

  答案是會觸發Test.say(Human)這個重載方法,可是爲何呢?這裏有幾個概念,Human是變量man的靜態類型,Man是變量man的實際類型。編譯階段編譯器是不清楚實際類型的變化的,其只關注靜態類型,使用哪一個方法重載徹底取決於傳入參數的數量和類型。虛擬機重載的時候判斷的是參數的靜態類型而不是實際類型,因此就使用了say(Human)這個方法。

  靜態分派發生在編譯階段,肯定靜態分派的動做實際上不是由虛擬機執行的。另外,編譯器雖然可以肯定方法的重載版本,可是不少狀況符合條件的不是惟一的,每每須要肯定一個更加符合的版本。模糊的緣由是由於字面量是不須要定義的,也就不知道其靜態類型是什麼,好比'a',它能夠是一個char或者Character或者Object或者int等等,這種時候就要有一些規則來判斷具體分配到哪一個方法上。

  以'a'爲例,大體過程以下:

    1.優先匹配char類型

    2.其次匹配int類型(發生自動類型轉換)

    3.其次匹配long類型(再次發生自動類型轉換)

    4.其次匹配Character類型(自動裝箱)

    5.其次匹配Serializable(這個是因爲Character實現了這個接口,因此找不到Character會優先找接口)

    6.其次匹配Object(找父類)

    7.最後匹配char...可變長參數

3.2.2 動態分派

  動態分派和重寫密切相關。仍是Man和Woman都實現了Human的抽象方法say。仍是Human man = new Man(),Human woman = new Woman();這個時候調用man.say()和woman,say(),其會執行具體的man和woman的相關方法。

  這裏顯然不可能經過靜態類型來決定了,使用的是invokevirtual指令,具體步驟以下:

    1.找到操做數棧頂的第一個元素所指向的對象的實際類型,記作C

    2.若是在C中找到指定方法,進行權限校驗,經過返回方法的直接引用,不經過拋出異常IllegalAccessError異常

    3.沒找到,從下到上繼承體系進行步驟2的查找

    4.始終沒找到,拋出AbstractMethodError異常

   invokevirtual的第一步就是肯定實際類型,因此才解析到了不一樣的方法直接引用上,這就是重寫的本質。稱爲動態分派。

3.2.3 單分派和多分派

  方法的接收者和方法的參數統稱爲方法的宗量,基於多少種宗量能夠分爲單分派和多分派兩種。

  單分派基於一個宗量進行選擇,多分派基於多於1個宗量進行選擇。靜態分派首先要選擇靜態類型,再判斷方法參數,因此靜態分派是多分派。動態分派編譯時期就知道了目標方法的方法簽名,因此不須要關心參數是什麼,因此屬於單分派類型。目前就是靜態多分配、動態單分派語言。JDK7實現了invokedynamic指令,這個十分複雜,用於知足動態性的需求,後續會進行描述。

3.2.4 虛擬機的動態分派實現

  動態分派十分頻繁,在運行過程當中搜索合適的目標方法,因此實現要考慮性能,不會真正進行頻繁的搜索。最多見的優化就是爲類在方法區建立一個虛方法表vtable,一樣invokeinterface也會有一個接口方法表itable。使用虛方法索引來代替元數據查找提高性能。

  虛方法表中存放着各個方法的實際入口地址。若是這個方法沒有被子類重寫,那麼子類的虛方法表和父類的方法是同一個,都是父類的入口。若是重寫了,天然是替換成子類的入口地址。

  爲了實現方便,具備相同簽名的方法在父類、子類的虛方法都應該是同樣的索引序號,查找的時候就能夠直接從不一樣的虛方法表的同一索引地址搜索。方法表在類加載的鏈接過程完成初始化,準備了類的變量初始值,虛擬機會把該類的方法表也初始化完畢。

  除了方法表,還有內聯緩存,基於類繼承關係分析技術的守護內聯這兩種非穩定的激進化手段得到更性能。

3.3 動態類型語言支持

  Java是靜態類型語言,知道JDK7才提供了動態類型語言的指令invokedynamic進行改進,這也是JDK8能夠實現Lambda表達式的技術準備。靜態類型語言在編譯時就肯定了類型,能夠提供嚴謹的類型檢查,利於穩定性和規模化的代碼。動態類型語言在運行時肯定類型,能夠提升靈活性,代碼會簡潔清晰(好比python、PHP,可是代碼量上去後維護就很痛苦了,參數具體是什麼沒法肯定)。

  Java早期在編譯階段就肯定了符號引用,這就至關於肯定了具體的接收者,哪怕是前面說到的invokevirtual和invokeinterface符號引用也是肯定了的,只是要查找具體的類型,雖然是運行中肯定實際類型,可是仍是不夠動態。Java要實現動態類型語言就要採起其餘方式(好比編譯時留個佔位符類型,運行階段動態生成字節碼實現具體類型到佔位符類型的適配),這樣會致使實現複雜,可能帶來額外的開銷。這就是invokedynamic指令和java.lang.invoke包出現的背景。

  invoke包是JSR-292的一個重要組成部分,這個包的主要目的是在以前單純靠符號引用來肯定調用的目標方法這種方式以外,提供一種新的動態肯定目標方法的機制,稱爲MethodHandle。能夠簡單的當作函數指針。Java不像C和C++那樣,能夠將一個函數當成參數傳遞給另外一個函數,廣泛的作法是設置一個類包含這個函數,而後將這個類傳給另外一個函數。不過有了MethodHandle以後,能夠擁有函數指針相似的工具了。

  上面的getPrintlnMH()的步驟就模擬了一個invokevirtual指令執行的過程,根據參數返回類型方法名和實例對象找到對應的方法的直接引用,這裏的表現形式就是MethonHandler了。這裏就會疑惑了,這和反射有什麼區別,不是同樣獲取方法,經過方法invoke嗎?

  實際上區別並非很明顯:

    1.本質上都是模擬方法調用,不過反射是代碼層次的方法調用,MethonHandle是字節碼層次的方法調用。

    2.反射的Method對象包含的信息比MethodHandle更多,方法簽名、描述符、方法屬性表中各類屬性等,後者只有執行該方法的相關信息。反射是重量級的,MethodHandle是輕量級的。

    3.MethodHandle理論上能夠像虛擬機同樣進行優化(目前不行),反射調用方法作不到(像黑盒)。

  關鍵之處在於反射是爲Java語言設計的,MethodHandle服務於全部虛擬機上的語言。

3.4 invokedynamic指令

  invokedynamic指令與MethodHandle機制的做用是同樣的,爲了解決其餘4條invoke指令方法分派規則固化在虛擬機之中的問題,把如何查找目標方法的決定權從虛擬機轉嫁到具體用戶代碼之中。不過一個是字節碼層,一個是Java代碼層。

  每個invokedynamic指令的位置都被稱爲「動態調用點」,這條指令的第一個參數不是方法符合引用的CONSTANT_Methodref_info常量,而是CONSTANT_InvokeDynamic_info常量,能夠得到3個信息:引導方法、方法類型和名稱。引導方法有固定的參數,返回java.lang.invoke.CallSite對象,這個表明真正要執行的目標方法的調用。

  invokedynamic指令面向的使用者不是Java語言,是其餘虛擬機上的動態語言,因此javac是沒辦法生成invokedynamic指令的字節碼。

相關文章
相關標籤/搜索