我要悄悄学习 Java 字节码指令,在成为技术大佬的路上一去不复返
大家好,我是二哥呀。
Java 字節碼指令是 JVM 體系中非常難啃的一塊硬骨頭,我估計有些讀者會有這樣的疑惑,“Java 字節碼難學嗎?我能不能學會啊?”
講良心話,不是我謙虛,一開始學 Java 字節碼和 Java 虛擬機方面的知識我也感覺頭大!但硬著頭皮學了一陣子之后,突然就開竅了,覺得好有意思,尤其是明白了 Java 代碼在底層竟然是這樣執行的時候,感覺既膨脹又飄飄然,渾身上下散發著自信的光芒!
我在 CSDN 共輸出了 100 多篇 Java 方面的文章,總字數超過 30 萬字, 內容風趣幽默、通俗易懂,收獲了很多初學者的認可和支持,內容包括 Java 語法、Java 集合框架、Java 并發編程、Java 虛擬機等核心內容。
為了幫助更多的 Java 初學者,我“一怒之下”就把這些文章重新整理并開源到了 GitHub,起名《教妹學 Java》,聽起來是不是就很有趣?
GitHub 開源地址(歡迎 star):https://github.com/itwanger/jmx-java
Java 官方的虛擬機 Hotspot 是基于棧的,而不是基于寄存器的。
基于棧的優點是可移植性更好、指令更短、實現起來簡單,但不能隨機訪問棧中的元素,完成相同功能所需要的指令數也比寄存器的要多,需要頻繁的入棧和出棧。
基于寄存器的優點是速度快,有利于程序運行速度的優化,但操作數需要顯式指定,指令也比較長。
Java 字節碼由操作碼和操作數組成。
- 操作碼(Opcode):一個字節長度(0-255,意味著指令集的操作碼總數不可能超過 256 條),代表著某種特定的操作含義。
- 操作數(Operands):零個或者多個,緊跟在操作碼之后,代表此操作需要的參數。
由于 Java 虛擬機是基于棧而不是寄存器的結構,所以大多數指令都只有一個操作碼。比如 aload_0(將局部變量表中下標為 0 的數據壓入操作數棧中)就只有操作碼沒有操作數,而 invokespecial #1(調用成員方法或者構造方法,并傳遞常量池中下標為 1 的常量)就是由操作碼和操作數組成的。
01、加載與存儲指令
加載(load)和存儲(store)相關的指令是使用最頻繁的指令,用于將數據從棧幀的局部變量表和操作數棧之間來回傳遞。
1)將局部變量表中的變量壓入操作數棧中
- xload_(x 為 i、l、f、d、a,n 默認為 0 到 3),表示將第 n 個局部變量壓入操作數棧中。
- xload(x 為 i、l、f、d、a),通過指定參數的形式,將局部變量壓入操作數棧中,當使用這個指令時,表示局部變量的數量可能超過了 4 個
解釋一下。
x 為操作碼助記符,表明是哪一種數據類型。見下表所示。
像 arraylength 指令,沒有操作碼助記符,它沒有代表數據類型的特殊字符,但操作數只能是一個數組類型的對象。
大部分的指令都不支持 byte、short 和 char,甚至沒有任何指令支持 boolean 類型。編譯器會將 byte 和 short 類型的數據帶符號擴展(Sign-Extend)為 int 類型,將 boolean 和 char 零位擴展(Zero-Extend)為 int 類型。
舉例來說。
private void load(int age, String name, long birthday, boolean sex) {System.out.println(age + name + birthday + sex); }通過 jclasslib 看一下 load() 方法(4 個參數)的字節碼指令。
- iload_1:將局部變量表中下標為 1 的 int 變量壓入操作數棧中。
- aload_2:將局部變量表中下標為 2 的引用數據類型變量(此時為 String)壓入操作數棧中。
- lload_3:將局部變量表中下標為 3 的 long 型變量壓入操作數棧中。
- iload 5:將局部變量表中下標為 5 的 int 變量(實際為 boolean)壓入操作數棧中。
通過查看局部變量表就能關聯上了。
2)將常量池中的常量壓入操作數棧中
根據數據類型和入棧內容的不同,此類又可以細分為 const 系列、push 系列和 Idc 指令。
const 系列,用于特殊的常量入棧,要入棧的常量隱含在指令本身。
push 系列,主要包括 bipush 和 sipush,前者接收 8 位整數作為參數,后者接收 16 位整數。
Idc 指令,當 const 和 push 不能滿足的時候,萬能的 Idc 指令就上場了,它接收一個 8 位的參數,指向常量池中的索引。
- Idc_w:接收兩個 8 位數,索引范圍更大。
- 如果參數是 long 或者 double,使用 Idc2_w 指令。
舉例來說。
public void pushConstLdc() {// 范圍 [-1,5]int iconst = -1;// 范圍 [-128,127]int bipush = 127;// 范圍 [-32768,32767]int sipush= 32767;// 其他 intint ldc = 32768;String aconst = null;String IdcString = "沉默王二"; }通過 jclasslib 看一下 pushConstLdc() 方法的字節碼指令。
- iconst_m1:將 -1 入棧。范圍 [-1,5]。
- bipush 127:將 127 入棧。范圍 [-128,127]。
- sipush 32767:將 32767 入棧。范圍 [-32768,32767]。
- ldc #6 <32768>:將常量池中下標為 6 的常量 32768 入棧。
- aconst_null:將 null 入棧。
- ldc #7 <沉默王二>:將常量池中下標為 7 的常量“沉默王二”入棧。
3)將棧頂的數據出棧并裝入局部變量表中
主要是用來給局部變量賦值,這類指令主要以 store 的形式存在。
- xstore_(x 為 i、l、f、d、a,n 默認為 0 到 3)
- xstore(x 為 i、l、f、d、a)
明白了 xload_ 和 xload,再看 xstore_ 和 xstore 就會輕松得多,作用反了一下而已。
大家來想一個問題,為什么要有 xstore_ 和 xload_ 呢?它們的作用和 xstore n、xload n 不是一樣的嗎?
xstore_ 和 xstore n 的區別在于,前者相當于只有操作碼,占用 1 個字節;后者相當于由操作碼和操作數組成,操作碼占 1 個字節,操作數占 2 個字節,一共占 3 個字節。
由于局部變量表中前幾個位置總是非常常用,雖然 xstore_<n> 和 xload_<n> 增加了指令數量,但字節碼的體積變小了!
舉例來說。
public void store(int age, String name) {int temp = age + 2;String str = name; }通過 jclasslib 看一下 store() 方法的字節碼指令。
- istore_3:從操作數中彈出一個整數,并把它賦值給局部變量表中索引為 3 的變量。
- astore 4:從操作數中彈出一個引用數據類型,并把它賦值給局部變量表中索引為 4 的變量。
通過查看局部變量表就能關聯上了。
02、算術指令
算術指令用于對兩個操作數棧上的值進行某種特定運算,并把結果重新壓入操作數棧。可以分為兩類:整型數據的運算指令和浮點數據的運算指令。
需要注意的是,數據運算可能會導致溢出,比如兩個很大的正整數相加,很可能會得到一個負數。但 Java 虛擬機規范中并沒有對這種情況給出具體結果,因此程序是不會顯式報錯的。所以,大家在開發過程中,如果涉及到較大的數據進行加法、乘法運算的時候,一定要注意!
當發生溢出時,將會使用有符號的無窮大 Infinity 來表示;如果某個操作結果沒有明確的數學定義的話,將會使用 NaN 值來表示。而且所有使用 NaN 作為操作數的算術操作,結果都會返回 NaN。
舉例來說。
public void infinityNaN() {int i = 10;double j = i / 0.0;System.out.println(j); // Infinitydouble d1 = 0.0;double d2 = d1 / 0.0;System.out.println(d2); // NaN }- 任何一個非零的數除以浮點數 0(注意不是 int 類型),可以想象結果是無窮大 Infinity 的。
- 把這個非零的數換成 0 的時候,結果又不太好定義,就用 NaN 值來表示。
Java 虛擬機提供了兩種運算模式:
- 向最接近數舍入:在進行浮點數運算時,所有的結果都必須舍入到一個適當的精度,不是特別精確的結果必須舍入為可被表示的最接近的精確值,如果有兩種可表示的形式與該值接近,將優先選擇最低有效位為零的(類似四舍五入)。
- 向零舍入:將浮點數轉換為整數時,采用該模式,該模式將在目標數值類型中選擇一個最接近但是不大于原值的數字作為最精確的舍入結果(類似取整)。
我把所有的算術指令列一下:
- 加法指令:iadd、ladd、fadd、dadd
- 減法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem
- 自增指令:iinc
舉例來說。
public void calculate(int age) {int add = age + 1;int sub = age - 1;int mul = age * 2;int div = age / 3;int rem = age % 4;age++;age--; }通過 jclasslib 看一下 calculate() 方法的字節碼指令。
- iadd,加法
- isub,減法
- imul,乘法
- idiv,除法
- irem,取余
- iinc,自增的時候 +1,自減的時候 -1
03、類型轉換指令
可以分為兩種:
1)寬化,小類型向大類型轉換,比如 int–>long–>float–>double,對應的指令有:i2l、i2f、i2d、l2f、l2d、f2d。
- 從 int 到 long,或者從 int 到 double,是不會有精度丟失的;
- 從 int、long 到 float,或者 long 到 double 時,可能會發生精度丟失;
- 從 byte、char 和 short 到 int 的寬化類型轉換實際上是隱式發生的,這樣可以減少字節碼指令,畢竟字節碼指令只有 256 個,占一個字節。
2)窄化,大類型向小類型轉換,比如從 int 類型到 byte、short 或者 char,對應的指令有:i2b、i2s、i2c;從 long 到 int,對應的指令有:l2i;從 float 到 int 或者 long,對應的指令有:f2i、f2l;從 double 到 int、long 或者 float,對應的指令有:d2i、d2l、d2f。
- 窄化很可能會發生精度丟失,畢竟是不同的數量級;
- 但 Java 虛擬機并不會因此拋出運行時異常。
舉例來說。
public void updown() {int i = 10;double d = i;float f = 10f;long ong = (long)f; }通過 jclasslib 看一下 updown() 方法的字節碼指令。
- i2d,int 寬化為 double
- f2l, float 窄化為 long
04、對象的創建和訪問指令
Java 是一門面向對象的編程語言,那么 Java 虛擬機是如何從字節碼層面進行支持的呢?
1)創建指令
數組也是一種對象,但它創建的字節碼指令和普通的對象不同。創建數組的指令有三種:
- newarray:創建基本數據類型的數組
- anewarray:創建引用類型的數組
- multianewarray:創建多維數組
普通對象的創建指令只有一個,就是 new,它會接收一個操作數,指向常量池中的一個索引,表示要創建的類型。
舉例來說。
public void newObject() {String name = new String("沉默王二");File file = new File("無愁河的浪蕩漢子.book");int [] ages = {}; }通過 jclasslib 看一下 newObject() 方法的字節碼指令。
- new #13 <java/lang/String>,創建一個 String 對象。
- new #15 <java/io/File>,創建一個 File 對象。
- newarray 10 (int),創建一個 int 類型的數組。
2)字段訪問指令
字段可以分為兩類,一類是成員變量,一類是靜態變量(static 關鍵字修飾的),所以字段訪問指令可以分為兩類:
- 訪問靜態變量:getstatic、putstatic。
- 訪問成員變量:getfield、putfield,需要創建對象后才能訪問。
舉例來說。
public class Writer {private String name;static String mark = "作者";public static void main(String[] args) {print(mark);Writer w = new Writer();print(w.name);}public static void print(String arg) {System.out.println(arg);} }通過 jclasslib 看一下 main() 方法的字節碼指令。
- getstatic #2 <com/itwanger/jvm/Writer.mark>,訪問靜態變量 mark
- getfield #6 <com/itwanger/jvm/Writer.name>,訪問成員變量 name
05、方法調用和返回指令
方法調用指令有 5 個,分別用于不同的場景:
- invokevirtual:用于調用對象的成員方法,根據對象的實際類型進行分派,支持多態。
- invokeinterface:用于調用接口方法,會在運行時搜索由特定對象實現的接口方法進行調用。
- invokespecial:用于調用一些需要特殊處理的方法,包括構造方法、私有方法和父類方法。
- invokestatic:用于調用靜態方法。
- invokedynamic:用于在運行時動態解析出調用點限定符所引用的方法,并執行。
舉例來說。
public class InvokeExamples {private void run() {List ls = new ArrayList();ls.add("難頂");ArrayList als = new ArrayList();als.add("學不動了");}public static void print() {System.out.println("invokestatic");}public static void main(String[] args) {print();InvokeExamples invoke = new InvokeExamples();invoke.run();} }我們用 javap -c InvokeExamples.class 來反編譯一下。
Compiled from "InvokeExamples.java" public class com.itwanger.jvm.InvokeExamples {public com.itwanger.jvm.InvokeExamples();Code:0: aload_01: invokespecial #1 // Method java/lang/Object."<init>":()V4: returnprivate void run();Code:0: new #2 // class java/util/ArrayList3: dup4: invokespecial #3 // Method java/util/ArrayList."<init>":()V7: astore_18: aload_19: ldc #4 // String 難頂11: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z16: pop17: new #2 // class java/util/ArrayList20: dup21: invokespecial #3 // Method java/util/ArrayList."<init>":()V24: astore_225: aload_226: ldc #6 // String 學不動了28: invokevirtual #7 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z31: pop32: returnpublic static void print();Code:0: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;3: ldc #9 // String invokestatic5: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V8: returnpublic static void main(java.lang.String[]);Code:0: invokestatic #11 // Method print:()V3: new #12 // class com/itwanger/jvm/InvokeExamples6: dup7: invokespecial #13 // Method "<init>":()V10: astore_111: aload_112: invokevirtual #14 // Method run:()V15: return }InvokeExamples 類有 4 個方法,包括缺省的構造方法在內。
1)InvokeExamples() 構造方法中
缺省的構造方法內部會調用超類 Object 的初始化構造方法:
`invokespecial #1 // Method java/lang/Object."<init>":()V`2)成員方法 run() 中
invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z由于 ls 變量的引用類型為接口 List,所以 ls.add() 調用的是 invokeinterface 指令,等運行時再確定是不是接口 List 的實現對象 ArrayList 的 add() 方法。
invokevirtual #7 // Method java/util/ArrayList.add:(Ljava/lang/Object;)Z由于 als 變量的引用類型已經確定為 ArrayList,所以 als.add() 方法調用的是 invokevirtual 指令。
3)main() 方法中
invokestatic #11 // Method print:()Vprint() 方法是靜態的,所以調用的是 invokestatic 指令。
方法返回指令根據方法的返回值類型進行區分,常見的返回指令見下圖。
06、操作數棧管理指令
常見的操作數棧管理指令有 pop、dup 和 swap。
- 將一個或兩個元素從棧頂彈出,并且直接廢棄,比如 pop,pop2;
- 復制棧頂的一個或兩個數值并將其重新壓入棧頂,比如 dup,dup2,dup_×1,dup2_×1,dup_×2,dup2_×2;
- 將棧最頂端的兩個槽中的數值交換位置,比如 swap。
這些指令不需要指明數據類型,因為是按照位置壓入和彈出的。
舉例來說。
public class Dup {int age;public int incAndGet() {return ++age;} }通過 jclasslib 看一下 incAndGet() 方法的字節碼指令。
- aload_0:將 this 入棧。
- dup:復制棧頂的 this。
- getfield #2:將常量池中下標為 2 的常量加載到棧上,同時將一個 this 出棧。
- iconst_1:將常量 1 入棧。
- iadd:將棧頂的兩個值相加后出棧,并將結果放回棧上。
- dup_x1:復制棧頂的元素,并將其插入 this 下面。
- putfield #2: 將棧頂的兩個元素出棧,并將其賦值給字段 age。
- ireturn:將棧頂的元素出棧返回。
07、控制轉移指令
控制轉移指令包括:
- 比較指令,比較棧頂的兩個元素的大小,并將比較結果入棧。
- 條件跳轉指令,通常和比較指令一塊使用,在條件跳轉指令執行前,一般先用比較指令進行棧頂元素的比較,然后進行條件跳轉。
- 比較條件轉指令,類似于比較指令和條件跳轉指令的結合體,它將比較和跳轉兩個步驟合二為一。
- 多條件分支跳轉指令,專為 switch-case 語句設計的。
- 無條件跳轉指令,目前主要是 goto 指令。
1)比較指令
比較指令有:dcmpg,dcmpl、fcmpg、fcmpl、lcmp,指令的第一個字母代表的含義分別是 double、float、long。注意,沒有 int 類型。
對于 double 和 float 來說,由于 NaN 的存在,有兩個版本的比較指令。拿 float 來說,有 fcmpg 和 fcmpl,區別在于,如果遇到 NaN,fcmpg 會將 1 壓入棧,fcmpl 會將 -1 壓入棧。
舉例來說。
public void lcmp(long a, long b) {if(a > b){} }通過 jclasslib 看一下 lcmp() 方法的字節碼指令。
lcmp 用于兩個 long 型的數據進行比較。
2)條件跳轉指令
這些指令都會接收兩個字節的操作數,它們的統一含義是,彈出棧頂元素,測試它是否滿足某一條件,滿足的話,跳轉到對應位置。
對于 long、float 和 double 類型的條件分支比較,會先執行比較指令返回一個整形值到操作數棧中后再執行 int 類型的條件跳轉指令。
對于 boolean、byte、char、short,以及 int,則直接使用條件跳轉指令來完成。
舉例來說。
public void fi() {int a = 0;if (a == 0) {a = 10;} else {a = 20;} }通過 jclasslib 看一下 fi() 方法的字節碼指令。
3 ifne 12 (+9) 的意思是,如果棧頂的元素不等于 0,跳轉到第 12(3+9)行 12 bipush 20。
3)比較條件轉指令
前綴“if_”后,以字符“i”開頭的指令針對 int 型整數進行操作,以字符“a”開頭的指令表示對象的比較。
舉例來說。
public void compare() {int i = 10;int j = 20;System.out.println(i > j); }通過 jclasslib 看一下 compare() 方法的字節碼指令。
11 if_icmple 18 (+7) 的意思是,如果棧頂的兩個 int 類型的數值比較的話,如果前者小于后者時跳轉到第 18 行(11+7)。
4)多條件分支跳轉指令
主要有 tableswitch 和 lookupswitch,前者要求多個條件分支值是連續的,它內部只存放起始值和終止值,以及若干個跳轉偏移量,通過給定的操作數 index,可以立即定位到跳轉偏移量位置,因此效率比較高;后者內部存放著各個離散的 case-offset 對,每次執行都要搜索全部的 case-offset 對,找到匹配的 case 值,并根據對應的 offset 計算跳轉地址,因此效率較低。
舉例來說。
public void switchTest(int select) {int num;switch (select) {case 1:num = 10;break;case 2:case 3:num = 30;break;default:num = 40;} }通過 jclasslib 看一下 switchTest() 方法的字節碼指令。
case 2 的時候沒有 break,所以 case 2 和 case 3 是連續的,用的是 tableswitch。如果等于 1,跳轉到 28 行;如果等于 2 和 3,跳轉到 34 行,如果是 default,跳轉到 40 行。
5)無條件跳轉指令
goto 指令接收兩個字節的操作數,共同組成一個帶符號的整數,用于指定指令的偏移量,指令執行的目的就是跳轉到偏移量給定的位置處。
前面的例子里都出現了 goto 的身影,也很好理解。如果指令的偏移量特別大,超出了兩個字節的范圍,可以使用指令 goto_w,接收 4 個字節的操作數。
巨人的肩膀:
https://segmentfault.com/a/1190000037628881
除了以上這些指令,還有異常處理指令和同步控制指令,我打算吊一吊大家的胃口,大家可以期待一波~~
(騷操作)
路漫漫其修遠兮,吾將上下而求索
想要走得更遠,Java 字節碼這塊就必須得硬碰硬地吃透,希望二哥的這些分享可以幫助到大家~
叨逼叨
二哥在 CSDN 上寫了很多 Java 方面的系列文章,有 Java 核心語法、Java 集合框架、Java IO、Java 并發編程、Java 虛擬機等,也算是體系完整了。
為了能幫助到更多的 Java 初學者,二哥把自己連載的《教妹學Java》開源到了 GitHub,盡管只整理了 50 篇,發現字數已經來到了 10 萬+,內容更是沒得說,通俗易懂、風趣幽默、圖文并茂。
GitHub 開源地址(歡迎 star):https://github.com/itwanger/jmx-java
如果有幫助的話,還請給二哥點個贊,這將是我繼續分享下去的最強動力!
總結
以上是生活随笔為你收集整理的我要悄悄学习 Java 字节码指令,在成为技术大佬的路上一去不复返的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 常用Python模块下载网站
- 下一篇: PMP考试通过率