JVM位元組碼指令 及 反編譯分析
在文章《Java前端編譯:Java源代碼編譯成Class檔案的過程》了解到javac編譯的大體過程,在《Java Class檔案結構解析 及 執行個體分析驗證》中了解到了Class檔案結構,我們可以知道Class檔案中的各方法表後面的"code"屬性存儲了各方法對應的JVM位元組碼指令。
下面我們詳細了解JVM位元組碼指令:先對位元組碼指令組成結構有個大體了解,并通過前面的"getMap"方法的位元組碼資料來分析JVM指令及操作碼助記符,而後了解位元組碼指令與資料類型的關系,最後分類說明JVM指令的功能及注意事項。
1、位元組碼指令集概述
1-1、位元組碼指令的組成結構
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsIyMzYDOxIDMyIjNyITM2EDMy8CX0Vmbu4GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpg)
一條JVM指令由一個位元組長度、代表某種特定操作含義的數字(稱為操作碼,Opcode),以及跟随其後的零到多個代表此操作所需參數(稱為操作數,Operands)構成。
1、對于JVM指令,由于JVM采用面向操作數棧而不是寄存器的架構,是以大多指令隻有操作碼;
2、對于操作碼,由于操作碼長度為一個位元組,是以操作碼(指令)最多不超過256條;
3、對于操作數長度超過了一個位元組的情況(取決于操作碼):由于Class檔案格式放棄了編譯後代碼的操作數長度對齊,是以,當JVM處理超過一個位元組長度的資料時,需要在運作時從位元組中重建出具體資料的結構,如16位資料需要(byte1 << 8)|byte2操作(Big-Endian 順序存儲——即高位在前的位元組序);
這種操作會使得在解釋執行時損失一些性能,但這也可以省略很多填充和間隔符号,盡可能獲得短小精幹的編譯代碼,資料量小,傳輸效率高;
1-2、操作碼助記符及指令解釋
在《Java Class檔案結構解析 及 執行個體分析驗證》"3-8節、屬性表集合"的Code屬性分析中,可以看到"getMap"方法程式如下:
使用javac編譯為Class檔案後,"getMap"方法表對應的Code屬性中有22個位元組JVM位元組碼指令資料,如下:
使用javap反編譯後的JVM指令如下:
JVM指令的解釋:"new #4"、"dup"、"aload_1"、"invokeinterface #9, 3"等;
操作碼助記符:"new"、"dup"、aload_1"、"invokeinterface等;
操作數:"new "一個操作數"#4"、"invokeinterface"兩個"#9, 3";其中的"#"号表示索引常量池中的第幾項常量資料。
從位元組碼到指令解釋的"手動"翻譯過程如下:
(A)、"BB0004":先是一個操作碼"BB",查詢《JVM操作碼助記符表》可以看到整理如下:![]()
JVM位元組碼指令 及 反編譯分析 ,而後再《JVM指令集》的介紹中找到"new"指令的詳細說明,知道後面接一個操作數,并且是兩個位元組長度,是以操作數是"0004",即"BB0004"就表示指令"new #4";
(B)、"59":查表得知表示"dup"指令,後面沒有操作數;
(C)、"B70005":其中"B7"查指令集表得知為"invokeinterface",後面接一個兩位元組的操作數,即表示指令"invokespecial #5";
位元組碼指令 | 操作碼 | 操作數1 | 操作數2 | 操作數3 | 助記符及解釋 | 備注 |
BB0004 | BB | (00<<8)|04 | new #4 | "#"号表示索引常量池中的第幾項常量資料; | ||
59 | 59 | dup | 該指令無操作數 | |||
B70005 | B7 | (00<<8)|05 | invokespecial #5 | |||
4C | 4C | astore_1 | ||||
2B | 2B | aload_1 | ||||
B20006 | B2 | (00<<8)|06 | getstatic #6 | |||
1208 | 12 | 08 | ldc #8 | 操作數為一個位元組 | ||
B900090300 | 12 | (00<<8)|09 | 03 | 00 | invokeinterface #9, 3 | 第3個操作數是為了給 Oracle 實作的虛拟機的額外操作數而預留的空間 |
57 | 57 | pop | ||||
2B | 2B | aload_1 | ||||
B0 | B0 | areturn |
JVM規範中《操作碼助記符表》:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-7.html
JVM規範中《JVM指令集》介紹:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5
1-3、JVM指令與資料類型
1、指令助記符與資料類型
(A)、大多數指令包含了其操作所對應的資料類型資訊,如:
iload指令用于從局部變量表中加載int類型資料到操作數棧中;
fload指令加載的則是float類型資料;
(B)、對于大部分與資料類型相關的位元組碼指令,它們的操作碼助記符中都有特殊的字元來表明專門為哪種資料類型服務,如:
i代表int類型,f代表float類型,d代表double,a代表reference;
(C)、數組類型的一般帶有array字元,如:
arraylength指令;
(D)、還有一些指令與資料類型無關,如:
無條件跳轉指令goto;
2、"Not Orthogonal"特性與資料類型轉換
由于操作碼最多不超過256條,不可能每種對資料的操作都為每種類型單獨一條指令,即并非第種資料類型和每一種操作都有對應的指令,這稱為"Not Orthogonal"特性;
大部分指令都沒有支援boolean(沒有任何支援)、byte、char和short資料類型;JVM、編譯器會在編譯期或運作期将byte和short類型的資料帶符号擴充(Sign-Extend)為相應的Int類型資料;将boolean和char類型資料零位擴充(Zero-Extend)為相應的int類型資料;
處理這些類型的數組時,會轉換為int類型的位元組碼指令來處理;
2、JVM指令分類說明
可以将JVM指令操作按用途分為9類,下面分别介紹。
2-1、加載和存儲指令
用于将資料在棧幀中的局部變量表和操作數棧之間來回傳輸,包括如下内容指令:1、将一個局部變量加載到操作棧:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>;
2、将一個數值從操作數棧存儲到局部變量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>;
3、将一個常量加載到操作數棧:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_ml、iconst_<i>、lconst_<i>、fconst_<i>、dconst_<i>;
4、擴充局部變量表的通路索引的指令:wide;
<>結尾的代表了一組指令,如iload_<n>,代表了iload_1、iload_2、iload_3、iload_4;
它們省略了顯式的操作數,不需要進行操作數的動作,實際上操作資料隐含在指令中,如iload_1與iload操作數為0時完全一緻。
2-2、運算指令
用于對兩個操作數棧上的值進行某種特定運算,并把結果重新存入到操作棧頂;
可以分為兩種:1、對整型資料進行運算的指令;2、對浮點型資料進行運算的指令;整數與浮點數算術指令在溢出和被零除時也有各自不同的行為表現。
所有算術指令如下:
1、加法指令:iadd、ladd、fadd、dadd;
2、減法指令:isub、lsub、fsub、dsub;
3、乘法指令:imul、lmul、fmul、dmul;
4、除法指令:idiv、ldiv、fdiv、ddiv;
5、求餘指令:irem、lrem、frem、drem;
6、取反指令:ineg、lneg、fneg、dneg;
7、位移指令:ishl、ishr、iushr、lshl、lshr、lushr、;
8、按位或指令:ior、lor;
9、按位與指令:iand、land;
10、按位異或指令:ixor、lxor;
11、局部變量自增指令:iinc;
12、比較指令:dempg、dempl、fempg、fempl、lemp;
1、整型資料的運算
JVM規範沒有定義整型資料溢出的具體運算結果。
隻定義除法指令(idiv和ldiv)以及求餘指令(irem和lrem)中,當出現除數為零時會導緻JVM抛出ArithmethicException異常;除此外,其他任何整型資料運算都不應該抛出異常。
另外,前面說過,沒有直接支援boolean、byte、char和short資料類型的算術指令,使int類型的指令代替。
2、浮點型資料的運算
VM規範要求JVM處理浮點數時,必須嚴格遵守IEEE 754規範中規定的行為和限制,包括非正規浮點數值(Denormalized Floating-point Number)和逐級下溢(Gradual Underflow)的運算規則。
(A)、浮點型資料的舍入模式
浮點數運算時,非精确的結果必須舍入為可被表示的最接近的精确值,采用IEEE 754預設的舍入模式,優先選擇最低有效位為零的,稱為向最接近數舍入模式。
而把浮點數轉換為整數時,采用IEEE 754标準的向零舍入模式,即把小數部分的有效位元組丢棄,如:
float f1 = (float) 1.0;
float f2 = (float) 0.8;
double d1 = 1.00000005;
double d2 = 1.00000015;
double d3 = 1.00000025;
//測試向最接近數舍入模式
float fd1 = (float) (f1+d1);
float fd2 = (float) (f1+d2);
float fd3 = (float) (f1+d3);
System.out.println("fd1 = " + fd1);
System.out.println("fd3 = " + fd2);
System.out.println("fd3 = " + fd3);
//測試向零舍入模式
int i = (int) (f1+f2);
System.out.println("i = " + i);
輸出:
fd1 = 2.0
fd3 = 2.0000002
fd3 = 2.0000002
i = 1
(B)、異常、溢出、NaN值
JVM處理浮點數運算時,不會抛出任何運作時異常;
當一個操作産生溢出時,使用有符号的無窮大來表示;
沒有數學定義的值,使用NaN值來表示;
(C)、比較
JVM在long類型數值比較時,采用有符号的比較方式;
而在浮點數值比較(dempg、dempl、fempg、fempl)時,采用IEEE 754定義的無符号比較(Nosignaling Comparisons)方式;
2-3、類型轉換指令
用于将兩種不同的數值類型進行互相轉換;
1、寬化類型轉換
JVM直接支援(無需顯式的轉換指令)寬化類型轉換(Widening Numeric Conversions,即小範圍類型向大範圍類型的安全轉換),如下:
2、窄化類型轉換(A)、int類型到long、float、double類型;
(B)、long類型到float、double類型;
(C)、float類型到double類型;
而處理窄化類型轉換(Narrowing Numeric Conversions)時,必須顯式地使用轉換指令來完成,包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2f;這可能導緻轉換結果産生不同的正負号、不同的數量級的情況,轉換過程中還可能導緻精确度丢失,但JVM不會抛出任何運作時異常,如:(A)、int或long類型窄化轉換為整數類型T(T位元組長度為N)時,轉換過程僅僅是簡單地丢棄除最低位N個位元組以外的内容;
(B)、浮點值窄化轉換為整數類型T(T限于int或long類型)時,遵循以下規則:
1)、如果浮點值是NaN,那轉換結果是T為0;
2)、如果浮點值不是無窮大,采用向零舍入模式取整,獲得其整數值v;
如果v在T的表示範圍内,T就等于v;
否則,根據v的符号,轉換為T所能表示的最大或最小正數;
(C)、double類到float類型的窄化轉換采用向最接近數舍入模式,舍入得到一個可以用float表示的數字;1)、如果該數字絕對值太小無法用float來表示,将傳回float類型的正負零;
2)、如果該數字絕對值太大無法用float來表示,将傳回float類型的正負無窮大;
3)、而NaN轉換還是NaN;
2-4、對象建立與通路指令
JVM對類執行個體和數組建立和操作使用了不同的位元組碼指令,包括:
1、建立類執行個體的指令:new;
2、建立數組的指令:newarray、anewarray、multianewarray;
3、通路類字段(static字段或類變量)和執行個體字段(非static字段或執行個體變量)的指令:getfield、putfield、getstatic、putstatic;
4、把一個數組元素加載到操作數棧的指令:baload、caload、saload、iaload、laload、faload、daload、aaload;
5、把一個操作數棧的值存儲到數組元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore;
6、取數組長度的指令:arraylength;
7、檢查類執行個體類型的指令:instanceof、checkcast;
2-5、操作數棧管理指令
JVM直接操作操作數棧的指令:
1、将操作數棧的棧頂一個或兩個元素出棧:pop、pop2;
2、複制棧頂一個或兩個數值并将複制值或雙份的複制值重新壓入棧頂:dup、dup2;
3、将棧最頂端的兩個數值互換:swap;
2-6、控制轉移指令
用于讓JVM有條件或無條件地從指定的位置指令(而不是控制轉移指令的下一條指令)繼續執行程式,即有條件或無條件地修改PC寄存器的值。
控制轉移指令如下:
1、條件分支:ifeq、iflt、ifle、ifgt、ifge、ifnull、ifnonnull、empeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne;
2、複合條件分支:tableswitch、lookupswitch;
3、無條件分支:goto、goto_w、jsr、jsr_w、ret;
JVM有專門處理int、reference類型和檢測null值的指令;
對boolean、byte、char和short類型的條件分支比較操作,都是使用int類型的比較操作指令;
對long、float和double類型的條件分支比較操作,則先會執行相應類型的比較運算指令(dempg、dempl、fempg、fempl、lemp),然後傳回一個整型值到操作數棧中,再執行int類型的條件分支比較操作完成跳轉;
是以int類型的條件分支指令是最為豐富和強大的。
2-7、操作數棧管理指令
方法調用指令主要是的以下5條:
1、invokevirtual指令:用于調用對象的執行個體方法,根據實際類型進行分派(虛方法分派),最常見的分派方式;
2、invokeinterface指令:用于調用對象接口方法,運作時會搜尋一個實作了該接口方法的對象,找出适合的方法進行調用;
3、invokespecial指令:用于調用一些需要特殊處理的執行個體方法,包括執行個體初始化方法、私有方法和父類方法;
4、invokestatic指令:用于調用類方法(static方法);
5、invokedynamic指令:用于在運作時動态解析出調用點限定符所引用的方法,并執行該方法;
前面4條指令的分派邏輯都固化在JVM内,而invokedynamic指令的分派邏輯是由使用者所設定的引導方法決定的。
方法調用指令與資料類型無關,而方法傳回指令是根據傳回值類型區分的,包括:
1、ireturn、lreturn、freturn、dreturn、areturn;
2、return:void方法、執行個體初始化方法以及類和接口的類初始化方法使用。
2-8、操作數棧管理指令
Java程式中顯式抛出異常的操作(throw語句)都是由athrow指令來實作的;
還有許多運作時異常,會在JVM指令檢測到異常時自動抛出,如idiv或ldiv指令除數為零時,自動抛出ArithmeticException異常;
另外,處理異常的catch語句,不是由位元組碼指令實作的(JDK1.4.2前是由jsr和ret指令實作),而是采用異常表來完成(方法表中Code屬性有異常表)。
2-9、同步指令
JVM支援方法級的同步和方法内部一段指令序列的同步,兩種同步結構都使用管程(Monitor)來支援,如下:
1、方法級的同步
是隐式的,無須通過位元組碼指令來控制,實作在方法調用和傳回操作之中;
方法調用時,先檢查方法表(method_info)的ACC_SYNCHRONIZED通路标志,設定了表示該方法為同步方法;
執行線程需要先成功持有管程,才能執行該方法,方法完成傳回時釋放管程;
如果方法執行期間抛出異常,且方法内部無法處理,管程在異常抛出到方法外時自動釋放;
2、同步一段指令序列
通常在java程式中由synchronized語句來表示,有monitorenter和monitorexit兩條指令來支援;
需要javac編譯器和JVM共同協作支援;
一條monitorenter指令需要一條monitorexit指令對應,是以,編譯器可能會自動産生一個可以處理所有異常的異常處理器,來執行monitorexit指令;
2-10、其他
1、三個保留操作碼
有三個是保留操作碼,它們是被Java虛拟機内部使用的,不能真的出現在一個有效的Class檔案之中:
兩個操作碼值分别為 254(0xfe)和 255(0xff),助記符分别為impdep1和impdep2的兩個操作碼是作為"後門"和"陷阱"出現,目的是在某些硬體和軟體中提供一些與實作相關的功能;
第三個操作碼值分别為 202(0xca)、助記符為 breakpoint 的操作碼是用于調試器實作斷點功能;
2、虛拟機錯誤
當Java虛拟機出現了内部錯誤,或者由于資源限制導緻虛拟機無法實作Java語言中的語義時,Java虛拟機将會抛出一個屬于VirtualMachineError的子類的異常對象執行個體;
可能會出現在Java虛拟機運作過程中的任意時刻,主要錯誤如下:
(A)、InternalError
Java虛拟機實作的軟體或硬體錯誤都會導緻InternalError異常的出現,InternalError是一個典型的異步異常,它可能出現在程式中的任何位置。
(B)、OutOfMemoryError
當Java虛拟機實作耗盡了所有虛拟和實體記憶體,并且記憶體自動管理子系統無法回收到足夠共新對象配置設定所需的記憶體空間時,虛拟機将抛出OutOfMemoryError異常。
(C)、StackOverflowError
當Java虛拟機實作耗盡了線程全部的棧空間,這種情況經常是由于程式執行時無限制的遞歸調用而導緻的,虛拟機将會抛出StackOverflowError異常。
(D)、UnknownError
當某種異常或錯誤出現,但虛拟機實作無法确定具體實際是哪種異常或錯誤的時候,将會抛出UnknownError異常。
到這裡,我們大體了解JVM位元組碼指令是什麼,有些什麼功能了,但是一個方法的JVM指令執行過程是怎麼樣的呢?這個需要先來了解JVM如何加載Class檔案,JVM運作時的資料區是怎麼樣的,這樣才能解釋得清楚指令操作的是什麼,如前篇文章分析的"getMap"方法,其中"ldc #8"直接将第8項常量"java"字元串加載到操作數棧頂,這個常量"java"字元串存儲在哪裡,操作數棧又是什麼。
後面我們将分别去了解:JVM運作時資料區、JIT編譯--在運作時把Class檔案位元組碼編譯成本地機器碼的過程、以及JVM垃圾收集相關内容……
【參考資料】
1、《The Java Virtual Machine Specification》Java SE 8 Edition:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html
2、《深入了解Java虛拟機:JVM進階特性與最佳實踐》第二版 第6章
3、《The Java Language Specification》Java SE 8 Edition:https://docs.oracle.com/javase/specs/jls/se8/html/index.html
4、Java前端編譯:Java源代碼編譯成Class檔案的過程