1. 程式人生 > 實用技巧 >虛擬機器位元組碼執行引擎(一)

虛擬機器位元組碼執行引擎(一)

執行時棧幀結構

Java虛擬機器以方法作為最基本的執行單元,“棧幀”(Stack Frame)則是用於支援虛擬機器進行方法呼叫和方法執行背後的資料結構,它也是虛擬機器執行時資料區中的虛擬機器棧(Virtual MachineStack)的棧元素。棧幀儲存了方法的區域性變量表、運算元棧、動態連線和方法返回地址等資訊。每
一個方法從呼叫開始至執行結束的過程,都對應著一個棧幀在虛擬機器棧裡面從入棧到出棧的過程。

每一個棧幀都包括了局部變量表、運算元棧、動態連線、方法返回地址和一些額外的附加資訊。在編譯Java程式原始碼的時候,棧幀中需要多大的區域性變量表,需要多深的運算元棧就已經被分析計算出來,並且寫入到方法表的Code屬性之中。換言之,一個棧幀需要分配多少記憶體,並不會受到程式執行期變數資料的影響,而僅僅取決於程式原始碼和具體的虛擬機器實現的棧記憶體佈局形式。

一個執行緒中的方法呼叫鏈可能會很長,以Java程式的角度來看,同一時刻、同一條執行緒裡面,在呼叫堆疊的所有方法都同時處於執行狀態。而對於執行引擎來講,在活動執行緒中,只有位於棧頂的方法才是在執行的,只有位於棧頂的棧幀才是生效的,其被稱為“當前棧幀”(Current Stack Frame),與這個棧幀所關聯的方法被稱為“當前方法”(Current Method)。執行引擎所執行的所有位元組碼指令都只針對當前棧幀進行操作,在概念模型上,典型的棧幀結構如圖8-1所示。

圖8-1所示的就是虛擬機器棧和棧幀的總體結構,接下來,我們將會詳細瞭解棧幀中的區域性變量表、運算元棧、動態連線、方法返回地址等各個部分的作用和資料結構。

圖8-1 棧幀的概念結構

一個變數槽可以存放一個32位以內的資料型別,Java中佔用不超過32位儲存空間的資料型別有boolean、byte、char、short、int、float、reference[1]和returnAddress這8種類型。前面6種不需要多加解釋,而第7種reference型別表示對一個物件例項的引用,《Java虛擬機器規範》既沒有說明它的長度,也沒有明確指出這種引用應有怎樣的結構。但是一般來說,虛擬機器實現至少都應當能通過這個引用做到兩件事情,一是從根據引用直接或間接地查詢到物件在Java堆中的資料存放的起始地址或索引,二是根據引用直接或間接地查詢到物件所屬資料型別在方法區中的儲存的型別資訊,否則將無法實現《Java語言規範》中定義的語法約定。第8種returnAddress型別目前已經很少見了,它是為位元組碼指令jsr、jsr_w和ret服務的,指向了一條位元組碼指令的地址,某些很古老的Java虛擬機器曾經使用這幾條指令來實現異常處理時的跳轉,但現在也已經全部改為採用異常表來代替了。

對於64位的資料型別,Java虛擬機器會以高位對齊的方式為其分配兩個連續的變數槽空間。Java語言中明確的64位的資料型別只有long和double兩種。這裡把long和double資料型別分割儲存的做法與“long和double的非原子性協定”中允許把一次long和double資料型別讀寫分割為兩次32位讀寫的做法有些類似。不過,由於區域性變量表是建立線上程堆疊中的,屬於執行緒私有的資料,無論讀寫兩個連續的變數槽是否為原子操作,都不會引起資料競爭和執行緒安全問題。

Java虛擬機器通過索引定位的方式使用區域性變量表,索引值的範圍是從0開始至區域性變量表最大的變數槽數量。如果訪問的是32位資料型別的變數,索引N就代表了使用第N個變數槽,如果訪問的是64位資料型別的變數,則說明會同時使用第N和N+1兩個變數槽。對於兩個相鄰的共同存放一個64位資料的兩個變數槽,虛擬機器不允許採用任何方式單獨訪問其中的某一個,《Java虛擬機器規範》中明確要求瞭如果遇到進行這種操作的位元組碼序列,虛擬機器就應該在類載入的校驗階段中丟擲異常。

當一個方法被呼叫時,Java虛擬機器會使用區域性變量表來完成引數值到引數變數列表的傳遞過程,即實參到形參的傳遞。如果執行的是例項方法(沒有被static修飾的方法),那區域性變量表中第0位索引的變數槽預設是用於傳遞方法所屬物件例項的引用,在方法中可以通過關鍵字“this”來訪問到這個隱含的引數。其餘引數則按照引數表順序排列,佔用從1開始的區域性變數槽,引數表分配完畢後,再根據方法體內部定義的變數順序和作用域分配其餘的變數槽。

為了儘可能節省棧幀耗用的記憶體空間,區域性變量表中的變數槽是可以重用的,方法體中定義的變數,其作用域並不一定會覆蓋整個方法體,如果當前位元組碼PC計數器的值已經超出了某個變數的作用域,那這個變數對應的變數槽就可以交給其他變數來重用。不過,這樣的設計除了節省棧幀空間以外,還會伴隨有少量額外的副作用,例如在某些情況下變數槽的複用會直接影響到系統的垃圾收集行為,請看程式碼8-1、程式碼8-2和程式碼8-3的3個演示。

程式碼8-1 區域性變量表槽複用對垃圾收集的影響之一

public static void main(String[] args)() {
	byte[] placeholder = new byte[64 * 1024 * 1024];
	System.gc();
}

  

程式碼8-1中的程式碼很簡單,向記憶體填充了64MB的資料,然後通知虛擬機器進行垃圾收集。我們在虛擬機器執行引數中加上“-verbose:gc”來看看垃圾收集的過程,發現在System.gc()執行後並沒有回收掉這64MB的記憶體,下面是執行的結果:

[GC (System.gc())  70792K->66352K(251392K), 0.0007000 secs]
[Full GC (System.gc())  66352K->66212K(251392K), 0.0041151 secs]

  

程式碼8-1的程式碼沒有回收掉placeholder所佔的記憶體是能說得過去,因為在執行System.gc()時,變數placeholder還處於作用域之內,虛擬機器自然不敢回收掉placeholder的記憶體。那我們把程式碼修改一下,變成程式碼8-2的樣子。

程式碼8-2 區域性變量表Slot複用對垃圾收集的影響之二

public static void main(String[] args) {
    {
        byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    System.gc();
}

  

加入了花括號之後,placeholder的作用域被限制在花括號以內,從程式碼邏輯上講,在執行System.gc()的時候,placeholder已經不可能再被訪問了,但執行這段程式,會發現執行結果如下,還是有64MB的記憶體沒有被回收掉,這又是為什麼呢?

[GC (System.gc())  70792K->66416K(251392K), 0.0007739 secs]
[Full GC (System.gc())  66416K->66212K(251392K), 0.0044062 secs]

  

在解釋為什麼之前,我們先對這段程式碼進行第二次修改,在呼叫System.gc()之前加入一行“inta=0;”,變成程式碼8-3的樣子。

程式碼8-3 區域性變量表Slot複用對垃圾收集的影響之三

public static void main(String[] args) {
    {
        byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    int a = 0;
    System.gc();
}

  

這個修改看起來很莫名其妙,但執行一下程式,卻發現這次記憶體真的被正確回收了。

[GC (System.gc())  70792K->66400K(251392K), 0.0008860 secs]
[Full GC (System.gc())  66400K->675K(251392K), 0.0044742 secs]

  

程式碼8-1至8-3中,placeholder能否被回收的根本原因就是:區域性變量表中的變數槽是否還存有關於placeholder陣列物件的引用。第一次修改中,程式碼雖然已經離開了placeholder的作用域,但在此之後,再沒有發生過任何對區域性變量表的讀寫操作,placeholder原本所佔用的變數槽還沒有被其他變數所複用,所以作為GC Roots一部分的區域性變量表仍然保持著對它的關聯。這種關聯沒有被及時打斷,絕大部分情況下影響都很輕微。但如果遇到一個方法,其後面的程式碼有一些耗時很長的操作,而前面
又定義了佔用了大量記憶體但實際上已經不會再使用的變數,手動將其設定為null值(用來代替那句int a=0,把變數對應的區域性變數槽清空) 便不見得是一個絕對無意義的操作,這種操作可以作為一種在極特殊情形(物件佔用記憶體大、此方法的棧幀長時間不能被回收、方法呼叫次數達不到即時編譯器的編譯條件) 下的“奇技”來使用。

雖然程式碼清單8-1至8-3的示例說明了賦null操作在某些極端情況下確實是有用的,但我們不應當對賦null值操作有什麼特別的依賴,更沒有必要把它當作一個普遍的編碼規則來推廣。原因有兩點:

從編碼角度講,以恰當的變數作用域來控制變量回收時間才是最優雅的解決方法,如程式碼清單8-3那樣的場景除了做實驗外幾乎毫無用處。更關鍵的是,從執行角度來講,使用賦null操作來優化記憶體回收是建立在對位元組碼執行引擎概念模型的理解之上的,當虛擬機器使用直譯器執行時,通常與概念模型還會比較接近,但經過即時編
譯器施加了各種編譯優化措施以後,兩者的差異就會非常大,只保證程式執行的結果與概念一致。在實際情況中,即時編譯才是虛擬機器執行程式碼的主要方式,賦null值的操作在經過即時編譯優化後幾乎是一定會被當作無效操作消除掉的,這時候將變數設定為null就是毫無意義的行為。位元組碼被即時編譯為原生代碼後,對GC Roots的列舉也與解釋執行時期有顯著差別,以前面的例子來看,經過第一次修改的程式碼清單8-2在經過即時編譯後,System.gc()執行時就可以正確地回收記憶體,根本無須寫成程式碼清單8-3的樣子。

關於區域性變量表,還有一點可能會對實際開發產生影響,就是區域性變數不像前面介紹的類變數那樣存在“準備階段”。我們知道類的欄位變數有兩次賦初始值的過程,一次在準備階段,賦予系統初始值; 另外一次在初始化階段,賦予程式設計師定義的初始值。因此即使在初始化階段程式設計師沒有為類變數賦值也沒有關係,類變數仍然具有一個確定的初始值,不會產生歧義。但區域性變數就不一樣了,如果一個區域性變數定義了但沒有賦初始值,那它是完全不能使用的。所以不要認為Java中任何情況下都存在諸如整型變數預設為0、布林型變數預設為false等這樣的預設值規則。如程式碼8-4所示,這段程式碼在Java中其實並不能執行( 但是在其他語言,譬如C和C++中類似的程式碼是可以執行的),所幸編譯器能在編譯期間就檢查到並提示出這一點,即便編譯能通過或者手動生成位元組碼的方式製造出下面程式碼的效果,位元組碼校驗的時候也會被虛擬機發現而導致類載入失敗。

程式碼8-4

public static void main(String[] args) {
    int a;
    System.out.println(a);
}

  

運算元棧(Operand Stack)也常被稱為操作棧,它是一個後入先出(Last In First Out,LIFO)棧。同區域性變量表一樣,運算元棧的最大深度也在編譯的時候被寫入到Code屬性的max_stacks資料項之中。運算元棧的每一個元素都可以是包括long和double在內的任意Java資料型別。32位資料型別所佔的棧容量為1,64位資料型別所佔的棧容量為2。Javac編譯器的資料流分析工作保證了在方法執行的任何時候,運算元棧的深度都不會超過在max_stacks資料項中設定的最大值。

當一個方法剛剛開始執行的時候,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令往運算元棧中寫入和提取內容,也就是出棧和入棧操作。譬如在做算術運算的時候是通過將運算涉及的運算元棧壓入棧頂後呼叫運算指令來進行的,又譬如在呼叫其他方法的時候是通過運算元棧來進行方法引數的傳遞。舉個例子,例如整數加法的位元組碼指令iadd,這條指令在執行的時候要求運算元棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會把這兩個int
值出棧並相加,然後將相加的結果重新入棧。

運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,在編譯程式程式碼的時候,編譯器必須要嚴格保證這一點,在類校驗階段的資料流分析中還要再次驗證這一點。再以上面的iadd指令為例,這個指令只能用於整型數的加法,它在執行時,最接近棧頂的兩個元素的資料型別必須為int型,不能出現一個long和一個float使用iadd命令相加的情況。

另外在概念模型中,兩個不同棧幀作為不同方法的虛擬機器棧的元素,是完全相互獨立的。但是在大多虛擬機器的實現裡都會進行一些優化處理,令兩個棧幀出現一部分重疊。讓下面棧幀的部分運算元棧與上面棧幀的部分區域性變量表重疊在一起,這樣做不僅節約了一些空間,更重要的是在進行方法呼叫時就可以直接共用一部分資料,無須進行額外的引數複製傳遞了,重疊的過程如圖8-2所示。

圖8-2 兩個棧幀之間的資料共享


Java虛擬機器的解釋執行引擎被稱為“基於棧的執行引擎”,裡面的“棧”就是運算元棧。後面會對基於棧的程式碼執行過程進行更詳細的講解,介紹它與更常見的基於暫存器的執行引擎有哪些差別。

每個棧幀都包含一個指向執行時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線(Dynamic Linking)。我們知道Class檔案的常量池中存有大量的符號引用,位元組碼中的方法呼叫指令就以常量池裡指向方法的符號引用作為引數。這些符號引用一部分會在類載入階段或者第一次使用的時候就被轉化為直接引用,這種轉化被稱為靜態解析。另外一部分將在每一次執行期間都轉化為直接引用,這部分就稱為動態連線。關於這兩個轉化過程的具體過程,將在後面的章節中再詳細講解。

當一個方法開始執行後,只有兩種方式退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法呼叫者(呼叫當前方法的方法稱為呼叫者或者主調方法),方法是否有返回值以及返回值的型別將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為“正常呼叫完成”(Normal Method Invocation Completion)。

另外一種退出方式是在方法執行的過程中遇到了異常,並且這個異常沒有在方法體內得到妥善處理。無論是Java虛擬機器內部產生的異常,還是程式碼中使用athrow位元組碼指令產生的異常,只要在本方法的異常表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為“異常呼叫完成(Abrupt Method Invocation Completion)”。一個方法使用異常完成出口的方式退出,是不會給它的上層呼叫者提供任何返回值的。

無論採用何種退出方式,在方法退出之後,都必須返回到最初方法被呼叫時的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層主調方法的執行狀態。一般來說,方法正常退出時,主調方法的PC計數器的值就可以作為返回地址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中就一般不會儲存這部分資訊。

方法退出的過程實際上等同於把當前棧幀出棧,因此退出時可能執行的操作有: 恢復上層方法的區域性變量表和運算元棧,把返回值(如果有的話)壓入呼叫者棧幀的運算元棧中,調整PC計數器的值以指向方法呼叫指令後面的一條指令等。