深入理解Java虛擬機總結(jié)-虛擬機字節(jié)碼執(zhí)行引擎

注:此文是我在讀完周志明老師的深入理解Java虛擬機之后總結(jié)的一篇文章,請閱讀此書獲取更加詳細的信息.

另外,需要注意的是,讀此文前,各位應(yīng)當(dāng)對Java字節(jié)碼文件格式以及字節(jié)碼指令有一個清楚的認識.

運行時棧幀結(jié)構(gòu)

在介紹Java內(nèi)存布局時,我們就提到過,每個方法在執(zhí)行時,都會在虛擬機棧中創(chuàng)建一個棧幀,其中包括局部變量表,操作數(shù)棧,動態(tài)鏈接,返回地址等.

那么這幾個區(qū)域到底都是做什么用的呢?

局部變量表

局部變量表(Local Variable Table)是一組變量值存儲空間,用于存放方法參數(shù)和方法內(nèi)部定義的局部變量.在Java程序編譯為Class文件時,就在方法的Code屬性的max_local數(shù)據(jù)項中確定了該方法所需要分配的局部變量表的最大容量.

局部變量表的容量以及變量槽(Variable Slot,下稱Slot)為最小單位,一個Slot可以存放一個32位以內(nèi)的數(shù)據(jù)類型,Java中占用32位以內(nèi)的數(shù)據(jù)類型有boolean, byte, char, short, int, float, reference和returnAddress8種類型.對于64位的數(shù)據(jù)類型,虛擬機會以高位對其的方式為其分配兩個連續(xù)的Slot空間,Java語言中明確的64位的數(shù)據(jù)類型只有l(wèi)ong和double兩種.

reference類型表示對一個對象實例的引用,虛擬機規(guī)范既沒有說明它的長度,也沒有明確指出這種引用應(yīng)有怎樣的結(jié)構(gòu).但一般來說,虛擬機實現(xiàn)至少都應(yīng)當(dāng)能通過這個引用做到兩點,一是此引用中直接或間接地查找到對象在Java堆中的數(shù)據(jù)存放的起始地址索引,二是此引用中直接或間接地查找到對象所屬數(shù)據(jù)類型在方法區(qū)中的存儲的類型信息,否則無法實現(xiàn)Java語言規(guī)范中定義的語法約束.第8種即returnAddress類型目前已經(jīng)很少見了,它是為字節(jié)碼指令jsr,jsr_w和ret服務(wù)的,指向了一條字節(jié)碼指令的地址,很古老的Java虛擬機曾經(jīng)使用這幾條指令來實現(xiàn)異常處理,現(xiàn)在已經(jīng)由異常表代替.

虛擬機通過索引定位的方式使用局部變量表,索引值的范圍是從0開始至局部變量表最大的Slot數(shù)量.如果訪問的是32位數(shù)據(jù)類型的變量,索引n就代表了使用第n個Slot,如果是64位數(shù)據(jù)類型的變量,則說明會同時使用n和n+1兩個Slot.對于兩個相鄰的共同存放一個64位數(shù)據(jù)的兩個Slot,不允許采用任何方式單獨訪問其中的某一個,Java虛擬機規(guī)范中明確要求了如果遇到進行這種操作的字節(jié)碼序列,虛擬機應(yīng)該在類加載的校驗階段拋出異常.

在方法執(zhí)行時,虛擬機是使用局部變量表完成參數(shù)值到參數(shù)變量列表的傳遞過程的,如果執(zhí)行的是實例方法(非static方法),那局部變量表中第0位索引的Slot默認是用于傳遞方法所屬對象實例的引用,在方法中可以通過關(guān)鍵字"this"來訪問到這個隱含的參數(shù).其余參數(shù)則按照參數(shù)表順序排列,占用從1開始的局部變量Slot,參數(shù)表分配完成后,再根據(jù)方法體內(nèi)部定義的變量順序和作用域分配其余的Slot.

為了盡可能節(jié)省棧幀空間,局部變量表的Slot是可以重用的,方法體重定義的變量,其作用域不一定會覆蓋整個方法體,如果當(dāng)前字節(jié)碼PC計數(shù)器的值已經(jīng)超過了某個變量的作用域,那這個變量對應(yīng)的Slot就可以交給其他變量使用.

局部變量不像類變量那樣,在類加載時有一個準備階段,所以,即使你不給類變量賦初值,依然能夠使用,如果不給局部變量賦初值,那么這個局部變量是不能使用的.

操作數(shù)棧

操作數(shù)棧中存放了字節(jié)碼指令的參數(shù),如iadd指令就是取操作數(shù)棧中處于棧頂?shù)膬蓚€操作數(shù)來進行加法操作.

同局部變量表一樣,操作數(shù)棧的最大深度,在編譯期也是可以被確定的,寫入到Code屬性中的max_stacks數(shù)據(jù)項中.操作數(shù)棧的每一個元素可以是任意的Java數(shù)據(jù)類型,包括long和double.32位數(shù)據(jù)類型所占的棧容量為1,64位數(shù)據(jù)類型所占的棧容量為2.在方法執(zhí)行的過程中,操作數(shù)棧的深入不會超過max_stacks.

操作數(shù)棧中元素的數(shù)據(jù)類型必須與字節(jié)碼指令的序列嚴格匹配,在編譯程序代碼的時候,編譯器需要嚴格保證這一點,在類校驗階段的數(shù)據(jù)流分析中還要再次驗證這一點.

動態(tài)連接

我們在之前的文章中也提到過,Java在類加載階段,會對一部分符號引用進行解析,將其轉(zhuǎn)化為直接引用.如類,接口,類方法,字段等.而還有一部分符號引用,需要在鏈接時才能確定其直接引用,才能夠解析,這部分就被稱為動態(tài)連接.

方法返回地址

這個就很容易理解了,因為棧幀本身就是方法獨有的,那么方法執(zhí)行完畢后,肯定還需要知道它要返回到哪里呀,所以就需要保存方法返回地址.

一個方法開始執(zhí)行后,只有兩種方式可以退出這個方法.第一種方式是執(zhí)行引擎遇到任意一個方法返回的字節(jié)碼指令,這時候可能會有返回值傳遞給上層的方法調(diào)用者(調(diào)用當(dāng)前方法的方法稱為調(diào)用者),是否有返回值和返回值的類型將根據(jù)遇到何種方法返回指令來決定,這種退出方法的方式成為正常完成出口.

另外一種退出方式是,在方法執(zhí)行過程中遇到了異常,并且這個異常沒有在方法體內(nèi)得到處理,無論是Java虛擬機內(nèi)部產(chǎn)生的異常,還是代碼塊中使用athrow字節(jié)碼指令產(chǎn)生的異常,只要在本方法的異常表中沒有搜索到匹配的異常處理器,就會導(dǎo)致方法退出,這種退出方法的方式稱為異常完成出口.一個方法使用異常完成出口的方式退出,是不會給它的上層調(diào)用者產(chǎn)生任何返回值的.

方法調(diào)用

方法調(diào)用的唯一任務(wù)是確定需要被執(zhí)行的方法,并不是執(zhí)行被執(zhí)行的方法,它跟方法執(zhí)行是有區(qū)別的.

解析

方法在程序真正運行之前就有一個可確定的調(diào)用版本,并且這個方法的調(diào)用版本在運行期是不可改變的.換句話說,調(diào)用目標在程序代碼寫好,編譯器運行編譯時就必須完全確定下來.這類方法的調(diào)用稱為解析.

在Java語言中符合"編譯器可知,運行期不可變"這個要求的方法,主要包括靜態(tài)方法和私有方法兩大類,前者直接與類型直接關(guān)聯(lián),后者在外部不可訪問,這兩種各自的特性決定了它們在運行期不可能被改變,因此它們適合在類加載階段進行解析.

Java虛擬機中提供了五條方法調(diào)動字節(jié)碼指令,分別是:

  • invokestatic:調(diào)用靜態(tài)方法
  • invokespecial:調(diào)用實例構(gòu)造器<init>方法,私有方法和父類方法
  • invokevirtual:調(diào)用所有的虛方法
  • invokeinterface:調(diào)用接口方法,會在運行時再確定一個實現(xiàn)此接口的對象
  • invokedynamic:現(xiàn)在運行時動態(tài)解析出調(diào)用點限定符所引用的方法,然后再執(zhí)行該方法,在此之前的四條調(diào)用指令,分派邏輯是固化在Java虛擬機內(nèi)部的,而invokedynamic指令的分派邏輯是由用戶所設(shè)定的引導(dǎo)方法決定的

只要能被invokestatic和invokespecial指令調(diào)用的方法,都可以在解析階段唯一確定一個調(diào)用版本,符合這個條件的有靜態(tài)方法,私有方法,實例構(gòu)造器,父類方法,用final修飾的方法五類,它們在類加載階段就可以把符號引用解析為該方法的直接引用.這些方法可以稱為非虛方法,與之相反,其他方法稱為虛方法.

分派

在介紹靜態(tài)分派和動態(tài)分派之前,我們先來介紹一下什么叫做靜態(tài)類型,什么叫做實際類型.考慮下面的代碼:

Human man = new Man();

其中Man是變量man的實際類型,而Human是變量man的靜態(tài)類型.

靜態(tài)類型和實際類型在程序中都可以發(fā)生一些變化,區(qū)別是靜態(tài)類型的變量僅僅在使用時發(fā)生,變量本身的靜態(tài)類型是不會發(fā)生變化的,并且最終的靜態(tài)類型是在編譯器克制的;而實際類型變化的結(jié)果在運行期才可確定,編譯器在編譯程序時,并不知道一個對象的實際類型是什么.

那么什么是靜態(tài)類型變化,什么是實際類型變化呢?

// 實際類型變化
Human man = new Man();
man = new Woman();

// 靜態(tài)類型變化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);

為什么靜態(tài)變化的結(jié)果在編譯器可知,而實際類型的變化只能在運行時才可知呢?

我是這么理解的,看這段代碼:

1 Human man = new Man();
2 sr.sayHello((Woman)man);

即使在第二行中,靜態(tài)類型發(fā)生了變化,但是由于這個變化并沒有保存,而只是作為一個臨時變化傳遞給了sayHello()方法,所以,實際上,運行到最后,變量man的靜態(tài)類型還是Human.

在考慮下面的這段代碼:

1 Human man = new Man();
2 man = new Woman();

在這里,明顯實際類型的變化被保存了下來,所以,實際類型的變化是隨著運行而改變的,所以,編譯期明顯無法確定最終的實際類型.

靜態(tài)分派

靜態(tài)分派在重載中用的比較多.

編譯器在重載時,是通過參數(shù)的靜態(tài)類型而不是實際類型作為判定依據(jù)的,編譯階段,Javac編譯器會根據(jù)參數(shù)的靜態(tài)類型決定使用哪個重載版本,所以選擇了sayHello(Human)作為調(diào)用目標,并把這個方法的符號引用寫到main()方法里的兩條invokevirtual指令的參數(shù)中.

動態(tài)分派

動態(tài)分派就是在運行期根據(jù)實際類型確定方法執(zhí)行版本的分派過程,動態(tài)分派在重寫中用的比較多,動態(tài)分派在執(zhí)行invokevirtual指令的運行時解析過程大致分為以下幾個步驟:

  • 找到操作數(shù)棧頂?shù)牡谝粋€元素所指向的實際類型,記作C
  • 如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權(quán)限校驗,如果通過則返回這個方法的直接引用,查找過程結(jié)束;如果不通過,則返回java.lang.IllegalAccessError異常
  • 否則,按照繼承關(guān)系從下往上依次對C的各個父類進行第二步的搜索和驗證過程
    - 如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常

基于棧的指令集與基于寄存器的指令集

1+1用這兩種指令集計算時,基于棧的指令集是這樣的:

iconst_1
iconst_1
iadd
istore_0

而如果采用基于寄存器的指令集,則程序可能是這樣的:

mov eax, 1
add eax, 1

基于棧的指令集的主要優(yōu)點就是可移植,當(dāng)然還有一些其他的優(yōu)點,比如代碼相對更加緊湊(字節(jié)碼中每個字節(jié)就對應(yīng)一條指令,而多地址指令集中還需要存放參數(shù)),編譯器實現(xiàn)更加簡單(不需要考慮空間分配的問題,所需空間都在棧上操作)等.

棧架構(gòu)指令集的主要缺點是執(zhí)行速度相對來說會慢一些,完成相同功能所需的指令數(shù)量一般會比寄存器架構(gòu)多.而且,由于棧是在內(nèi)存中實現(xiàn)的,所以頻繁的棧訪問也就意味著頻繁的內(nèi)存訪問,相對于處理器來說,內(nèi)存始終是執(zhí)行速度的瓶頸.盡管虛擬機可以采用棧頂緩存的手段,把最常用的操作映射到寄存器中避免直接內(nèi)存訪問,但這也只能是優(yōu)化措施而不是解決本質(zhì)問題的方法.

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容