最近剛看完 深入理解 Java 虛擬機 一書中的第 6 章 (類文件結構),便迫不及待地自己寫一個小的 Demo,來自己分析一把 Java 源文件經過編譯之后成為字節碼文件到底是個什么東西?先由一個簡單的小 Demo 開始:
package com.lighters.demo;
public class Test {
private String name;
public Test(String name) {
this.name = name;
}
public void sayHello() {
System.out.printf("Hello " + name);
}
}
運行 javac Test.java
,會在此目錄下生成 Test.class
的文件。但是這個 class 字節碼文件,是以二進制的形式存儲的,我們需要以十六進制的形式進行查看。這里我使用 Vim 進行查看,在命令行模式輸入:%!xxd
,來采用十六進制的格式查看,得到下面的輸出:
location: 0 1 2 3 4 5 6 7 8 9 a b c d e f
0000000: cafe babe 0000 0034 002c 0a00 0900 1609 .......4.,......
0000010: 000b 0017 0900 1800 1907 001a 0a00 0400 ................
0000020: 1608 001b 0a00 0400 1c0a 0004 001d 0700 ................
0000030: 1e0a 001f 0020 0700 2101 0004 6e61 6d65 ..... ..!...name
0000040: 0100 124c 6a61 7661 2f6c 616e 672f 5374 ...Ljava/lang/St
0000050: 7269 6e67 3b01 0006 3c69 6e69 743e 0100 ring;...<init>..
0000060: 1528 4c6a 6176 612f 6c61 6e67 2f53 7472 .(Ljava/lang/Str
0000070: 696e 673b 2956 0100 0443 6f64 6501 000f ing;)V...Code...
0000080: 4c69 6e65 4e75 6d62 6572 5461 626c 6501 LineNumberTable.
0000090: 0008 7361 7948 656c 6c6f 0100 0328 2956 ..sayHello...()V
00000a0: 0100 0a53 6f75 7263 6546 696c 6501 0009 ...SourceFile...
00000b0: 5465 7374 2e6a 6176 610c 000e 0013 0c00 Test.java.......
00000c0: 0c00 0d07 0022 0c00 2300 2401 0017 6a61 ....."..#.$...ja
00000d0: 7661 2f6c 616e 672f 5374 7269 6e67 4275 va/lang/StringBu
00000e0: 696c 6465 7201 0006 4865 6c6c 6f20 0c00 ilder...Hello ..
00000f0: 2500 260c 0027 0028 0100 106a 6176 612f %.&..'.(...java/
0000100: 6c61 6e67 2f4f 626a 6563 7407 0029 0c00 lang/Object..)..
0000110: 2a00 2b01 0016 636f 6d2f 6c69 6768 7465 *.+...com/lighte
0000120: 7273 2f64 656d 6f2f 5465 7374 0100 106a rs/demo/Test...j
0000130: 6176 612f 6c61 6e67 2f53 7973 7465 6d01 ava/lang/System.
0000140: 0003 6f75 7401 0015 4c6a 6176 612f 696f ..out...Ljava/io
0000150: 2f50 7269 6e74 5374 7265 616d 3b01 0006 /PrintStream;...
0000160: 6170 7065 6e64 0100 2d28 4c6a 6176 612f append..-(Ljava/
0000170: 6c61 6e67 2f53 7472 696e 673b 294c 6a61 lang/String;)Lja
0000180: 7661 2f6c 616e 672f 5374 7269 6e67 4275 va/lang/StringBu
0000190: 696c 6465 723b 0100 0874 6f53 7472 696e ilder;...toStrin
00001a0: 6701 0014 2829 4c6a 6176 612f 6c61 6e67 g...()Ljava/lang
00001b0: 2f53 7472 696e 673b 0100 136a 6176 612f /String;...java/
00001c0: 696f 2f50 7269 6e74 5374 7265 616d 0100 io/PrintStream..
00001d0: 0670 7269 6e74 6601 003c 284c 6a61 7661 .printf..<(Ljava
00001e0: 2f6c 616e 672f 5374 7269 6e67 3b5b 4c6a /lang/String;[Lj
00001f0: 6176 612f 6c61 6e67 2f4f 626a 6563 743b ava/lang/Object;
0000200: 294c 6a61 7661 2f69 6f2f 5072 696e 7453 )Ljava/io/PrintS
0000210: 7472 6561 6d3b 0021 000b 0009 0000 0001 tream;.!........
0000220: 0002 000c 000d 0000 0002 0001 000e 000f ................
0000230: 0001 0010 0000 002a 0002 0002 0000 000a .......*........
0000240: 2ab7 0001 2a2b b500 02b1 0000 0001 0011 *...*+..........
0000250: 0000 000e 0003 0000 0007 0004 0008 0009 ................
0000260: 0009 0001 0012 0013 0001 0010 0000 003e ...............>
0000270: 0003 0001 0000 0022 b200 03bb 0004 59b7 ......."......Y.
0000280: 0005 1206 b600 072a b400 02b6 0007 b600 .......*........
0000290: 0803 bd00 09b6 000a 57b1 0000 0001 0011 ........W.......
00002a0: 0000 000a 0002 0000 000c 0021 000d 0001 ...........!....
00002b0: 0014 0000 0002 0015 ........
這里的輸出還是蠻人性化的,每行開頭前面的冒號前那一串是表示每行開頭的第一個字符的位置索引。1 個字符是 4 位,即每兩位是 1 個字節,一行則是 16 個 字節,對應十六進制表示為 0 - F。
PS: 第一行的內容為人為添加,方便定位列的索引。
可知,這里用十六進制表示,整個文件內容的大小沒超過 3 位,所以這里用 3 位的十六進制,來表示地址。例如,若地址為0x000,指向的內容為 ca ;地址為 0x001,指向的內容為 fe;地址為0x011 ,指向的內容為 0b;下文都將以這樣的形式來指向字節碼中的內容。
準備工作做好之后,我們還需要關于的 Java 字節碼的結構信息表,如下:
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];
}
這里的 u1 、u2、 u4 表示的分別為 1 個字節,2個字節,4 個字節。接下來,我們將會跟字節碼的結構信息表來一一對應在上面 Test.class 文件的解析:
魔數 - magic
在字節碼結構表中,可知 magic 對應的是 4 個字節的容量,相應在 Test.class 文件位置為0x000 - 0x003 的 4 個字節,信息為 cafe babe
。用來表示為這是一個 class 文件,能夠被 JVM 所識別。
次版本 - minor_version
大小為兩個字節,對應位置索引 0x004 - 0x005 的 2個字節,即 0x0000,表示此版本的大小為 0。
主版本 - major_version
大小為兩個字節,對應位置索引為 0x006 - 0x007 的2個字節,即 0034,對應十進制的大小為 52,而初始的 Jvm 版本 1.0 支持的大小為 45,也就意味著這是由 JDK 1.8 生成的字節碼,則之能由 Jvm 1.8 及以上版本才能解析上文的字節碼文件。
常量池容量 - constant_pool_count
由 2 個字節來表示常量池的大小,這個大小包含自身,即其余的常量大小只能為 216 - 1,對應 Test.class 文件中的描述為 002c ,對應十進制大小為 44,表明還將有 43 個字節用來描述常量池。
常量池中主要存放兩大類常量:字面量和符號引用。字面量主要指文本字符串,聲明為 final 的常量值等。而符號引用屬于編譯原理方面的概念,主要包含類和接口的全限定名,字段的名稱和描述符,方法的名稱和描述符。
常量池 - constant_pool[constant_pool_count-1]
這里將會有 43 個常量。常量池包含著一組信息,不過他們的通用格式如下:
cp_info {
u1 tag;
u1 info[];
}
即 1 個自己的 tag描述,加上一組相應信息的描述。看到這里,我們繼續接下來的字節,內容為 0x0a,對應十進制的 10,在下面的常量 tag 表中,進行查找,可知對應的常量類型為 Constant_Methodref,表示當前類方法的符號引用。
接著查找 Constant_Methodref 的結構,如下:
CONSTANT_Methodref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
可知 第一個字節是為 tag 標記,這里已經確定了是剛才的 Constant_Methodref
;然后是兩個字節的 class_index ,指向的內容是指類在常量池中的索引;最后是兩個字節的 name_and_type_index,同樣也是方法的描述在常量池中的索引值。
先看 class_index,可知其內容是地址0x00b - 0x00c ,相應內容為 0x0009,即這里我們需要從 0x00b 的位置開始數,數至第 9 個常量。常量尋找定位的過程如下:
- 常量1:
CONSTANT_Methodref
,一共占 5 個字節,位置為 0x00a - 0x00e - 常量2:
CONSTANT_Fieldref
,格式與常量1methodref
相同,5個字節,位置為 0x00f - 0x013 - 常量3:
CONSTANT_Fieldref
,同上,位置為 0x014 - 0x018 - 常量4:
CONSTANT_Class
,其格式為下述代碼,3個字節,位置為 0x019 - 0x01b
CONSTANT_Class_info {
u1 tag;
u2 name_index;
}
- 常量5:
CONSTANT_Methodref
, 5個字節,位置為 0x01c - 0x020 - 常量6;
CONSTANT_String
,其格式如下代碼,3個字節,位置為 0x021 - 0x023,這里的 string_index,也指向的是常量池中的內容,不過它將會指向的
是一個CONSTANT_Utf8
的常量。
CONSTANT_String_info {
u1 tag;
u2 string_index;
}
- 常量7:
CONSTANT_Methodref
, 5個字節,位置為 0x024 - 0x028 - 常量8:
CONSTANT_Methodref
, 5個字節,位置為 0x029 - 0x02d - 常量9:
CONSTANT_Class
,3個字節,位置為 0x02e - 0x030。
到這里,可以看出第一個常量 CONSTANT_Methodref
中的 class_index
指向的是一個 CONSTANT_Class
,另一個 name_and_type_index
將會指向一個 CONSTANT_NameAndType
的常量。發現這樣閱讀定位,實在是太費力了,好在 jdk 給我們提供了 javap
的命令工具。 用它來輸出字節碼的信息,來幫助我們閱讀。在命令行下輸入 javap -verbose Test.class
,過濾其他輸出,只關心我們的常量池輸出,如下 :
Constant pool:
#1 = Methodref #9.#22 // java/lang/Object."<init>":()V
#2 = Fieldref #11.#23 // com/lighters/demo/Test.name:Ljava/lang/String;
#3 = Fieldref #24.#25 // java/lang/System.out:Ljava/io/PrintStream;
#4 = Class #26 // java/lang/StringBuilder
#5 = Methodref #4.#22 // java/lang/StringBuilder."<init>":()V
#6 = String #27 // Hello
#7 = Methodref #4.#28 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#8 = Methodref #4.#29 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#9 = Class #30 // java/lang/Object
#10 = Methodref #31.#32 // java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
#11 = Class #33 // com/lighters/demo/Test
#12 = Utf8 name
#13 = Utf8 Ljava/lang/String;
#14 = Utf8 <init>
#15 = Utf8 (Ljava/lang/String;)V
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 sayHello
#19 = Utf8 ()V
#20 = Utf8 SourceFile
#21 = Utf8 Test.java
#22 = NameAndType #14:#19 // "<init>":()V
#23 = NameAndType #12:#13 // name:Ljava/lang/String;
#24 = Class #34 // java/lang/System
#25 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#26 = Utf8 java/lang/StringBuilder
#27 = Utf8 Hello
#28 = NameAndType #37:#38 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#29 = NameAndType #39:#40 // toString:()Ljava/lang/String;
#30 = Utf8 java/lang/Object
#31 = Class #41 // java/io/PrintStream
#32 = NameAndType #42:#43 // printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
#33 = Utf8 com/lighters/demo/Test
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 append
#38 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#39 = Utf8 toString
#40 = Utf8 ()Ljava/lang/String;
#41 = Utf8 java/io/PrintStream
#42 = Utf8 printf
#43 = Utf8 (Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
結合這個輸出,再回過頭來,查看之前的第一個常量,其類型為 Methodref
,另包含指向 #9 和 #22 的索引;#9 類型為 Class
,其包含一個 #30 的 utf8 的字符串描述 :java/lang/Object
;# 22 指向的是 NameAndType
的索引,其指向的內容的 #14 的方法描述,及 #19 的方法參數及其返回值的描述。可知這個 Methodref
的最終指向的內容為 Object
的 init
方法,()V
表達的意思是參數為空,返回值為 Void。
另外還有其他的方法,字段,類以及 utf8 的描述,而 uft8 描述的則是一組 ascii 碼字符。
訪問標記 - access_flags
在經過了 43 個大小的常量池,接下來便是兩個字節的訪問標記,其主要用來表示當前類的訪問符。這里具體的取值如下表:
從表中得到這里的取值都是數字 1 進行移位得到的結果,這樣就可以通過或運算得到我們類有哪些訪問標記。
訪問標記對應在字節碼中的位置為 0x216 - 0x217,內容為 0x0021,可知這結果是由訪問標記中的 ACC_PUBLIC | ACC_SUPER 所得。ACC_SUPER 在 JDK 1.2 添加,默認類都會帶上這個訪問標記。
當前類 - this_class
兩個字節的當前類標識,其地址為 0x218 - 0x219,內容為 0x000b。其內容表示的是在常量池中第 11 個,指向的內容為 class。通過查看之前的常量池表,可知其 class 的內容為 com/lighters/demo/Test。
父類 - super_class
其格式同上,可知其對應字節碼的內容為 0x0009,在常量池表中針對的 class 內容為 java/lang/Object 。
接口數量 - interfaces_count
兩個字節的表示,其地址為 0x21c - 0x21d,內容為 0x0000。表示當前類沒有實現任何接口。
接口 - interfaces[interfaces_count]
這里描述的是 interfaces_count 的兩個自己的接口描述。因為 interfaces_count 為零,所以這里不會有任何地址的指向。
字段數量 - fields_count
兩字節的字段數量,字節碼中對應地址為 0x21e - 0x21f,內容為 0x001。表示有個 1 字段。
字段 - fields[fields_count]
同樣是有 fields_count 的 field_info,這里 fields_count 為 1, 我們只用分析一個即可,而 field_info 的格式如下:
field_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
第一項為兩個字節的 access_flags ,字節碼中對應的地址為 0x220 - 0x221,內容為 0x0002,而 acess_flags 對應的表結構定義如下;
所以,可知我們的字段為 private。接下來是兩字節的 name_index,內容為 0x000c,對應常量表中的索引為 12,內容為 name。兩字節的 descriptor_index,內容為 0x000d,對應常量表中的索引為13,內容為 Ljava/lang/String。
接下來則是 attributes_count,這里對應結果為 0,就不看了。
最終,我們可知道 Test 類中,有一個 private ,名稱為 name,類型為 String的字段。而 attributes_count 為空,表示這里沒有直接對其進行賦值。
方法數量 - methods_count
兩個字節描述方法數量,字節碼中地址為 0x228 - 0x229,其內容為 0x0002,表示兩個方法。
方法 - methods[methods_count]
這里的方法則對應著 method_info 的的結構,如下:
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
這里的 method_info 跟 field_info 的結構相同。先看 access_flags
,在字節碼中其位置為 0x22a - 0x22b,相應內容為 0x0001。根據如下 method 的 access_flag 表,可知其相對應的為 public。
接下來的 4 個字節是 0x000e 和 0x000f,分別指向常量池中 14 和 15,對應著 方法名稱 <init> 和方法描述 (Ljava/lang/String;)V。
接下來的兩個字節為 0x0001, 表示 attributes_count 為1。這就要分析一下 attribute_info 是什么內容?先看它的結構:
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
兩字節的 attribute_name_index 對應字節碼表的位置為 0x232 - 0x233,表示的內容為 0x0010,其對應的是常量池的 utf8 的信息,索引內容為 16,表示相應的內容為 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];
}
Code 主要用來描述方法的內部實現,其中會用指令來描述方法的運行狀態,另外以及異常的信息等。但是 abstarct 與 native 的 method_info 并不會有 code_info。
這里的 method_info 信息我們通過之前的 javap 對 Test.class 文件輸出的信息來進行查看,可以更加清晰明了。
{
public com.lighters.demo.Test(java.lang.String);
descriptor: (Ljava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: aload_1
6: putfield #2 // Field name:Ljava/lang/String;
9: return
LineNumberTable:
line 7: 0
line 8: 4
line 9: 9
public void sayHello();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #4 // class java/lang/StringBuilder
6: dup
7: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
10: ldc #6 // String Hello
12: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
15: aload_0
16: getfield #2 // Field name:Ljava/lang/String;
19: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
22: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
25: iconst_0
26: anewarray #9 // class java/lang/Object
29: invokevirtual #10 // Method java/io/PrintStream.printf:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream;
32: pop
33: return
LineNumberTable:
line 12: 0
line 13: 33
}
輸出信息中的 Code 可以看出方法的操作棧最大深度為 2,內部變量為 2,之后便是以 1 個字節為單位的指令描述,這里就不對指定講解了,可參照氣候的注釋進行理解。
在最后的 attributes 中,存放的是 LineNumberTable。它是做什么用的?當我們需要進行斷點調試的時候,它便可以用來對應我們在源文件的方法代碼位置,這樣更方便我們定位代碼錯誤位置。其格式如下:
LineNumberTable_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 line_number_table_length;
{ u2 start_pc;
u2 line_number;
} line_number_table[line_number_table_length];
}
其中的 attribute_name_index,便是對應常量池索引為 17 的 LineNumberTable。主要研究 line_number_table 的數據結構:
{
u2 start_pc;
u2 line_number;
}
這里的 start_pc 表示針對在 Code 塊的起始位置,而 line_number 則表示相對應的在源碼中的行數。所以上面第一個方法,(類構造器 init )輸出 LineNumberTable :
LineNumberTable:
line 7: 0
line 8: 4
line 9: 9
就相當好理解了,方法所在源碼中的第 7 行對應在 Code info 塊中的索引為 0;第 8 行對應 Code 中索引為 4;第 9 行對應 Code 中的索引為 9。
方法 sayHello
的格式同理,就不贅述了。有個小細節需要注意的是,在其 Code 中的索引 19 調用 invokevirtual 指令時,對應源文件中調用 +
操作符,可以在注釋中,看到其相對應調用的是 StringBuilder
對象的 append
方法。
說明了什么呢?當我們在調用 +
操作符時,編譯器在進行編譯的時候,會創建一個 StringBuilder
對象,通過 append
方法進行相加操作。這樣我們在多次使用 +
操作時,IDE 會給我們一個警告的提示,也就不足為怪了。
附屬屬性數量 - attributes_count
這里對應位置為 0x2ae- 0x2af,信息為 0x0001,表示為只存在 1 個 attribute。
附屬屬性 - attributes[attributes_count]
在 0x2b0 - 0x2b1 的信息為 0x0014,對應常量池的索引 20 的值,為 SourceFile。其結構如下:
SourceFile_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 sourcefile_index;
}
可知接下來的 4 字節表示長度,為 2 ; 接下來的 2 字節表示源文件索引,值為 0x0015,對應常量池中的索引為 21 的值,為 Test.java。
總結
在根據主線 ClassFile 的結構表一一分析之后,字節碼 class
文件終于被我們完整的看完了。當然其中一些細節如其他的 attribute 結構、Code 中相應的指令操作等,并沒有去深入講解,但是這并不妨礙我們對字節碼(只聞其名,不知其人)產生一個更加深入而又完整的認識。我們只需編寫出符合 JVM 規范的字節碼文件,即可運行與 JVM 之上,像其他的語言如 JRuby、Scala、Kotlin等就是,不過它們使用的是特定的編譯器。另外,需要提及的兩個命令 javac 及 javap ,需要熟練使用。當然其中的指令操作還是需要去深入研究一番,這篇也有許多不足之處,也歡迎小伙伴一起深入探討。