注:此文是我在讀完周志明老師的深入理解Java虛擬機之后總結的一篇文章,請閱讀此書獲取更加詳細的信息.
另外,需要注意的是,讀此文前,各位應當對Java字節碼文件格式以及字節碼指令有一個清楚的認識.
運行時棧幀結構
在介紹Java內存布局時,我們就提到過,每個方法在執行時,都會在虛擬機棧中創建一個棧幀,其中包括局部變量表,操作數棧,動態鏈接,返回地址等.
那么這幾個區域到底都是做什么用的呢?
局部變量表
局部變量表(Local Variable Table)是一組變量值存儲空間,用于存放方法參數和方法內部定義的局部變量.在Java程序編譯為Class文件時,就在方法的Code屬性的max_local數據項中確定了該方法所需要分配的局部變量表的最大容量.
局部變量表的容量以及變量槽(Variable Slot,下稱Slot)為最小單位,一個Slot可以存放一個32位以內的數據類型,Java中占用32位以內的數據類型有boolean, byte, char, short, int, float, reference和returnAddress8種類型.對于64位的數據類型,虛擬機會以高位對其的方式為其分配兩個連續的Slot空間,Java語言中明確的64位的數據類型只有long和double兩種.
reference類型表示對一個對象實例的引用,虛擬機規范既沒有說明它的長度,也沒有明確指出這種引用應有怎樣的結構.但一般來說,虛擬機實現至少都應當能通過這個引用做到兩點,一是此引用中直接或間接地查找到對象在Java堆中的數據存放的起始地址索引,二是此引用中直接或間接地查找到對象所屬數據類型在方法區中的存儲的類型信息,否則無法實現Java語言規范中定義的語法約束.第8種即returnAddress類型目前已經很少見了,它是為字節碼指令jsr,jsr_w和ret服務的,指向了一條字節碼指令的地址,很古老的Java虛擬機曾經使用這幾條指令來實現異常處理,現在已經由異常表代替.
虛擬機通過索引定位的方式使用局部變量表,索引值的范圍是從0開始至局部變量表最大的Slot數量.如果訪問的是32位數據類型的變量,索引n就代表了使用第n個Slot,如果是64位數據類型的變量,則說明會同時使用n和n+1兩個Slot.對于兩個相鄰的共同存放一個64位數據的兩個Slot,不允許采用任何方式單獨訪問其中的某一個,Java虛擬機規范中明確要求了如果遇到進行這種操作的字節碼序列,虛擬機應該在類加載的校驗階段拋出異常.
在方法執行時,虛擬機是使用局部變量表完成參數值到參數變量列表的傳遞過程的,如果執行的是實例方法(非static方法),那局部變量表中第0位索引的Slot默認是用于傳遞方法所屬對象實例的引用,在方法中可以通過關鍵字"this"來訪問到這個隱含的參數.其余參數則按照參數表順序排列,占用從1開始的局部變量Slot,參數表分配完成后,再根據方法體內部定義的變量順序和作用域分配其余的Slot.
為了盡可能節省棧幀空間,局部變量表的Slot是可以重用的,方法體重定義的變量,其作用域不一定會覆蓋整個方法體,如果當前字節碼PC計數器的值已經超過了某個變量的作用域,那這個變量對應的Slot就可以交給其他變量使用.
局部變量不像類變量那樣,在類加載時有一個準備階段,所以,即使你不給類變量賦初值,依然能夠使用,如果不給局部變量賦初值,那么這個局部變量是不能使用的.
操作數棧
操作數棧中存放了字節碼指令的參數,如iadd指令就是取操作數棧中處于棧頂的兩個操作數來進行加法操作.
同局部變量表一樣,操作數棧的最大深度,在編譯期也是可以被確定的,寫入到Code屬性中的max_stacks數據項中.操作數棧的每一個元素可以是任意的Java數據類型,包括long和double.32位數據類型所占的棧容量為1,64位數據類型所占的棧容量為2.在方法執行的過程中,操作數棧的深入不會超過max_stacks.
操作數棧中元素的數據類型必須與字節碼指令的序列嚴格匹配,在編譯程序代碼的時候,編譯器需要嚴格保證這一點,在類校驗階段的數據流分析中還要再次驗證這一點.
動態連接
我們在之前的文章中也提到過,Java在類加載階段,會對一部分符號引用進行解析,將其轉化為直接引用.如類,接口,類方法,字段等.而還有一部分符號引用,需要在鏈接時才能確定其直接引用,才能夠解析,這部分就被稱為動態連接.
方法返回地址
這個就很容易理解了,因為棧幀本身就是方法獨有的,那么方法執行完畢后,肯定還需要知道它要返回到哪里呀,所以就需要保存方法返回地址.
一個方法開始執行后,只有兩種方式可以退出這個方法.第一種方式是執行引擎遇到任意一個方法返回的字節碼指令,這時候可能會有返回值傳遞給上層的方法調用者(調用當前方法的方法稱為調用者),是否有返回值和返回值的類型將根據遇到何種方法返回指令來決定,這種退出方法的方式成為正常完成出口.
另外一種退出方式是,在方法執行過程中遇到了異常,并且這個異常沒有在方法體內得到處理,無論是Java虛擬機內部產生的異常,還是代碼塊中使用athrow字節碼指令產生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口.一個方法使用異常完成出口的方式退出,是不會給它的上層調用者產生任何返回值的.
方法調用
方法調用的唯一任務是確定需要被執行的方法,并不是執行被執行的方法,它跟方法執行是有區別的.
解析
方法在程序真正運行之前就有一個可確定的調用版本,并且這個方法的調用版本在運行期是不可改變的.換句話說,調用目標在程序代碼寫好,編譯器運行編譯時就必須完全確定下來.這類方法的調用稱為解析.
在Java語言中符合"編譯器可知,運行期不可變"這個要求的方法,主要包括靜態方法和私有方法兩大類,前者直接與類型直接關聯,后者在外部不可訪問,這兩種各自的特性決定了它們在運行期不可能被改變,因此它們適合在類加載階段進行解析.
Java虛擬機中提供了五條方法調動字節碼指令,分別是:
- invokestatic:調用靜態方法
- invokespecial:調用實例構造器<init>方法,私有方法和父類方法
- invokevirtual:調用所有的虛方法
- invokeinterface:調用接口方法,會在運行時再確定一個實現此接口的對象
- invokedynamic:現在運行時動態解析出調用點限定符所引用的方法,然后再執行該方法,在此之前的四條調用指令,分派邏輯是固化在Java虛擬機內部的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的
只要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段唯一確定一個調用版本,符合這個條件的有靜態方法,私有方法,實例構造器,父類方法,用final修飾的方法五類,它們在類加載階段就可以把符號引用解析為該方法的直接引用.這些方法可以稱為非虛方法,與之相反,其他方法稱為虛方法.
分派
在介紹靜態分派和動態分派之前,我們先來介紹一下什么叫做靜態類型,什么叫做實際類型.考慮下面的代碼:
Human man = new Man();
其中Man是變量man的實際類型,而Human是變量man的靜態類型.
靜態類型和實際類型在程序中都可以發生一些變化,區別是靜態類型的變量僅僅在使用時發生,變量本身的靜態類型是不會發生變化的,并且最終的靜態類型是在編譯器克制的;而實際類型變化的結果在運行期才可確定,編譯器在編譯程序時,并不知道一個對象的實際類型是什么.
那么什么是靜態類型變化,什么是實際類型變化呢?
// 實際類型變化
Human man = new Man();
man = new Woman();
// 靜態類型變化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);
為什么靜態變化的結果在編譯器可知,而實際類型的變化只能在運行時才可知呢?
我是這么理解的,看這段代碼:
1 Human man = new Man();
2 sr.sayHello((Woman)man);
即使在第二行中,靜態類型發生了變化,但是由于這個變化并沒有保存,而只是作為一個臨時變化傳遞給了sayHello()方法,所以,實際上,運行到最后,變量man的靜態類型還是Human.
在考慮下面的這段代碼:
1 Human man = new Man();
2 man = new Woman();
在這里,明顯實際類型的變化被保存了下來,所以,實際類型的變化是隨著運行而改變的,所以,編譯期明顯無法確定最終的實際類型.
靜態分派
靜態分派在重載中用的比較多.
編譯器在重載時,是通過參數的靜態類型而不是實際類型作為判定依據的,編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪個重載版本,所以選擇了sayHello(Human)作為調用目標,并把這個方法的符號引用寫到main()方法里的兩條invokevirtual指令的參數中.
動態分派
動態分派就是在運行期根據實際類型確定方法執行版本的分派過程,動態分派在重寫中用的比較多,動態分派在執行invokevirtual指令的運行時解析過程大致分為以下幾個步驟:
- 找到操作數棧頂的第一個元素所指向的實際類型,記作C
- 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.IllegalAccessError異常
- 否則,按照繼承關系從下往上依次對C的各個父類進行第二步的搜索和驗證過程
- 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常
基于棧的指令集與基于寄存器的指令集
1+1用這兩種指令集計算時,基于棧的指令集是這樣的:
iconst_1
iconst_1
iadd
istore_0
而如果采用基于寄存器的指令集,則程序可能是這樣的:
mov eax, 1
add eax, 1
基于棧的指令集的主要優點就是可移植,當然還有一些其他的優點,比如代碼相對更加緊湊(字節碼中每個字節就對應一條指令,而多地址指令集中還需要存放參數),編譯器實現更加簡單(不需要考慮空間分配的問題,所需空間都在棧上操作)等.
棧架構指令集的主要缺點是執行速度相對來說會慢一些,完成相同功能所需的指令數量一般會比寄存器架構多.而且,由于棧是在內存中實現的,所以頻繁的棧訪問也就意味著頻繁的內存訪問,相對于處理器來說,內存始終是執行速度的瓶頸.盡管虛擬機可以采用棧頂緩存的手段,把最常用的操作映射到寄存器中避免直接內存訪問,但這也只能是優化措施而不是解決本質問題的方法.