詳解JVM記憶體管理與垃圾回收機制4 - References(上)
Java通過 new
關鍵字來建立物件時,JVM在堆中開闢空間存放物件例項資料,這時,定義的區域性變數仍儲存在棧中,它包含指向堆中物件的指標 ( 即物件在堆記憶體的起始地址索引 ),而不是物件本身,這個指標在Java中,被稱為引用。來看下面的Java方法,它持有一個由 String
解析而來的 Integer
物件。
public static void foo(String bar) { Integer baz = new Integer(bar); }
在呼叫foo方法時,JVM的記憶體是如何變化的?理解這個變化過程,會讓你更好的理解引用,對後面理解垃圾回收也有一定的裨益。首先使用 javap -v
命令來檢視foo方法的位元組碼, javap
命令可以將位元組碼翻譯成易讀的JVM指令。
public static void foo(java.lang.String); descriptor: (Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: // 堆疊深度,區域性變量表中元素個數,引數個數(如果是非static方法,引數個數=實際引數個數+1) stack=3, locals=2, args_size=1 // 建立執行型別的物件例項,對其預設初始化(null),並將指向該例項的一個引用壓入運算元棧頂 0: new#2// class java/lang/Integer // 複製棧頂的值,並壓入到棧頂 3: dup // 將第0個Slot中為reference型別的本地變數推送到運算元棧頂 4: aload_0 // 棧頂的reference資料是Integer型別,呼叫其構造方法 // 這裡會建立新的棧幀,併成為當前棧幀,直到Integer的構造方法呼叫完成後,完成當前棧幀的出棧操作 5: invokespecial #3// Method java/lang/Integer."<init>":(Ljava/lang/String;)V // 運算元棧出棧,將棧頂的引用儲存到區域性變數中,即第1個Slot 8: astore_1 9: return // 描述指令與原始碼行號之間的關係 LineNumberTable: line 5: 0 line 6: 9 // 描述區域性變量表 LocalVariableTable: StartLengthSlotNameSignature 0100barLjava/lang/String; 911bazLjava/lang/Integer;
每個方法在執行的同時都會建立一個棧幀(Stack Frame)用於儲存區域性變量表、運算元棧、動態連線、方法出口等資訊,當方法呼叫結束,隨著函式棧幀的銷燬,區域性變量表、運算元棧等也隨之消失。更多關於關於虛擬機器棧以及棧幀的相關內容,請參考 ofollow,noindex">詳解JVM記憶體管理與垃圾回收機制1 - 記憶體管理
如果你可以輕鬆理解以上指令程式碼,可以跳過下一小節,直接進入第二部分,下面介紹程式碼執行時的棧幀結構。
執行時棧幀結構
1.1 區域性變量表
區域性變量表是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數,它的容量是以變數槽 ( Variable Slot )為最小單位,但JVM規範中並未規定一個Slot應占用多大空間,只是要求每個Slot都應該能容納一個boolean、byte、char、short、int、float、reference和retureAddress型別的資料。而對於64位資料型別long和double,虛擬機器會以高位對齊的方式為其分配兩個連續的Slot空間。虛擬機器為方法中的每個區域性變數都分配了一個索引,通過索引可以訪問區域性變數的指定元素,區域性變數的索引從0開始。因為long和double型別的資料佔用兩個Slot,所以這兩種資料型別值採用兩個Slot索引中較小的索引來定位。
這裡需要著重強調的是:reference型別表示對一個物件例項的引用,JVM規範既沒有說明它的長度,也沒有指明其應有怎樣的結構,但一般來說,虛擬機器至少都需要通過這個引用做到兩點:
- 直接或間接地查詢到物件在堆中資料的起始地址
- 直接或間接地找到物件所屬資料型別在方法區中儲存的型別資訊
在方法執行時,虛擬機器是使用區域性變量表完成引數值到引數變數列表的傳遞過程的,如果執行的是例項方法(非static的方法),那區域性變量表中第0個Slot預設是用於傳遞方法所屬物件例項的引用,在方法中可以通過關鍵字“this”來訪問到這個隱含的引數。其餘引數則按照引數表順序排列,佔用從1開始的區域性變數Slot,引數表分配完畢後,再根據方法體內部定義的變數順序和作用域分配其餘的Slot。而如果是static方法,那麼引數列表直接從第0個Slot順序排列,這個也比較好理解,類方法嘛,也不存在物件例項的引用,也就不需要浪費一個Slot。
1.2 運算元棧
運算元棧也常稱為操作棧,是一個後入先出 ( LIFO
) 棧,運算元棧的元素可以是任意Java資料型別,包括long和double。當一個方法剛剛開始執行的時候,這個方法的運算元棧是空的,在方法的執行過程中,會有各種位元組碼指令往運算元棧中寫入和提取內容,也就是出棧/入棧操作。運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,在編譯程式程式碼的時候,編譯器要嚴格保證這一點,在類校驗階段的資料流分析中還要再次驗證這一點。比如執行 iadd
指令時,接近棧頂的兩個元素資料型別必須時整型的。
1.3 方法返回地址
當一個方法開始執行後,只有兩個方式可以退出這個方法:
- 正常退出:執行引擎遇到方法返回的位元組碼指令,這時候可能會有返回值傳遞給上層的方法呼叫者
- 異常退出:在方法執行過程中遇到異常,且異常沒有在方法體內得到處理
無論採用何種退出方式,在方法退出之後,都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,呼叫者的PC計數器的值可以作為返回地址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會儲存這部分資訊。
方法退出的過程實際上就等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的區域性變量表和運算元棧,把返回值(如果有的話)壓入呼叫者棧幀的運算元棧中,調整PC計數器的值以指向方法呼叫指令後面的一條指令等。
執行時棧幀記憶體變化過程
接下來以呼叫 foo("123")
為例,介紹整個方法呼叫過程中棧幀的記憶體變化過程。
2.1 new指令
new
指令用於建立一個物件,其格式: new indexbyte1 indexbyte2
,無符號數indexbyte1和indexbyte2用於構建一個指向當前類的執行時常量池索引值,構建方式: (indexbyte1<<8)|indexbyte2
,該索引指向執行時常量池中的一個類或介面的符號引用,且這個類或者介面應當是已經解析過的,比如這個例子 new #2
中的 #2
代表: class java/lang/Integer
。
當 new
指令執行完成後,一個以此類的新例項將會被分配在堆中,並且它所有的例項變數都會初始化為相應型別的初始值,一個代表該物件例項的reference型別資料objectref將入棧到運算元棧中。這裡Integer屬於引用型別,因此它的初始值預設為null值。

執行new指令後的記憶體結構示意圖
2.2 dup指令
dup
指令用於複製運算元棧頂的值,並插入到棧頂。

執行dup指令後的記憶體結構示意圖
2.3 aload指令
aload_n
指令用於從區域性變量表載入一個reference型別值到運算元棧。這個例項中,由於後面呼叫Integer構造方法時需要傳遞一個字串引數,因此在呼叫之前肯定要把資料壓入棧頂。指令中的 n
代表棧幀中區域性變量表的索引值,通過 n
定位到的區域性變數必須是reference型別,成為objectref,指令執行後,objectref將會壓入到運算元棧棧頂。

執行aload指令後的記憶體結構示意圖
2.4 invokespecial指令
invokespecial
指令專門用於呼叫父類方法、私有方法和例項初始化方法。其格式與 new
指令類似,都是通過兩個無符號數計算出方法在常量池中的索引以便得到該方法所在類或者介面的符號引用。
前面也介紹過,JVM在執行static方法時傳遞的引數即方法中定義的引數,位元組碼中 args_size=1
也表明引數個數確實是1。如果將 foo
方法改成非static方法,這時候 args_size=2
,即兩個引數,除了方法定義的引數,還包含一個物件引用 this
引數。因此在執行 invokespecial
指令,需要消耗運算元棧頂的引用作為 this
引數傳遞給 Integer
類的構造方法。所以,當執行 invokespecial
指令後還需要在運算元棧頂維持有一個指向新建物件的引用,就得在invokespecial之前複製一份引用。這就是為什麼要執行 dup
命令的原因,因此,網上關於一些為什麼要執行 dup
命令原因的解釋是錯誤的。

執行invokespecial指令後的記憶體結構示意圖
2.5 astore指令
astore_n
指令將一個reference型別的資料儲存到區域性變量表中,與 aload
指令一樣, n
也表示指向當前棧幀區域性變量表的索引值,而運算元棧棧頂的objectref必須是reference型別的資料。執行此命令後,資料將從運算元棧中出棧,然後儲存到 n
所指向的區域性變量表中。

執行astore指令後的記憶體結構示意圖
總結
本文主要分析了執行時棧幀記憶體結構以及其記憶體中資料的變化過程,希望通過這個簡單的示例,讓大家對Java中的引用有一個更直觀和深刻的理解。對於文中涉及的指令相關介紹,主要參考了Java虛擬機器規範等相關內容。如果大家想要查詢某些JVM指令的作用,建議直接查閱虛擬機器規範,相比於網上的內容更準確也更節約時間。而關於執行時棧幀結構的內容主要參考深入理解Java虛擬機器一書,建議大家閱讀。
下一小節將介紹引用的4種類型及其應用。