Java虛擬機運行時棧的棧幀
Java虛擬機棧概述
Java虛擬機棧(Java Virtual Machine Stacks)是線程私有的,它的生命周期與線程相同。虛擬機棧描述的是Java方法執行的內存模型:棧幀(Stack Frame)是用於支持Java虛擬機進行方法調用和執行的數據結構,它是虛擬機棧中的棧元素。每個方法在執行的同到都會創建一個棧幀用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。
在編譯程序代碼的時候,棧幀中需要多大的局部變量表,多深的操作數棧都已經完全確定瞭,並且寫入到方法表的Code屬性之中,因此一個棧幀需要分配多少內存,不會受到程序運行期變量數據的影響,而僅僅取決於具體的虛擬機實現。
每一個方法從調用直至執行完成的過程,就對應著一個棧幀在虛擬棧中從入棧到出棧的過程(說人話就是要執行一個方法,將該方法的棧幀壓入棧頂,方法執行完成其棧幀出棧)。在JVM裡面,棧幀的操作隻有兩種:出棧和入棧。正在被線程執行的方法稱為當前線程方法,而該方法的棧幀就稱為當前幀,執行引擎運行時隻對當前棧幀有效。
下面對棧幀的每個組成部分分別介紹一下。
局部變量表
局部變量表(Local Variable Table)是一組變量值存儲空間,用於存放方法參數和方法內部定義的局部變量。在Java程序編譯為Class文件時就在方法的code屬性的max_locals數據項中確定瞭該方法所需要分配的局部變量表的最大容量。
局部變量表的容量以變量槽(Variable 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、float、reference和returnAddress8種類型。第7種reference類表示對一個對象實例的引用,虛擬機規范既沒有說明它的長度,也沒有明確指出這種引用應有怎樣的結構。但是一般來說,虛擬機實現至少都應當能通過這個引用做到兩點,一是從此引用直接或間接地查找到對象在Java堆中的數據存放的起始地址索引,二是此引用中直接或間接地查找到對象所屬數據類型在方法區中的存儲的類型信息。第8種即returnAddress類型目前已經很少見瞭,現在已經由異常表代替。
對於64位的數據類型,虛擬機會以高位對齊的方式為其分配兩個連續的引Slot空間。Java語言中明確的(reference類型則可能是32位也可能是64位),64位的數據類型隻有long和double兩種。虛擬機通過索引定位的方式使用局部變量表,索引值的范圍是從0開始至局部變量表最大的Slot數量。如果訪問的是32位數據類型的變量,索引 n 就代表瞭使用第n個Slot,如果是64位數據類型的變量,則說明會同時使用n和n+1兩個Slot對於兩個相鄰的共同存放一個64位數據的兩個Slot,不允許采用任何方式單獨訪問其中的某一個,Java虛擬機規范中明確要求瞭如果遇到進行這種操作的字節碼序列,虛擬機應該在類加載的校驗階段拋出異常。
如果是實例方法(非static的方法),那麼局部變量表中第0位索引的Slot默認是用於傳遞方法所屬對象實例的引用”this”。其餘參數則按照參數表的順序來排列,占用從1開始的局部變量Slot,參數表分配完畢後,再根據方法體內部定義的變量順序和作用域分配其餘的Slot(比如方法method(int a1,inta2),參數表為a1和a2,則局部變量表索引0、1、2則分別存儲瞭this指針、a1、a2,如果方法內部有其他內部變量,則在局部變量表中存在a2之後的位置)。
為瞭盡可能節省棧幀空間,局部變量表中的Slot是可以重用的,方法體中定義的變量,其作用域並不一定會覆蓋整個方法體,如果當前字節碼PC計數器的值已經超出瞭某個變量的作用域,那這個變量對應的Slot就可以交給其他變量使用。
局部變量不像的類成員變量那樣存在”準備階段”。我們知道類變量有兩次賦初始值的過程,一次在準備階段,賦予系統初始值;另外一次在初始化階段,賦予程序員定義的初始值。因此,即使在初始化階段程序員沒有為類變量賦值也沒有關系,類變量仍然具有一個確定的初始值。但局部變量就不一樣,如果一個局部變量定義瞭但沒有賦初始值是不能使用的,不要認為Java中任何情況下都存在諸如整型變量默認為0,佈爾型變量默認為false等這樣的默認值。
操作數棧
操作數棧(Operand Stack)也常稱為操作棧,它是一個後入先出(Last In First out,LIFO)棧。同局部變量表一樣,操作數棧的最大深度也在編譯的時候寫入到code屬性的max_stacks數據項中。操作數棧的每一個元素可以是任意的Java數據類型,包括long和double。32位數據類型所占的棧容量為1,64位數據類型所占的棧容量為2。在方法執行的任何時候,操作數棧的深度都不會超過在maxstacks數據項中設定的最大值。
當一個方法剛剛開始執行的時候,這個方法的操作數棧是空的,在方法的執行過程中,會有各種字節碼指令往操作數棧中寫入和提取內容,也就是出棧/入棧操作。例如,在做算術運算的時候是通過操作數棧來進行的,又或者在調用其他方法的時候是通過操作數棧來進行參數傳遞的。舉個例子,整數加法的字節碼指令iadd在運行的時候操作數棧中最接近棧頂的兩個元素已經存入瞭兩個int型的數值,當執行這個指令時,會將這兩個int值出棧並相加,然後將相加的結果入棧。
Java虛擬機的解釋執行引擎稱為“基於棧的執行引擎”,其中所指的“棧”就是操作數棧。如果當前線程請求的棧深度大於虛擬機所允許的最大深度,將拋出StackOverflowError異常。
動態連接
每個棧幀都包含一個指向運行時常量池中該棧幀所屬方法的引用,持有這個引用是為瞭支持方法調用過程中的動態連接(Dynamic Linking)。Class文件的常量池中存有大量的符號引用,字節碼中的方法調用指令就以常量池中指向方法的符號引用作為參數。這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化為直接引用,這種轉化稱為靜態解析。另外一部分將在每一次運行期間轉化為直接引用,這部分稱為動態連接。
Java代碼在進行Javac編譯的時候,並不像C和C++那樣有“連接”這一步驟,而是在虛擬機加載Class文件的時候進行動態連接。也就是說,在Class文件中不會保存各個方法、字段的最終內存佈局信息,因此這些字段、方法的符號引用不經過運行期轉換的話無法得到真正的內存入口地址,也就無法直接被虛擬機使用。當虛擬機運行時,需要從常量池獲得對應的符號引用,再在類創建時或運行時解析、翻譯到具體的內存地址之中。
Math math=new Math(); math.compute();//調用實例方法compute()
以上面兩行代碼為例,解釋一下動態連接:math.compute()調用時compute()叫符號,需要通過compute()這個符號去到常量池中去找到對應方法的符號引用,運行時將通過符號引用找到方法的字節碼指令的內存地址。
方法的返回地址
當一個方法開始執行後,隻有兩種方式可以退出這個方法。第一種方式是執行引擎遇任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱為調用者),這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)。另外一種退出方式是,在方法執行過程中遇到瞭異常,並且這個異常沒有在方法體內得到處理,無論是Java虛擬機內部產生的異常,還是代碼中使用athrow字節碼指令產生的異常,隻要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層調用者產生任何返回值的。
無論采用何種退出方式,在方法退出之後,都需要返回到方法被調用的位置,程序才能繼續執行,方法返回時可能需要在棧幀中保存一些信息,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,調用者的PC計數器的值可以作為返回地址,棧幀中很可能會保存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會保存這部分信息。方法退出的過程實際上就等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的局部變量表和操作數棧,把返回值(如果有的話)壓入調用者棧幀的操作數棧中,調整pc計數器的值以指向方法調用指令後面的一條指令等。
結合javap命令理解棧幀
上面進行瞭大段的文文字介紹,還是不太好理解,下面我們通過javap命令來分析一下方法中的操作指令、局部變量表、操作數棧等。
javap是jdk自帶的反解析工具。它的作用就是根據class字節碼文件,反解析出當前類對應的code區(匯編指令)、本地變量表、異常表和代碼行偏移量映射表、常量池等等信息。下面是其用法說明:
D:\wyonline\myworkspaces\framework\Test\bin\com\wkp\jvm>javap 用法: javap <options> <classes> 其中, 可能的選項包括: -help --help -? 輸出此用法消息 -version 版本信息 -v -verbose 輸出附加信息 -l 輸出行號和本地變量表 -public 僅顯示公共類和成員 -protected 顯示受保護的/公共類和成員 -package 顯示程序包/受保護的/公共類 和成員 (默認) -p -private 顯示所有類和成員 -c 對代碼進行反匯編 -s 輸出內部類型簽名 -sysinfo 顯示正在處理的類的 系統信息 (路徑, 大小, 日期, MD5 散列) -constants 顯示最終常量 -classpath <path> 指定查找用戶類文件的位置 -cp <path> 指定查找用戶類文件的位置 -bootclasspath <path> 覆蓋引導類文件的位置
下面我們寫一個簡單的Java程序:
package com.wkp.jvm; public class Math { public static final Integer CONSTANT=666; public int compute() {//一個方法對應一塊棧幀內存區域 int a=3; int b=5; int c=(a+b)*10; return c; } public static void main(String[] args) { Math math=new Math(); math.compute(); } }
然後進入到Math.class所在目錄執行: javap -c Math.class > Math.txt 命令,將Math.class字節碼文件反匯編然後輸出到Math.txt文件中:
然後我們查看Math.txt的內容如下:我們重點分析下compute方法內的指令,其內部的指令後面我加瞭註釋(這裡我是參考上一節的《JVM字節碼指令集大全及其介紹》,感興趣的話可以看一看),註釋中的棧就是指的棧幀中的操作數棧,本地變量表就是指的局部變量表。
Compiled from "Math.java" public class com.wkp.jvm.Math { public static final java.lang.Integer CONSTANT; static {}; Code: 0: sipush 666 3: invokestatic #10 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 6: putstatic #16 // Field CONSTANT:Ljava/lang/Integer; 9: return public com.wkp.jvm.Math(); Code: 0: aload_0 1: invokespecial #21 // Method java/lang/Object."<init>":()V 4: return public int compute(); Code: 0: iconst_3 //將int類型的3推送至棧頂 1: istore_1 //將棧頂int類型數值(上面的3)出棧並存入第二個本地變量 2: iconst_5 //將int類型的5推送至棧頂 3: istore_2 //將棧頂int類型數值(上面的5)出棧並存入第三個本地變量 4: iload_1 //將第二個int型本地變量(上面的3)推送至棧頂 5: iload_2 //將第三個int型本地變量(上面的5)推送至棧頂 6: iadd //將棧頂兩int型數值出棧,然後相加並將結果壓入棧頂 7: bipush 10 //將常量值10推送至棧頂 9: imul //將棧頂兩int型數值出棧,然後相乘並將結果壓入棧頂 10: istore_3 //將棧頂int類型數值(上面的乘積)出棧並存入第四個本地變量 11: iload_3 //將第四個int類型本地變量推送至棧頂 12: ireturn //從當前方法返回int類型值 public static void main(java.lang.String[]); Code: 0: new #1 // class com/wkp/jvm/Math 3: dup 4: invokespecial #33 // Method "<init>":()V 7: astore_1 8: aload_1 9: invokevirtual #34 // Method compute:()I 12: pop 13: return }
下面我們通過圖示簡單表示一下上面compute方法中指令操作時關於本地變量表、操作數棧的情況:
我們先看下第一行 0: iconst_3 //將int類型的3推送至棧頂,可以看到下圖3已經被入棧到操作數棧的棧頂。
我們再看下第二行 1: istore_1 //將棧頂int類型數值(上面的3)出棧並存入第二個本地變量,將上圖中棧頂的3出棧然後存入本地表中第二個位置,如下圖所示:
第三行、第四行跟上面的一二行指令類似,第四行指令執行後變成如下所示:
第五行、第六行中 4: iload_1 //將第二個int型本地變量(上面的3)推送至棧頂; 5: iload_2 //將第三個int型本地變量(上面的5)推送至棧頂,即將局部變量表中的3和5依次壓入棧頂,如下圖所示:
然後第七行執行iadd操作,將棧頂的兩個int類型數據5和3出棧相加,將得到的和壓入棧頂,得到如下結果:
後面的指令操作過程與上面類似,執行完第12行的iload_3指令之後,會得到如下圖所示:
關於局部變量表的信息,還可以通過javap -l 命令查看如下圖所示,另外還可以通過Idea中的jclasslib 查看。
LocalVariableTable表示的就是局部變量表的信息:
public int compute(); LineNumberTable: line 8: 0 line 9: 2 line 10: 4 line 11: 11 LocalVariableTable: Start Length Slot Name Signature 0 13 0 this Lcom/wkp/jvm/Math; 2 11 1 a I 4 9 2 b I 11 2 3 c I
我們還可以通過 javap -c Math.class > Math.txt 查看更多的信息如下:我們可以看到 9: invokevirtual #34 // Method compute:() 中的 #34 可以在常量池中找到 #34 = Methodref #1.#35 // com/wkp/jvm/Math.compute:()也就是方法的符號引用,運行時通過符號引用解析出來方法的執行指令的內存地址,這個其實就是動態連接。
Classfile /D:/wyonline/myworkspaces/framework/Test/bin/com/wkp/jvm/Math.class Last modified 2019-8-24; size 761 bytes MD5 checksum be0cdf4bcd037929d3fe0af86d44a837 Compiled from "Math.java" public class com.wkp.jvm.Math minor version: 0 major version: 52 //魔數 flags: ACC_PUBLIC, ACC_SUPER Constant pool: //常量池 #1 = Class #2 // com/wkp/jvm/Math #2 = Utf8 com/wkp/jvm/Math #3 = Class #4 // java/lang/Object #4 = Utf8 java/lang/Object #5 = Utf8 CONSTANT #6 = Utf8 Ljava/lang/Integer; #7 = Utf8 <clinit> #8 = Utf8 ()V #9 = Utf8 Code #10 = Methodref #11.#13 // java/lang/Integer.valueOf:(I)Ljava/lang/Integer; #11 = Class #12 // java/lang/Integer #12 = Utf8 java/lang/Integer #13 = NameAndType #14:#15 // valueOf:(I)Ljava/lang/Integer; #14 = Utf8 valueOf #15 = Utf8 (I)Ljava/lang/Integer; #16 = Fieldref #1.#17 // com/wkp/jvm/Math.CONSTANT:Ljava/lang/Integer; #17 = NameAndType #5:#6 // CONSTANT:Ljava/lang/Integer; #18 = Utf8 LineNumberTable #19 = Utf8 LocalVariableTable #20 = Utf8 <init> #21 = Methodref #3.#22 // java/lang/Object."<init>":()V #22 = NameAndType #20:#8 // "<init>":()V #23 = Utf8 this #24 = Utf8 Lcom/wkp/jvm/Math; #25 = Utf8 compute #26 = Utf8 ()I #27 = Utf8 a #28 = Utf8 I #29 = Utf8 b #30 = Utf8 c #31 = Utf8 main #32 = Utf8 ([Ljava/lang/String;)V #33 = Methodref #1.#22 // com/wkp/jvm/Math."<init>":()V #34 = Methodref #1.#35 // com/wkp/jvm/Math.compute:()I #35 = NameAndType #25:#26 // compute:()I #36 = Utf8 args #37 = Utf8 [Ljava/lang/String; #38 = Utf8 math #39 = Utf8 SourceFile #40 = Utf8 Math.java { public static final java.lang.Integer CONSTANT; descriptor: Ljava/lang/Integer; flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL static {}; descriptor: ()V flags: ACC_STATIC Code: stack=1, locals=0, args_size=0 0: sipush 666 3: invokestatic #10 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 6: putstatic #16 // Field CONSTANT:Ljava/lang/Integer; 9: return LineNumberTable: line 5: 0 LocalVariableTable: Start Length Slot Name Signature public com.wkp.jvm.Math(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #21 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 3: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/wkp/jvm/Math; public int compute(); descriptor: ()I flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=1 0: iconst_3 1: istore_1 2: iconst_5 3: istore_2 4: iload_1 5: iload_2 6: iadd 7: bipush 10 9: imul 10: istore_3 11: iload_3 12: ireturn LineNumberTable: line 8: 0 line 9: 2 line 10: 4 line 11: 11 LocalVariableTable: Start Length Slot Name Signature 0 13 0 this Lcom/wkp/jvm/Math; 2 11 1 a I 4 9 2 b I 11 2 3 c I public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=1 0: new #1 // class com/wkp/jvm/Math 3: dup 4: invokespecial #33 // Method "<init>":()V 7: astore_1 8: aload_1 9: invokevirtual #34 // Method compute:()I 12: pop 13: return LineNumberTable: line 15: 0 line 16: 8 line 17: 13 LocalVariableTable: Start Length Slot Name Signature 0 14 0 args [Ljava/lang/String; 8 6 1 math Lcom/wkp/jvm/Math; } SourceFile: "Math.java"
參考:《深入理解Java虛擬機第二版》、《Java虛擬機規范 JavaSE8版》