摘要:實際上前三步會被編譯器優化爲:iconst_3  istore_1 //把棧頂的內容放入局部變量表中索引爲 1 的 slot 中,也就是 a 對應的空間中  iload_1 // 把局部變量表索引爲 1 的 slot 中存放的變量值(3)加載至操作數棧  iconst_3   iadd //棧頂的兩個數出棧後相加,結果入棧  istore_2 // 把棧頂的內容放入局部變量表中索引爲 2 的 slot 中,也就是 b 對應的空間中  return // 方法返回指令,回到調用點。Notes 是注意事項:實現 Java 中的 finally 子句時,astore 指令使用的操作數類型是一個 returnAddress,與 astore 對應的 aload 指令(將局部變量表的的引用值壓棧)不能將類型爲 returnAddress 類型的值加載到操作數棧,而只能是 reference 類型。

熟悉 Java 的同學應該都知道了,JVM 是基於棧的。但是這個“棧” 具體指的是什麼?難道就是虛擬機棧?想要回答這個問題我們先要從虛擬機棧的結構談起。

虛擬機棧

何爲虛擬機棧

虛擬機棧的棧元素是棧幀,當有一個方法被調用時,代表這個方法的棧幀入棧;當這個方法返回時,其棧幀出棧。因此,虛擬機棧中棧幀的入棧順序就是方法調用順序。什麼是棧幀呢?棧幀可以理解爲一個方法的運行空間。它主要由兩部分構成,一部分是局部變量表,方法中定義的局部變量以及方法的參數就存放在這張表中;另一部分是操作數棧,用來存放操作數。我們知道,Java 程序編譯之後就變成了一條條字節碼指令,其形式類似彙編,但和彙編有不同之處:彙編指令的操作數存放在數據段和寄存器中,可通過存儲器或寄存器尋址找到需要的操作數;而 Java 字節碼指令的操作數存放在操作數棧中,當執行某條帶 n 個操作數的指令時,就從棧頂取 n 個操作數,然後把指令的計算結果(如果有的話)入棧。因此,當我們說 JVM 執行引擎是基於棧的時候,其中的“棧”指的就是操作數棧。舉個簡單的例子對比下彙編指令和 Java 字節碼指令的執行過程,比如計算 1 + 2,在彙編指令是這樣的:

mov ax, 1 ;把 1 放入寄存器 ax 
add ax, 2 ;用 ax 的內容和 2 相加後存入 ax 

而 JVM 的字節碼指令是這樣的:

iconst_1 //把整數 1 壓入操作數棧 
iconst_2 //把整數 2 壓入操作數棧 
iadd //棧頂的兩個數相加後出棧,結果入棧 

由於操作數棧是內存空間,所以字節碼指令不必擔心不同機器上寄存器以及機器指令的差別,從而做到了平臺無關。

注意,局部變量表中的變量不可直接使用,如需使用必須通過相關指令將其加載至操作數棧中作爲操作數使用。比如有一個方法 void foo(),其中的代碼爲:int a = 1 + 2; int b = a + 3;,編譯爲字節碼指令就是這樣的:

iconst_1 //把整數 1 壓入操作數棧 
iconst_2 //把整數 2 壓入操作數棧 
iadd //棧頂的兩個數出棧後相加,結果入棧;實際上前三步會被編譯器優化爲:iconst_3 
istore_1 //把棧頂的內容放入局部變量表中索引爲 1 的 slot 中,也就是 a 對應的空間中 
iload_1 // 把局部變量表索引爲 1 的 slot 中存放的變量值(3)加載至操作數棧 
iconst_3  
iadd //棧頂的兩個數出棧後相加,結果入棧 
istore_2 // 把棧頂的內容放入局部變量表中索引爲 2 的 slot 中,也就是 b 對應的空間中 
return // 方法返回指令,回到調用點 

需要說明的是,局部變量表以及操作數棧的容量的最大值在編譯時就已經確定了,運行時不會改變。並且局部變量表的空間是可以複用的,例如,當指令的位置超出了局部變量表中某個變量 a 的作用域時,如果有新的局部變量 b 要被定義,b 就會覆蓋 a 在局部變量表的空間。

盜用別人的圖以讓大家對虛擬機棧有個直觀的認識(其中小字體 Stack 指的的是虛擬機棧,Frame 是棧幀,Local variables 是局部變量表,Operand Stack 是操作數棧):

由虛擬機棧引出的問題

看完上面的代碼大家可能會有幾點疑惑:什麼是 slot?那些指令是什麼意思?爲什麼 a 對應的 slot 的索引值不是從零開始的,它明明是第一個定義的變量啊?

對於這些問題我們一個個來解決。

什麼是 slot

首先什麼是 slot?slot 是局部變量表中的空間單位,虛擬機規範中有規定,對於 32 位之內的數據,用一個 slot 來存放,如 int,short,float 等;對於 64 位的數據用連續的兩個 slot 來存放,如 long,double 等。引用類型的變量 JVM 並沒有規定其長度,它可能是 32 位,也有可能是 64 位的,所以既有可能佔一個 slot,也有可能佔兩個 slot。

JVM 字節碼指令

第二個問題,那些指令是什麼意思?

指令格式

首先我們要理解 Java 指令的格式,Java 的指令以字節爲單位,也就是一個字節代表一條指令。比如 iconst_1 就是一條指令,它佔一個字節,那麼自然 Java 指令不會超過 256 條。實際上 Java 指令目前定義了 200 多條。指令雖然是一個字節,但是它也可以帶自己的操作數。JVM 中有這樣一條指令 putstatic,其作用是給特定的的靜態字段賦值。但是給哪個字段賦值呢?僅僅通過這條指令並不能說明,那麼只有通過操作數來指定了。緊跟在 putstatic 後面的兩個字節就是它的操作數,這個操作數是一個索引值,指向運行時常量池中該靜態字段對應的符號引用。由於符號引用包含了該字段的基本信息,如所屬類、簡單名稱以及描述符,因此 putstatic 指令就知道是給哪個類的哪個字段賦值了。

指令的操作數分兩種:一種是嵌入在指令中的,通常是指令字節後面的若干個字節;另一種是存放在操作數棧中的。爲了區別,我們把前者叫做嵌入式操作數,把後者叫做棧內操作數。這兩者的區別是:嵌入式操作數是在編譯時就已經確定的,運行時不會改變,它和指令一樣存放於類文件方法表的 Code 屬性中;而操作數是運行時確定的,即程序在執行過程中動態生成的。拿 putstatic 指令來說,它有一個嵌入式操作數,該操作數是一個索引值(前面已經提到),它由兩個字節組成,緊跟在 putstatic 對應的字節之後;同時它還有一個棧內操作數,位於操作數棧的棧頂,這個操作數就是要賦給靜態字段的值,其對應的字節數根據靜態字段的類型決定。如果靜態字段的類型是 short、int、boolean、char 或者 byte,那麼這個操作數就必須是 int 類型,即由棧頂的 4 個字節組成;如果是 float、double 或者 long 類型,那麼操作數就是相應的類型,即由棧頂的 4 個、8 個 或者 8 個 字節組成;如果靜態字段是引用類型,那麼這個操作數的類型也必須是引用類型,即由棧頂的 8 個字節組成。

再舉一個例子。iconst_ 代表了一個指令族,它的意思是把整數 i 放入操作數棧中,i 的範圍是(m1, 0, 1, 2, 3, 4, 5),其中 m1 代表的是 -1。注意,這裏的 i 並不是指令的操作數(即非嵌入式操作數,也非棧內操作數),如 iconst_1、iconst_2 和 iconst_3 都是由一個字節組成的字節碼指令。我們可以把 i 可以看作是指令的 “隱含操作數”,即指令本身就蘊含了操作數。如果整數 i 超過 [-1, 5] 這個範圍,就不能用 iconst_ 表示了,因爲僅一個字節的字節碼指令不可能蘊含所有的整數。此時就需要 bipush 這條指令了,這條指令有一個嵌入式操作數,由一個字節組成,用來表示要放入棧頂的那個整數,該整數放入棧頂時通過擴展符號位變爲 32 位的整型。但是一個字節也表示不了所有的整數,如果整數值超過一個字節所能表示的範圍,就只能通過 ldc 這條指令了,這條指令帶有一個字節的嵌入式操作數,它代表的是一個指向運行時常量池中 Constant_Integer_info 類型常量的索引,通過索引的方式引用運行時常量池中的整數,再大的整數也不怕了。

閱讀指令文檔

授之以魚不如授之以漁,在這裏不可能將所有的指令都講解一番,因此我和大家分享一下如何閱讀 oracle 官網關於字節碼指令的文檔吧。文檔的地址是:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html

我們拿 astore 指令來說: 關於它的文檔描述如下:

astore 指令

說明和翻譯:

  • 第一行的粗體字是指令的名稱;
  • Operation 是指令的功能:把引用存入本地變量中;
  • Format 是指令的格式:它的第一個字節是指令,名稱爲 astore,第二個字節是指令的嵌入式操作數,名稱爲 index;Forms 指的是指令的十進制(十六進制)碼,astore 的十進制(十六進制)碼是 58(0x3a);
  • Operation Stack 是指令執行前後的操作數棧的狀態:第一行代表的是指令執行前操作數的狀態,第二行是指令執行後操作數棧的狀態,箭頭是棧頂方向。astore 執行前棧頂是對象引用 objectRef,它是 astore 的棧內操作數,執行後 objectRef 被彈出並存入局部變量表中;
  • Description 是對這條指令的描述:index 是無符號字節,這個 index 必須指向當前棧幀的局部變量表的某個位置。操作數棧的棧頂的那個引用值必須是 returnAddress(方法返回地址)或者是 reference (對象引用)。這個引用會被彈出,其值會被存入局部變量表中索引爲 index 的 slot 中;
  • Notes 是注意事項:實現 Java 中的 finally 子句時,astore 指令使用的操作數類型是一個 returnAddress,與 astore 對應的 aload 指令(將局部變量表的的引用值壓棧)不能將類型爲 returnAddress 類型的值加載到操作數棧,而只能是 reference 類型。aload 和 astore 這種不對稱的設計是有意而爲之的。astore 指令可以和 wide 指令配合使用以用無符號雙字節類型的索引來獲取局部變量表中的變量。

局部變量表的第一個變量

從 Java 語言的層面講,靜態方法和實例方法的本質區別在於是否是對象所共享的。而從 JVM 的角度來看,方法(無論靜態方法還是實例方法)其實都是對象共享的,實例變量纔是對象私有的。對 JVM 而言,靜態方法和實例方法的本質區別在於是否需要和具體對象關聯:靜態方法可以通過類名來調用,它不需要和具體對象關聯;而實例方法必須通過對象來進行調用,它需要和具體對象關聯。那麼,實例方法和具體對象是如何產生關聯的呢?其實很簡單,編譯器在編譯時會將方法接收者作爲一個隱含參數傳入該實例方法,這個參數在方法中有一個很熟悉的名字,叫做 “this”。之所以實例方法可以訪問該類的實例變量和其它實例方法,正是因爲它有 “this” 這個隱含參數。舉個例子,類 A 中的某個方法 b 需要訪問實例變量 x,由於實例變量是對象私有的,如果 b 是靜態方法,由於它沒有具體對象的引用,它並不知道該訪問哪個對象的實例變量 x;如果 b 是實例方法,通過隱含參數 this 就能確定要訪問的實例變量是 this.x。那麼,爲什麼靜態方法也不能調用該類的實例方法呢?本質原因也是沒有 this 引用。因爲調用實例方法的前提是要傳入一個隱含參數,實例方法本來就有這個引用,所以能夠把它作爲隱含參數傳入另一個實例方法;靜態方法沒有 this 引用,無法給實例方法提供指向方法接收者的隱含參數,因此不能調用實例方法。

如果看懂了上面說的那些,第三個問題也就迎刃而解了。因爲我們定義的方法是 void foo(),它是實例方法,因此會有一個指向具體對象的隱含參數 this,this 就存放在局部變量表的第一個位置,即存放在索引爲 0 的 slot 中,又由於它的作用域從方法開始一直到方法結束,因此它在局部變量表中的位置不會被其他變量覆蓋,從而使得我們在方法中定義的變量只能放在局部變量表後面的位置中。需要注意的是,如果方法有參數(非隱含參數),那麼參數會按順序緊接着 this 存放在局部變量表中,由於參數作用域也是整個方法體,所以方法中定義的局部變量就只能放在參數後面了。總的來說局部變量表中變量的存放順序爲: this(如果是實例方法)=> 參數(如果有的話)=> 定義的局部變量(如果有的話)。

關於虛擬機棧就講這麼多了,Java 虛擬機是一個完整的知識體系,僅僅瞭解虛擬機棧是不夠的,這裏沒有細講的關於虛擬機的其它知識,如內存模型、運行時常量池、類加載模型等,還需讀者自己學習掌握。這篇文章權起激發大家的學習 JVM 興趣的作用,同時也作爲個人的學習記錄和知識總結。之後可能還會寫些 JVM 其它方面的總結性文章和大家分享。

參考書籍 :

《深入理解 Java 虛擬機》周志明 著

Java 虛擬機規範

相關文章