Java語言最廣為人知的口號就是“一次編譯到處執行”,這裡的“編譯”指的是編譯器將Java原始碼編譯為Java位元組碼檔案(也就是.class檔案,本文中不做區分),“執行”則指的是Java虛擬機器執行位元組碼檔案。Java的跨平臺得益於不同平臺上不同的JVM的實現,只要提供規範的位元組碼檔案,無論是什麼平臺的JVM都能夠執行,這樣位元組碼檔案就做到了到處執行。這篇文章將通過一個簡單的例項來分析位元組碼的結構,加深對Java程式執行機制的理解。
1、 準備.class檔案
第一步,我們要準備一個位元組碼檔案。先寫一個簡單的Java源程式TestByteCode.java
:
package com.sinosun.test;
public class TestByteCode{
private int a = 1;
public String b = "2";
protected void method1(){}
public int method2(){
return this.a;
}
private String method3(){
return this.b;
}
}
複製程式碼
使用javac
命令將上面的程式碼進行編譯,得到對應的TestByteCode.class檔案,到這裡就完成了第一步。
2、 人工解析.class檔案
經過上一步已經得到了TestByteCode.class檔案,也就是我們需要的位元組碼。我們不妨先來看一下檔案的內容。(注意IDEA開啟.class檔案時會自動進行反編譯,這裡使用IDEA中的HexView外掛檢視.class檔案,也可以使用Sublime Text直接開啟.class檔案)可以看到位元組碼檔案中是一大堆16進位制位元組,下圖中紅色框中的部分就是.class檔案中的真實內容:
要想理解class檔案,必須先知道它的組成結構。按照JVM的位元組碼規範,一個典型的class檔案由十個部分組成:MagicNumber、Version、Constant_Pool、Access_flag、This_class、Super_class、Interface、Fields、Method以及Attributes。位元組碼中包括兩種資料型別:無符號數和表。無符號數又包括 u1,u2,u4,u8四種,分別代表1個位元組、2個位元組、4個位元組和8個位元組。而表結構則是由無符號資料組成的。
根據規定,一個位元組碼檔案的格式固定如下:
根據上表可以清晰地看出,位元組碼採用固定的檔案結構和資料型別來實現對內容的分割,結構非常緊湊,沒有任何冗餘的資訊,連分隔符都沒有。
3、 魔數及版本號
根據結構表,.class檔案的前四個位元組存放的內容就是.class檔案的魔數(magic number)。魔數是一個固定值:0xcafebabe
,也是JVM識別.class檔案的標誌。我們通常是根據字尾名來區分檔案型別的,但是字尾名是可以任意修改的,因此虛擬機器在載入類檔案之前會先檢查這四個位元組,如果不是0xcafebabe
則拒絕載入該檔案。
關於魔數為什麼是0xcafebabe
,請移步DZone圍觀James Gosling的解釋。
版本號緊跟在魔數之後,由兩個2位元組的欄位組成,分別表示當前.class檔案的主版本號和次版本號,版本號數字與實際JDK版本的對應關係如下圖。編譯生成.class檔案的版本號與編譯時使用的-target引數有關。
編譯器版本 | -target引數 | 十六進位制表示 | 十進位制表示 |
---|---|---|---|
JDK 1.6.0_01 | 不帶(預設 -target 1.6) | 00 00 00 32 | 50 |
JDK 1.6.0_01 | -target 1.5 | 00 00 00 31 | 49 |
JDK 1.6.0_01 | -target 1.4 -source 1.4 | 00 00 00 30 | 48 |
JDK 1.7.0 | 不帶(預設 -target 1.6) | 00 00 00 32 | 50 |
JDK 1.7.0 | -target 1.7 | 00 00 00 33 | 51 |
JDK 1.7.0 | -target 1.4 -source 1.4 | 00 00 00 30 | 48 |
JDK 1.8.0 | 無-target引數 | 00 00 00 34 | 52 |
第二節中得到的.class檔案中,魔數對應的值為:0x0000 0034
,表示對應的JDK版本為1.8.0,與編譯時使用的JDK版本一致。
4、 常量池
常量池是解析.class檔案的重點之一,首先看常量池中物件的數量。根據第二節可知,constant_pool_count
的值為0x001c
,轉換為十進位制為28,根據JVM規範,constant_pool_count
的值等於constant_pool
中的條目數加1,因此,常量池中共有27個常量。
根據JVM規範,常量池中的常量的一般格式如下:
cp_info {
u1 tag;
u1 info[];
}
複製程式碼
共有11種型別的資料常量,各自的tag和內容如下表所示:
我們通過例子來檢視如何分析常量,下圖中,紅線部分為常量池的部分內容。
首先第一個tag值為0x0a
,檢視上面的表格可知該常量對應的是CONSTANT_Methodref_info
,即指向一個方法的引用。tag後面的兩個2位元組分別指向常量池中的一個CONSTANT_Class_info型常量和一個CONSTANT_NameAndType_info型常量,該常量的完整資料為:0a 0006 0016
,兩個索引常量池中的第6個常量和第22個常量,根據上表可以知道其含義為:
0a 0006 0016 Methodref class#6 nameAndType#22
因為還未解析第6個及第22個常量,這裡先使用佔位符代替。
同理可以解析出其它的常量,分析得到的完整常量池如下:
序號 | 16進製表示 | 含義 | 常量值 |
---|---|---|---|
1 | 0a 0006 0016 | Methodref #6 #22 | java/lang/Object."":()V |
2 | 09 0005 0017 | Fieldref #5 #23 | com/sinosun/test/TestByteCode.a:I |
3 | 08 0018 | String #24 | 2 |
4 | 09 0005 0019 | Fieldref #5 #25 | com/sinosun/test/TestByteCode.b:Ljava/lang/String; |
5 | 07 001a | Class #26 | com/sinosun/test/TestByteCode |
6 | 07 001b | Class #27 | java/lang/Object |
7 | 01 0001 61 | UTF8編碼 | a |
8 | 01 0001 49 | UTF8編碼 | I |
9 | 01 0001 62 | UTF8編碼 | b |
10 | 01 0012 4c6a6176612f6c616e672f537472696e673b | UTF8編碼 | Ljava/lang/String; |
11 | 01 0006 3c 69 6e 69 74 3e | UTF8編碼 | |
12 | 01 0003 28 29 56 | UTF8編碼 | ()V |
13 | 01 0004 43 6f 64 65 | UTF8編碼 | Code |
14 | 01 000f 4c696e654e756d6265725461626c65 | UTF8編碼 | LineNumberTable |
15 | 01 0007 6d 65 74 68 6f 64 31 | UTF8編碼 | method1 |
16 | 01 0007 6d 65 74 68 6f 64 32 | UTF8編碼 | method2 |
17 | 01 0003 28 29 49 | UTF8編碼 | ()I |
18 | 01 0007 6d 65 74 68 6f 64 33 | UTF8編碼 | method3 |
19 | 01 0014 28294c6a6176612f6c616e672f537472696e673b | UTF8編碼 | ()Ljava/lang/String; |
20 | 01 000a 53 6f 75 72 63 65 46 69 6c 65 | UTF8編碼 | SourceFile |
21 | 01 0011 5465737442797465436f64652e6a617661 | UTF8編碼 | TestByteCode.java |
22 | 0c 000b 000c | NameAndType #11 #12 | "":()V |
23 | 0c 0007 0008 | NameAndType #7 #8 | a:I |
24 | 01 0001 32 | UTF8編碼 | 2 |
25 | 0c 0009 000a | NameAndType #9 #10 | b:Ljava/lang/String; |
26 | 01 001d 636f6d2f73696e6f73756e2f746573 742f5465737442797465436f6465 | UTF8編碼 | com/sinosun/test/TestByteCode |
27 | 01 0010 6a6176612f6c616e672f4f626a656374 | UTF8編碼 | java/lang/Object |
上表所示即為常量池中解析出的所有常量,關於這些常量的用法會在後文進行解釋。
5、訪問標誌
access_flag
標識的是當前.class檔案的訪問許可權和屬性。根據下表可以看出,該標誌包含的資訊包括該class檔案是類還是介面,外部訪問許可權,是否是abstract
,如果是類的話,是否被宣告為final
等等。
Flag Name | Value | Remarks |
---|---|---|
ACC_PUBLIC | 0x0001 | public |
ACC_PRIVATE | 0x0002 | private |
ACC_PROTECTED | 0x0004 | protected |
ACC_STATIC | 0x0008 | static |
ACC_FINAL | 0x0010 | final |
ACC_SUPER | 0x0020 | 用於相容早期編譯器,新編譯器都設定該標記,以在使用 invokespecial 指令時對子類方法做特定處理。 |
ACC_INTERFACE | 0x0200 | 介面,同時需要設定:ACC_ABSTRACT。不可同時設定:ACC_FINAL、ACC_SUPER、ACC_ENUM |
ACC_ABSTRACT | 0x0400 | 抽象類,無法例項化。不可與ACC_FINAL同時設定。 |
ACC_SYNTHETIC | 0x1000 | synthetic,由編譯器產生,不存在於原始碼中。 |
ACC_ANNOTATION | 0x2000 | 註解型別(annotation),需同時設定:ACC_INTERFACE、ACC_ABSTRACT |
ACC_ENUM | 0x4000 | 列舉型別 |
本文的位元組碼檔案中access_flag
標誌的取值為0021
,上表中無法直接查詢到該值,因為access_flag
的值是一系列標誌位的並集,0x0021 = 0x0020+0x0001
,因此該類是public型的。
訪問標誌在後文的一些屬性中也會多次使用。
6、類索引、父類索引、介面索引
類索引this_class
儲存的是當前類的全限定名在常量池中的索引,取值為0x0005
,指向常量池中的第5個常量,查表可知內容為:com/sinosun/test/TestByteCode
。
父類索引super_class
儲存的是當前類的父類的全侷限定名在常量池中的索引,取值為0x0006
,指向池中的第6個常量,值為:java/lang/Object
。
介面資訊interfaces
儲存了當前類實現的介面列表,包含介面數量和包含所有介面全侷限定名索引的陣列。本文的示例程式碼中沒有實現介面,因此數量為0。
7、欄位
接下來解析欄位Fields
部分,前兩個位元組是fields_count
,值為0x0002
,表明欄位數量為2。 其中每個欄位的結構用field_info
表示:
field_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
複製程式碼
根據該結構來分析兩個欄位,第一個欄位的內容為0002 0007 0008 0000
,訪問標誌位0x0002
表示該欄位是private
型的,名稱索引指向常量池中第7個值為a
,型別描述符指向常量池中第8個值為I
,關聯的屬性數量為0,可知該欄位為private I a
,其中I
表示 int
。
同樣,通過0001 0009 000a 0000
可以分析出第二個欄位,其值為public Ljava/lang/String; b
。其中的Ljava/lang/String;
表示String
。
關於欄位描述符與原始碼的對應關係,下表是一個簡單的示意:
描述符 | 原始碼 |
---|---|
Ljava/lang/String; | String |
I | int |
[Ljava/lang/Object; | Object[] |
[Z | boolean[] |
[[Lcom/sinosun/generics/FileInfo; | com.sinosun.generics.FileInfo[][] |
8、方法
欄位結束後進入對方法methods
的解析,首先可以看到方法的數量為0x0004
,共四個。
不對啊!TestByteCode.java
中明明只有三個方法,為什麼.class
檔案中的方法數變成了4個?
因為編譯時自動生成了一個<init>
方法作為類的預設構造方法。
接下來對每個方法進行分析,老規矩,分析之前首先了解方法的格式定義:
method_info {
u2 access_flags;
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
複製程式碼
根據該格式,首先得到第一個方法的前8個位元組0001 000b 000c 0001
,對照上面的格式以及之前常量池和訪問標誌的內容,可以知道該方法是:public <init> ()V
,且附帶一個屬性。可以看到該方法名就是<init>
。對於方法附帶的屬性而言,有著如下格式:
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
複製程式碼
繼續分析後面的內容000d
,查詢常量池可以知道該屬性的名稱為:Code
。Code
屬性是method_info
屬性表中一種可變長度的屬性,該屬性中包括JVM指令及方法的輔助資訊,如例項初始化方法或者類或介面的初始化方法。如果一個方法被宣告為native
或者abstract
,那麼其method_info
結構中的屬性表中一定不包含Code
屬性。否則,其屬性表中必定包含一個Code
屬性。
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];
}
複製程式碼
對照上面的結構分析位元組序列000d 00000030 0002 0001
,該屬性為Code
屬性,屬性包含的位元組數為0x00000030
,即48個位元組,這裡的長度不包括名稱索引與長度這兩個欄位。max_stack
表示方法執行時所能達到的運算元棧的最大深度,為2;max_locals
表示方法執行過程中建立的區域性變數的數目,包含用來在方法執行時向其傳遞引數的區域性變數。
接下來是一個方法真正的邏輯核心——位元組碼指令,這些JVM指令是方法的真正實現。首先是code_length
表示code長度,這裡的值為16,表示後面16個位元組是指令內容,2a b7 0001 2a 04 b5 0002 2a 12 03 b5 0004 b1
。
為了便於理解,將這些指令翻譯為對應的助記符:
位元組碼 | 助記符 | 指令含義 |
---|---|---|
0x2a | aload_0 | 將第一個引用型別本地變數推送至棧頂 |
0xb7 | invokespecial | 呼叫超類構建方法, 例項初始化方法, 私有方法 |
0x04 | iconst_1 | 將int型1推送至棧頂 |
0xb5 | putfield | 為指定類的例項域賦值 |
0x12 | ldc | 將int,float或String型常量值從常量池中推送至棧頂 |
0xb1 | return | 從當前方法返回void |
對照表格可以看出這幾個指令的含義為:
2a aload_0
b7 0001 invokespecial #1 //Method java/lang/Object."":()V
2a aload_0
04 iconst_1
b5 0002 putfield #2 //Field a:I
2a aload_0
12 03 ldc #3 //String 2
b5 0004 putfield #4 //Field b:Ljava/lang/String;
b1 return
可以看出,在初始化方法中,先後將類自身引用this_class、類中的變數a和變數b入棧,併為兩個變數賦值,之後方法結束。
指令分析結束後,是方法中的異常表,本方法中未丟擲任何異常,因此表長度為0000
。後面的0001
表示後面有一個屬性。根據之前的屬性格式可以知道,該屬性的名稱索引為0x000e
,查詢常量池可知該屬性為LineNumberTable
屬性。
下面是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];
}
複製程式碼
結合該結構,分析0000000e 0003 0000 0003 0004 0004 0009 0005
可知,該表中共有三項,第一個數字表示指令碼中的位元組位置,第二個數字表示原始碼中的行數。
同理,可以對後面的方法進行分析。
第二個方法,0004 000f 000c 0001
表示方法名及訪問控制符為protected method1 ()V
,且附有一個屬性。000d 00000019
,毫無疑問,屬性就是Code
,長度為25個位元組。
0000 0001 00000001 b1
可以看出運算元棧深度max_stack
為0,max_locals
為1表示有一個區域性變數,所有方法預設都會有一個指向其所在類的引數。方法體中只有一個位元組指令,就是return
,因為該方法是一個空方法。0000 0001
表明沒有異常,且附有一個屬性。000e 00000006 0001 0000 0007
屬性是LineNumberTable
,內容表明第一個位元組指令與程式碼的第7行對應。
在後面兩個方法中,使用了三個新的位元組指令:
位元組碼 | 助記符 | 指令含義 |
---|---|---|
0xb4 | getfield | 獲取指定類的例項域, 並將其壓入棧頂 |
0xac | ireturn | 從當前方法返回int |
0xb0 | areturn | 從當前方法返回物件引用 |
解析0001 0010 0011 0001 000d 0000 001d
可知第三個方法為public method2 ()I
,其Code
屬性內容為0001 0001 00000005 2a b4 0002 ac
, 獲取變數a
並返回。 後面仍然是異常資訊和LineNumberTable
。
第四個方法這裡不再贅述。
0002 0012 0013 0001 000d 0000 001d private method3 ()Ljava/lang/String;
Code
0001 0001 00000005
2a b4 0004 b0 獲取變數b並返回
0000
LineNumberTable
0001 000e 00000006 0001 0000 000e //line 14 : 0
這樣,我們就在位元組碼中解析出了類中的方法。位元組指令是方法實現的核心,位元組指令在任何一個JVM中都對應的是一樣的操作,因此位元組碼檔案可以實現跨平臺執行。但是每一個平臺中對位元組指令的實現細節各有不同,這是Java程式在不同平臺間真正"跨"的一步。
9、屬性
最後一部分是該類的屬性Attributes
,數量為0x0001
,根據attribute_info
來分析該屬性。
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
複製程式碼
前兩個位元組對應name_index
,為0x0014
,即常量池中的第20個常量,查表得到SourceFile
,說明該屬性是SourceFile
屬性。該屬性是類檔案屬性表中的一個可選定長屬性,其結構如下:
SourceFile_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 sourcefile_index;
}
複製程式碼
得到該屬性的全部內容為0014 00000002 0015
,對比常量表可知內容為“SourceFile ——TestByteCode.java”,也就是指定了該.class
檔案對應的原始碼檔案。
10、後記
本文到此就算結束了,看到這裡的話應該對位元組碼的結構有了基本的瞭解。
但是,前面花了這麼大篇幅所做的事情,Java早就提供了一個命令列工具javap
全部實現了,進入.class
檔案所在的資料夾,開啟命令列工具,鍵入如下命令:
javap -verbose XXX.class
複製程式碼
結果如下所示:
PS E:\blog\Java位元組碼\資料> javap -verbose TestByteCode.class
Classfile /E:/blog/Java位元組碼/資料/TestByteCode.class
Last modified 2018-9-6; size 494 bytes
MD5 checksum 180292e6f6e8e9e48807195b235fa8ef
Compiled from "TestByteCode.java"
public class com.sinosun.test.TestByteCode
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#22 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#23 // com/sinosun/test/TestByteCode.a:I
#3 = String #24 // 2
#4 = Fieldref #5.#25 // com/sinosun/test/TestByteCode.b:Ljava/lang/String;
#5 = Class #26 // com/sinosun/test/TestByteCode
#6 = Class #27 // java/lang/Object
#7 = Utf8 a
#8 = Utf8 I
#9 = Utf8 b
#10 = Utf8 Ljava/lang/String;
#11 = Utf8 <init>
#12 = Utf8 ()V
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 method1
#16 = Utf8 method2
#17 = Utf8 ()I
#18 = Utf8 method3
#19 = Utf8 ()Ljava/lang/String;
#20 = Utf8 SourceFile
#21 = Utf8 TestByteCode.java
#22 = NameAndType #11:#12 // "<init>":()V
#23 = NameAndType #7:#8 // a:I
#24 = Utf8 2
#25 = NameAndType #9:#10 // b:Ljava/lang/String;
#26 = Utf8 com/sinosun/test/TestByteCode
#27 = Utf8 java/lang/Object
{
public java.lang.String b;
descriptor: Ljava/lang/String;
flags: ACC_PUBLIC
public com.sinosun.test.TestByteCode();
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: aload_0
10: ldc #3 // String 2
12: putfield #4 // Field b:Ljava/lang/String;
15: return
LineNumberTable:
line 3: 0
line 4: 4
line 5: 9
protected void method1();
descriptor: ()V
flags: ACC_PROTECTED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 7: 0
public int method2();
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 10: 0
}
SourceFile: "TestByteCode.java"
複製程式碼
基本就是我們之前解析得到的結果。
當然,我分享這些過程的初衷並不是希望自己或讀者變成反編譯工具,一眼看穿位元組碼的真相。這些事情人不會做的比工具更好,但是理解這些東西可以幫助我們做出更好的工具,比如CGlib,就是通過在類載入之前新增某些操作或者直接動態的生成位元組碼來實現動態代理,比使用java反射的JDK動態代理要快。
我總認為,人應該好好利用工具,但是也應該對工具背後的細節懷有好奇心與探索欲。就這篇文章來說,如果能讓大家對位元組碼多一些認識,那目的就已經達到了。括弧笑