JAVA 中的字節(jié)碼,英文名為 bytecode, 是 Java 代碼編譯后的中間代碼格式。JVM 需要讀取并解析字節(jié)碼才能執(zhí)行相應(yīng)的任務(wù)。
從技術(shù)人員的角度看,Java 字節(jié)碼是 JVM 的指令集。JVM 加載字節(jié)碼格式的 class 文件,校驗(yàn)之后通過 JIT 編譯器轉(zhuǎn)換為本地機(jī)器代碼執(zhí)行。 簡單說字節(jié)碼就是我們編寫的 Java 應(yīng)用程序大廈的每一塊磚,如果沒有字節(jié)碼的支撐,大家編寫的代碼也就沒有了用武之地,無法運(yùn)行。也可以說,Java 字節(jié)碼就是 JVM 執(zhí)行的指令格式。
那么我們?yōu)槭裁葱枰莆账兀?/p>
不管用什么編程語言,對于卓越而有追求的程序員,都能深入去探索一些技術(shù)細(xì)節(jié),在需要的時(shí)候,可以在代碼被執(zhí)行前解讀和理解中間形式的代碼。對于 Java 來說,中間代碼格式就是 Java 字節(jié)碼。 了解字節(jié)碼及其工作原理,對于編寫高性能代碼至關(guān)重要,對于深入分析和排查問題也有一定作用,所以我們要想深入了解 JVM 來說,了解字節(jié)碼也是夯實(shí)基礎(chǔ)的一項(xiàng)基本功。同時(shí)對于我們開發(fā)人員來時(shí),不了解平臺的底層原理和實(shí)現(xiàn)細(xì)節(jié),想要職業(yè)進(jìn)階絕對不是長久之計(jì),畢竟我們都希望成為更好的程序員, 對吧?
任何有實(shí)際經(jīng)驗(yàn)的開發(fā)者都知道,業(yè)務(wù)系統(tǒng)總不可能沒有 BUG,了解字節(jié)碼以及 Java 編譯器會生成什么樣的字節(jié)碼,才能說具備扎實(shí)的 JVM 功底,會在排查問題和分析錯(cuò)誤時(shí)非常有用,也能更好地解決問題。
而對于工具領(lǐng)域和程序分析來說, 字節(jié)碼就是必不可少的基礎(chǔ)知識了,通過修改字節(jié)碼來調(diào)整程序的行為是司空見慣的事情。想了解分析器(Profiler),Mock 框架,AOP 等工具和技術(shù)這一類工具,則必須完全了解 Java 字節(jié)碼。
4.1 Java 字節(jié)碼簡介
有一件有趣的事情,就如名稱所示, Java bytecode 由單字節(jié)(byte)的指令組成,理論上最多支持 256 個(gè)操作碼(opcode)。實(shí)際上 Java 只使用了 200 左右的操作碼, 還有一些操作碼則保留給調(diào)試操作。
操作碼, 下面稱為 指令, 主要由類型前綴和操作名稱兩部分組成。
例如,'i' 前綴代表 ‘integer’,所以,'iadd' 很容易理解, 表示對整數(shù)執(zhí)行加法運(yùn)算。
根據(jù)指令的性質(zhì),主要分為四個(gè)大類:
- 棧操作指令,包括與局部變量交互的指令
- 程序流程控制指令
- 對象操作指令,包括方法調(diào)用指令
- 算術(shù)運(yùn)算以及類型轉(zhuǎn)換指令
此外還有一些執(zhí)行專門任務(wù)的指令,比如同步(synchronization)指令,以及拋出異常相關(guān)的指令等等。下文會對這些指令進(jìn)行詳細(xì)的講解。
4.2 獲取字節(jié)碼清單
可以用 javap 工具來獲取 class 文件中的指令清單。 javap 是標(biāo)準(zhǔn) JDK 內(nèi)置的一款工具, 專門用于反編譯 class 文件。
讓我們從頭開始, 先創(chuàng)建一個(gè)簡單的類,后面再慢慢擴(kuò)充。
package demo.jvm0104;
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode obj = new HelloByteCode();
}
}
代碼很簡單, main 方法中 new 了一個(gè)對象而已。然后我們編譯這個(gè)類:
javac demo/jvm0104/HelloByteCode.java
使用 javac 編譯 ,或者在 IDEA 或者 Eclipse 等集成開發(fā)工具自動(dòng)編譯,基本上是等效的。只要能找到對應(yīng)的 class 即可。
javac 不指定 -d 參數(shù)編譯后生成的 .class 文件默認(rèn)和源代碼在同一個(gè)目錄。
注意: javac 工具默認(rèn)開啟了優(yōu)化功能, 生成的字節(jié)碼中沒有局部變量表(LocalVariableTable),相當(dāng)于局部變量名稱被擦除。如果需要這些調(diào)試信息, 在編譯時(shí)請加上 -g 選項(xiàng)。有興趣的同學(xué)可以試試兩種方式的區(qū)別,并對比結(jié)果。
JDK 自帶工具的詳細(xì)用法, 請使用: javac -help 或者 javap -help 來查看; 其他類似。
然后使用 javap 工具來執(zhí)行反編譯, 獲取字節(jié)碼清單:
javap -c demo.jvm0104.HelloByteCode
# 或者:
javap -c demo/jvm0104/HelloByteCode
javap -c demo/jvm0104/HelloByteCode.class
javap 還是比較聰明的, 使用包名或者相對路徑都可以反編譯成功, 反編譯后的結(jié)果如下所示:
Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode {
public demo.jvm0104.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
}
OK,我們成功獲取到了字節(jié)碼清單, 下面進(jìn)行簡單的解讀。
4.3 解讀字節(jié)碼清單
可以看到,反編譯后的代碼清單中, 有一個(gè)默認(rèn)的構(gòu)造函數(shù) public
demo.jvm0104.HelloByteCode(), 以及 main 方法。
剛學(xué) Java 時(shí)我們就知道, 如果不定義任何構(gòu)造函數(shù),就會有一個(gè)默認(rèn)的無參構(gòu)造函數(shù),這里再次驗(yàn)證了這個(gè)知識點(diǎn)。好吧,這比較容易理解!我們通過查看編譯后的 class 文件證實(shí)了其中存在默認(rèn)構(gòu)造函數(shù),所以這是 Java 編譯器生成的, 而不是運(yùn)行時(shí)JVM自動(dòng)生成的。
自動(dòng)生成的構(gòu)造函數(shù),其方法體應(yīng)該是空的,但這里看到里面有一些指令。為什么呢?
再次回顧 Java 知識, 每個(gè)構(gòu)造函數(shù)中都會先調(diào)用 super 類的構(gòu)造函數(shù)對吧? 但這不是 JVM 自動(dòng)執(zhí)行的, 而是由程序指令控制,所以默認(rèn)構(gòu)造函數(shù)中也就有一些字節(jié)碼指令來干這個(gè)事情。
基本上,這幾條指令就是執(zhí)行 super() 調(diào)用;
public demo.jvm0104.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
至于其中解析的 java/lang/Object 不用說, 默認(rèn)繼承了 Object 類。這里再次驗(yàn)證了這個(gè)知識點(diǎn),而且這是在編譯期間就確定了的。
繼續(xù)往下看 c,
public static void main(java.lang.String[]);
Code:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
main 方法中創(chuàng)建了該類的一個(gè)實(shí)例, 然后就 return 了, 關(guān)于里面的幾個(gè)指令, 稍后講解。
4.4 查看 class 文件中的常量池信息
常量池 大家應(yīng)該都聽說過, 英文是 Constant pool。這里做一個(gè)強(qiáng)調(diào): 大多數(shù)時(shí)候指的是 運(yùn)行時(shí)常量池。但運(yùn)行時(shí)常量池里面的常量是從哪里來的呢? 主要就是由 class 文件中的 常量池結(jié)構(gòu)體 組成的。
要查看常量池信息, 我們得加一點(diǎn)魔法參數(shù):
javap -c -verbose demo.jvm0104.HelloByteCode
在反編譯 class 時(shí),指定 -verbose 選項(xiàng), 則會 輸出附加信息。
結(jié)果如下所示:
Classfile /XXXXXXX/demo/jvm0104/HelloByteCode.class
Last modified 2019-11-28; size 301 bytes
MD5 checksum 542cb70faf8b2b512a023e1a8e6c1308
Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // demo/jvm0104/HelloByteCode
#3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 HelloByteCode.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Utf8 demo/jvm0104/HelloByteCode
#15 = Utf8 java/lang/Object
{
public demo.jvm0104.HelloByteCode();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
LineNumberTable:
line 5: 0
line 6: 8
}
SourceFile: "HelloByteCode.java"
其中顯示了很多關(guān)于 class 文件信息: 編譯時(shí)間, MD5 校驗(yàn)和, 從哪個(gè) .java 源文件編譯得來,符合哪個(gè)版本的 Java 語言規(guī)范等等。
還可以看到 ACC_PUBLIC 和 ACC_SUPER 訪問標(biāo)志符。 ACC_PUBLIC 標(biāo)志很容易理解:這個(gè)類是 public 類,因此用這個(gè)標(biāo)志來表示。
但 ACC_SUPER 標(biāo)志是怎么回事呢? 這就是歷史原因, JDK 1.0 的 BUG 修正中引入 ACC_SUPER 標(biāo)志來修正 invokespecial 指令調(diào)用 super 類方法的問題,從 Java 1.1 開始, 編譯器一般都會自動(dòng)生成ACC_SUPER 標(biāo)志。
有些同學(xué)可能注意到了, 好多指令后面使用了 #1, #2, #3 這樣的編號。
這就是對常量池的引用。 那常量池里面有些什么呢?
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = Class #14 // demo/jvm0104/HelloByteCode
#3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
#4 = Class #15 // java/lang/Object
#5 = Utf8 <init>
......
這是摘取的一部分內(nèi)容, 可以看到常量池中的常量定義。還可以進(jìn)行組合, 一個(gè)常量的定義中可以引用其他常量。
比如第一行: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V, 解讀如下:
- #1 常量編號, 該文件中其他地方可以引用。
- = 等號就是分隔符.
- Methodref 表明這個(gè)常量指向的是一個(gè)方法;具體是哪個(gè)類的哪個(gè)方法呢? 類指向的 #4, 方法簽名指向的 #13; 當(dāng)然雙斜線注釋后面已經(jīng)解析出來可讀性比較好的說明了。
同學(xué)們可以試著解析其他的常量定義。 自己實(shí)踐加上知識回顧,能有效增加個(gè)人的記憶和理解。
總結(jié)一下,常量池就是一個(gè)常量的大字典,使用編號的方式把程序里用到的各類常量統(tǒng)一管理起來,這樣在字節(jié)碼操作里,只需要引用編號即可。
4.5 查看方法信息
在 javap 命令中使用 -verbose 選項(xiàng)時(shí), 還顯示了其他的一些信息。 例如, 關(guān)于 main 方法的更多信息被打印出來:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
可以看到方法描述: ([Ljava/lang/String;)V:
- 其中小括號內(nèi)是入?yún)⑿畔?形參信息;
- 左方括號表述數(shù)組;
- L 表示對象;
- 后面的java/lang/String就是類名稱;
- 小括號后面的 V 則表示這個(gè)方法的返回值是 void;
- 方法的訪問標(biāo)志也很容易理解 flags: ACC_PUBLIC, ACC_STATIC,表示 public 和 static。
還可以看到執(zhí)行該方法時(shí)需要的棧(stack)深度是多少,需要在局部變量表中保留多少個(gè)槽位, 還有方法的參數(shù)個(gè)數(shù): stack=2, locals=2, args_size=1。把上面這些整合起來其實(shí)就是一個(gè)方法:
public static void main(java.lang.String[]);
注:實(shí)際上我們一般把一個(gè)方法的修飾符+名稱+參數(shù)類型清單+返回值類型,合在一起叫“方法簽名”,即這些信息可以完整的表示一個(gè)方法。
稍微往回一點(diǎn)點(diǎn),看編譯器自動(dòng)生成的無參構(gòu)造函數(shù)字節(jié)碼:
public demo.jvm0104.HelloByteCode();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
你會發(fā)現(xiàn)一個(gè)奇怪的地方, 無參構(gòu)造函數(shù)的參數(shù)個(gè)數(shù)居然不是 0: stack=1, locals=1, args_size=1。 這是因?yàn)樵?Java 中, 如果是靜態(tài)方法則沒有 this 引用。 對于非靜態(tài)方法, this 將被分配到局部變量表的第 0 號槽位中, 關(guān)于局部變量表的細(xì)節(jié),下面再進(jìn)行介紹。
有反射編程經(jīng)驗(yàn)的同學(xué)可能比較容易理解: Method#invoke(Object obj, Object... args); 有JavaScript編程經(jīng)驗(yàn)的同學(xué)也可以類比: fn.Apply(obj, args) && fn.call(obj, arg1, arg2);
4.6 線程棧與字節(jié)碼執(zhí)行模型
想要深入了解字節(jié)碼技術(shù),我們需要先對字節(jié)碼的執(zhí)行模型有所了解。
JVM 是一臺基于棧的計(jì)算機(jī)器。每個(gè)線程都有一個(gè)獨(dú)屬于自己的線程棧(JVM stack),用于存儲棧幀(Frame)。每一次方法調(diào)用,JVM都會自動(dòng)創(chuàng)建一個(gè)棧幀。棧幀 由 操作數(shù)棧, 局部變量數(shù)組 以及一個(gè)class 引用組成。class 引用 指向當(dāng)前方法在運(yùn)行時(shí)常量池中對應(yīng)的 class)。
我們在前面反編譯的代碼中已經(jīng)看到過這些內(nèi)容。
局部變量數(shù)組 也稱為 局部變量表(LocalVariableTable), 其中包含了方法的參數(shù),以及局部變量。 局部變量數(shù)組的大小在編譯時(shí)就已經(jīng)確定: 和局部變量+形參的個(gè)數(shù)有關(guān),還要看每個(gè)變量/參數(shù)占用多少個(gè)字節(jié)。操作數(shù)棧是一個(gè) LIFO 結(jié)構(gòu)的棧, 用于壓入和彈出值。 它的大小也在編譯時(shí)確定。
有一些操作碼/指令可以將值壓入“操作數(shù)棧”; 還有一些操作碼/指令則是從棧中獲取操作數(shù),并進(jìn)行處理,再將結(jié)果壓入棧。操作數(shù)棧還用于接收調(diào)用其他方法時(shí)返回的結(jié)果值。
4.7 方法體中的字節(jié)碼解讀
看過前面的示例,細(xì)心的同學(xué)可能會猜測,方法體中那些字節(jié)碼指令前面的數(shù)字是什么意思,說是序號吧但又不太像,因?yàn)樗麄冎g的間隔不相等??纯?main 方法體對應(yīng)的字節(jié)碼:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
間隔不相等的原因是, 有一部分操作碼會附帶有操作數(shù), 也會占用字節(jié)碼數(shù)組中的空間。
例如, new 就會占用三個(gè)槽位: 一個(gè)用于存放操作碼指令自身,兩個(gè)用于存放操作數(shù)。
因此,下一條指令 dup 的索引從 3 開始。
如果將這個(gè)方法體變成可視化數(shù)組,那么看起來應(yīng)該是這樣的:
每個(gè)操作碼/指令都有對應(yīng)的十六進(jìn)制(HEX)表示形式, 如果換成十六進(jìn)制來表示,則方法體可表示為HEX字符串。例如上面的方法體百世成十六進(jìn)制如下所示:
甚至我們還可以在支持十六進(jìn)制的編輯器中打開 class 文件,可以在其中找到對應(yīng)的字符串:
(此圖由開源文本編輯軟件Atom的hex-view插件生成)
粗暴一點(diǎn),我們可以通過 HEX 編輯器直接修改字節(jié)碼,盡管這樣做會有風(fēng)險(xiǎn), 但如果只修改一個(gè)數(shù)值的話應(yīng)該會很有趣。
其實(shí)要使用編程的方式,方便和安全地實(shí)現(xiàn)字節(jié)碼編輯和修改還有更好的辦法,那就是使用 ASM 和 Javassist 之類的字節(jié)碼操作工具,也可以在類加載器和 Agent 上面做文章,下一節(jié)課程會討論 類加載器,其他主題則留待以后探討。
4.8 對象初始化指令:new 指令, init 以及 clinit 簡介
我們都知道 new是 Java 編程語言中的一個(gè)關(guān)鍵字, 但其實(shí)在字節(jié)碼中,也有一個(gè)指令叫做 new。 當(dāng)我們創(chuàng)建類的實(shí)例時(shí), 編譯器會生成類似下面這樣的操作碼:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
當(dāng)你同時(shí)看到 new, dup 和 invokespecial 指令在一起時(shí),那么一定是在創(chuàng)建類的實(shí)例對象!
為什么是三條指令而不是一條呢?這是因?yàn)椋?/p>
- new 指令只是創(chuàng)建對象,但沒有調(diào)用構(gòu)造函數(shù)。
- invokespecial 指令用來調(diào)用某些特殊方法的, 當(dāng)然這里調(diào)用的是構(gòu)造函數(shù)。
- dup 指令用于復(fù)制棧頂?shù)闹怠?/li>
由于構(gòu)造函數(shù)調(diào)用不會返回值,所以如果沒有 dup 指令, 在對象上調(diào)用方法并初始化之后,操作數(shù)棧就會是空的,在初始化之后就會出問題, 接下來的代碼就無法對其進(jìn)行處理。
這就是為什么要事先復(fù)制引用的原因,為的是在構(gòu)造函數(shù)返回之后,可以將對象實(shí)例賦值給局部變量或某個(gè)字段。因此,接下來的那條指令一般是以下幾種:
- astore {N} or astore_{N} – 賦值給局部變量,其中 {N} 是局部變量表中的位置。
- putfield – 將值賦給實(shí)例字段
- putstatic – 將值賦給靜態(tài)字段
在調(diào)用構(gòu)造函數(shù)的時(shí)候,其實(shí)還會執(zhí)行另一個(gè)類似的方法 <init> ,甚至在執(zhí)行構(gòu)造函數(shù)之前就執(zhí)行了。
還有一個(gè)可能執(zhí)行的方法是該類的靜態(tài)初始化方法 <clinit>, 但 <clinit> 并不能被直接調(diào)用,而是由這些指令觸發(fā)的: new, getstatic, putstatic or invokestatic。
也就是說,如果創(chuàng)建某個(gè)類的新實(shí)例, 訪問靜態(tài)字段或者調(diào)用靜態(tài)方法,就會觸發(fā)該類的靜態(tài)初始化方法【如果尚未初始化】。
實(shí)際上,還有一些情況會觸發(fā)靜態(tài)初始化, 詳情請參考 JVM 規(guī)范: [
http://docs.oracle.com/javase/specs/jvms/se8/html/]
4.9 棧內(nèi)存操作指令
有很多指令可以操作方法棧。 前面也提到過一些基本的棧操作指令: 他們將值壓入棧,或者從棧中獲取值。 除了這些基礎(chǔ)操作之外也還有一些指令可以操作棧內(nèi)存; 比如 swap 指令用來交換棧頂兩個(gè)元素的值。下面是一些示例:
最基礎(chǔ)的是 dup 和 pop 指令。
- dup 指令復(fù)制棧頂元素的值。
- pop 指令則從棧中刪除最頂部的值。
還有復(fù)雜一點(diǎn)的指令:比如,swap, dup_x1 和 dup2_x1。
- 顧名思義,swap 指令可交換棧頂兩個(gè)元素的值,例如A和B交換位置(圖中示例4);
- dup_x1 將復(fù)制棧頂元素的值,并在棧頂插入兩次(圖中示例5);
- dup2_x1 則復(fù)制棧頂兩個(gè)元素的值,并插入第三個(gè)值(圖中示例6)。
dup_x1 和 dup2_x1 指令看起來稍微有點(diǎn)復(fù)雜。而且為什么要設(shè)置這種指令呢? 在棧中復(fù)制最頂部的值?
請看一個(gè)實(shí)際案例:怎樣交換 2 個(gè) double 類型的值?
需要注意的是,一個(gè) double 值占兩個(gè)槽位,也就是說如果棧中有兩個(gè) double 值,它們將占用 4 個(gè)槽位。
要執(zhí)行交換,你可能想到了 swap 指令,但問題是 swap 只適用于單字(one-word, 單字一般指 32 位 4 個(gè)字節(jié),64 位則是雙字),所以不能處理 double 類型,但 Java 中又沒有 swap2 指令。
怎么辦呢? 解決方法就是使用 dup2_x2 指令,將操作數(shù)棧頂部的 double 值,復(fù)制到棧底 double 值的下方, 然后再使用 pop2 指令彈出棧頂?shù)?double 值。結(jié)果就是交換了兩個(gè) double 值。 示意圖如下圖所示:
dup、dup_x1、dup2_x1 指令補(bǔ)充說明
指令的詳細(xì)說明可參考 JVM 規(guī)范:
dup 指令
官方說明是:復(fù)制棧頂?shù)闹?,并將?fù)制的值壓入棧。
操作數(shù)棧的值變化情況(方括號標(biāo)識新插入的值):
..., value →
..., value [,value]
dup_x1 指令
官方說明是:復(fù)制棧頂?shù)闹担?fù)制的值插入到最上面 2 個(gè)值的下方。
操作數(shù)棧的值變化情況(方括號標(biāo)識新插入的值):
..., value2, value1 →
..., [value1,] value2, value1
dup2_x1 指令
官方說明是:復(fù)制棧頂 1 個(gè) 64 位/或 2 個(gè) 32 位的值, 并將復(fù)制的值按照原始順序,插入原始值下面一個(gè) 32 位值的下方。
操作數(shù)棧的值變化情況(方括號標(biāo)識新插入的值):
# 情景 1: value1, value2, and value3 都是分組 1 的值(32 位元素)
..., value3, value2, value1 →
..., [value2, value1,] value3, value2, value1
# 情景 2: value1 是分組 2 的值(64 位,long 或double), value2 是分組 1 的值(32 位元素)
..., value2, value1 →
..., [value1,] value2, value1
Table 2.11.1-B 實(shí)際類型與 JVM 計(jì)算類型映射和分組
實(shí)際類型 |
JVM 計(jì)算類型 |
類型分組 |
boolean |
int |
1 |
byte |
int |
1 |
char |
int |
1 |
short |
int |
1 |
int |
int |
1 |
float |
float |
1 |
reference |
reference |
1 |
returnAddress |
returnAddress |
1 |
long |
long |
2 |
double |
double |
2 |
4.10 局部變量表
stack 主要用于執(zhí)行指令,而局部變量則用來保存中間結(jié)果,兩者之間可以直接交互。
讓我們編寫一個(gè)復(fù)雜點(diǎn)的示例:
第一步,先編寫一個(gè)計(jì)算移動(dòng)平均數(shù)的類:
package demo.jvm0104;
//移動(dòng)平均數(shù)
public class MovingAverage {
private int count = 0;
private double sum = 0.0D;
public void submit(double value){
this.count ++;
this.sum += value;
}
public double getAvg(){
if(0 == this.count){ return sum;}
return this.sum/this.count;
}
}
第二步,然后寫一個(gè)類來調(diào)用:
package demo.jvm0104;
public class LocalVariableTest {
public static void main(String[] args) {
MovingAverage ma = new MovingAverage();
int num1 = 1;
int num2 = 2;
ma.submit(num1);
ma.submit(num2);
double avg = ma.getAvg();
}
}
其中 main 方法中向 MovingAverage 類的實(shí)例提交了兩個(gè)數(shù)值,并要求其計(jì)算當(dāng)前的平均值。
然后我們需要編譯(還記得前面提到, 生成調(diào)試信息的 -g 參數(shù)嗎)。
javac -g demo/jvm0104/*.java
然后使用 javap 反編譯:
javap -c -verbose demo/jvm0104/LocalVariableTest
看 main 方法對應(yīng)的字節(jié)碼:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=6, args_size=1
0: new #2 // class demo/jvm0104/MovingAverage
3: dup
4: invokespecial #3 // Method demo/jvm0104/MovingAverage."<init>":()V
7: astore_1
8: iconst_1
9: istore_2
10: iconst_2
11: istore_3
12: aload_1
13: iload_2
14: i2d
15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V
18: aload_1
19: iload_3
20: i2d
21: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V
24: aload_1
25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D
28: dstore 4
30: return
LineNumberTable:
line 5: 0
line 6: 8
line 7: 10
line 8: 12
line 9: 18
line 10: 24
line 11: 30
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 ma Ldemo/jvm0104/MovingAverage;
10 21 2 num1 I
12 19 3 num2 I
30 1 4 avg D
- 編號 0 的字節(jié)碼 new, 創(chuàng)建 MovingAverage 類的對象;
- 編號 3 的字節(jié)碼 dup 復(fù)制棧頂引用值。
- 編號 4 的字節(jié)碼 invokespecial 執(zhí)行對象初始化。
- 編號 7 開始, 使用 astore_1 指令將引用地址值(addr.)存儲(store)到編號為1的局部變量中: astore_1 中的 1 指代 LocalVariableTable 中ma對應(yīng)的槽位編號,
- 編號8開始的指令: iconst_1 和 iconst_2 用來將常量值1和2加載到棧里面, 并分別由指令 istore_2 和 istore_3 將它們存儲到在 LocalVariableTable 的槽位 2 和槽位 3 中。
8: iconst_1
9: istore_2
10: iconst_2
11: istore_3
請注意,store 之類的指令調(diào)用實(shí)際上從棧頂刪除了一個(gè)值。 這就是為什么再次使用相同值時(shí),必須再加載(load)一次的原因。
例如在上面的字節(jié)碼中,調(diào)用 submit 方法之前, 必須再次將參數(shù)值加載到棧中:
12: aload_1
13: iload_2
14: i2d
15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V
調(diào)用 getAvg() 方法后,返回的結(jié)果位于棧頂,然后使用 dstore 將 double 值保存到本地變量4號槽位,這里的d表示目標(biāo)變量的類型為double。
24: aload_1
25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D
28: dstore 4
關(guān)于 LocalVariableTable 有個(gè)有意思的事情,就是最前面的槽位會被方法參數(shù)占用。
在這里,因?yàn)?main 是靜態(tài)方法,所以槽位0中并沒有設(shè)置為 this 引用的地址。 但是對于非靜態(tài)方法來說, this 會將分配到第 0 號槽位中。
再次提醒: 有過反射編程經(jīng)驗(yàn)的同學(xué)可能比較容易理解: Method#invoke(Object obj, Object... args); 有JavaScript編程經(jīng)驗(yàn)的同學(xué)也可以類比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);
理解這些字節(jié)碼的訣竅在于:
給局部變量賦值時(shí),需要使用相應(yīng)的指令來進(jìn)行 store,如 astore_1。store 類的指令都會刪除棧頂值。 相應(yīng)的 load 指令則會將值從局部變量表壓入操作數(shù)棧,但并不會刪除局部變量中的值。
4.11 流程控制指令
流程控制指令主要是分支和循環(huán)在用, 根據(jù)檢查條件來控制程序的執(zhí)行流程。
一般是 If-Then-Else 這種三元運(yùn)算符(ternary operator), Java中的各種循環(huán),甚至異常處的理操作碼都可歸屬于 程序流程控制。
然后,我們再增加一個(gè)示例,用循環(huán)來提交給 MovingAverage 類一定數(shù)量的值:
package demo.jvm0104;
public class ForLoopTest {
private static int[] numbers = {1, 6, 8};
public static void main(String[] args) {
MovingAverage ma = new MovingAverage();
for (int number : numbers) {
ma.submit(number);
}
double avg = ma.getAvg();
}
}
同樣執(zhí)行編譯和反編譯:
javac -g demo/jvm0104/*.java
javap -c -verbose demo/jvm0104/ForLoopTest
因?yàn)?numbers 是本類中的 static 屬性, 所以對應(yīng)的字節(jié)碼如下所示:
0: new #2 // class demo/jvm0104/MovingAverage
3: dup
4: invokespecial #3 // Method demo/jvm0104/MovingAverage."<init>":()V
7: astore_1
8: getstatic #4 // Field numbers:[I
11: astore_2
12: aload_2
13: arraylength
14: istore_3
15: iconst_0
16: istore 4
18: iload 4
20: iload_3
21: if_icmpge 43
24: aload_2
25: iload 4
27: iaload
28: istore 5
30: aload_1
31: iload 5
33: i2d
34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V
37: iinc 4, 1
40: goto 18
43: aload_1
44: invokevirtual #6 // Method demo/jvm0104/MovingAverage.getAvg:()D
47: dstore_2
48: return
LocalVariableTable:
Start Length Slot Name Signature
30 7 5 number I
0 49 0 args [Ljava/lang/String;
8 41 1 ma Ldemo/jvm0104/MovingAverage;
48 1 2 avg D
位置 [8~16] 的指令用于循環(huán)控制。 我們從代碼的聲明從上往下看, 在最后面的LocalVariableTable 中:
- 0 號槽位被 main 方法的參數(shù) args 占據(jù)了。
- 1 號槽位被 ma 占用了。
- 5 號槽位被 number 占用了。
- 2 號槽位是for循環(huán)之后才被 avg 占用的。
那么中間的 2,3,4 號槽位是誰霸占了呢? 通過分析字節(jié)碼指令可以看出,在 2,3,4 槽位有 3 個(gè)匿名的局部變量(astore_2, istore_3, istore 4等指令)。
- 2號槽位的變量保存了 numbers 的引用值,占據(jù)了 2號槽位。
- 3號槽位的變量, 由 arraylength 指令使用, 得出循環(huán)的長度。
- 4號槽位的變量, 是循環(huán)計(jì)數(shù)器, 每次迭代后使用 iinc 指令來遞增。
如果我們的 JDK 版本再老一點(diǎn), 則會在 2,3,4 槽位發(fā)現(xiàn)三個(gè)源碼中沒有出現(xiàn)的變量: arr$, len$, i$, 也就是循環(huán)變量。
循環(huán)體中的第一條指令用于執(zhí)行 循環(huán)計(jì)數(shù)器與數(shù)組長度 的比較:
18: iload 4
20: iload_3
21: if_icmpge 43
這段指令將局部變量表中 4號槽位 和 3號槽位的值加載到棧中,并調(diào)用 if_icmpge 指令來比較他們的值。
【if_icmpge 解讀: if, integer, compare, great equal】, 如果一個(gè)數(shù)的值大于或等于另一個(gè)值,則程序執(zhí)行流程跳轉(zhuǎn)到pc=43的地方繼續(xù)執(zhí)行。
在這個(gè)例子中就是, 如果4號槽位的值 大于或等于 3號槽位的值, 循環(huán)就結(jié)束了,這里 43 位置對于的是循環(huán)后面的代碼。如果條件不成立,則循環(huán)進(jìn)行下一次迭代。
在循環(huán)體執(zhí)行完,它的循環(huán)計(jì)數(shù)器加 1,然后循環(huán)跳回到起點(diǎn)以再次驗(yàn)證循環(huán)條件:
37: iinc 4, 1 // 4號槽位的值加1
40: goto 18 // 跳到循環(huán)開始的地方
4.12 算術(shù)運(yùn)算指令與類型轉(zhuǎn)換指令
Java 字節(jié)碼中有許多指令可以執(zhí)行算術(shù)運(yùn)算。實(shí)際上,指令集中有很大一部分表示都是關(guān)于數(shù)學(xué)運(yùn)算的。對于所有數(shù)值類型(int, long, double, float),都有加,減,乘,除,取反的指令。
那么 byte 和 char, boolean 呢? JVM 是當(dāng)做 int 來處理的。另外還有部分指令用于數(shù)據(jù)類型之間的轉(zhuǎn)換。
算術(shù)操作碼和類型
當(dāng)我們想將 int 類型的值賦值給 long 類型的變量時(shí),就會發(fā)生類型轉(zhuǎn)換。
類型轉(zhuǎn)換操作碼
在前面的示例中, 將 int 值作為參數(shù)傳遞給實(shí)際上接收 double 的 submit() 方法時(shí),可以看到, 在實(shí)際調(diào)用該方法之前,使用了類型轉(zhuǎn)換的操作碼:
31: iload 5
33: i2d
34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V
也就是說, 將一個(gè) int 類型局部變量的值, 作為整數(shù)加載到棧中,然后用 i2d 指令將其轉(zhuǎn)換為 double 值,以便將其作為參數(shù)傳給submit方法。
唯一不需要將數(shù)值load到操作數(shù)棧的指令是 iinc,它可以直接對 LocalVariableTable 中的值進(jìn)行運(yùn)算。 其他的所有操作均使用棧來執(zhí)行。
4.13 方法調(diào)用指令和參數(shù)傳遞
前面部分稍微提了一下方法調(diào)用: 比如構(gòu)造函數(shù)是通過 invokespecial 指令調(diào)用的。
這里列舉了各種用于方法調(diào)用的指令:
- invokestatic,顧名思義,這個(gè)指令用于調(diào)用某個(gè)類的靜態(tài)方法,這也是方法調(diào)用指令中最快的一個(gè)。
- invokespecial, 我們已經(jīng)學(xué)過了, invokespecial 指令用來調(diào)用構(gòu)造函數(shù),但也可以用于調(diào)用同一個(gè)類中的 private 方法, 以及可見的超類方法。
- invokevirtual,如果是具體類型的目標(biāo)對象,invokevirtual用于調(diào)用公共,受保護(hù)和打包私有方法。
- invokeinterface,當(dāng)要調(diào)用的方法屬于某個(gè)接口時(shí),將使用 invokeinterface 指令。
那么 invokevirtual 和 invokeinterface 有什么區(qū)別呢?這確實(shí)是個(gè)好問題。 為什么需要 invokevirtual 和 invokeinterface 這兩種指令呢? 畢竟所有的接口方法都是公共方法, 直接使用 invokevirtual 不就可以了嗎?
這么做是源于對方法調(diào)用的優(yōu)化。JVM 必須先解析該方法,然后才能調(diào)用它。
- 使用 invokestatic 指令,JVM 就確切地知道要調(diào)用的是哪個(gè)方法:因?yàn)檎{(diào)用的是靜態(tài)方法,只能屬于一個(gè)類。
- 使用 invokespecial 時(shí), 查找的數(shù)量也很少, 解析也更加容易, 那么運(yùn)行時(shí)就能更快地找到所需的方法。
使用 invokevirtual 和 invokeinterface 的區(qū)別不是那么明顯。想象一下,類定義中包含一個(gè)方法定義表, 所有方法都有位置編號。下面的示例中:A 類包含 method1 和 method2 方法; 子類B繼承A,繼承了 method1,覆寫了 method2,并聲明了方法 method3。
請注意,method1 和 method2 方法在類 A 和類 B 中處于相同的索引位置。
class A
1: method1
2: method2
class B extends A
1: method1
2: method2
3: method3
那么,在運(yùn)行時(shí)只要調(diào)用 method2,一定是在位置 2 處找到它。
現(xiàn)在我們來解釋invokevirtual 和 invokeinterface 之間的本質(zhì)區(qū)別。
假設(shè)有一個(gè)接口 X 聲明了 methodX 方法, 讓 B 類在上面的基礎(chǔ)上實(shí)現(xiàn)接口 X:
class B extends A implements X
1: method1
2: method2
3: method3
4: methodX
新方法 methodX 位于索引 4 處,在這種情況下,它看起來與 method3 沒什么不同。
但如果還有另一個(gè)類 C 也實(shí)現(xiàn)了 X 接口,但不繼承 A,也不繼承 B:
class C implements X
1: methodC
2: methodX
類 C 中的接口方法位置與類 B 的不同,這就是為什么運(yùn)行時(shí)在 invokinterface 方面受到更多限制的原因。 與 invokinterface 相比, invokevirtual 針對具體的類型方法表是固定的,所以每次都可以精確查找,效率更高(具體的分析討論可以參見參考材料的第一個(gè)鏈接)。
4.14 JDK7 新增的方法調(diào)用指令 invokedynamic
Java 虛擬機(jī)的字節(jié)碼指令集在 JDK7 之前一直就只有前面提到的 4 種指令(invokestatic,invokespecial,invokevirtual,invokeinterface)。隨著 JDK 7 的發(fā)布,字節(jié)碼指令集新增了invokedynamic指令。這條新增加的指令是實(shí)現(xiàn)“動(dòng)態(tài)類型語言”(Dynamically Typed Language)支持而進(jìn)行的改進(jìn)之一,同時(shí)也是 JDK 8 以后支持的 lambda 表達(dá)式的實(shí)現(xiàn)基礎(chǔ)。
為什么要新增加一個(gè)指令呢?
我們知道在不改變字節(jié)碼的情況下,我們在 Java 語言層面想調(diào)用一個(gè)類 A 的方法 m,只有兩個(gè)辦法:
- 使用A a=new A(); a.m(),拿到一個(gè) A 類型的實(shí)例,然后直接調(diào)用方法;
- 通過反射,通過 A.class.getMethod 拿到一個(gè) Method,然后再調(diào)用這個(gè)Method.invoke反射調(diào)用;
這兩個(gè)方法都需要顯式的把方法 m 和類型 A 直接關(guān)聯(lián)起來,假設(shè)有一個(gè)類型 B,也有一個(gè)一模一樣的方法簽名的 m 方法,怎么來用這個(gè)方法在運(yùn)行期指定調(diào)用 A 或者 B 的 m 方法呢?這個(gè)操作在 JavaScript 這種基于原型的語言里或者是 C# 這種有函數(shù)指針/方法委托的語言里非常常見,Java 里是沒有直接辦法的。Java 里我們一般建議使用一個(gè) A 和 B 公有的接口 IC,然后 IC 里定義方法 m,A 和 B 都實(shí)現(xiàn)接口 IC,這樣就可以在運(yùn)行時(shí)把 A 和 B 都當(dāng)做 IC 類型來操作,就同時(shí)有了方法 m,這樣的“強(qiáng)約束”帶來了很多額外的操作。
而新增的 invokedynamic 指令,配合新增的方法句柄(Method Handles,它可以用來描述一個(gè)跟類型 A 無關(guān)的方法 m 的簽名,甚至不包括方法名稱,這樣就可以做到我們使用方法 m 的簽名,但是直接執(zhí)行的時(shí)候調(diào)用的是相同簽名的另一個(gè)方法 b),可以在運(yùn)行時(shí)再決定由哪個(gè)類來接收被調(diào)用的方法。在此之前,只能使用反射來實(shí)現(xiàn)類似的功能。該指令使得可以出現(xiàn)基于 JVM 的動(dòng)態(tài)語言,讓 jvm 更加強(qiáng)大。而且在 JVM 上實(shí)現(xiàn)動(dòng)態(tài)調(diào)用機(jī)制,不會破壞原有的調(diào)用機(jī)制。這樣既很好的支持了 Scala、Clojure 這些 JVM 上的動(dòng)態(tài)語言,又可以支持代碼里的動(dòng)態(tài) lambda 表達(dá)式。
RednaxelaFX 評論說:
簡單來說就是以前設(shè)計(jì)某些功能的時(shí)候把做法寫死在了字節(jié)碼里,后來想改也改不了了。 所以這次給 lambda 語法設(shè)計(jì)翻譯到字節(jié)碼的策略是就用 invokedynamic 來作個(gè)弊,把實(shí)際的翻譯策略隱藏在 JDK 的庫的實(shí)現(xiàn)里(metafactory)可以隨時(shí)改,而在外部的標(biāo)準(zhǔn)上大家只看到一個(gè)固定的 invokedynamic。
參考材料
- Why Should I Know About Java Bytecode: https://jrebel.com/rebellabs/rebel-labs-report-mastering-java-bytecode-at-the-core-of-the-jvm/
- 輕松看懂Java字節(jié)碼: https://juejin.im/post/5aca2c366fb9a028c97a5609
- invokedynamic指令:https://www.cnblogs.com/wade-luffy/p/6058087.html
- Java 8的Lambda表達(dá)式為什么要基于invokedynamic?:https://www.zhihu.com/question/39462935
- Invokedynamic:https://www.jianshu.com/p/ad7d572196a8
- JVM之動(dòng)態(tài)方法調(diào)用:invokedynamic: https://ifeve.com/jvm%E4%B9%8B%E5%8A%A8%E6%80%81%E6%96%B9%E6%B3%95%E8%B0%83%E7%94%A8%EF%BC%9Ainvokedynamic/