者 | 東升的思考
責編 | Elle
不啰嗦,直接從最最簡單的一段Java源代碼開啟Java整體字節碼分析之旅。
package com.dskj.jvm.bytecode;
public class MyTest1 {
private int a = 1;
public intgetA {
return a;
}
public voidsetA(int a) {
this.a = a;
}
}Java字節碼文件
IDEA工具編譯代碼后,Terminal 終端控制臺,進入到生成class文件的目錄下。
執行如下命令:
javap -verbose com.dskj.jvm.bytecode.MyTest1
生成字節碼文件內容:
Classfile
/.../classes/com/dskj/jvm/bytecode/MyTest.class
Last modified Jul 31, 2018; size 489 bytes
MD5 checksum bdb537edd2d216ea99d6ce529073ee42
Compiled from "MyTest1.java"
public class com.dskj.jvm.bytecode.MyTest
minor version: 0
major version: 52 # JDK最大版本號
flags: ACC_PUBLIC, ACC_SUPER
Constant pool: #
#1 = Methodref #4.#20 // java/lang/Object."<init>":V
#2 = Fieldref #3.#21 // com/dskj/jvm/bytecode/MyTest1.a:I
#3 = Class #22 // com/dskj/jvm/bytecode/MyTest1
#4 = Class #23 // java/lang/Object
#5 = Utf8 a
#6 = Utf8 I
#7 = Utf8 <init>
#8 = Utf8 V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/dskj/jvm/bytecode/MyTest1;
#14 = Utf8 getA
#15 = Utf8 I
#16 = Utf8 setA
#17 = Utf8 (I)V
#18 = Utf8 SourceFile
#19 = Utf8 MyTest1.java
#20 = NameAndType #7:#8 // "<init>":V
#21 = NameAndType #5:#6 // a:I
#22 = Utf8 com/dskj/jvm/bytecode/MyTest1
#23 = Utf8 java/lang/Object
{
public com.dskj.jvm.bytecode.MyTest1;
descriptor: V
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":V
4: aload_0
5: iconst_1
6: putfield #2 // Field a:I
9: return
LineNumberTable:
line 6: 0
line 8: 4
LocalVariableTable:
Start Length Slot Name Signature
0 10 0 this Lcom/dskj/jvm/bytecode/MyTest1;
public int getA;
descriptor: I
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field a:I
4: ireturn
LineNumberTable:
line 11: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/dskj/jvm/bytecode/MyTest1;
public void setA(int);
descriptor: (I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: iload_1
2: putfield #2 // Field a:I
5: return
LineNumberTable:
line 15: 0
line 16: 5
LocalVariableTable:
Start Length Slot Name Signature
0 6 0 this Lcom/dskj/jvm/bytecode/MyTest1;
0 6 1 a I
}
SourceFile: "MyTest1.java”
Java字節碼十六進制
Mac操作系統下建議使用 Hex Fiend 工具查看 MyTest1.class 文件的十六進制格式。
十六進制文本如下,便于后續分析使用:
CA FE BA BE 00 00 00 34 00 18 0A 00 04 00 14 09 00 03 00 15 07 00 16 07 00 17 01 00 01 61 01 00 01 49 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65 01 00 12 4C 6F 63 61 6C 56 61 72 69 61 62 6C 65 54 61 62 6C 65 01 00 04 74 68 69 73 01 00 1F 4C 63 6F 6D 2F 64 73 6B 6A 2F 6A 76 6D 2F 62 79 74 65 63 6F 64 65 2F 4D 79 54 65 73 74 31 3B 01 00 04 67 65 74 41 01 00 03 28 29 49 01 00 04 73 65 74 41 01 00 04 28 49 29 56 01 00 0A 53 6F 75 72 63 65 46 69 6C 65 01 00 0C 4D 79 54 65 73 74 31 2E 6A 61 76 61 0C 00 07 00 08 0C 00 05 00 06 01 00 1D 63 6F 6D 2F 64 73 6B 6A 2F 6A 76 6D 2F 62 79 74 65 63 6F 64 65 2F 4D 79 54 65 73 74 31 01 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74 00 21 00 03 00 04 00 00 00 01 00 02 00 05 00 06 00 00 00 03 00 01 00 07 00 08 00 01 00 09 00 00 00 38 00 02 00 01 00 00 00 0A 2A B7 00 01 2A 04 B5 00 02 B1 00 00 00 02 00 0A 00 00 00 0A 00 02 00 00 00 06 00 04 00 08 00 0B 00 00 00 0C 00 01 00 00 00 0A 00 0C 00 0D 00 00 00 01 00 0E 00 0F 00 01 00 09 00 00 00 2F 00 01 00 01 00 00 00 05 2A B4 00 02 AC 00 00 00 02 00 0A 00 00 00 06 00 01 00 00 00 0B 00 0B 00 00 00 0C 00 01 00 00 00 05 00 0C 00 0D 00 00 00 01 00 10 00 11 00 01 00 09 00 00 00 3E 00 02 00 02 00 00 00 06 2A 1B B5 00 02 B1 00 00 00 02 00 0A 00 00 00 0A 00 02 00 00 00 0F 00 05 00 10 00 0B 00 00 00 16 00 02 00 00 00 06 00 0C 00 0D 00 00 00 00 00 06 00 05 00 06 00 01 00 01 00 12 00 00 00 02 00 13
前面都是鋪墊,來到重磅分析的一節。
Java字節碼整體結構如下圖所示,以下圖示以不同緯度展示了字節碼結構中所包含的關鍵內容。
Java字節碼整體結構圖:
完整的Java字節碼結構圖:
接下來結合十六進制格式的 class 文件,參照 Java字節碼文件來剖析下都包含了哪些內容。
1)4個字節,Magic Number
魔數,值為0xCAFEBABE,這是Java創始人James Gosling制定
2)2+2個字節,Version
包括minor_version和major_version,major_version:1.1(45),1.2(46),1.3(47),1.4(48),1.5(49),1.6(50),1.7(51),1.8(52),1.9(53),1.10(54)
3)2+n個字節,Constant Pool
包括字符串常量、數值常量等
4)2個字節,Access Flags
訪問標記,標記當前的類是public、final、abstract等等,是不是滿足某些特定要求。
5)2個字節,This Class Name
當前類的名字
6)2個字節,Super Class Name
當前類所屬父類的名字
7)2+n個字節,Interfaces
當前類所實現的接口
8)2+n個字節,Fields
字段表,描述了當前類的字段的各種各樣的信息
9)2+n個字節,Methods
方法表,當前類所定義的方法,這部分內容相對比以上字節結構是比較不容易理解
因為在我們一個類的定義當中方法是最常見的,方法里面包含若干的重要信息,包含簽名、訪問修飾符、名字、方法的執行代碼邏輯、返回值等等。
這些方法也是以信息的形式存儲在編譯之后的字節碼class文件當中,接下來,JVM去執行字節碼文件時,當你調用某個特定方法時,JVM才能根據你所編寫的源代碼的意圖去執行字節碼里的指令。
對于這個方法來說,在JVM中最終是形成一條條指令的去執行的,也就是說在字節碼里形成的每一條指令對應源碼文件中的每一行源代碼。
這些指令也可以稱作為助記符,比如aload_0,iload_1等。
10)2+n個字節,Attributes
附加屬性
Class字節碼中有兩種數據類型:
字節數據直接量:這是基本的數據類型。共細分為u1、u2、u4、u8四種,分別代表連續的1個字節、2個字節、4個字節、8個字節組成的整體數據。
表(數組),是一種復合的數據結構,表是由多個基本數據或其他表,按照既定順序組成的大的數據集合。表是有結構的,它的結構體現在:組成表的成分所在的位置和順序都是已經嚴格定義好的。
接下來,我們使用 javap -verbose 命令分析一個字節碼文件時,將會分析該字節碼文件的魔數、版本號、常量池、訪問標記、類信息、類變量、類的成員變量、類的構造方法與類中的方法信息等信息。
魔數:所有的.class字節碼文件的前4個字節都是魔數,文件中魔數為:CA FE BA BE,魔數值為固定值:0xCAFEBABE(咖啡寶貝?),這個值的獲得很有“浪漫氣息”,其作用是確定這個文件是否為一個能被虛擬機接受的Class文件。
版本號:魔數之后的4個字節為Class文件版本信息,前兩個字節表示minor version(次版本號),后兩個字節表示major version(主版本號)。
這里的版本號為00 00 00 34,換算成十進制(3 * 16的1次方 + 4 = 52),表示次版本號為0,主版本號為52。
所以,該文件的版本號為:1.8.0。可以通過java -version命令來驗證這一點。Java的版本號是從45開始的,JDK1.0之后大的主版本號線上加1,如JDK1.1(45)、JDK1.2(46)以此類推JDK1.8(52)。
常量池(constant pool):緊接著主版本號之后的就是常量池入口。
一個Java類中定義的很多信息都是由常量池來維護和描述的,可以將常量池看作是Class文件的資源倉庫,比如說Java類中定義的方法與變量信息,都是存儲在常量池中。由于常量池中常量的數量是不固定的,故在常量池入口需要放置一項u2類型的數據,代表常量池容量計數值(constant_pool_count)。
這里的容量計數是從1開始的,十六進制數為:00 18,轉換為十進制為24,代表常量池中有24項常量,索引值范圍1~24。
常量池數組中元素的個數 = 常量池數 - 1(其中0暫時不使用),所以Java字節碼文件中constant_pool中只看到了23項目常量。那為什么容量計數不從0開始呢?具體原因下一節說明。
常量池中主要存儲兩類常量:
字面量:字面量如文本字符串,Java中聲明為final的常量值等。
符號引用:類和接口的全局限定名,字段的名稱和描述符,方法的名稱和描述符等。
Java類所對應的常量池主要由常量池數量與常量池數組(常量表)這兩部分共同構成。
常量池數量緊跟在主版本號后面,占據2個字節;常量池數組緊跟在常量池數量之后。常量池數組與一般的數組不同的是,常量池數組中元素的類型、結構都是不同的,長度當然也就不同;但是,每一種元素第一個數據都是一個u1類型,該字節是個標志位,占據1個字節。
JVM在解析常量池時,會根據這個u1類型來獲取元素的具體類型。值得注意的是,常量池數組中元素的個數 = 常量池數 - 1(其中0暫時不使用),目的是滿足某些常量池索引值的數據在特定情況下需要表達「不引用任何一個常量池」的含義;根本原因在于,索引為0也是一個常量(保留常量),只不過它不位于常量表中,這個常量就對應值;所以,常量池的索引從1而非0開始。
Class文件結構中常量池中實際是有14種數據類型的,12~14種數據類型是在JDK1.7之后添加進來的(新增三種類型分別為:CONSTANT_MethodHandle_info、CONSTANT_MethodType_info、CONSTANT_InvokeDynamic_info),主要是為了更好的支持動態語言調用的。但是,最常用如下所列的列出了11種常規的數據類型:
上述常量都是以「CONSTANT」開頭,以「info」結尾的常量名。每一個常量包含的信息的段都是不同的,我們可以根據每一個段自身的起始和結束位置是什么來進行分析。
抽出兩個代表性的常量進行解析:
CONSTANT_Utf8_info
如果這個tag的值為1,占1個字節,它就表示的UTF-8編碼的字符串;length,占2個字節,比如length值是4,表示的是從length的下后面讀取4個字節長度的字符串。
這個就表示CONSTANT_Utf8_info的具體的文本內容。就是說根據length就能夠知道接下來我要讀取多少個字節才能讀完,這些字節是由bytes來表示的。
CONSTANT_Fieldref_info
tag是U1類型,值為9。有兩個index值,都是U2類型的,第一個index代表的是指向聲明字段的類或接口描述符CONSTANT_Class_info的索引項,第二個index代表的指向字段描述符CONSTANT_NameAndType_info的索引項。
具體可以理解為當我們定義一個字段時,一定是附屬在某一個類上的,所以要先索引到類信息上,可以具體看下CONSTANT_Class_info,其tag是U1類型,值為7,它的index代表指向全限定名常量項的索引,很好理解了。
然后再找到這個字段的描述符,這里指向了會索引到CONSTANT_NameAndType_info,其tag是U1類型,值為12,根據兩個index的描述可以理解為要有字段或方法的名稱以及字段或方法的描述符即可找到源碼中對應的字段和方法。
接下來,我們以上述Java字節碼結構總表為依據分析下Java字節碼十六進制對應到Java字節碼文件中的constant_pool常量池。
Java字節碼十六進制:
從第9位開始的十六進制
0A 00 04 00 14 0A表示值為10,從字節碼結構總表中找到值為10的是CONSTANT_Methodref_info,有兩個index值,第一個index占用的字節 00 04 轉換為十進制為4,第二個index占用的字節00 14 轉化為十進制為20。
從Java字節碼文件中Constant pool定義可看到:
Constant pool: #
#1 = Methodref #4.#20 // java/lang/Object."<init>":V
索引到位置#4和#20,從常量池中找到這兩個索引項如下:
#4 = Class #23 // java/lang/Object
#20 = NameAndType #7:#8 // "<init>":V
這兩個索引正好可以跟結構總表中對應上。其中,#4表示的類全限定名為java/lang/Object,而索引20位置又引用了#7:#8。繼續找到#7和#8:
#7 = Utf8 <init>
#8 = Utf8 V
從第16位開始的十六進制
09 00 03 00 15 這個標志位值為09,從字節碼結構總表中找到值為9的常量為CONSTANT_Fieldref_info,其后面跟著兩個index,對應十六進制轉換為十進制為3和21。
#2 = Fieldref #3.#21 // com/dskj/jvm/bytecode/MyTest1.a:I
對應有兩個索引項#3和#21,如下所示:
#3 = Class #22 // com/dskj/jvm/bytecode/MyTest1
#21 = NameAndType #5:#6 // a:I
索引項#3引用了索引項#22,索引項#21引用了索引項#5:#6
#22 = Utf8 com/dskj/jvm/bytecode/MyTest1
#5 = Utf8 a
#6 = Utf8 I
根據以上,#5表示的變量名為a,#6表示的變量a的返回類型是I,即int類型的。也就知道了#2 = Fileldref,對應的是com/dskj/jvm/bytecode/MyTest1.a:I。
對應到MyTest1類的變量:
private int a = 1;
從第21位開始的十六進制
07 00 16 標志位為07,值為7字節碼結構總表中對應常量CONSTANT_Class_info,索引占用2個字節,對應轉換為十進制為22。
#3 = Class #22 // com/dskj/jvm/bytecode/MyTest1
#22 = Utf8 com/dskj/jvm/bytecode/MyTest1
從第27位開始的十六進制
十六進制字節碼文件:
01 00 01 61 01 00 01 49 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65
查找標志位為01 ,值為1的結構總表常量為CONSTANT_Utf8-info,length的占用2個字節十六進制為 00 01 ,那么length長度就是1(轉換為十進制的值,即0 * 16的一次方 + 1),后面找到1個字節為61,通過HexFiend工具也能看到指向了a。
所以,找到的十六進制:01 00 01 61
常量池中進一步印證下:
#6 = Utf8 I
十六進制字節碼文件:
01 00 01 49 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65...
繼續查找標志位為01 ,值為1的結構總表常量為CONSTANT_Utf8-info,length的占用2個字節十六進制為 00 01 ,那么length長度就是1,后面找到1個字節為49,通過HexFiend工具也能看到指向了I。
所以,找到的十六進制:01 00 01 49
常量池中進一步印證下:
#6 = Utf8 I
十六進制字節碼文件:
01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65...
繼續查找標志位為01 ,值為1的結構總表常量為CONSTANT_Utf8-info,length的占用2個字節十六進制為 00 06 ,那么length長度就是6(轉換為十進制的值,即0 * 16的一次方 + 6),后面找到6個字節為 3C 69 6E 69 74 3E,通過HexFiend工具也能看到指向了<init>。
所以,找到的十六進制:01 00 06 3C 69 6E 69 74 3E
常量池中進一步印證下:
#7 = Utf8 <init>
以此類推,最終都能通過十六進制字節碼并結合字節碼結構總表分析在常量池中找到對應的字節碼內容。
訪問標志信息包括該Class文件是類還是接口,是否被定義成public,是否是abstract,如果是類,是否被聲明成final。通過上面的MyTest1源代碼,我們知道該文件是類并且是public的。
Access_Flag訪問標志結構表:
上述MyTest1類十六進制字節碼中的位置:0x 00 21
這個 0x 00 21 是訪問標志結構表中的 0x 00 20 和 0x 00 01 的并集,表示 ACC_PUBLIC 與 ACC_SUPER。
public class com.dskj.jvm.bytecode.MyTest1
...
flags: ACC_PUBLIC, ACC_SUPER
訪問標志之后的是This Class Name,對應十六進制為 0x 00 03
在常量池項目類型中查找:
#3 = Class #22 // com/dskj/jvm/bytecode/MyTest1
This Class Name之后的是Super Class Name,對應十六進制為 0x 00 04
在常量池項目類型中查找:
#4 = Class #23 // java/lang/Object
Interfaces
接口包括兩部分,第一個是interfaces_count(接口個數),第二部分interfaces(接口名)。
當前這個類對應的十六進制:00 00 轉換為十進制仍然是0,說明當前這個類是沒有實現任何接口的。
因此,這個interfaces接口表就不會再出現了。如果接口數量interfaces_count大于等于1的話,那么這個interfaces接口表是存在的。
Fields
字段包括兩部分,第一個是fields_count(字段個數),第二部分fields(字段名)。
當前這個類對應的十六進制:00 01 轉換為十進制值為1,說明這個類內部有一個字段。
字段表集合
字段表用于描述類和接口中聲明的變量。這里的字段包含了類級別變量以及實例變量,但是不包括方法內部聲明的局部變量。
字段表結構:
第一個是access_flags訪問標志符,如public、private、protected、final、abstract等等。
第二個name_index和第三個descriptor_index兩個構成一個字段結構的完整信息。
attributes_count是字段的獨有的信息,如果值是0,后面的attributes也就不存在了。
具體結構示例:
當前類字段對應的十六進制如下所示:
field_info {
u2 access_flags; 0002
u2 name_index; 0005
u2 descriptor_index; 0006
u2 attributes_counts; 0000
attribute_info attributes[attributes_count];
}
0x0002在訪問標志結構表中對應的是ACC_PRIVATE。
名稱索引 0x0005 與 描述符索引 0x0006 轉換為十六進制為 5 和 6,從 常量池結構表中查找結果:
#5 = Utf8 a
#6 = Utf8 I
附加屬性的數量為0x0000,轉換為十進制為0,后面的附加屬性attributes也就不會出現了。
00 03 // methods_count
00 01 // access_flags
00 07 // name_index
00 08 // descriptor_index
00 01 // attributes_count
00 09 // attribute_name_index
00 00 00 38 // attribute_length
00 02 // 附加屬性的 max_stacks
00 01 // 附加屬性的 max_locals
00 00 00 0A // 附加屬性的 code_length
2A B7 00 01 2A 04 B5 00 02 B1 // code_lengthc長度的字節,具體執行的字節碼指令
00 00 00 02 00 0A 00 00 00 0A 00 02 00 00 00 06 00 04 00 08 00 0B 00 00 00 0C 00 01 00 00 00 0A 00 0C 00 0D 00 00 00 01 00 0E 00 0F 00 01 00 09 00 00 00 2F 00 01 00 01 00 00 00 05 2A B4 00 02 AC 00 00 00 02 00 0A 00 00 00 06 00 01 00 00 00 0B 00 0B 00 00 00 0C 00 01 00 00 00 05 00 0C 00 0D 00 00 00 01 00 10 00 11 00 01 00 09 00 00 00 3E 00 02 00 02 00 00 00 06 2A 1B B5 00 02 B1 00 00 00 02 00 0A 00 00 00 0A 00 02 00 00 00 0F 00 05 00 10 00 0B 00 00 00 16 00 02 00 00 00 06 00 0C 00 0D 00 00 00 00 00 06 00 05 00 06 00 01 00 01 00 12 00 00 00 02 00 13
Methods
方法包括兩部分,第一個是methods_count(方法個數),第二部分methods(方法名)。
當前這個類對應的十六進制:00 03轉換為十進制值為3,說明這個類內部有三個方法。
三個方法為:
setA、getA,以及默認無參的構造方法。
方法表結構:
具體含義類似于上述的字段表結構。
access_flags 對應的十六進制:00 01 在標志結構表中查找為ACC_PUBLIC。
name_index名稱索引對應十六進制 00 07 descriptor_index描述符索引對應十六進制 00 08
分別轉換為十進制為 7 和 8,在常量池中查找結果:
#7 = Utf8 <init> // 表示這個類的構造方法
#8 = Utf8 V // 表示不接收任何參數的不返回結果的描述符
attributes_count對應十六進制:00 01 ,其個數為1,表示會有一個附加屬性。也說明了有一個attributes。
方法的屬性結構構成:
方法中的每一個屬性都是一個atrribute_info結構。
atrribute_info {
u2 atrribute_name_index;
u4 attribute_length;
u1 info[atrribute_length];
}
attribute_name_index對應十六進制為 00 09,在常量池結構表中查找結果:
#9 = Utf8 Code
從字節碼中每一個方法中都能體現出來,比如默認構造方法:
public com.dskj.jvm.bytecode.MyTest1;
descriptor: V
flags: ACC_PUBLIC
Code:
...
然后根據 atrribute_length 對應十六進制為 00 00 00 38 轉換為十進制為3 * 16的一次方 + 8 = 56
說明在這個十六進制后面找到56個字節作為Code這個屬性的具體的值。
方法表結構:
前三個字段和field_info一樣。
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count]
}
方法的屬性結構:
JVM預定了部分atrribute,但是編譯器自己也可以實現自己的atrribute寫入class文件里,供運行時使用。
不同的attribute通過attribute_name_index來區分。
Code結構:
Code attribute的作用是保存該方法的結構,如所對應的字節碼。
attribute_length表示attribute所包含的字節數,不包含atrribute_name_index和attribute_length字段。
max_stack表示這個方法運行的任何時刻所能達到的操作數棧的最大深度。// 00 02
max_locals表示方法執行期間創建的局部變量的數目,包含用來表示傳入的參數的局部變量的。// 00 01
code_length表示該方法所包含的字節碼的字節數以及具體的指令碼。也即助記符。// 00 00 00 0A 轉換為十進制值為10,即跟著后面的10個字節 2A B7 00 01 2A 04 B5 00 02 B1 這些是字節碼具體指令,對應到構造方法的字節碼:
那么,這些十六進制是怎么和下面的助記符對應的呢?
我們通過jclasslib工具(字節碼查看工具,支持IDEA插件形式安裝)查看時,點擊助記符的鏈接會跳到Oracle官網可查看具體詳細解釋。第一個助記符: 0: aload_0 打開鏈接可以看到:
鏈接地址:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.aload_n
具體解釋內容所示:
aload_<n>
Operation
Load reference from local variable
Format
aload_<n>
Forms
aload_0 = 42 (0x2a) // 通過這里就能直接看到 aload_0 對應的十進制是42,轉換為十六進制就是 0x2a,對應字節碼文件中的 2A
aload_1 = 43 (0x2b)
aload_2 = 44 (0x2c)
aload_3 = 45 (0x2d)
Description
The <n> must be an index into the local variable array of the current frame (§2.6). The local variable at <n> must contain a reference. The objectref in the local variable at <n> is pushed onto the operand stack.
這個<n>必須是一個到當前棧幀局部變量數組的一個索引,位于<n>位置上的局部變量會包含一個引用,位于<n>位置上的局部變量的這個引用會被推送到棧頂(準備進行操作)。
第二個助記符:
1: invokespecial #1 // Method java/lang/Object."<init>":V
連接地址:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.invokespecial
invokespecial
Operation
Invoke instance method; special handling for superclass, private, and instance initialization method invocations
Format
invokespecial
indexbyte1
indexbyte2
Forms
invokespecial = 183 (0xb7)
Operand Stack
..., objectref, [arg1, [arg2 ...]] →
...
具體字節碼即是該方法被調用時,虛擬機所執行的字節碼。
exception_table,這里存放的是處理異常的信息。
每個exception_table表項由start_pc,end_pc,handler_pc,catch_type組成。
start_pc和end_pc表示在code數組中的從start_pc到end_pc處(包含start_pc,不包含end_pc)的指令拋出的異常會由這個表項來處理。
handler_pc表示處理異常的代碼的開始處。catch_type表示會被處理的異常類型,它指向常量池里的一個異常類。當catch_type為0時,表示處理所有的異常。
附加屬性
LineNumberTable:這個屬性用來表示code數組中的字節碼和Java代碼行數之間的關系。這個屬性可以用來在調試的時候定位代碼的執行行數。
LocalVariableTable:局部變量表,當前類中只有唯一的局部變量,而這個局部變量就是this當前對象。
局部變量表屬性類似于行號表屬性。
請注意:
Java源代碼角度:Java類中的實例方法中可以直接使用this。
Java字節碼角度: Java類中的非靜態方法,即實例方法中的這個this實際是通過編譯器隱示的作為方法的第一個參數傳遞進來(有點類似于Python中的方法,其方法中的第一個參數都會傳遞一個self變量,表示當前對象本身)。這樣使得每一個實例方法內部都可以很順利的訪問this。換句話說針對類的實例方法它至少會有一個LocalVariable局部變量,這個變量就是this。
在JVM規范中,每個變量/字段都有描述信息,描述信息主要的作用是描述字段的數據類型、方法的參數列表(包括數量、類型與順序)與返回值。根據描述符規則,基本數據類型和代表無返回值的void類型都用一個大寫字符來表示,對象類型則使用字符L加對象的全限定名稱來表示。為了壓縮字節碼文件的體積,對于基本數據類型,JVM都只使用一個大寫字母來表示,如下所示:
B - byte,C - char,D - double,F - float,I - int,J - long,S - short,Z - boolean,V - void,L - 對象類型,如Ljava/lang/String;
數組類型: 針對數組類型來說,每一個維度使用一個前置的[來表示,如:
int數組被記錄[I,String[]二維數組被記錄為[[Ljava/lang/String;
方法描述符
用描述符描述方法時,按照先參數列表,后返回值的順序來描述。參數列表按照參數的嚴格順序放在一組之內,如方法:
String getInfoByIdAndName(int id, String name),該方法的描述符為:(I, Ljava/lang/String;)Ljava/lang/String;
Java字節碼文件的工具推薦:
https://github.com/ingokegel/jclasslib
聲明:本文為作者投稿,版權歸作者個人所有。
掌握jvm 字節碼,最關鍵的是學習class文件格式以及字節碼指令集等細節,今天我們來學習class字節碼文件格式(jdk8版本)。
Java代碼經過javac編譯器編譯成class文件,JVM虛擬機讀取class文件執行其中的代碼。
通過JVM虛擬機規范,實現了jvm跨平臺、跨語言的能力,JVM規范中非常重要的一部分就是class字節碼文件格式。
class文件的整體結構如下圖所示,其中u1,u2,u4分別表示1個、2個、4個字節長度的無符號數據,無符號byte數據按照具體的場景可以用來表示數字、字符等。 結構中還可以使用復合結構,比如cp_info, cp_info結構也會在規范中進行定義。
ClassFile {
u4 magic;
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags;
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
魔法字符串,固定為0xCAFEBABE
分別是class文件的小版本號和大版本號,jvm規范要求運行的jvm版本必須大于等于(更嚴格說是能支持,不過目前大于等于即可)class文件的major_version才能運行,否則拋出異常。
常量池數量,是下面的常量池表的長度加一,因為index=0的常量引用沒有使用。
常量池表,每個常量池的結構cp_info如下,常量池可以表示字符串常量、類名、接口名、方法等信息,這些常量池會在class文件中其他地方進行引用(比如字段中字段類型、字段名等)。 常量通過index進行引用,常量之間也可以通過index進行引用。
cp_info中的tag字段用來標識當前的常量類型,不同的常量類型有不同的子結構,然后就可以用具體的結構來解析info[]這個byte數組。 常量的結構有,
cp_info {
u1 tag;
u1 info[];
}
Constant type | tag value |
CONSTANT_Class | 7 |
CONSTANT_Fieldref | 9 |
CONSTANT_Methodref | 10 |
CONSTANT_InterfaceMethodref | 11 |
CONSTANT_Integer | 3 |
CONSTANT_Float | 4 |
CONSTANT_Long | 5 |
CONSTANT_Double | 6 |
CONSTANT_NameAndType | 12 |
CONSTANT_Utf8 | 1 |
CONSTANT_MethodHandle | 15 |
CONSTANT_MethodType | 16 |
CONSTANT_InvokeDynamic | 18 |
我們提前查看一下各個常量類型的結構,給后面介紹Field, Method做鋪墊。
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}
CONSTANT_Class_info表示類或接口
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}
tag: 是CONSTANT_Class對應的值(7) name_index: name_index是這個類或接口的類名的字符串常量的index
CONSTANT_Fieldref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_Methodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
CONSTANT_InterfaceMethodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
字段引用、方法引用、接口方法引用這三個結構比較類似,都是各自的tag以及class_index和name_and_type_index
class_index: 這個字段、方法所在類的class的常量的index
name_and_type_index: 這個字段的名稱和類型結構常量CONSTANT_NameAndType_info的index。name分別是字段名和方法名,類型是字段、方法的descriptor描述符。
字符串常量結構
string_index: 指向CONSTANT_Utf8_info的index
CONSTANT_String_info {
u1 tag;
u2 string_index;
}
整數和浮點數常量結構,對應的數值占用4個字節。
CONSTANT_Integer_info {
u1 tag;
u4 bytes;
}
CONSTANT_Float_info {
u1 tag;
u4 bytes;
}
這兩個常量結構分別存儲long和double類型的數值,大小占用8個字節 high_bytes和low_bytes分別表示高位和低位的數據,以long為例,對應值為((long) high_bytes << 32) + low_bytes
CONSTANT_Long_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
CONSTANT_Double_info {
u1 tag;
u4 high_bytes;
u4 low_bytes;
}
CONSTANT_NameAndType_info常量用來表示名稱和類型,在前面的CONSTANT_Fieldref_info, CONSTANT_Methodref_info, CONSTANT_InterfaceMethodref_info 常量中有使用,結構如下
CONSTANT_NameAndType_info {
u1 tag;
u2 name_index;
u2 descriptor_index;
}
name_index: 指向對應名稱的utf8常量的CONSTANT_Utf8_info的index descriptor_index: 指向類型描述符的CONSTANT_Utf8_info的index。
在jvm中,數據分為primitive type(基本類型,比如int, long)和reference type(引用類型),類型的描述符規則如下
類型 | 描述符 |
byte | B |
char | C |
double | D |
float | F |
int | I |
long | J |
short | S |
boolean | Z |
reference,引用類型 | LClassName; |
數組 | [ |
引用類型的ClassName是/間隔的字符串,比如java.lang.String的描述符為Ljava/lang/String; 數組是在對應的類型前加[,比如int[]描述符為[I, String[]描述符為[Ljava/lang/String;, 多維數組距離 int[][]描述符為[[I
Field Descriptor是對應字段的類型的描述符 Method Descriptor為( {ParameterDescriptor} ) ReturnDescriptor,比如public String test(int a, Long b)的方法描述符為(ILjava/lang/Long)Ljava/lang/String;,如果返回值是void,則使用V
CONSTANT_Utf8_info常量存儲utf8編碼的字符串內容,包含一個字符串長度字段和對應長度的byte數組。
CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}
access_flags用來表示當前類的一些bit信息(類似bitmap),這樣用2個字節的空間就可以表示16個標記信息。
Flag Name | Value | 表頭 |
ACC_PUBLIC | 0x0001 | 表示當前類/接口是否是public |
ACC_FINAL | 0x0010 | 是否聲明了final |
ACC_SUPER | 0x0020 | 都是true, 為了兼容舊版本的字節碼的標記 |
ACC_INTERFACE | 0x0200 | 是否是接口 |
ACC_ABSTRACT | 0x0400 | 是否是抽象類,接口也是抽象類 |
ACC_SYNTHETIC | 0x1000 | 表示不是代碼中生成的類,比如jdk為實現lambda表達式在運行時生成的一些類 |
ACC_ANNOTATION | 0x2000 | 是否是@interface這樣的注解類 |
ACC_ENUM | 0x4000 | 是否枚舉類 |
Fields是field_info的數組,每個field_info結構如下。
field_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
access_flags: 字段的access_flags,和class的access_flags類似,用來描述字段的public,private,volatile等等標識信息。
Flag Name | Value | 描述 |
ACC_PUBLIC | 0x0001 | 是否是public字段 |
ACC_PRIVATE | 0x0002 | 是否是private字段 |
ACC_PROTECTED | 0x0004 | 是否是static字段 |
ACC_STATIC | 0x0008 | 是否是static字段 |
ACC_FINAL | 0x0010 | 是否是final字段 |
ACC_VOLATILE | 0x0040 | 是否是volatile字段 |
ACC_TRANSIENT | 0x0080 | 是否是transient字段 |
ACC_SYNTHETIC | 0x1000 | 單元格 |
ACC_ENUM | 0x4000 | 單元格 |
name_index: 字段名稱的CONSTANT_Utf8_info常量index descriptor_index: 字段類型描述符的CONSTANT_Utf8_info常量index attributes_count: 字段的屬性數量 attributes: 字段的屬性,結構為attribute_info,比如ConstantValue,描述常量字段的常量值,屬性的結構稍后介紹。
類中所有的方法包括構造函數(<init>)、靜態初始化方法(<clinit>),都使用method_info結構,在一個類中,方法名稱和方法簽名聯合起來必須唯一
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
access_flags: 方法的標識數據,包括public, private, synchronized等等信息
Flag Name | Value | 描述 |
ACC_PUBLIC | 0x0001 | public方法 |
ACC_PRIVATE | 0x0002 | private方法 |
ACC_PROTECTED | 0x0004 | protected方法 |
ACC_STATIC | 0x0008 | static方法 |
ACC_FINAL | 0x0010 | final方法 |
ACC_SYNCHRONIZED | 0x0020 | synchronized方法(方法維度的synchronized聲明,不同于synchronized代碼塊的monitor_enter和monitor_exit) |
ACC_BRIDGE | 0x0040 | 是否是transient字段 |
ACC_VARARGS | 0x0080 | 有可變參數的方法 |
ACC_NATIVE | 0x0100 | native方法 |
ACC_ABSTRACT | 0x0400 | 抽象方法 |
ACC_STRICT | 0x0800 | 浮點數模式是FT-strict的,這個很少見 |
ACC_SYNTHETIC | 0x1000 | 是否是合成方法,即不再源代碼中的方法 |
name_index: 指向方法名的CONSTANT_Utf8_info常量 descriptor_index: 指向方法描述符的CONSTANT_Utf8_info常量 attributes_count: 方法的屬性數量 attributes[]: 方法的各個屬性,其中比較關鍵的是名字為Code的屬性,包含的是方法體的字節碼指令。
Attributes屬性在classfile, field_info, method_info中都有使用,結構如下
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
attribute_name_index: 指向屬性的名稱的CONSTANT_Utf8_info常量 attribute_length: 屬性信息的字節長度,即info的長度 info[]: 屬性的具體信息,每種屬性有自己的結構
屬性有ConstantValue,Code,StackMapTable,Exceptions,BootstrapMethods等等很多種屬性,我們這里重點介紹一下ConstantValue和Code。
常量值屬性用來表示常量字段的常量值,數值(int,long,float等)和字符串字段能夠聲明成常量。
ConstantValue_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;
}
attribute_name_index: 指向"ConstantValue"的CONSTANT_Utf8_info attribute_length: 2,因為constantvalue_index是兩個byte長度的index constantvalue_index: 指向具體的常量池中的常量,按照類型不同分為CONSTANT_Long,CONSTANT_Float,CONSTANT_Double,CONSTANT_Integer(int, short, char, byte, boolean都用CONSTANT_Integer),CONSTANT_String,
Code屬性用來表示方法體中的代碼字節碼。
Code_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
attribute_name_index: 指向"Code"的CONSTANT_Utf8_info常量 attribute_length: 后面所有的字段信息的字節數 max_stack: 方法的字節碼指令執行過程中需要的操作數棧的最大棧層數,關于方法字節碼指令的執行,在字節碼指令文章中進行介紹。 max_locals: 方法的字節碼指令執行過程中需要的本地變量表的最大長度(注意局部變量表的元素長度是4字節,long和double變量在局部變量表中占兩個位置) code_length: 方法體的字節碼的長度 code[]: 方法體的字節碼 exception_table_length: 異常表的長度 exception_table[]: 異常表數組,每個異常表包含start_pc,end_pc,handler_pc,catch_type。pc是指code[]數組中的索引,也就是從code[]字節碼數組start_pc(包含)到end_pc(不包含)中的字節碼執行時出現catch_type(指向異常類的CONSTANT_Class_info常量)異常,則轉到code[]的handler_pc位置來處理異常。 attributes_count: Code屬性的數量 attributes[]: Code屬性數組,比如LineNumberTable,LocalVariableTable, LocalVariableTypeTable, StackMapTable
其他的屬性可以參考jvm規范
假如我們現在有一個class文件,想去查看其中的Java源代碼,該如何實現呢?有如下幾種方法。
javap是jdk里自帶的反編譯工具,可以打印出更加可讀的class字節碼信息。
javap -c -cp /Users/liuzhengyang/Code/work/code-test/target/classes/ test.Test
Compiled from "Test.java"
public class test.Test {
public test.Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public java.lang.String hello();
Code:
0: ldc #2 // String hello world
2: areturn
}
javap參數說明
參數 | 說明 |
-cp | 指定classpath, javap需要到classpath中尋找class文件 |
-p | 默認情況下javap不打印出private的方法、字段,通過-p可以打印全部信息 |
-c | 默認情況下javap不打印出方法的body字節碼,通過-c可以打印 |
-v | 打印最全的信息,包括常量池、方法stack size、方法本地變量表等等 |
把class文件拖動到IDEA中即可查看到反編譯的java代碼結果,相比javap更加易讀。
如果要查看運行中的程序中使用到的代碼,可以使用arthas的[jad](https://arthas.aliyun.com/doc/jad.html)命令。
更詳細的資料包括java語言規范、java虛擬機規范可以在[Java Language and Virtual Machine Specifications](https://docs.oracle.com/javase/specs/index.html)中找到
本篇文章介紹了class文件的結構,包括常量池、字段、方法、屬性等,詳細了解了每個數據的結構,最后了解查看class文件的幾種方式。
ava 中的字節碼,英文名為 bytecode, 是 Java 代碼編譯后的中間代碼格式。JVM 需要讀取并解析字節碼才能執行相應的任務。
從技術人員的角度看,Java 字節碼是 JVM 的指令集。JVM 加載字節碼格式的 class 文件,校驗之后通過 JIT 編譯器轉換為本地機器代碼執行。 簡單說字節碼就是我們編寫的 Java 應用程序大廈的每一塊磚,如果沒有字節碼的支撐,大家編寫的代碼也就沒有了用武之地,無法運行。也可以說,Java 字節碼就是 JVM 執行的指令格式。
那么我們為什么需要掌握它呢?
不管用什么編程語言,對于卓越而有追求的程序員,都能深入去探索一些技術細節,在需要的時候,可以在代碼被執行前解讀和理解中間形式的代碼。對于 Java 來說,中間代碼格式就是 Java 字節碼。 了解字節碼及其工作原理,對于編寫高性能代碼至關重要,對于深入分析和排查問題也有一定作用,所以我們要想深入了解 JVM 來說,了解字節碼也是夯實基礎的一項基本功。同時對于我們開發人員來時,不了解平臺的底層原理和實現細節,想要職業進階絕對不是長久之計,畢竟我們都希望成為更好的程序員, 對吧?
任何有實際經驗的開發者都知道,業務系統總不可能沒有 BUG,了解字節碼以及 Java 編譯器會生成什么樣的字節碼,才能說具備扎實的 JVM 功底,會在排查問題和分析錯誤時非常有用,也能更好地解決問題。
而對于工具領域和程序分析來說, 字節碼就是必不可少的基礎知識了,通過修改字節碼來調整程序的行為是司空見慣的事情。想了解分析器(Profiler),Mock 框架,AOP 等工具和技術這一類工具,則必須完全了解 Java 字節碼。
4.1 Java 字節碼簡介
有一件有趣的事情,就如名稱所示, Java bytecode 由單字節(byte)的指令組成,理論上最多支持 256 個操作碼(opcode)。實際上 Java 只使用了 200 左右的操作碼, 還有一些操作碼則保留給調試操作。
操作碼, 下面稱為 指令, 主要由類型前綴和操作名稱兩部分組成。
例如,'i' 前綴代表 ‘integer’,所以,'iadd' 很容易理解, 表示對整數執行加法運算。
根據指令的性質,主要分為四個大類:
此外還有一些執行專門任務的指令,比如同步(synchronization)指令,以及拋出異常相關的指令等等。下文會對這些指令進行詳細的講解。
4.2 獲取字節碼清單
可以用 javap 工具來獲取 class 文件中的指令清單。 javap 是標準 JDK 內置的一款工具, 專門用于反編譯 class 文件。
讓我們從頭開始, 先創建一個簡單的類,后面再慢慢擴充。
package demo.jvm0104;
public class HelloByteCode {
public static void main(String[] args) {
HelloByteCode obj = new HelloByteCode();
}
}
代碼很簡單, main 方法中 new 了一個對象而已。然后我們編譯這個類:
javac demo/jvm0104/HelloByteCode.java
使用 javac 編譯 ,或者在 IDEA 或者 Eclipse 等集成開發工具自動編譯,基本上是等效的。只要能找到對應的 class 即可。
javac 不指定 -d 參數編譯后生成的 .class 文件默認和源代碼在同一個目錄。
注意: javac 工具默認開啟了優化功能, 生成的字節碼中沒有局部變量表(LocalVariableTable),相當于局部變量名稱被擦除。如果需要這些調試信息, 在編譯時請加上 -g 選項。有興趣的同學可以試試兩種方式的區別,并對比結果。
JDK 自帶工具的詳細用法, 請使用: javac -help 或者 javap -help 來查看; 其他類似。
然后使用 javap 工具來執行反編譯, 獲取字節碼清單:
javap -c demo.jvm0104.HelloByteCode
# 或者:
javap -c demo/jvm0104/HelloByteCode
javap -c demo/jvm0104/HelloByteCode.class
javap 還是比較聰明的, 使用包名或者相對路徑都可以反編譯成功, 反編譯后的結果如下所示:
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,我們成功獲取到了字節碼清單, 下面進行簡單的解讀。
4.3 解讀字節碼清單
可以看到,反編譯后的代碼清單中, 有一個默認的構造函數 public demo.jvm0104.HelloByteCode(), 以及 main 方法。
剛學 Java 時我們就知道, 如果不定義任何構造函數,就會有一個默認的無參構造函數,這里再次驗證了這個知識點。好吧,這比較容易理解!我們通過查看編譯后的 class 文件證實了其中存在默認構造函數,所以這是 Java 編譯器生成的, 而不是運行時JVM自動生成的。
自動生成的構造函數,其方法體應該是空的,但這里看到里面有一些指令。為什么呢?
再次回顧 Java 知識, 每個構造函數中都會先調用 super 類的構造函數對吧? 但這不是 JVM 自動執行的, 而是由程序指令控制,所以默認構造函數中也就有一些字節碼指令來干這個事情。
基本上,這幾條指令就是執行 super() 調用;
public demo.jvm0104.HelloByteCode();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
至于其中解析的 java/lang/Object 不用說, 默認繼承了 Object 類。這里再次驗證了這個知識點,而且這是在編譯期間就確定了的。
繼續往下看 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 方法中創建了該類的一個實例, 然后就 return 了, 關于里面的幾個指令, 稍后講解。
4.4 查看 class 文件中的常量池信息
常量池 大家應該都聽說過, 英文是 Constant pool。這里做一個強調: 大多數時候指的是 運行時常量池。但運行時常量池里面的常量是從哪里來的呢? 主要就是由 class 文件中的 常量池結構體 組成的。
要查看常量池信息, 我們得加一點魔法參數:
javap -c -verbose demo.jvm0104.HelloByteCode
在反編譯 class 時,指定 -verbose 選項, 則會 輸出附加信息。
結果如下所示:
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"
其中顯示了很多關于 class 文件信息: 編譯時間, MD5 校驗和, 從哪個 .java 源文件編譯得來,符合哪個版本的 Java 語言規范等等。
還可以看到 ACC_PUBLIC 和 ACC_SUPER 訪問標志符。 ACC_PUBLIC 標志很容易理解:這個類是 public 類,因此用這個標志來表示。
但 ACC_SUPER 標志是怎么回事呢? 這就是歷史原因, JDK 1.0 的 BUG 修正中引入 ACC_SUPER 標志來修正 invokespecial 指令調用 super 類方法的問題,從 Java 1.1 開始, 編譯器一般都會自動生成ACC_SUPER 標志。
有些同學可能注意到了, 好多指令后面使用了 #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>
......
這是摘取的一部分內容, 可以看到常量池中的常量定義。還可以進行組合, 一個常量的定義中可以引用其他常量。
比如第一行: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V, 解讀如下:
同學們可以試著解析其他的常量定義。 自己實踐加上知識回顧,能有效增加個人的記憶和理解。
總結一下,常量池就是一個常量的大字典,使用編號的方式把程序里用到的各類常量統一管理起來,這樣在字節碼操作里,只需要引用編號即可。
4.5 查看方法信息
在 javap 命令中使用 -verbose 選項時, 還顯示了其他的一些信息。 例如, 關于 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:
還可以看到執行該方法時需要的棧(stack)深度是多少,需要在局部變量表中保留多少個槽位, 還有方法的參數個數: stack=2, locals=2, args_size=1。把上面這些整合起來其實就是一個方法:
public static void main(java.lang.String[]);
注:實際上我們一般把一個方法的修飾符+名稱+參數類型清單+返回值類型,合在一起叫“方法簽名”,即這些信息可以完整的表示一個方法。
稍微往回一點點,看編譯器自動生成的無參構造函數字節碼:
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
你會發現一個奇怪的地方, 無參構造函數的參數個數居然不是 0: stack=1, locals=1, args_size=1。 這是因為在 Java 中, 如果是靜態方法則沒有 this 引用。 對于非靜態方法, this 將被分配到局部變量表的第 0 號槽位中, 關于局部變量表的細節,下面再進行介紹。
有反射編程經驗的同學可能比較容易理解: Method#invoke(Object obj, Object... args); 有JavaScript編程經驗的同學也可以類比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);
4.6 線程棧與字節碼執行模型
想要深入了解字節碼技術,我們需要先對字節碼的執行模型有所了解。
JVM 是一臺基于棧的計算機器。每個線程都有一個獨屬于自己的線程棧(JVM stack),用于存儲棧幀(Frame)。每一次方法調用,JVM都會自動創建一個棧幀。棧幀 由 操作數棧, 局部變量數組 以及一個class 引用組成。class 引用 指向當前方法在運行時常量池中對應的 class)。
我們在前面反編譯的代碼中已經看到過這些內容。
局部變量數組 也稱為 局部變量表(LocalVariableTable), 其中包含了方法的參數,以及局部變量。 局部變量數組的大小在編譯時就已經確定: 和局部變量+形參的個數有關,還要看每個變量/參數占用多少個字節。操作數棧是一個 LIFO 結構的棧, 用于壓入和彈出值。 它的大小也在編譯時確定。
有一些操作碼/指令可以將值壓入“操作數?!?; 還有一些操作碼/指令則是從棧中獲取操作數,并進行處理,再將結果壓入棧。操作數棧還用于接收調用其他方法時返回的結果值。
4.7 方法體中的字節碼解讀
看過前面的示例,細心的同學可能會猜測,方法體中那些字節碼指令前面的數字是什么意思,說是序號吧但又不太像,因為他們之間的間隔不相等。看看 main 方法體對應的字節碼:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: return
間隔不相等的原因是, 有一部分操作碼會附帶有操作數, 也會占用字節碼數組中的空間。
例如, new 就會占用三個槽位: 一個用于存放操作碼指令自身,兩個用于存放操作數。
因此,下一條指令 dup 的索引從 3 開始。
如果將這個方法體變成可視化數組,那么看起來應該是這樣的:
每個操作碼/指令都有對應的十六進制(HEX)表示形式, 如果換成十六進制來表示,則方法體可表示為HEX字符串。例如上面的方法體百世成十六進制如下所示:
甚至我們還可以在支持十六進制的編輯器中打開 class 文件,可以在其中找到對應的字符串:
(此圖由開源文本編輯軟件Atom的hex-view插件生成)
粗暴一點,我們可以通過 HEX 編輯器直接修改字節碼,盡管這樣做會有風險, 但如果只修改一個數值的話應該會很有趣。
其實要使用編程的方式,方便和安全地實現字節碼編輯和修改還有更好的辦法,那就是使用 ASM 和 Javassist 之類的字節碼操作工具,也可以在類加載器和 Agent 上面做文章,下一節課程會討論 類加載器,其他主題則留待以后探討。
4.8 對象初始化指令:new 指令, init 以及 clinit 簡介
我們都知道 new是 Java 編程語言中的一個關鍵字, 但其實在字節碼中,也有一個指令叫做 new。 當我們創建類的實例時, 編譯器會生成類似下面這樣的操作碼:
0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V
當你同時看到 new, dup 和 invokespecial 指令在一起時,那么一定是在創建類的實例對象!
為什么是三條指令而不是一條呢?這是因為:
由于構造函數調用不會返回值,所以如果沒有 dup 指令, 在對象上調用方法并初始化之后,操作數棧就會是空的,在初始化之后就會出問題, 接下來的代碼就無法對其進行處理。
這就是為什么要事先復制引用的原因,為的是在構造函數返回之后,可以將對象實例賦值給局部變量或某個字段。因此,接下來的那條指令一般是以下幾種:
在調用構造函數的時候,其實還會執行另一個類似的方法 <init> ,甚至在執行構造函數之前就執行了。
還有一個可能執行的方法是該類的靜態初始化方法 <clinit>, 但 <clinit> 并不能被直接調用,而是由這些指令觸發的: new, getstatic, putstatic or invokestatic。
也就是說,如果創建某個類的新實例, 訪問靜態字段或者調用靜態方法,就會觸發該類的靜態初始化方法【如果尚未初始化】。
實際上,還有一些情況會觸發靜態初始化, 詳情請參考 JVM 規范: [http://docs.oracle.com/javase/specs/jvms/se8/html/]
4.9 棧內存操作指令
有很多指令可以操作方法棧。 前面也提到過一些基本的棧操作指令: 他們將值壓入棧,或者從棧中獲取值。 除了這些基礎操作之外也還有一些指令可以操作棧內存; 比如 swap 指令用來交換棧頂兩個元素的值。下面是一些示例:
最基礎的是 dup 和 pop 指令。
還有復雜一點的指令:比如,swap, dup_x1 和 dup2_x1。
dup_x1 和 dup2_x1 指令看起來稍微有點復雜。而且為什么要設置這種指令呢? 在棧中復制最頂部的值?
請看一個實際案例:怎樣交換 2 個 double 類型的值?
需要注意的是,一個 double 值占兩個槽位,也就是說如果棧中有兩個 double 值,它們將占用 4 個槽位。
要執行交換,你可能想到了 swap 指令,但問題是 swap 只適用于單字(one-word, 單字一般指 32 位 4 個字節,64 位則是雙字),所以不能處理 double 類型,但 Java 中又沒有 swap2 指令。
怎么辦呢? 解決方法就是使用 dup2_x2 指令,將操作數棧頂部的 double 值,復制到棧底 double 值的下方, 然后再使用 pop2 指令彈出棧頂的 double 值。結果就是交換了兩個 double 值。 示意圖如下圖所示:
dup、dup_x1、dup2_x1 指令補充說明
指令的詳細說明可參考 JVM 規范:
dup 指令
官方說明是:復制棧頂的值,并將復制的值壓入棧。
操作數棧的值變化情況(方括號標識新插入的值):
..., value →
..., value [,value]
dup_x1 指令
官方說明是:復制棧頂的值,并將復制的值插入到最上面 2 個值的下方。
操作數棧的值變化情況(方括號標識新插入的值):
..., value2, value1 →
..., [value1,] value2, value1
dup2_x1 指令
官方說明是:復制棧頂 1 個 64 位/或 2 個 32 位的值, 并將復制的值按照原始順序,插入原始值下面一個 32 位值的下方。
操作數棧的值變化情況(方括號標識新插入的值):
# 情景 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 實際類型與 JVM 計算類型映射和分組
實際類型 | JVM 計算類型 | 類型分組 |
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 主要用于執行指令,而局部變量則用來保存中間結果,兩者之間可以直接交互。
讓我們編寫一個復雜點的示例:
第一步,先編寫一個計算移動平均數的類:
package demo.jvm0104;
//移動平均數
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;
}
}
第二步,然后寫一個類來調用:
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 類的實例提交了兩個數值,并要求其計算當前的平均值。
然后我們需要編譯(還記得前面提到, 生成調試信息的 -g 參數嗎)。
javac -g demo/jvm0104/*.java
然后使用 javap 反編譯:
javap -c -verbose demo/jvm0104/LocalVariableTest
看 main 方法對應的字節碼:
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
8: iconst_1
9: istore_2
10: iconst_2
11: istore_3
請注意,store 之類的指令調用實際上從棧頂刪除了一個值。 這就是為什么再次使用相同值時,必須再加載(load)一次的原因。
例如在上面的字節碼中,調用 submit 方法之前, 必須再次將參數值加載到棧中:
12: aload_1
13: iload_2
14: i2d
15: invokevirtual #4 // Method demo/jvm0104/MovingAverage.submit:(D)V
調用 getAvg() 方法后,返回的結果位于棧頂,然后使用 dstore 將 double 值保存到本地變量4號槽位,這里的d表示目標變量的類型為double。
24: aload_1
25: invokevirtual #5 // Method demo/jvm0104/MovingAverage.getAvg:()D
28: dstore 4
關于 LocalVariableTable 有個有意思的事情,就是最前面的槽位會被方法參數占用。
在這里,因為 main 是靜態方法,所以槽位0中并沒有設置為 this 引用的地址。 但是對于非靜態方法來說, this 會將分配到第 0 號槽位中。
再次提醒: 有過反射編程經驗的同學可能比較容易理解: Method#invoke(Object obj, Object... args); 有JavaScript編程經驗的同學也可以類比: fn.apply(obj, args) && fn.call(obj, arg1, arg2);
理解這些字節碼的訣竅在于:
給局部變量賦值時,需要使用相應的指令來進行 store,如 astore_1。store 類的指令都會刪除棧頂值。 相應的 load 指令則會將值從局部變量表壓入操作數棧,但并不會刪除局部變量中的值。
4.11 流程控制指令
流程控制指令主要是分支和循環在用, 根據檢查條件來控制程序的執行流程。
一般是 If-Then-Else 這種三元運算符(ternary operator), Java中的各種循環,甚至異常處的理操作碼都可歸屬于 程序流程控制。
然后,我們再增加一個示例,用循環來提交給 MovingAverage 類一定數量的值:
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();
}
}
同樣執行編譯和反編譯:
javac -g demo/jvm0104/*.java
javap -c -verbose demo/jvm0104/ForLoopTest
因為 numbers 是本類中的 static 屬性, 所以對應的字節碼如下所示:
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] 的指令用于循環控制。 我們從代碼的聲明從上往下看, 在最后面的LocalVariableTable 中:
那么中間的 2,3,4 號槽位是誰霸占了呢? 通過分析字節碼指令可以看出,在 2,3,4 槽位有 3 個匿名的局部變量(astore_2, istore_3, istore 4等指令)。
如果我們的 JDK 版本再老一點, 則會在 2,3,4 槽位發現三個源碼中沒有出現的變量: arr$, len$, i$, 也就是循環變量。
循環體中的第一條指令用于執行 循環計數器與數組長度 的比較:
18: iload 4
20: iload_3
21: if_icmpge 43
這段指令將局部變量表中 4號槽位 和 3號槽位的值加載到棧中,并調用 if_icmpge 指令來比較他們的值。
【if_icmpge 解讀: if, integer, compare, great equal】, 如果一個數的值大于或等于另一個值,則程序執行流程跳轉到pc=43的地方繼續執行。
在這個例子中就是, 如果4號槽位的值 大于或等于 3號槽位的值, 循環就結束了,這里 43 位置對于的是循環后面的代碼。如果條件不成立,則循環進行下一次迭代。
在循環體執行完,它的循環計數器加 1,然后循環跳回到起點以再次驗證循環條件:
37: iinc 4, 1 // 4號槽位的值加1
40: goto 18 // 跳到循環開始的地方
4.12 算術運算指令與類型轉換指令
Java 字節碼中有許多指令可以執行算術運算。實際上,指令集中有很大一部分表示都是關于數學運算的。對于所有數值類型(int, long, double, float),都有加,減,乘,除,取反的指令。
那么 byte 和 char, boolean 呢? JVM 是當做 int 來處理的。另外還有部分指令用于數據類型之間的轉換。
算術操作碼和類型
當我們想將 int 類型的值賦值給 long 類型的變量時,就會發生類型轉換。
類型轉換操作碼
在前面的示例中, 將 int 值作為參數傳遞給實際上接收 double 的 submit() 方法時,可以看到, 在實際調用該方法之前,使用了類型轉換的操作碼:
31: iload 5
33: i2d
34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V
也就是說, 將一個 int 類型局部變量的值, 作為整數加載到棧中,然后用 i2d 指令將其轉換為 double 值,以便將其作為參數傳給submit方法。
唯一不需要將數值load到操作數棧的指令是 iinc,它可以直接對 LocalVariableTable 中的值進行運算。 其他的所有操作均使用棧來執行。
4.13 方法調用指令和參數傳遞
前面部分稍微提了一下方法調用: 比如構造函數是通過 invokespecial 指令調用的。
這里列舉了各種用于方法調用的指令:
那么 invokevirtual 和 invokeinterface 有什么區別呢?這確實是個好問題。 為什么需要 invokevirtual 和 invokeinterface 這兩種指令呢? 畢竟所有的接口方法都是公共方法, 直接使用 invokevirtual 不就可以了嗎?
這么做是源于對方法調用的優化。JVM 必須先解析該方法,然后才能調用它。
使用 invokevirtual 和 invokeinterface 的區別不是那么明顯。想象一下,類定義中包含一個方法定義表, 所有方法都有位置編號。下面的示例中: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
那么,在運行時只要調用 method2,一定是在位置 2 處找到它。
現在我們來解釋invokevirtual 和 invokeinterface 之間的本質區別。
假設有一個接口 X 聲明了 methodX 方法, 讓 B 類在上面的基礎上實現接口 X:
class B extends A implements X
1: method1
2: method2
3: method3
4: methodX
新方法 methodX 位于索引 4 處,在這種情況下,它看起來與 method3 沒什么不同。
但如果還有另一個類 C 也實現了 X 接口,但不繼承 A,也不繼承 B:
class C implements X
1: methodC
2: methodX
類 C 中的接口方法位置與類 B 的不同,這就是為什么運行時在 invokinterface 方面受到更多限制的原因。 與 invokinterface 相比, invokevirtual 針對具體的類型方法表是固定的,所以每次都可以精確查找,效率更高(具體的分析討論可以參見參考材料的第一個鏈接)。
4.14 JDK7 新增的方法調用指令 invokedynamic
Java 虛擬機的字節碼指令集在 JDK7 之前一直就只有前面提到的 4 種指令(invokestatic,invokespecial,invokevirtual,invokeinterface)。隨著 JDK 7 的發布,字節碼指令集新增了invokedynamic指令。這條新增加的指令是實現“動態類型語言”(Dynamically Typed Language)支持而進行的改進之一,同時也是 JDK 8 以后支持的 lambda 表達式的實現基礎。
為什么要新增加一個指令呢?
我們知道在不改變字節碼的情況下,我們在 Java 語言層面想調用一個類 A 的方法 m,只有兩個辦法:
這兩個方法都需要顯式的把方法 m 和類型 A 直接關聯起來,假設有一個類型 B,也有一個一模一樣的方法簽名的 m 方法,怎么來用這個方法在運行期指定調用 A 或者 B 的 m 方法呢?這個操作在 JavaScript 這種基于原型的語言里或者是 C# 這種有函數指針/方法委托的語言里非常常見,Java 里是沒有直接辦法的。Java 里我們一般建議使用一個 A 和 B 公有的接口 IC,然后 IC 里定義方法 m,A 和 B 都實現接口 IC,這樣就可以在運行時把 A 和 B 都當做 IC 類型來操作,就同時有了方法 m,這樣的“強約束”帶來了很多額外的操作。
而新增的 invokedynamic 指令,配合新增的方法句柄(Method Handles,它可以用來描述一個跟類型 A 無關的方法 m 的簽名,甚至不包括方法名稱,這樣就可以做到我們使用方法 m 的簽名,但是直接執行的時候調用的是相同簽名的另一個方法 b),可以在運行時再決定由哪個類來接收被調用的方法。在此之前,只能使用反射來實現類似的功能。該指令使得可以出現基于 JVM 的動態語言,讓 jvm 更加強大。而且在 JVM 上實現動態調用機制,不會破壞原有的調用機制。這樣既很好的支持了 Scala、Clojure 這些 JVM 上的動態語言,又可以支持代碼里的動態 lambda 表達式。
RednaxelaFX 評論說:
簡單來說就是以前設計某些功能的時候把做法寫死在了字節碼里,后來想改也改不了了。 所以這次給 lambda 語法設計翻譯到字節碼的策略是就用 invokedynamic 來作個弊,把實際的翻譯策略隱藏在 JDK 的庫的實現里(metafactory)可以隨時改,而在外部的標準上大家只看到一個固定的 invokedynamic。
參考材料
*請認真填寫需求信息,我們會在24小時內與您取得聯系。