天天看點

運作時棧幀結構

棧幀(Stack Frame)是用于支援虛拟機進行方法調用和方法執行的資料結構,它是虛拟機運作時資料區中的虛拟機棧(Virtual Machine Stack)的棧元素。棧幀存儲了方法的局部變量表、操作數棧、動态連接配接和方法傳回位址等資訊。每一個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛拟機棧裡面從入棧到出棧的過程。每一個棧幀都包括了局部變量表、操作數棧、動态連接配接、方法傳回位址和一些額外的附加資訊。在編譯程式代碼的時候,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全确定了,并且寫入到方法表的Code屬性之中,是以一個棧幀需要配置設定多少記憶體,不會受到程式運作期變量資料的影響,而僅僅取決于具體的虛拟機實作。

一個線程中的方法調用鍊可能會很長,很多方法都同時處于執行狀态。對于執行引擎來說,在活動線程中,隻有位于棧頂的棧幀才是有效的,稱為目前棧幀(Current Stack Frame),與這個棧幀相關聯的方法稱為目前方法(Current Method)。執行引擎運作的所有位元組碼指令都隻針對目前棧幀進行操作,在概念模型上,典型的棧幀結構如圖所示。

運作時棧幀結構

局部變量表(Local Variable Table)是一組變量值存儲空間,用于存放方法參數和方法内部定義的局部變量。在Java程式編譯為Class檔案時,就在方法的Code屬性的max_locals資料項中确定了該方法所需要配置設定的局部變量表的最大容量。

局部變量表的容量以變量槽(Variable Slot,下稱Slot)為最小機關,虛拟機規範中并沒有明确指明一個Slot應占用的記憶體空間大小,隻是很有導向性地說到每個Slot都應該能存放一個boolean、byte、char、short、int、float、reference或returnAddress類型的資料,這8種資料類型,都可以使用32位或更小的實體記憶體來存放,但這種描述與明确指出“每個Slot占用32位長度的記憶體空間”是有一些差别的,它允許Slot的長度可以随着處理器、作業系統或虛拟機的不同而發生變化。隻要保證即使在64位虛拟機中使用了64位的實體記憶體空間去實作一個Slot,虛拟機仍要使用對齊和補白的手段讓Slot在外觀上看起來與32位虛拟機中的一緻。

一個Slot可以存放一個32位以内的資料類型,Java中占用32位以内的資料類型有boolean、byte、char、short、int、float、reference(Java虛拟機規範中沒有明确規定reference類型的長度,它的長度與實際使用32還是64位虛拟機有關,如果是64位虛拟機,還與是否開啟某些對象指針壓縮的優化有關)和returnAddress 8種類型。第7種reference類型表示對一個對象執行個體的引用,虛拟機規範既沒有說明它的長度,也沒有明确指出這種引用應有怎樣的結構。但一般來說,虛拟機實作至少都應當能通過這個引用做到兩點,一是從此引用中直接或間接地查找到對象在Java堆中的資料存放的起始位址索引,二是此引用中直接或間接地查找到對象所屬資料類型在方法區中的存儲的類型資訊,否則無法實作Java語言規範中定義的文法限制。并不是所有語言的對象引用都能滿足這兩點,例如C++語言,預設情況下(不開啟RTTI支援的情況),就隻能滿足第一點,而不滿足第二點。這也是為何C++中提供Java語言裡很常見的反射的根本原因。第8種即returnAddress類型目前已經很少見了,它是為位元組碼指令jsr、jsr_w和ret服務的,指向了一條位元組碼指令的位址,很古老的Java虛拟機曾經使用這幾條指令來實作異常處理,現在已經由異常表代替。

對于64位的資料類型,虛拟機會以高位對齊的方式為其配置設定兩個連續的Slot空間。Java語言中明确的(reference類型則可能是32位也可能是64位)64位的資料類型隻有long和double兩種。值得一提的是,這裡把long和double資料類型分割存儲的做法與“long和double的非原子性協定”中把一次long和double資料類型讀寫分割為兩次32位讀寫的做法有些類似。不過,由于局部變量表建立線上程的堆棧上,是線程私有的資料,無論讀寫兩個連續的Slot是否為原子操作,都不會引起資料安全問題。

虛拟機通過索引定位的方式使用局部變量表,索引值的範圍是從0開始至局部變量表最大的Slot數量。如果通路的是32位資料類型的變量,索引n就代表了使用第n個Slot,如果是64位資料類型的變量,則說明會同時使用n和n+1兩個Slot。對于兩個相鄰的共同存放一個64位資料的兩個Slot,不允許采用任何方式單獨通路其中的某一個,Java虛拟機規範中明确要求了如果遇到進行這種操作的位元組碼序列,虛拟機應該在類加載的校驗階段抛出異常。

在方法執行時,虛拟機是使用局部變量表完成參數值到參數變量清單的傳遞過程的,如果執行的是執行個體方法(非static的方法),那局部變量表中第0位索引的Slot預設是用于傳遞方法所屬對象執行個體的引用,在方法中可以通過關鍵字“this”來通路到這個隐含的參數。其餘參數則按照參數表順序排列,占用從1開始的局部變量Slot,參數表配置設定完畢後,再根據方法體内部定義的變量順序和作用域配置設定其餘的Slot。

為了盡可能節省棧幀空間,局部變量表中的Slot是可以重用的,方法體中定義的變量,其作用域并不一定會覆寫整個方法體,如果目前位元組碼PC計數器的值已經超出了某個變量的作用域,那這個變量對應的Slot就可以交給其他變量使用。不過,這樣的設計除了節省棧幀空間以外,還會伴随一些額外的副作用,例如,在某些情況下,Slot的複用會直接影響到系統的垃圾收集行為

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

關于局部變量表,還有一點可能會對實際開發産生影響,就是局部變量不像前面介紹的類變量那樣存在“準備階段”。我們已經知道類變量有兩次賦初始值的過程,一次在準備階段,賦予系統初始值;另外一次在初始化階段,賦予程式員定義的初始值。是以,即使在初始化階段程式員沒有為類變量指派也沒有關系,類變量仍然具有一個确定的初始值。但局部變量就不一樣,如果一個局部變量定義了但沒有賦初始值是不能使用的,不要認為Java中任何情況下都存在諸如整型變量預設為0,布爾型變量預設為false等這樣的預設值。這段代碼其實并不能運作,還好編譯器能在編譯期間就檢查到并提示這一點,即便編譯能通過或者手動生成位元組碼的方式制造出下面代碼的效果,位元組碼校驗的時候也會被虛拟機發現而導緻類加載失敗。

操作數棧(Operand Stack)也常稱為操作棧,它是一個後入先出(Last In First Out,LIFO)棧。同局部變量表一樣,操作數棧的最大深度也在編譯的時候寫入到Code屬性的max_stacks資料項中。操作數棧的每一個元素可以是任意的Java資料類型,包括long和double。32位資料類型所占的棧容量為1,64位資料類型所占的棧容量為2。在方法執行的任何時候,操作數棧的深度都不會超過在max_stacks資料項中設定的最大值。

當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種位元組碼指令往操作數棧中寫入和提取内容,也就是出棧/入棧操作。例如,在做算術運算的時候是通過操作數棧來進行的,又或者在調用其他方法的時候是通過操作數棧來進行參數傳遞的。舉個例子,整數加法的位元組碼指令iadd在運作的時候操作數棧中最接近棧頂的兩個元素已經存入了兩個int型的數值,當執行這個指令時,會将這兩個int值出棧并相加,然後将相加的結果入棧。

操作數棧中元素的資料類型必須與位元組碼指令的序列嚴格比對,在編譯程式代碼的時候,編譯器要嚴格保證這一點,在類校驗階段的資料流分析中還要再次驗證這一點。再以上面的iadd指令為例,這個指令用于整型數加法,它在執行時,最接近棧頂的兩個元素的資料類型必須為int型,不能出現一個long和一個float使用iadd指令相加的情況。另外,在概念模型中,兩個棧幀作為虛拟機棧的元素,是完全互相獨立的。但在大多虛拟機的實作裡都會做一些優化處理,令兩個棧幀出現一部分重疊。讓下面棧幀的部分操作數棧與上面棧幀的部分局部變量表重疊在一起,這樣在進行方法調用時就可以共用一部分資料,無須進行額外的參數複制傳遞,Java虛拟機的解釋執行引擎稱為“基于棧的執行引擎”,其中所指的“棧”就是操作數棧。

運作時棧幀結構

每個棧幀都包含一個指向運作時常量池中該棧幀所屬方法的引用,持有這個引用是為了支援方法調用過程中的動态連接配接(Dynamic Linking)。我們知道Class檔案的常量池中存有大量的符号引用,位元組碼中的方法調用指令就以常量池中指向方法的符号引用作為參數。這些符号引用一部分會在類加載階段或者第一次使用的時候就轉化為直接引用,這種轉化稱為靜态解析。另外一部分将在每一次運作期間轉化為直接引用,這部分稱為動态連接配接。(靜态分派,動态分派)

當一個方法開始執行後,隻有兩種方式可以退出這個方法。第一種方式是執行引擎遇到任意一個方法傳回的位元組碼指令,這時候可能會有傳回值傳遞給上層的方法調用者(調用目前方法的方法稱為調用者),是否有傳回值和傳回值的類型将根據遇到何種方法傳回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)。

另外一種退出方式是,在方法執行過程中遇到了異常,并且這個異常沒有在方法體内得到處理,無論是Java虛拟機内部産生的異常,還是代碼中使用athrow位元組碼指令産生的異常,隻要在本方法的異常表中沒有搜尋到比對的異常處理器,就會導緻方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者産生任何傳回值的。

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

方法退出的過程實際上就等同于把目前棧幀出棧,是以退出時可能執行的操作有:恢複上層方法的局部變量表和操作數棧,把傳回值(如果有的話)壓入調用者棧幀的操作數棧中,調整PC計數器的值以指向方法調用指令後面的一條指令等。

虛拟機規範允許具體的虛拟機實作增加一些規範裡沒有描述的資訊到棧幀之中,例如與調試相關的資訊,這部分資訊完全取決于具體的虛拟機實作。在實際開發中,一般會把動态連接配接、方法傳回位址與其他附加資訊全部歸為一類,稱為棧幀資訊。