深入理解 Java 虛擬機(八)運行時棧幀結構

虛擬機是一個相對於“物理機”的概念,這兩種機器都有代碼執行能力,區別是物理機的執行引擎是直接建立在處理器、硬件、指令集和操作系統層面上的,而虛擬機的執行引擎則是由自己實現的,因此可以自行制定指令集與執行引擎的結構體系,並且能夠執行不被硬件直接支持的指令集格式。

在不同的虛擬機實現裏面,執行引擎在執行 Java 代碼的時候可能會有解釋執行(通過解釋器執行)和編譯執行(通過即時編譯器生成本地代碼執行)兩種選擇,也可能兩者兼備。但從外觀上看,所有 Java 虛擬機的執行引擎都是一直的:輸入字節碼文件,解析字節碼,輸出執行結果。

運行時棧幀結構

棧幀是用於支持虛擬機進行方法調用和方法執行的數據結構,它是虛擬機運行時數據區中的虛擬機棧的元素。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息,每一個方法從調用開始至執行完成的過程,都對應着一個棧幀在虛擬機裏從入棧到出棧的過程。

在編譯代碼的時候,棧幀需要多大的局部變量表、多深的操作數棧都已經完成確定了,並且寫入到了方法表的 Code 屬性中,因此一個棧幀需要分配多少內存,不會受到運行期變量數據的影響,而僅僅取決於具體的虛擬機實現。

一個線程中的方法調用鏈可能很長,很多方法都同時處於執行狀態,對於執行引擎來說,在活動線程中,只有位於棧頂的棧幀纔是有效的,稱爲當前棧幀,與這個棧幀相關聯的方法稱爲當前方法。

局部變量表

局部變量表是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。

方法的 Code 屬性的 max_locals 代表該方法所需要分配的最大容量,以 Slot 爲單位。一個 Slot 可以存放 32 位以內的數據,對應的類型有 boolean、byte、char、short、int、float、reference 和 returnAddress 等(可以按照 Java 語言對應的數據類型理解,但其實 Java 虛擬機中的基本數據類型和 Java 語言有較大的區別)。

reference 可能是 32 位,也可能是 64 位的,它至少起到兩點作用:一是從此引用中直接或間接地查找對象在 Java 堆中的數據存放的真實地址索引;二是從此引用中直接或間接地查找對象所屬的數據類型在方法區中存儲的類型信息。

long 和 double 需要使用兩個 Slot 存放,因此 long 和 double 是非原子性的。但由於局部變量表建立在線程的堆棧上,是線程私有的數據,無論讀寫兩個連續的 Slot 是否爲原子操作,都不會引起數據安全問題。而且,對於兩個相鄰的共同存放一個 64 位數據的兩個 Slot,不允許採用任何方法單獨地訪問其中一個,Java 虛擬機規範中明確規定了如果遇到這種操作,虛擬機應該在類加載的校驗階段拋出異常。

如果執行的是類的非 static 成員方法,則局部變量表第 0 項數據默認是 “this”。

爲了節省棧幀空間,Slot 是可以重用的,即如果當前字節碼 PC 計數器的值超出了某個變量的作用域,那這個變量對應的 Slot 就可以交給其它變量使用。但這可能會帶來一些問題:

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

運行結果:

[Full GC (System.gc()) [PSYoungGen: 897K->0K(38400K)] [ParOldGen: 65544K->66363K(87552K)] 66441K->66363K(125952K)

可以看到,即使離開了 placeHolder 的作用區域,gc 之後卻沒有回收 placeHolder 的 64M 內存。

但如果:

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

運行結果:

[Full GC (System.gc()) [PSYoungGen: 929K->0K(38400K)] [ParOldGen: 65544K->827K(87552K)] 66473K->827K(125952K)

這時候發現,placeHolder 被回收了!

這是爲什麼呢?其實 placeHolder 是否被回收的根本依據是局部變量表中的 Slot 是否還存有關於 placeHolder 數組對象的引用。在前一份代碼中,雖然代碼已經離開了 placeHolder 的作用域,但在此之後,沒有任何對局部變量表的讀寫操作,placeHolder 所佔用的 Slot 還沒有被其它變量複用,所以作爲 GC Roots 一部分的局部變量表仍然保持着對它的關聯,因此 gc 後也不會被回收。因此在一個方法中,如果後面的代碼有一些很耗時的操作,前面又有佔用了大量內存但實際上不會再用到的變量,手動將其設爲 null 值便不見得是一個絕對無意義的操作。但在通常情況下,不需要關心這些,因爲經過 JIT 編譯後,手動設 null 值的操作會在編譯優化後被消除掉,而 gc 也能正確回收內存。

另外,局部變量不存在準備階段,因此必須手動賦初始值纔可以使用。

操作數棧

操作數棧(Operand Stack)也常稱爲操作棧,它的最大深度在編譯的時候寫入到 Code 屬性的 max_stacks 數據項中。

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

操作數棧中元素的數據類型必須與字節碼指令的序列嚴格匹配,比如 iadd 指令只能用於 int 型,不能用於 long 型。

在概念模型中,兩個棧幀作爲虛擬機棧的元素,是完全相互獨立的,但在大多虛擬機的實現裏都會做一些優化處理,令兩個棧幀出現一部分重疊。

動態連接

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

方法返回地址

當一個方法開始執行後,只有兩種方式可以退出這個方法:一是執行引擎遇到任意一個方法返回的字節碼指令,這種方式稱爲正常退出出口;另一種是在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,這種方式不會產生返回值,稱爲異常完成出口。

無論採用哪種退出方式,方法退出之後都需要返回到方法被調用的位置,程序才能正常執行。一般來說,方法正常退出時,調用者的 PC 計數器的值可以作爲返回地址;而方法異常退出時,返回地址要通過異常處理器表來確定。

附加信息

虛擬機允許具體的虛擬機實現增加一些規範裏沒有描述的信息到棧幀中,例如與調試相關的信息,這部分信息稱爲附加信息。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章