例項分析理解Java位元組碼

GuoYaxiang發表於2019-03-01

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和內容如下表所示:

常量結構

我們通過例子來檢視如何分析常量,下圖中,紅線部分為常量池的部分內容。

常量池示例1

首先第一個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,查詢常量池可以知道該屬性的名稱為:CodeCode屬性是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動態代理要快。

我總認為,人應該好好利用工具,但是也應該對工具背後的細節懷有好奇心與探索欲。就這篇文章來說,如果能讓大家對位元組碼多一些認識,那目的就已經達到了。括弧笑

參考文章

  1. 一文讓你明白Java位元組碼
  2. 深入理解JVM之Java位元組碼(.class)檔案詳解
  3. [從位元組碼層面看“HelloWorld”]
  4. JVM之位元組碼——Class檔案格式
  5. JavaCodeToByteCode
  6. JVM 虛擬機器位元組碼指令表

相關文章