[深入理解Java虛擬機器]第六章 位元組碼指令簡介

Coding-lover發表於2015-10-23

Java虛擬機器的指令由一個位元組長度的、代表著某種特定操作含義的數字(稱為操作碼 ,Opcode ) 以及跟隨其後的零至多個代表此操作所需引數(稱為運算元,Operands )而構成。由於Java虛擬機器採用面向運算元棧而不是暫存器的架構(這兩種架構的區別和影響將在第8章中探討),所以大多數的指令都不包含運算元,只有一個操作碼。

位元組碼指令集是一種具有鮮明特點、優劣勢都很突出的指令集架構,由於限制了Java虛擬機器操作碼的長度為一個位元組(即0〜255) ,這意味著指令集的操作碼總數不可能超過256條;又由於Class檔案格式放棄了編譯後程式碼的運算元長度對齊,這就意味著虛擬機器處理那些超過一個位元組資料的時候,不得不在執行時從位元組中重建出具體資料的結構,如果要將一個16位長度的無符號整數使用兩個無符號位元組儲存起來(將它們命名為byte1和byte2) ,那它們的值應該是這樣的:

(byte1<<8)|byte2

這種操作在某種程度上會導致解釋執行位元組碼時損失一些效能。但這樣做的優勢也非常明顯,放棄了運算元長度對齊,就意味著可以省略很多填充和間隔符號;用一個位元組來代表操作碼,也是為了儘可能獲得短小精幹的編譯程式碼。這種追求儘可能小資料量、高傳輸效率的設計是由Java語言設計之初面向網路、智慧家電的技術背景所決定的,並一直沿用至今。

如果不考慮異常處理的話,那麼Java虛擬機器的直譯器可以使用下面這個虛擬碼當做最基本的執行模型來理解,這個執行模型雖然很簡單,但依然可以有效地工作:

位元組碼與資料型別

在Java虛擬機器的指令集中,大多數的指令都包含了其操作所對應的資料型別資訊。例如, iload指令用於從區域性變數表中載入int型的資料到運算元棧中,而fload指令載入的則是 float型別的資料。這兩條指令的操作在虛擬機器內部可能會是由同一段程式碼來實現的,但在 Class檔案中它們必須擁有各自獨立的操作碼。

對於大部分與資料型別相關的位元組碼指令,它們的操作碼助記符中都有特殊的字元來表明專門為哪種資料型別服務:i代表對int型別的資料操作,l代表long,s代表short,b代表byte,c代表char,f代表float,d代表double,a代表reference。也有一些指令的助記符中沒有明確地指明操作型別的字母,如arraylength指令,它沒有代表資料型別的特殊字元,但運算元永遠只能是一個陣列型別的物件。還有另外一些指令,如無條件跳轉指令goto則是與資料型別無關的。

由於Java虛擬機器的操作碼長度只有一個位元組,所以包含了資料型別的操作碼就為指令集的設計帶來了很大的壓力:如果每一種與資料型別相關的指令都支援Java虛擬機器所有執行時資料型別的話,那指令的數量恐怕就會超出一個位元組所能表示的數量範圍了。因此, Java虛擬機器的指令集對於特定的操作只提供了有限的型別相關指令去支援它,換句話說,指令集將會故意被設計成非完全獨立的(Java虛擬機器規範中把這種特性稱為“Not Orthogonal”,即並非每種資料型別和每一種操作都有對應的指令)。有一些單獨的指令可以在必要的時候用來將一些不支援的型別轉換為可被支援的型別。

表6-31列舉了Java虛擬機器所支援的與資料型別相關的位元組碼指令,通過使用資料型別列所代表的特殊字元替換opcode列的指令模板中的T , 就可以得到一個具體的位元組碼指令。如果在表中指令模板與資料型別兩列共同確定的格為空,則說明虛擬機器不支援對這種資料型別執行這項操作。例如,load指令有操作int型別的iload,但是沒有操作byte型別的同類指令。

注意,從表6-31中可以看出,大部分的指令都沒有支援整數型別byte、char和short,甚至沒有任何指令支援boolean型別。編譯器會在編譯期或執行期將byte和short型別的資料帶符號擴充套件(Sign-Extend)為相應的int型別資料,將boolean和char型別資料零位擴充套件(Zero-Extend)為相應的int型別資料。與之類似,在處理boolean、byte、short和char型別的陣列時, 也會轉換為使用對應的int型別的位元組碼指令來處理。因此 ,大多數對於boolean、byte、 short 和char型別資料的操作,實際上都是使用相應的int型別作為運算型別( Computational Type ) 。


在本章中,受篇幅所限,無法對位元組碼指令集中每條指令進行逐一講解,但閱讀位元組碼作為了解Java虛擬機器的基礎技能,是一項應當熟練掌握的能力。筆者將位元組碼操作按用途大致分為9類 ,按照分類來為讀者概略介紹一下這些指令的用法。如果讀者需要了解更詳細的資訊,可以參考閱讀筆者翻譯的《Java虛擬機器規範( Java SE 7版 )》的第6章。

位元組碼指令流基本上都是單位元組對齊的,只有“tableswitch”和“lookupswitch”兩條指令例外 ,由於它們的運算元比較特殊,是以4位元組為界劃分開的,所以這兩條指令也需要預留出相應的空位進行填充來實現對齊。

載入和儲存指令

載入和儲存指令用於將資料在棧幀中的區域性變數表和運算元棧(見第2章關於記憶體區域的介紹)之間來回傳輸,這類指令包括如下內容。

  • 將一個區域性變數載入到操作棧:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n> 。
  • 將一個數值從運算元棧儲存到區域性變數表 :istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_ <n> 、dstore、dstore_<n> 、astore、astore_ <n> 。
  • 將一個常量載入到運算元棧:bipush、sipush、ldc、 ldc_w、ldc2_w、 aconst_null、iconst_ml、iconst_、lconst_<l>、fconst_<f>、dconst_<d>。
  • 擴充區域性變數表的訪問索引的指令 : wide。

儲存資料的運算元棧和區域性變數表主要就是由載入和儲存指令進行操作,除此之外,還有少量指令,如訪問物件的欄位或陣列元素的指令也會向運算元棧傳輸資料。

上面所列舉的指令助記符中,有一部分是以尖括號結尾的(例如iload_<n> ),這些指令助記符實際上是代表了一組指令(例如iload_<n> , 它代表了iload_0、iload_1、iload_2和 iload_3這幾條指令)。這幾組指令都是某個帶有一個運算元的通用指令(例如iload) 的特殊形式 ,對於這若干組特殊指令來說,它們省略掉了顯式的運算元,不需要進行取運算元的動作 ,實際上運算元就隱含在指令中。除了這點之外,它們的語義與原生的通用指令完全一致(例如iload_0的語義與運算元為0時的iload指令語義完全一致)。這種指令表示方法在本書以及《Java虛擬機器規範》中都是通用的。

運算指令

運算或算術指令用於對兩個運算元棧上的值進行某種特定運算,並把結果重新存入到操作棧頂。大體上算術指令可以分為兩種:對整型資料進行運算的指令與對浮點型資料進行運算的指令,無論是哪種算術指令,都使用Java虛擬機器的資料型別,由於沒有直接支援byte、 short、char和boolean型別的算術指令,對於這類資料的運算,應使用操作int型別的指令代替。整數與浮點數的算術指令在溢位和被零除的時候也有各自不同的行為表現,所有的算術指令如下。

  • 加法指令:iadd、ladd、fadd、dadd。
  • 減法指令:isub、lsub、fsub、dsub。
  • 乘法指令:imul、lmul、fmul、dmul。
  • 除法指令:idiv、ldiv、fdiv、ddiv。
  • 求餘指令:irem、lrem、frem、drem。
  • 取反指令 : ineg、lneg、fneg、dneg。
  • 位移指令:ishl、ishr、iushr、lshl、lshr、lushr。
  • 按位或指令:ior、lor。
  • 按位與指令:iand、land。
  • 按位異或指令:ixor、lxor。
  • 區域性變數自增指令:iinc。
  • 比較指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。

Java虛擬機器規範要求虛擬機器實現在處理浮點數時,必須嚴格遵循IEEE 754規範中所規定的行為和限制。也就是說,Java虛擬機器必須完全支援IEEE 754中定義的非正規浮點數值 ( Denormalized Floating-Point Numbers ) 和逐級下溢( Gradual Underflow ) 的運算規則。這些特徵將會使某些數值演算法處理起來變得相對容易一些。

Java虛擬機器要求在進行浮點數運算時,所有的運算結果都必須舍入到適當的精度,非精確的結果必須被舍入為可被表示的最接近的精確值,如果有兩種可表示的形式與該值一樣接近,將優先選擇最低有效位為零的。這種舍入模式也是IEEE 754規範中的預設舍入模式,稱為向最接近數舍入模式。

在把浮點數轉換為整數時,Java虛擬機器使用正EE 754標準中的向零舍入模式,這種模式的舍入結果會導致數字被截斷,所有小數部分的有效位元組都會被丟棄掉。向零舍入模式將在目標數值型別中選擇一個最接近但是不大於原值的數字來作為最精確的舍入結果。

另外 ,Java虛擬機器在處理浮點數運算時,不會丟擲任何執行時異常(這裡所講的是Java 語言中的異常,請讀者勿與正EE 754規範中的浮點異常互相混淆, IEEE 754的浮點異常是一種運算訊號),當一個操作產生溢位時,將會使用有符號的無窮大來表示,如果某個操作結果沒有明確的數學定義的話,將會使用NaN值來表示。所有使用NaN值作為運算元的算術操作 ,結果都會返回NaN。

在對long型別數值進行比較時,虛擬機器採用帶符號的比較方式,而對浮點數值進行比較時 (dcmpg、dcmpl、fcmpg、 fcmpl ) ,虛擬機器會採用IEEE 754規範所定義的無訊號比較( Nonsignaling Comparisons ) 方式。

型別轉換指令

型別轉換指令可以將兩種不同的數值型別進行相互轉換,這些轉換操作一般用於實現使用者程式碼中的顯式型別轉換操作,或者用來處理本節開篇所提到的位元組碼指令集中資料型別相關指令無法與資料型別一一對應的問題。

Java 虛擬機器直接支援(即轉換時無需顯式的轉換指令)以下數值型別的寬化型別轉換 ( WideningNumeric Conversions , 即小範圍型別向大範圍型別的安全轉換):

  • int型別到long、float或者double型別。
  • long型別到float、double型別。
  • float型別到double型別。

相對的,處理窄化型別轉換( Narrowing Numeric Conversions ) 時 ,必須顯式地使用轉換指令來完成,這些轉換指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化型別轉換可能會導致轉換結果產生不同的正負號、不同的數量級的情況,轉換過程很可能會導致數值的精度丟失。

在將int或long型別窄化轉換為整數型別T的時候,轉換過程僅僅是簡單地丟棄除最低位N個位元組以外的內容,N是型別T的資料型別長度,這將可能導致轉換結果與輸入值有不同的正負號。這點很容易理解,因為原來符號位處於數值的最高位,高位被丟棄之後,轉換結果的符號就取決於低N個位元組的首位了。

在將一個浮點值窄化轉換為整數型別T ( T限於int或long型別之一 ) 的時候,將遵循以下轉換規則:

  • 如果浮點值是NaN ,那轉換結果就是int或long型別的0。
  • 如果浮點值不是無窮大的話,浮點值使用IEEE 754的向零舍入模式取整,獲得整數值v , 如果v在目標型別T ( int或long ) 的表示範圍之內,那轉換結果就是v。
  • 否則,將根據v的符號,轉換為T所能表示的最大或者最小正數。

從double型別到float型別的窄化轉換過程與IEEE 754中定義的一致 ,通過IEEE 754向最接近數舍入模式舍入得到一個可以使用float型別表示的數字。如果轉換結果的絕對值太小而無法使用float來表示的話,將返回float型別的正負零。如果轉換結果的絕對值太大而無法使用float來表示的話,將返回float型別的正負無窮大,對於double型別的NaN值將按規定轉換為float型別的NaN值。

儘管資料型別窄化轉換可能會發生上限溢位、下限溢位和精度丟失等情況,但是Java虛擬機器規範中明確規定數值型別的窄化轉換指令永遠不可能導致虛擬機器丟擲執行時異常。

物件建立與訪問指令

雖然類例項和陣列都是物件,但Java虛擬機器對類例項和陣列的建立與操作使用了不同的位元組碼指令(在第7章會講到陣列和普通類的型別建立過程是不同的)。物件建立後,就可以通過物件訪問指令獲取物件例項或者陣列例項中的欄位或者陣列元素,這些指令如下。

  • 建立類例項的指令:new。
  • 建立陣列的指令 : newarray、anewarray、multianewarray。
  • 訪問類欄位(static欄位 ,或者稱為類變數)和例項欄位(非static欄位 ,或者稱為例項變數 )的指令:getfield、putfield、getstatic、putstatic。
  • 把一個陣列元素載入到運算元棧的指令:baload、caload、saload、iaload、laload、faloads daload、aaload。
  • 將一個運算元棧的值儲存到陣列元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
  • 檢查類例項型別的指令:instanceof、checkcast。

運算元棧管理指令

如同操作一個普通資料結構中的堆疊那樣,Java虛擬機器提供了一些用於直接操作運算元棧的指令,包括:

  • 將運算元棧的棧頂一個或兩個元素出棧:pop、pop2。
  • 複製棧頂一個或兩個數值並將複製值或雙份的複製值重新壓入棧頂:dup、dup2、 dup_x1、dup2_x1、dup_x2、dup2_x2。
  • 將棧最頂端的兩個數值互換 : swap。

控制轉移指令

控制轉移指令可以讓Java虛擬機器有條件或無條件地從指定的位置指令而不是控制轉移指令的下一條指令繼續執行程式,從概念模型上理解,可以認為控制轉移指令就是在有條件或無條件地修改PC暫存器的值。控制轉移指令如下。

  • 條件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、 if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
  • 複合條件分支:tableswitch、 lookupswitch。
  • 無條件分支:goto、goto_w、jsr、jsr_w、ret。

在Java虛擬機器中有專門的指令集用來處理int和reference型別的條件分支比較操作,為了可以無須明顯標識一個實體值是否null,也有專門的指令用來檢測null值。

與前面算術運算時的規則一致,對於boolean型別、byte型別、char型別和short型別的條件分支比較操作,都是使用int型別的比較指令來完成,而對於long型別、float型別和double型別的條件分支比較操作,則會先執行相應型別的比較運算指令(dcmpg、dcmpl、fcmpg、 fcmpl、lcmp,見6.4.3節),運算指令會返回一個整型值到運算元棧中,隨後再執行int型別的條件分支比較操作來完成整個分支跳轉。由於各種型別的比較最終都會轉化為int型別的比較操作 ,int型別比較是否方便完善就顯得尤為重要,所以Java虛擬機器提供的int型別的條件分支指令是最為豐富和強大的。

方法呼叫和返回指令

方法呼叫(分派、執行過程)將在第8章具體講解,這裡僅列舉以下5條用於方法呼叫的指令。

  • invokevirtual指令用於呼叫物件的例項方法,根據物件的實際型別進行分派(虛方法分派),這也是Java語言中最常見的方法分派方式。
  • invokeinterface指令用於呼叫介面方法,它會在執行時搜尋一個實現了這個介面方法的物件 ,找出適合的方法進行呼叫。
  • invokespecial指令用於呼叫一些需要特殊處理的例項方法,包括例項初始化方法、私有方法和父類方法。
  • invokestatic指令用於呼叫類方法(static方法)。
  • invokedynamic指令用於在執行時動態解析出呼叫點限定符所引用的方法,並執行該方法 ,前面4條呼叫指令的分派邏輯都固化在Java虛擬機器內部,而invokedynamic指令的分派邏輯是由使用者所設定的引導方法決定的。

方法呼叫指令與資料型別無關,而方法返回指令是根據返回值的型別區分的,包括 ireturn (當返回值是boolean、byte、char、short和int型別時使用)、lreturn、freturn、dreturn和 areturn,另外還有一條return指令供宣告為void的方法、例項初始化方法以及類和介面的類初始化方法使用。

異常處理指令

在Java程式中顯式丟擲異常的操作(throw語句)都由athrow指令來實現,除了用throw語句顯式丟擲異常情況之外,Java虛擬機器規範還規定了許多執行時異常會在其他Java虛擬機器指令檢測到異常狀況時自動丟擲。例如 ,在前面介紹的整數運算中,當除數為零時,虛擬機器會在idiv或ldiv指令中丟擲ArithmeticException異常。

而在Java虛擬機器中,處理異常(catch語句)不是由位元組碼指令來實現的(很久之前曾經使用jsr和ret指令來實現,現在已經不用了),而是使用異常表來完成的。

同步指令

Java虛擬機器可以支援方法級的同步和方法內部一段指令序列的同步,這兩種同步結構都是使用管程(Monitor)來支援的。

方法級的同步是隱式的,即無須通過位元組碼指令來控制,它實現在方法呼叫和返回操作之中。虛擬機器可以從方法常量池的方法表結構中的ACC_SYNCHRONIZED訪問標誌得知一個方法是否宣告為同步方法。當方法呼叫時,呼叫指令將會檢查方法的ACC_SYNCHRONIZED 訪問標誌是否被設定,如果設定了,執行執行緒就要求先成功持有管程,然後才能執行方法, 最後當方法完成(無論是正常完成還是非正常完成)時釋放管程。在方法執行期間,執行執行緒持有了管程,其他任何執行緒都無法再獲取到同一個管程。如果一個同步方法執行期間丟擲了異常,並且在方法內部無法處理此異常,那麼這個同步方法所持有的管程將在異常拋到同步方法之外時自動釋放。

同步一段指令集序列通常是由Java語言中的synchronized語句塊來表不的,Java虛擬機器的指令集中有monitorenter和monitorexit兩條指令來支援synchronized關鍵字的語義,正確實現synchronized關鍵字需要Javac編譯器與Java虛擬機器兩者共同協作支援,譬如程式碼清單6-6中所 示的程式碼。

編譯後,這段程式碼生成的位元組碼序列如下:

編譯器必須確保無論方法通過何種方式完成,方法中呼叫過的每條monitorenter指令都必須執行其對應的monitorexit指令 ,而無論這個方法是正常結束還是異常結束。

從程式碼清單6-6的位元組碼序列中可以看到,為了保證在方法異常完成時momtorenter和monitorexit指令依然可以正確配對執行,編譯器會自動產生一個異常處理器,這個異常處理器宣告可處理所有的異常,它的目的就是用來執行momtorexit指令。

公有設計和私有實現

Java虛擬機器規範描繪了Java虛擬機器應有的共同程式儲存格式:Class檔案格式以及位元組碼指令集。這些內容與硬體、作業系統及具體的Java虛擬機器實現之間是完全獨立的,虛擬機器實現者可能更願意把它們看做是程式在各種Java平臺實現之間互相安全地互動的手段。

理解公有設計與私有實現之間的分界線是非常有必要的,Java虛擬機器實現必須能夠讀取Class檔案並精確實現包含在其中的Java虛擬機器程式碼的語義。拿著Java虛擬機器規範一成不變地逐字實現其中要求的內容當然是一種可行的途徑,但一個優秀的虛擬機器實現,在滿足虛擬機器規範的約束下對具體實現做出修改和優化也是完全可行的,並且虛擬機器規範中明確鼓勵實現者這樣做。只要優化後Class檔案依然可以被正確讀取,並且包含在其中的語義能得到完整的保持 ,那實現者就可以選擇任何方式去實現這些語義,虛擬機器後臺如何處理Class檔案完全是實現者自己的事情,只要它在外部介面上看起來與規範描述的一致即可。

虛擬機器實現者可以使用這種伸縮性來讓Java虛擬機器獲得更高的效能、更低的記憶體消耗或者更好的可移植性,選擇哪種特性取決於Java虛擬機器實現的目標和關注點是什麼。虛擬機器實現的方式主要有以下兩種:

  • 將輸入的Java虛擬機器程式碼在載入或執行時翻譯成另外一種虛擬機器的指令集。
  • 將輸入的Java虛擬機器程式碼在載入或執行時翻譯成宿主機CPU的本地指令集(即JIT程式碼生成技術)。

精確定義的虛擬機器和目標檔案格式不應當對虛擬機器實現者的創造性產生太多的限
制 ,Java虛擬機器應被設計成可以允許有眾多不同的實現,並且各種實現可以在保持相容性的同時提供不同的、新的、有趣的解決方案。

這裡其實多少存在一些例外:譬如偵錯程式(Debuggers )、效能監視器(Profilers )和即時編譯器(Just-In-Time Code Generator )等都可能需要訪問一些通常認為是“虛擬機器後臺”的元素。

相關文章