JVM字節碼執行模型及字節碼指令集

    一個Java類的生命週期概括來說需要經過加載、驗證、準備、解析以及初始化、使用及卸載的過程。這裏不展開加載Class 的過程以及Class文件格式(後期會陸續探討)。在執行過程中,JVM是如何把Class文件裏的字節碼轉換成我們的虛擬機棧的操作指令,以及整個虛擬機棧的內部數據結構是怎樣的,這篇文章後續會詳細介紹,並且稍微擴展下JVM規範中的一些字節碼指令集。

    這篇文章的主要目的還是爲了引入後續要介紹的ASM框架的CoreApi 中的Method接口和組件來做一個鋪墊。我們知道,Class文件是編譯後的以8byte爲單位存儲的二進制字節流,想要生成和解析一個Class文件,那麼我們需要更好地瞭解在JVM中他是怎樣被解析和執行的。整篇主要參考和總結了《Java Virtual Machine SpecificationJavaSE7 Version》以及《ASM 4.0 A Java bytecode engineering library》關於虛擬機執行模型及字節碼執行的部分。

一、字節碼執行

    方法調用在JVM中轉換成的是字節碼執行,字節碼指令執行的數據結構就是棧幀(stack frame)。也就是在虛擬機棧中的棧元素。虛擬機會爲每個方法分配一個棧幀,因爲虛擬機棧是LIFO(後進先出)的,所以當前線程正在活動的棧幀,也就是棧頂的棧幀,JVM規範中稱之爲“CurrentFrame”,這個當前棧幀對應的方法就是“CurrentMethod”。字節碼的執行操作,指的就是對當前棧幀數據結構進行的操作。

   棧幀的數據結構主要分爲四個部分:局部變量表、操作數棧、動態鏈接以及方法返回地址(包括正常調用和異常調用的完成結果)。下面就一一介紹下這四種數據結構。

1、局部變量表(local variables)

    當方法被調用時,參數會傳遞到從0開始的連續的局部變量表的索引位置上。棧幀中局部變量表的長度存儲在類或接口的二進制表示中。閱讀Class文件會找到Code屬性,所以我們能知道local variables的最大長度是在編譯期間決定的。一個局部變量表的佔用了32位的存儲空間(一個存儲單位稱之爲slot,槽),所以可以存儲一個boolean、byte、char、short、float、int、refrence和returnAdress數據,long和double需要2個連續的局部變量表來保存,通過較小位置的索引來獲取。如果被調用的是實例方法,那麼第0個位置存儲“this”關鍵字代表當前實例對象的引用。

 

2、操作數棧(operand stack)

    操作數棧同局部變量表一樣,也是編譯期間就能決定了其存儲空間(最大的單位長度),通過 Code屬性存儲在類或接口的字節流中。操作數棧也是個LIFO棧。

    操作數棧是在JVM字節碼執行一些指令(第二部分會介紹一些指令集)時創建的,主要是把局部變量表中的變量壓入操作數棧,在操作數棧中進行字節碼指令的操作,再將變量出操作數棧,結果入操作數棧。同局部變量表,除了long和double,其他類型數據都只佔用一個棧的單位深度。

3、動態鏈接

    每個棧幀指向運行時常量池中該棧幀所屬的方法的引用,也就是字節碼的發放調用的引用。動態鏈接就是將符號引用所表示的方法,轉換成方法的直接引用。加載階段或第一次使用時轉化爲直接引用的(將變量的訪問轉化爲訪問這些變量的存儲結構所在的運行時內存位置)就叫做靜態解析。JVM的動態鏈接還支持運行期轉化爲直接引用。也可以叫做Late Binding,晚期綁定。

4、方法返回地址

   方法正常退出會把返回值壓入調用者的棧幀的操作數棧,PC計數器的值就會調整到方法調用指令後面的一條指令。這樣使得當前的棧幀能夠和調用者連接起來,並且讓調用者的棧幀的操作數棧繼續往下執行。

    方法的異常調用完成,主要是JVM拋出的異常,如果異常沒有被捕獲住,或者遇到athrow字節碼指令顯示拋出,那麼就沒有返回值給調用者。

 

二、字節碼指令集

    瞭解了棧幀的數據結構之後,繼續擴展到字節碼指令集的擴展。那麼字節碼指令又是由哪些元素構成,以及會怎樣地影響我們的當前方法的棧幀的出棧、入棧操作的呢。從結構到用途開始詳述一部分指令集(主要是從作用範圍將指令集劃分爲兩類:局部變量表和操作數棧傳遞數據的指令集,只在操作數棧中操作的指令集)。

1、構成元素

    字節碼的指令,是由一個字節長度的助記符表示的操作碼(Opcode)以及其隨後的需要操作的若干參數構成。有的指令並不一定需要參數。但這裏注意不要混淆一個概念,這裏的參數和操作數(oprends)不是同一個概念。這裏的arguments(參數)是靜態的值,編譯期就存儲在編譯後的字節碼中,而Oprends(操作數)的值第一節介紹的操作數棧中運行期才知道值的數據結構。不知道講清楚沒有,但發現很多譯文以及文章都會混淆指令集的”參數”和操作數棧的”操作數”。如果這裏不夠清晰,那麼下面繼續看,後面具體的指令集的例子,就清楚了。

    對於操作參數的數量及長度都是由Opcode決定的,如果需要操作的長度超出了一個字節,就會按照高位在前的字節序存儲。並且字節碼指令流都是單字節對齊,所以超出單字節的操作參數會需要預留“位置”來實現對齊。

    這裏還需要我們記住的一點是,Opcode是由一個字節長度的助記符表示,JVM 規範制定中需要很謹慎小心得“節約”指令的命名。對於一些boolean、short、byte、char的操作都是講數據轉化成int數據進行操作的,這樣就可以使用同一條指令來操作更多的數據類型。下面可以看到一些指令的例子。

2、指令

    按照JVM規範中,將字節碼指令按照用途劃分成加載和存儲指令、運算指令、類型轉換指令、對象創建指令、操作數棧管理指令、方法調用和返回指令以及同步指令等等。看起來頗多。這裏我們按照指令的操作範圍劃分爲兩種:局部變量表和操作數棧傳遞數據的指令以及只在操作數棧中操作的指令。

   本文不打算詳細把所有字節碼指令全部列出來,所有的指令及規範可以閱讀《Java Virtual Machine Specification》。因爲我們先要概念上了解一個字節碼指令是如何在JVM棧幀上操作的。

   這裏先簡單列舉一下Class文件中對於Java的類型描述。以下是Java基礎類型和數組、Object的表述。對於類或者接口,類型描述其實是將如java.lang.String 變成了java/lang/String 。用斜線分隔。

    編譯後的方法描述對應關係如下:

 

   1、局部變量表和操作數棧傳遞數據的指令:

    ILOAD, LLOAD, FLOAD, DLOAD以及ALOAD指令都是從局部變量表中獲取參數壓入到操作數棧的,其中ILOAD包括了load boolean、char、short、byte和int類型的操作。FLOAD, DLOAD 指令操作的數據需要佔用兩個槽(slot i 及i+1)。ALOAD 是load 對象或者數組類型。ISTORE,LSTORE,ASTORE等操作是從操作數棧棧頂壓入局部變量表的指令。

  2、只在操作數棧操作數據的指令:

  2.1 棧操作:POP 指令把值壓到棧頂。還有DUP、SWAP指令

  2.2 常量值推入棧頂:ACONST_NULL 把null值推入,ICONST_0 把int 0推入棧頂,其他指令不一一列舉了

  2.3運算操作:xADD, xSUB, xMUL, xDIV 以及xREM。對應着+,-,*,/ ,%的運算。X分別對應前面提到的基本數據類型。

  2.4 類型轉換:I2F, F2D, L2D 等等是對類型轉換的操作。

  2.5 對象操作:如NEW 指令就將一個對象引用入棧。

  2.6 讀寫Fields:GETFIELD,PUTFIELD。對於static屬性的操作有:GETSTATIC ,PUTSTATIC

  2.7 調用Methods:對方法的調用,構造函數操作的時候,會操作所有方法參數入棧。如INVOKESTATIC、INVOKEINTERFACE等。

  2.8 讀寫數組值:xALOAD以及xASTORE 。x對應的是I, L, F, D ,A,  B, C , S等類型數據的數組的索引、值入棧出棧的操作。

  2.9 跳轉操作:TABLESWITCH、LOOKUPSWITCH 指令對應的是switch的操作指令。作爲條件判斷if、do while、continue 等的跳轉指令也是直接在操作數棧中進行的。

  2.10 返回指令:RETURN 以及xRETURN、前者是對應方法返回void類型的操作,後者是對應x類型的返回值,返回給方法調用者的指令。

 

三、例子

   下面來結合例子來看下字節碼指令在虛擬機棧中的操作,更進一步理解部分字節碼指令的含義。

package bytecode;

/**
 * Created by yunshen.ljy on 2015/6/16.
 */
public class Coffee {

    int bean;

    public int getBean() {
        return this.bean;
    }

    public void setBean(int bean) {
        this.bean = bean;
    }

}

  然後查看字節碼:

  1、getBean 方法如下:
   0:	aload_0
   1:	getfield	#2; //Field bean:I
   4:	ireturn

  第一行指令是當方法被調用,也就是方法的棧幀創建時,將獲取局部變量表索引值爲0 的值(也就是this),入操作數棧。

  第二行指令,將這個值(也就是this對應的值)出棧,賦給this對象的 bean field。

  第三行指令,將this.f 出棧,並且將值返回給調用者(這裏ireturn 是int類型)。

 

 2、 setBean() 方法字節碼:
   0:	aload_0
   1:	iload_1
   2:	putfield	#2; //Field bean:I
   5:	return

    第一行指令和getBean 方法一樣,都是將this如操作數棧。

    第二行指令是將已經初始化(棧幀創建,也就是方法調用時初始化)的參數bean 的值入操作數棧。

    第三行指令將這兩個值出棧,並且存儲這個int值存儲到到bean屬性的引用,也就是this.bean中。

    第四行指令,在源碼中沒有return 語句,但是在編譯後的字節碼中,會自動生成一個return 指令,消除當前方法(current method)的棧幀並且返回給調用者。

 

   當然,如果沒有程序實現自己的構造器的話,編譯後的類還有個默認的public 構造器。Coffee () { super(); }

  3、構造器的字節碼如下:

   0:	aload_0
   1:	invokespecial	#1; //Method java/lang/Object."<init>":()V
   4:	return
    第一行指令和getBean 方法一樣,都是將this如操作數棧。

    第二行指令,將值(this)出棧,並調用Object class的<init>方法,其實也就是因爲隱式調用了super()方法,而Coffee的父類是Object。這裏的<init>方法是編譯後的類對應的構造器的方法,編譯器會爲每個構造器生成一個<init>方法。

    第三行指令,同之前的幾個return命令一樣,返回給調用者。


四、後話

    至此,我們瞭解了虛擬機執行模型,線程執行會獨有一個方法棧,每個方法在調用時創建一個棧幀,當前正在執行的方法對應的棧幀是在棧頂,所有執行的分析都是針對當前方法進行的。後續又瞭解了部分字節碼指令,結合例子分析了指令集是如何對棧幀進行操作的。如果想更深入地瞭解虛擬機可以看看《深入理解JVM》,《JVM規範》,《EffectiveJava》都是不錯的參考資料。後續會繼續寫一下Class 文件結構,JVM中的類加載過程,GC收集及編譯、運行時調優等方面的內容。

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