碼指令手冊:從javap輸出到JVM執(zhí)行模型的高頻指令速查)
1. 為什么需要一份能隨時(shí)翻的字節(jié)碼指令手冊寫了幾年 Java 之后你遲早會遇到一個(gè)分水嶺一邊是繼續(xù)停留在“寫業(yè)務(wù)代碼、調(diào) API、改配置”的層面另一邊是開始好奇“我寫的這行 Java 代碼到了 JVM 里到底變成了什么”。這個(gè)分水嶺的標(biāo)志性事件往往就是第一次認(rèn)真去看javap -c的輸出。我第一次看字節(jié)碼的時(shí)候整個(gè)人是懵的。滿屏的aload_0、invokespecial、getfield、areturn每個(gè)單詞都認(rèn)識連起來完全不知道在干嘛。更讓人抓狂的是網(wǎng)上搜到的資料要么是 JVM 規(guī)范那種“給編譯器作者看”的嚴(yán)謹(jǐn)描述要么是零散的博客只講三五個(gè)指令根本湊不成一套能隨時(shí)查閱的手冊。于是我就萌生了一個(gè)想法自己整理一份字節(jié)碼指令手冊按功能分類配上實(shí)際例子遇到不認(rèn)識的指令隨時(shí)翻。這份手冊不是給 JVM 實(shí)現(xiàn)者看的而是給像我這樣的一線 Java 開發(fā)者看的。它的目標(biāo)很明確當(dāng)你看到一段javap輸出時(shí)能快速定位到每個(gè)指令屬于哪個(gè)類別、操作數(shù)棧怎么變、局部變量表怎么動、它對應(yīng)的高級語言寫法大概是什么樣。掌握了這套東西你排查詭異的編譯產(chǎn)物、理解 Lambda 和內(nèi)部類的實(shí)現(xiàn)、分析性能熱點(diǎn)、甚至手寫字節(jié)碼做 AOP都會有一個(gè)質(zhì)的飛躍。需要提前說明的是字節(jié)碼指令有兩百多條但真正高頻使用的也就五六十條。我的策略是先把這五六十條吃透剩下的按類別建立索引用到再查。下面我就按這個(gè)思路把整份手冊拆成幾個(gè)模塊來講每個(gè)模塊都配上我實(shí)際踩過的坑和驗(yàn)證過的例子。2. 從 class 文件結(jié)構(gòu)到指令執(zhí)行模型先建立正確的坐標(biāo)系2.1 class 文件里指令到底存在哪很多人一上來就背指令助記符結(jié)果背了半天還是不知道這些指令在 class 文件的哪個(gè)位置。我建議先花十分鐘把 class 文件的整體結(jié)構(gòu)過一遍建立坐標(biāo)系后面看指令才不會迷路。一個(gè) class 文件大致是這樣的順序魔數(shù)、版本號、常量池、訪問標(biāo)志、類索引、父類索引、接口索引集合、字段表集合、方法表集合、屬性表集合。字節(jié)碼指令就藏在方法表里每個(gè)方法的Code屬性中。Code屬性里有一個(gè)code_length和緊隨其后的code[]數(shù)組這個(gè)數(shù)組就是真正的指令字節(jié)流。關(guān)鍵點(diǎn)在于字節(jié)碼指令是變長的。大部分指令占 1 個(gè)字節(jié)操作碼但有些指令后面會跟操作數(shù)占 2 到 5 個(gè)字節(jié)不等。比如bipush后面跟 1 字節(jié)的立即數(shù)sipush跟 2 字節(jié)invokeinterface后面跟 4 字節(jié)。這就是為什么你在javap輸出里會看到類似10: invokespecial #5這樣的行——前面的數(shù)字是指令在 code 數(shù)組中的偏移量字節(jié)為單位后面的#5是常量池索引。我踩過的第一個(gè)坑就是以為偏移量是“第幾條指令”。實(shí)際上它是字節(jié)偏移因?yàn)橹噶钭冮L所以偏移量不連續(xù)是常態(tài)??磈avap輸出時(shí)左邊的數(shù)字是字節(jié)偏移不是行號這一點(diǎn)一定要記牢。2.2 操作數(shù)棧和局部變量表指令操作的兩個(gè)舞臺字節(jié)碼指令本質(zhì)上只干兩件事在操作數(shù)棧上壓入/彈出數(shù)據(jù)以及在局部變量表里讀寫數(shù)據(jù)。理解了這兩個(gè)結(jié)構(gòu)指令的行為就一目了然了。操作數(shù)棧是一個(gè)后進(jìn)先出的棧深度在編譯期就確定好了寫在Code屬性的max_stack里。局部變量表則是一個(gè)數(shù)組max_locals指定了它的容量。對于實(shí)例方法局部變量表的第 0 個(gè)槽位默認(rèn)是this對于靜態(tài)方法第 0 個(gè)槽位就是第一個(gè)參數(shù)。舉個(gè)最經(jīng)典的例子int add(int a, int b) { return a b; }編譯出來是0: iload_1 1: iload_2 2: iadd 3: ireturniload_1把局部變量表第 1 個(gè)槽位也就是參數(shù) a壓入操作數(shù)棧iload_2把第 2 個(gè)槽位參數(shù) b壓入此時(shí)棧里是[a, b]。iadd彈出棧頂兩個(gè) int 相加把結(jié)果壓回棧棧變成[ab]。ireturn彈出棧頂?shù)?int 作為返回值返回。你看只要盯住“棧怎么變、局部變量表怎么變”再復(fù)雜的指令都能拆解。我后來養(yǎng)成一個(gè)習(xí)慣每學(xué)一條新指令就在紙上畫一遍棧的變化。畫多了之后看javap輸出基本就是條件反射。2.3 為什么指令要分這么多類型類型前綴的意義初學(xué)者最容易困惑的一點(diǎn)是為什么加法有iadd、ladd、fadd、dadd四種為什么加載有iload、lload、fload、dload、aload這背后是 JVM 的一個(gè)核心設(shè)計(jì)字節(jié)碼指令在操作碼層面就區(qū)分了類型。i代表 intl代表 longf代表 floatd代表 doublea代表引用reference。這樣做的好處是解釋器執(zhí)行時(shí)不需要額外查類型直接根據(jù)操作碼就能確定操作數(shù)棧上數(shù)據(jù)的寬度和運(yùn)算方式執(zhí)行效率高。代價(jià)就是指令數(shù)量膨脹。但對我們使用者來說這反而降低了記憶負(fù)擔(dān)——你只要記住“前綴決定類型后綴決定操作”就能推導(dǎo)出大部分指令的含義。比如istore是存 int 到局部變量表lstore是存 longastore是存引用。i2l是 int 轉(zhuǎn) longl2i是 long 轉(zhuǎn) inti2f是 int 轉(zhuǎn) float規(guī)律非常清晰。有一個(gè)例外要特別注意boolean、byte、char、short在字節(jié)碼層面沒有獨(dú)立的類型前綴它們統(tǒng)一用iint來處理。也就是說boolean的加載用的是iload存儲用的是istore。這是因?yàn)?JVM 的操作數(shù)棧最小單位是 32 位這些小于 32 位的類型在棧上都會被擴(kuò)展成 int。這個(gè)細(xì)節(jié)在分析數(shù)組操作時(shí)特別重要后面講數(shù)組指令時(shí)會再展開。3. 按功能分類拆解高頻指令一份能直接查的速查表3.1 加載與存儲指令數(shù)據(jù)在兩個(gè)舞臺間搬運(yùn)加載load和存儲store指令是出現(xiàn)頻率最高的一類它們負(fù)責(zé)在局部變量表和操作數(shù)棧之間搬運(yùn)數(shù)據(jù)。加載是“局部變量表 → 操作數(shù)?!贝鎯κ恰安僮鲾?shù)棧 → 局部變量表”。按類型分加載指令有iload、lload、fload、dload、aload存儲指令有istore、lstore、fstore、dstore、astore。它們都帶一個(gè)操作數(shù)指定局部變量表的槽位索引。但你會發(fā)現(xiàn)javap輸出里經(jīng)常出現(xiàn)iload_0、iload_1、aload_0這種帶下劃線和數(shù)字的形式。這是 JVM 的一個(gè)優(yōu)化對于槽位 0 到 3 的訪問提供了專門的單字節(jié)指令不需要額外的操作數(shù)執(zhí)行更快。iload_0等價(jià)于iload 0但少占一個(gè)字節(jié)。這個(gè)優(yōu)化叫“隱式操作數(shù)”在方法參數(shù)少、局部變量少的方法里能顯著減小 class 文件體積。我整理了一張對照表方便你快速查閱類型通用加載隱式加載0-3通用存儲隱式存儲0-3intiloadiload_0 ~ iload_3istoreistore_0 ~ istore_3longlloadlload_0 ~ lload_3lstorelstore_0 ~ lstore_3floatfloadfload_0 ~ fload_3fstorefstore_0 ~ fstore_3doubledloaddload_0 ~ dload_3dstoredstore_0 ~ dstore_3引用aloadaload_0 ~ aload_3astoreastore_0 ~ astore_3還有一個(gè)特殊的加載指令ldc它用來把常量池中的常量加載到棧上。ldc只能加載 int、float、String 和 Class 常量ldc_w是它的寬版本索引超過 255 時(shí)用ldc2_w用來加載 long 和 double。這個(gè)指令在字符串字面量、類字面量場景下非常常見。注意long和double占兩個(gè)槽位。如果你在局部變量表里看到lload_1那么下一個(gè)可用槽位是 3不是 2。這個(gè)細(xì)節(jié)在分析復(fù)雜方法的局部變量表時(shí)經(jīng)常讓人算錯(cuò)。3.2 算術(shù)與邏輯指令棧頂兩個(gè)數(shù)的游戲算術(shù)指令全部是“彈出棧頂兩個(gè)數(shù)運(yùn)算后壓回結(jié)果”的模式。按類型分int 加法iadd、減法isub、乘法imul、除法idiv、取余irem、取負(fù)ineglong 對應(yīng)ladd、lsub、lmul、ldiv、lrem、lnegfloat 對應(yīng)fadd、fsub、fmul、fdiv、frem、fnegdouble 對應(yīng)dadd、dsub、dmul、ddiv、drem、dneg這里有個(gè)坑沒有frem和drem的隱式版本也沒有irem的隱式版本它們都是標(biāo)準(zhǔn)指令。另外iinc是一個(gè)很特殊的指令它直接在局部變量表上做自增不經(jīng)過操作數(shù)棧。iinc index, const表示把第 index 個(gè)槽位的 int 加上 const。這就是為什么for (int i 0; i n; i)里的i編譯出來是iinc而不是iadd——因?yàn)椴恍枰?i 加載到棧上再存回去直接在局部變量表里改效率更高。位運(yùn)算指令有ishl左移、ishr算術(shù)右移、iushr邏輯右移、iand、ior、ixorlong 版本對應(yīng)lshl、lshr、lushr、land、lor、lxor。注意移位指令的操作數(shù)是 int即使被移位的是 long移位量也是 int。比較指令lcmp、fcmpl、fcmpg、dcmpl、dcmpg比較特殊它們彈出兩個(gè)數(shù)壓入一個(gè) int 結(jié)果-1、0、1。l和g的區(qū)別在于遇到 NaN 時(shí)的處理fcmpl遇到 NaN 壓入 -1fcmpg壓入 1。這個(gè)區(qū)別在實(shí)現(xiàn)if (a b)和if (a b)時(shí)會被編譯器利用。3.3 類型轉(zhuǎn)換指令窄化與寬化的邊界類型轉(zhuǎn)換指令負(fù)責(zé)在不同數(shù)值類型之間轉(zhuǎn)換。寬化小類型到大類型通常是安全的窄化大類型到小類型可能丟失精度。int 到 longi2lint 到 floati2fint 到 doublei2dlong 到 intl2ilong 到 floatl2flong 到 doublel2dfloat 到 intf2ifloat 到 longf2lfloat 到 doublef2ddouble 到 intd2idouble 到 longd2ldouble 到 floatd2f還有一個(gè)i2b、i2c、i2s分別把 int 窄化成 byte、char、short。這三個(gè)指令在數(shù)組操作和強(qiáng)制類型轉(zhuǎn)換時(shí)會出現(xiàn)。這里有個(gè)反直覺的點(diǎn)從 int 到 byte/char/short 的轉(zhuǎn)換在字節(jié)碼層面是顯式指令但從 byte/char/short 到 int 的轉(zhuǎn)換是隱式的。因?yàn)樗鼈冊跅I媳緛砭褪且?int 形式存在的不需要額外指令。所以byte b 1; int i b;編譯出來只有bipush 1; istore_1; iload_1; istore_2沒有i2b或b2i。但int i 300; byte b (byte) i;就會有i2b。3.4 對象與數(shù)組指令new、checkcast 和數(shù)組家族對象創(chuàng)建指令主要是new它后面跟一個(gè)常量池索引指向要實(shí)例化的類。new只負(fù)責(zé)在堆上分配內(nèi)存并壓入引用真正的構(gòu)造函數(shù)調(diào)用要靠后面的invokespecial init。所以new Object()編譯出來是new #2; dup; invokespecial #1。dup是為了復(fù)制一份引用因?yàn)閕nvokespecial會消耗一個(gè)引用而new的結(jié)果還需要留給后續(xù)使用。數(shù)組指令有一整套家族newarray創(chuàng)建基本類型數(shù)組anewarray創(chuàng)建引用類型數(shù)組multianewarray創(chuàng)建多維數(shù)組。加載數(shù)組元素用iaload、laload、faload、daload、aaload、baload、caload、saload存儲用iastore、lastore等。注意baload和bastore同時(shí)用于 byte 和 boolean 數(shù)組caload和castore用于 char 數(shù)組。arraylength指令獲取數(shù)組長度它彈出數(shù)組引用壓入 int 長度。checkcast做類型檢查instanceof做類型判斷并壓入 int 結(jié)果。athrow拋出異常monitorenter和monitorexit用于同步塊。我踩過的一個(gè)坑是checkcast和instanceof后面跟的常量池索引指向的是類描述符不是類名。在javap -c輸出里看到checkcast #5要配合javap -v看常量池才能知道具體是哪個(gè)類。3.5 方法調(diào)用指令invoke 家族的四兄弟方法調(diào)用是字節(jié)碼里最復(fù)雜也最重要的一類指令有四個(gè)成員invokestatic調(diào)用靜態(tài)方法不需要對象引用invokespecial調(diào)用實(shí)例構(gòu)造方法、私有方法、父類方法invokevirtual調(diào)用普通實(shí)例方法支持多態(tài)invokeinterface調(diào)用接口方法Java 7 之后又加了invokedynamic用于動態(tài)語言支持和 Lambda 表達(dá)式。Java 8 的 Lambda 就是通過invokedynamic加LambdaMetafactory實(shí)現(xiàn)的而不是匿名內(nèi)部類。這個(gè)點(diǎn)我在分析 Lambda 性能時(shí)專門驗(yàn)證過確實(shí)和匿名內(nèi)部類走的是完全不同的路徑。invokevirtual和invokeinterface的區(qū)別在于前者在常量池里存的是方法引用后者存的是接口方法引用并且invokeinterface后面還跟兩個(gè)字節(jié)的操作數(shù)參數(shù)個(gè)數(shù)和保留字節(jié)。執(zhí)行時(shí)invokevirtual通過虛方法表做動態(tài)分派invokeinterface通過接口方法表做分派后者通常稍慢因?yàn)榻涌诜椒ū淼慕Y(jié)構(gòu)更復(fù)雜。invokedynamic最特殊它后面跟的是一個(gè)BootstrapMethods屬性的索引第一次執(zhí)行時(shí)會調(diào)用引導(dǎo)方法生成CallSite之后就直接調(diào)用CallSite的目標(biāo)方法。這個(gè)機(jī)制是 Java 實(shí)現(xiàn)動態(tài)語言支持的核心。3.6 控制轉(zhuǎn)移指令if、goto 和 switch 的實(shí)現(xiàn)控制轉(zhuǎn)移指令負(fù)責(zé)改變程序計(jì)數(shù)器實(shí)現(xiàn)分支和循環(huán)。條件分支有ifeq、ifne、iflt、ifge、ifgt、ifle以及if_icmpeq、if_icmpne等比較兩個(gè) int 的指令還有if_acmpeq、if_acmpne比較引用。這些指令后面跟一個(gè) 2 字節(jié)的有符號偏移量表示跳轉(zhuǎn)目標(biāo)相對于當(dāng)前指令的偏移。goto是無條件跳轉(zhuǎn)goto_w是寬版本。jsr、ret是早期用于實(shí)現(xiàn) finally 的指令現(xiàn)在已經(jīng)被編譯器用異常表替代了基本可以忽略。tableswitch和lookupswitch用于 switch 語句。tableswitch要求 case 值連續(xù)內(nèi)部用數(shù)組直接索引效率高lookupswitch用于稀疏的 case 值內(nèi)部用二分查找或線性查找。編譯器會根據(jù) case 值的分布自動選擇用哪個(gè)。我實(shí)測過一個(gè)有 10 個(gè)連續(xù) case 的 switch編譯出來是tableswitch把 case 值改成 1、100、10000 這種稀疏分布就變成了lookupswitch。tableswitch和lookupswitch的字節(jié)碼對齊要求比較特殊它們會插入 padding 字節(jié)使操作數(shù)按 4 字節(jié)對齊。這也是為什么在javap輸出里看到這兩個(gè)指令時(shí)偏移量有時(shí)會跳幾個(gè)字節(jié)。3.7 返回指令與異常處理areturn、athrow 和異常表返回指令按類型分ireturn、lreturn、freturn、dreturn、areturn以及returnvoid 方法。它們都彈出棧頂值void 除外并返回給調(diào)用者。異常處理不是靠指令實(shí)現(xiàn)的而是靠Code屬性里的異常表。異常表是一個(gè)數(shù)組每項(xiàng)包含start_pc、end_pc、handler_pc、catch_type。當(dāng)start_pc到end_pc之間拋出catch_type類型的異常時(shí)跳轉(zhuǎn)到handler_pc。catch_type為 0 表示捕獲所有異常finally 塊。athrow指令顯式拋出異常它彈出棧頂?shù)漠惓R貌伋觥H绻?dāng)前方法沒有匹配的異常處理器方法會立即返回并把異常傳播給調(diào)用者。我分析過一個(gè) try-catch-finally 的字節(jié)碼發(fā)現(xiàn) finally 塊被復(fù)制了三份一份在 try 正常結(jié)束時(shí)執(zhí)行一份在 catch 塊結(jié)束時(shí)執(zhí)行一份作為異常處理器捕獲所有異常后執(zhí)行再重新拋出。這個(gè)“復(fù)制 finally”的策略是早期編譯器的做法現(xiàn)在有些編譯器會用jsr/ret或者更聰明的跳轉(zhuǎn)來優(yōu)化但整體思路類似。4. 用真實(shí)代碼反推字節(jié)碼三個(gè)由淺入深的實(shí)戰(zhàn)案例4.1 案例一一個(gè)簡單的 getter 方法藏著多少指令先看最簡單的public class Foo { private int value; public int getValue() { return value; } }javap -c輸出public int getValue(); Code: 0: aload_0 1: getfield #2 4: ireturn三條指令。aload_0把this壓棧getfield #2彈出this讀取字段value并壓棧ireturn返回。getfield后面跟的#2是常量池索引指向字段引用。這里有個(gè)細(xì)節(jié)getfield占 3 個(gè)字節(jié)1 字節(jié)操作碼 2 字節(jié)索引所以下一條指令的偏移量是 4不是 2。這就是前面說的變長指令。如果你在分析字節(jié)碼時(shí)按“行號”來算就會對不上。4.2 案例二字符串拼接在 Java 8 和 Java 9 的差異字符串拼接是字節(jié)碼變化最明顯的地方。Java 8 里String s a b c;編譯出來是new StringBuilder dup invokespecial StringBuilder.init aload_1 invokevirtual StringBuilder.append aload_2 invokevirtual StringBuilder.append aload_3 invokevirtual StringBuilder.append invokevirtual StringBuilder.toString astore 4Java 9 之后引入了invokedynamic和StringConcatFactory同樣的代碼編譯出來是aload_1 aload_2 aload_3 invokedynamic #7 // makeConcatWithConstants astore 4指令數(shù)從十幾條降到四條。這個(gè)變化我在實(shí)際項(xiàng)目中驗(yàn)證過對于大量字符串拼接的場景Java 9 的字節(jié)碼更短啟動時(shí)類加載更快運(yùn)行時(shí)StringConcatFactory會生成更高效的拼接策略。但要注意invokedynamic第一次執(zhí)行有引導(dǎo)開銷如果拼接只執(zhí)行一次可能反而不如 StringBuilder 直接。4.3 案例三Lambda 表達(dá)式到底編譯成了什么Lambda 是很多人好奇的點(diǎn)。Runnable r () - System.out.println(hi);編譯出來invokedynamic #7 // run astore_1就兩條。invokedynamic的引導(dǎo)方法會調(diào)用LambdaMetafactory.metafactory生成一個(gè)實(shí)現(xiàn)了Runnable的類這個(gè)類在運(yùn)行時(shí)動態(tài)生成不會出現(xiàn)在 class 文件里。所以 Lambda 不是匿名內(nèi)部類不會生成Foo$1.class這樣的文件。但這里有個(gè)坑如果 Lambda 捕獲了外部變量生成的類會持有這些變量的引用行為上和匿名內(nèi)部類類似。如果沒捕獲變量生成的類是無狀態(tài)的可以被緩存復(fù)用。我在分析內(nèi)存占用時(shí)發(fā)現(xiàn)無捕獲的 Lambda 實(shí)例在多次調(diào)用間是同一個(gè)對象而有捕獲的每次都會創(chuàng)建新對象。這個(gè)差異在高頻調(diào)用場景下對 GC 有影響。5. 分析字節(jié)碼時(shí)最容易踩的五個(gè)坑5.1 偏移量不是行號變長指令讓計(jì)算變復(fù)雜前面提過但值得再強(qiáng)調(diào)。javap輸出左邊的數(shù)字是字節(jié)偏移不是指令序號。getfield占 3 字節(jié)invokeinterface占 5 字節(jié)tableswitch還有 padding。如果你要計(jì)算跳轉(zhuǎn)目標(biāo)必須用偏移量不能用行號。我踩過的坑手動計(jì)算goto的目標(biāo)時(shí)用行號差乘以平均指令長度結(jié)果完全對不上。后來老老實(shí)實(shí)按字節(jié)偏移算才準(zhǔn)確。5.2 long 和 double 占兩個(gè)槽位局部變量表索引會跳long和double在局部變量表里占兩個(gè)連續(xù)槽位。如果第 1 個(gè)槽位是 long下一個(gè)可用槽位是 3。這個(gè)規(guī)則在分析參數(shù)和方法體時(shí)經(jīng)常讓人算錯(cuò)。比如void foo(long a, int b)a在槽位 1-2b在槽位 3。iload_3加載的是b不是a。我第一次分析時(shí)以為b在槽位 2結(jié)果完全對不上。5.3 常量池索引需要配合 javap -v 才能看懂javap -c只顯示指令和常量池索引不顯示常量池內(nèi)容。要看到#2到底是什么需要javap -v。-v會輸出常量池、字段描述符、方法描述符等詳細(xì)信息。我的習(xí)慣是先用-c看指令流遇到不認(rèn)識的索引再用-v查常量池。兩個(gè)命令配合使用效率最高。5.4 編譯器優(yōu)化會改變指令序列同一個(gè)邏輯不同編譯器、不同優(yōu)化級別可能生成不同的字節(jié)碼。比如int x 1 2;可能被常量折疊成iconst_3也可能保留iconst_1; iconst_2; iadd。final字段的讀取可能被內(nèi)聯(lián)String常量可能被合并。所以分析字節(jié)碼時(shí)不要假設(shè)“源碼怎么寫字節(jié)碼就怎么生成”。要以實(shí)際javap輸出為準(zhǔn)。我一般會寫一個(gè)最小復(fù)現(xiàn)的類單獨(dú)編譯單獨(dú)分析避免其他代碼干擾。5.5 異常表和行號表不在指令流里異常處理和調(diào)試信息不在code[]數(shù)組里而在Code屬性的其他字段中。javap -c默認(rèn)不顯示異常表需要-v才能看到。行號表LineNumberTable和局部變量表LocalVariableTable也是-v才顯示。分析異常處理邏輯時(shí)一定要看異常表不能只看指令流。athrow只是拋出真正的捕獲邏輯在異常表里。6. 把指令手冊用起來我的日常分析流程和工具鏈6.1 我的標(biāo)準(zhǔn)分析流程遇到需要分析字節(jié)碼的場景我一般按這個(gè)流程走寫一個(gè)最小復(fù)現(xiàn)類只包含要分析的代碼javac -g編譯-g保留調(diào)試信息javap -c -p看指令流-p顯示私有成員遇到不認(rèn)識的常量池索引javap -v查常量池在紙上畫操作數(shù)棧和局部變量表的變化對照本文的分類表確認(rèn)每條指令的類別和作用這個(gè)流程看起來笨但最可靠。我試過用各種字節(jié)碼分析工具最后還是覺得javap加手畫最踏實(shí)。6.2 值得收藏的幾個(gè)輔助工具除了javap還有幾個(gè)工具我偶爾會用ASM字節(jié)碼操作框架適合做字節(jié)碼生成和轉(zhuǎn)換但學(xué)習(xí)曲線陡Byte Buddy更友好的字節(jié)碼生成庫API 設(shè)計(jì)得很好JBEJava Bytecode Editor圖形化字節(jié)碼編輯器適合直觀查看IDEA 的 bytecode viewer 插件直接在 IDE 里看字節(jié)碼方便快捷但工具只是輔助核心還是理解指令本身。我見過有人用 ASM 生成了錯(cuò)誤的字節(jié)碼排查了半天最后還是靠javap對比正確輸出才找到問題。6.3 從指令手冊到實(shí)際能力的躍遷整理這份手冊的過程中我最大的收獲不是記住了多少指令而是建立了一種“從字節(jié)碼反推源碼意圖”的思維??吹絠nvokedynamic就知道可能有 Lambda 或字符串拼接看到tableswitch就知道是密集 switch看到checkcast就知道有強(qiáng)制類型轉(zhuǎn)換。這種思維在實(shí)際工作中有很多應(yīng)用場景分析性能熱點(diǎn)時(shí)能看出哪些操作在字節(jié)碼層面更昂貴排查詭異 bug 時(shí)能確認(rèn)編譯器是否做了預(yù)期之外的優(yōu)化做 AOP 或字節(jié)碼增強(qiáng)時(shí)能準(zhǔn)確知道要插入指令的位置。我個(gè)人的體會是字節(jié)碼指令手冊不是用來背的而是用來查的。你不需要記住兩百多條指令但需要知道遇到問題時(shí)去哪里查、怎么查、查到了怎么用。這份手冊就是按這個(gè)思路整理的希望對你也有用。最后分享一個(gè)小技巧如果你在分析一個(gè)復(fù)雜方法的字節(jié)碼可以先只看方法調(diào)用的指令invoke家族把方法的骨架理出來再回頭看數(shù)據(jù)加載和存儲指令。這樣分層分析比從頭到尾逐條看要高效得多。