JVM虛擬機(六)虛擬機字節碼執行引擎

所有Java虛擬機的執行引擎都是一致的:輸入的字節碼文件,處理過程是字節碼解析的等效過程,輸出的是執行結果。以下主要從概念模型的角度來講解虛擬機的方法調用和字節碼執行。

1.運行時棧幀結構

棧幀(Stack Frame)是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧的棧元素。棧幀存儲了方法的局部變量,操作數棧、動態鏈接和方法返回地址等信息。

對於執行引擎來說,在活動線程中,只有位於棧頂的棧幀纔是有效的稱爲當前棧幀,與這個棧幀相關聯的方法稱爲當前方法。執行引擎運行的所有字節碼指令都只針對當前棧幀進行操作,在概念模型上,典型的棧幀結構如下:


1.1局部變量表

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

局部變量表的容量以變量槽(Variable Slot),爲最小單位,虛擬機並沒有明確指明一個Slot應占有的內存空間的大小,只是很有導向型地說到每個Slot、都應該存放一個boolean、byte、char、short、int、float、reference或returnAddress類型的數據,這8種數據類型,都可以使用32位或更小的物理內存來存放,它允許Slot的長度可以隨着處理器、操作系統或虛擬機的不同而發生變化。只要保證即使在64位虛擬機中使用了64位的物理內存空間去實現一個Slot,虛擬機仍要使用對齊和補白的手段讓Slot在外觀上看起來與32位虛擬機中的一致。

既然前面提到了Java虛擬機的數據類型,在此再簡單介紹一下它們。一個Slot可以存放一個32位以內的數據類型,Java中佔用32位以內的數據類型有boolean、byte、char、short、int、float、reference[3]和returnAddress8種類型。

    前面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是可以重用的。

1.2 操作數棧

操作數棧也常稱爲操作棧,它是一個後入先出(Last In First Out,LIFO)棧。同局部變量表一樣,操作數棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks數據項中。操作數棧的每一個元素可以是任意的Java數據類型,包括long和double。32位數據類型所佔的棧容量爲1,64位數據類型所佔的棧容量爲2.

當一個方法剛剛執行的時候,這個方法的操作數棧是空的,在方法執行的過程中,會有各種字節碼指令往操作數棧中寫入和提取內容,也就是出棧/入棧操作。

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

另外,在概念模型中,兩個棧幀作爲虛擬機棧的元素,是完全相互獨立的。但是在大多數虛擬機的實現裏都會做一些優化處理。令兩個棧幀出現一部分重疊。讓下面棧幀的部分操作數棧與上面棧幀的部分局部變量表重疊在一起,這樣在進行方法調用的時候可以共用一部分數據,無需進行額外的參數賦值傳遞:


java虛擬機的解釋執行引擎稱爲“基於棧的執行引擎”,其中的“棧”指的是操作數棧。

1.3動態鏈接

每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程中的動態鏈接。字節碼中的方法調用指令就是以常量池中指向方法的符號引用作爲參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化爲直接引用,這種轉化爲靜態解析。另外一部分將在每一次運行期間轉化爲直接引用,這部分稱爲動態連接。

1.4方法返回地址

當一個方法開始執行之後,只有兩種方式可以退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的字節碼指令。這時候可能會有返回值傳遞給上層的方法調用者,否則有返回值和返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式成爲正常完成出口。

另一種退出方式是,在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,無論是java虛擬機內部產生異常,還是diamante中使用athrow字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱爲異常完全出口。

無論採用何種退出方式,在方法退出之後,都需要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,調用者的PC計數器的值可以作爲返回地址,棧幀中很可能會保存這個計數器值。而方法異常退出時,返回地址是要通弩弓議程處理器表來確定的,棧幀中一般不會保存這部分信息。

方法退出的過程實際上就等同於把當前棧幀出棧,因此退出時可能額執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值壓入調用者棧幀的操作數棧中,吊證PC計數器的值以執行方法調用指令後面的一條指令等。

1.5 附加信息

一般會把動態連接,方法返回地址與其他附加信息全部歸爲一類,稱爲棧幀信息。

2 方法調用

方法調用並不等同於方法執行,方法調用階段唯一的任務就是確定被調用方法版本,暫時還不涉及方法內部的具體運行過程。一切方法調用在Class文件裏面存儲的都只是符號引用,而不是方法在實際運行時的內存佈局中的入口地址。這個特性給Java帶來了更強大的動態擴展能力,但也使得Java方法調用過程變得複雜起來,需要在類加載期間,甚至到運行期間才能確定目標方法的直接引用。

2.1 解析

所有方法調用中的目標方法在Class文件裏都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉化爲直接引用,這種解析能成立的前提是:方法在程序真正運行之前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可改變的。換句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來。這類方法的調用稱爲解析(Resolution)。

在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要包括靜態方法和私有方法倆大類,前者是類型直接相關聯,後者在外部不可被訪問,這兩種方法各自的特點決定了它們不可能通過繼承或者別的方式重寫其他版本,因此它們都適合在類加載階段進行解析。    

與之相對應的是,在Java虛擬機裏面提供了5條方法調用字節碼指令,分別如下:

    

    invokevirtual指令:調用所有虛方法。

    invokeinterface指令:調用藉口方法,會在運行時在確定一個實現此接口的對象。

    invokespecial指令:調用實例構造器<init>方法、私有方法和父類方法。

    invokestatic指令:用於調用類方法(static方法)。

    invokedynamic指令:先在運行時動態解析出調用點限定符引用的方法,然後在執行該方法,在此之前的5條調用指令,分派邏輯是固化在Java虛擬機內部的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。

只要能被invokestatic和invokespecial指令調用的方法都可以在解析階段中確定唯一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法4類,它們在類加載的時候就會把符號引用解析爲該方法的直接引用。這些方法可以成爲非虛方法。與之相反,其他方法稱爲虛方法(除去final方法)。Java中的非虛方法除了使用invokestatic、invokespecial調用的方法之外還有一種,就是被final修飾的方法。雖然final方法是使用invokevirtual指令調用的,但是由於它無法被覆蓋,沒有其他版本,所以也無須對方法接收者進行多態選擇,又或者說多態的結果肯定是唯一的。在Java語言規範中明確說明了final方法時一種非虛方法。

解析調用一定是個靜態過程,在編譯期間就完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變爲可確定的直接引用,不會延時到運行期再去完成。而分派調用則可能是靜態的也可能是動態的,根據分派依據的宗量數可分爲danfenpai和多分派。這兩類分派的方式的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派。

2.2分派

Java具備面向對象的3個特徵:繼承、封裝和多態。分派調用過程將會揭示多態的一些最基本的體現,如“重載”和“重寫”。

    靜態分派

後面我們的話題將圍繞這個類的方法來重載(Overload)代碼,以分析虛擬機和編譯器確定方法版本的過程。方法靜態分派如下代買清單:

/** *方法 靜態 分派 演示  */ 

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!

我們先按如下代碼定義兩個重要概念。

Human man = new man();

我們把上面代碼中的“Human”稱爲變量的靜態類型(Static Type),歐哲叫做外觀類型(Apparent Type),後面的“Man”則稱爲變量的實際類型(Actual Type),靜態類型和實際類型在程序中都可以發生一些 變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變,並且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期才確定的,編譯器在編譯程序的時候並不知道一個對象的實際類型是什麼。例如下面代碼:

    //實際類型變化

      Human man= new Man(); 

        Human woman= new Woman(); 

       //靜態類型變化

        sr. sayHello((Man) man); 

        sr. sayHello( (Woman) woman); 

解釋了這兩個概念,再回到上面代碼中main()裏面調用了兩次sayHello()方法調用,在方法接收者已經確定是對象“sr”的前提下,使用哪個重載版本,就完全取決於傳入參數的數量和數據類型。代碼中刻意地定義了兩個靜態類型相同但實際類型不同的變量,但虛擬機在重載時是通過參數的靜態類型而不是實際類型作爲判斷依據的。並且靜態類型是編譯期可知的,因此,在編譯階段,javac編譯器會根據參數的額靜態類型決定使用哪個重載版本,並把這個方法的符號引用寫到main()方法裏的兩條invokevirtual指令參數中。

所有依賴靜態類型來定位方法執行版本的分派動作稱爲靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,因此靜態分派的動作實際上不是由虛擬機來執行的。另外,編譯器雖然能確定出來方法的重載版本,但是在很多情況下這個重載版本並不是“唯一的”,往往只能確定一個“更加合適的”版本。

    動態分派

它和多態的另一個體現——重寫(Override)有着密切的關係。

/** *方法 動態 分派 演示 */

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虛擬機是如何根據實際類型來分派方法執行版本的呢?我們使用javap命令輸出這段代碼的字節碼,嘗試從中尋找答案,如下。

main()方法的字節碼

public static void main( java. lang. String[]); 

Code: 

Stack= 2, Locals= 3, Args_ size= 1

0: new# 16;// class org/ fenixsoft/ polymorphic/ Dynamic- Dispatch $ Man 

3: dup 

4: invokespecial# 18;//Method org/fenixsoft/polymorphic/Dynamic- Dispatch $ Man." < init >":() V 

7: astore_ 1 

8: new# 19;// class org/ fenixsoft/ polymorphic/ Dynamic- Dispatch $ Woman

11: dup 

12: invokespecial#21;//Method org/fenixsoft/polymorphic/DynamicDispa tch $Woman."<init>":() V 

15: astore_ 2 

16: aload_ 1 

17: invokevirtual# 22;// Method org/ fenixsoft/ polymorphic/ Dynamic- Dispatch $ Human. sayHello:() V

20: aload_ 2 

21: invokevirtual# 22;// Method org/ fenixsoft/ polymorphic/ Dynamic- Dispatch $ Human. sayHello:() V

24: new# 19;// class org/ fenixsoft/ polymorphic/ Dynamic-Dispatch $ Woman 

27: dup

28: invokespecial# 21;// Method org/ fenixsoft/ polymorphic/ Dynam icDispatch $ Woman." < init >":() V 

31: astore_ 1 

32: aload_ 1 

33: invokevirtual# 22;// Method org/ fenixsoft/ polymorphic/ DynamicDispatch $ Human. sayHello:() V 

36: return

0~15行的字節碼是準備動作,作用是建立man和woman的內存空間、調用Man和Woman類型的實例構造器,將這兩個實例的引用存放在第一、二個局部變量表Slot之中,這個動作對應了代碼中的這兩句代碼:

Human man = new Man();

Human woman = new Woman();

接下來的16~21行是關鍵部分,16/20兩句分別把剛剛創建的兩個對象的引用壓入到棧頂,這兩個對象是將要執行的sayHello()方法的調用者,稱爲接收者(receiver);17和21行是方法的調用指令,這兩條調用指令單從字節碼角度來看,無論是指令(都是invokevirtual)還是參數(都是常量池地22項的常量,註釋顯示了這個常量是Human.sayHello()的符號引用)完全一樣的,但是這兩句指令最終執行的目標方法並不相同。原因就需要從invokevirtual指令的多態查找過程開始說起,invokevirtual指令的運行時解析過程大致分爲以下幾個步驟:

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

    2)如果在類型C中找到與常量中的描述符合簡單名稱都相同的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,這返回java.lang.IllegalAccessError異常。

    3)否則,按照繼承關係從下往上一次對C的各個父類記性第2步的搜索和檢驗過程。

    4)如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。

由於invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是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()方法,這兩次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語言的動態分派屬於單分派類型。


今天的java語言是一門靜態多分派,動態單分派的語言。

    虛擬機動態分派的實現

由於動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要運行時在類的方法元數據中搜索合適的目標方法,因此虛擬機的實際實現中基於性能的考慮,大部分實現都不會真正地進行如此頻繁的搜索。最常用的“穩定優化”手段就是爲類在方法區中建立一個虛方法表(virtual Method Table),也稱爲vtable,與此對應的,在invokeinterface執行時也會用到接口方法表——interface method Table,簡稱itable),使用虛方法索引來代替元數據查找以提高性能。我們看看代碼清單所對應的虛方法表結構示例。


虛方法表中存放着各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裏面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換爲指向子類實現版本的入口地址。

爲了程序實現上的方便,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一樣的索引序號,這樣當類型轉換時,僅需要變更查找的方法表,就可以從不同的虛方法表中按索引轉換出所虛需的入口地址。

方法表一般在類加載的連接階段進行初始化,準備了類的變量初始值後,虛擬機會把該類的方法表也初始化完畢。

方法表示分派調用“穩定優化”的手段,虛擬機除了使用方法表之外,在條件允許的情況下,還會使用內聯緩存(Inline Cache)和基於‘類型繼承關係分析(Class Hierarchy Analysis,CHA)’技術的守護內聯(Guarded Inline)兩種非穩定的“激進優化”手段來獲得更高的性能。

總結:

1.局部變量表的最大容量通過Code屬性中的max_locals確定。局部變量表最小存儲單位Slot,可以直接存儲byte、boolean、char、short、int、float、reference和returnAddress8種類型。double和long需要佔用兩個連續的Slot空間。

2.操作數棧的最大容量通過Code屬性的max_stacks確定。在概念模型中,兩個棧幀作爲虛擬機棧的元素,是完全相互獨立的。但是在大多數虛擬機的實現裏都會做一些優化處理。令兩個棧幀出現一部分重疊。讓下面棧幀的部分操作數棧與上面棧幀的部分局部變量表重疊在一起,這樣在進行方法調用的時候可以共用一部分數據,無需進行額外的參數賦值傳遞。

3.動態鏈接:每個棧幀都包含一個指向運行時常量池的該棧幀所屬方法的引用,持有這個引用是爲了支持方法調用過程中動態鏈接。

4.方法返回地址:方法退出有兩種方式,一種是遇到字節碼返回指令,一種是遇到異常。

5.解析:方法在程序真正運行之前就有一個可確定的調用版本,這個調用版本在運行期是不可變的。解析的方法有:靜態方法、私有方法、實例構造方法、父類方法。

6.宗量:方法的接收者和方法的參數。

7.靜態分派——方法重載,動態分派——方法重寫,單分派——根據一個宗量對目標方法進行選擇;多分派——根據多個宗量對目標方法進行選擇;現在的Java程序的靜態多分派,動態單分派的語言。

8.動態調用使用虛方法表代替元數據查找提高性能。原理是父類子類簽名相同的方法(方法重寫)在虛方法表中的索引是相同的,如果子類沒有重寫,則指向父類的方法,若子類重寫了父類的方法,則 指向父類索引號相同的子類方法。


參考書籍:《深入理解虛擬機:JVM高級特性與最佳實踐》(第二版)

發佈了14 篇原創文章 · 獲贊 8 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章