位元組碼檔案的內部結構之謎

YangAM發表於2019-02-06

如果計算機的 CPU 只有「x86」這一種,或者作業系統只有 Windows 這一類,那麼或許 Java 就不會誕生。Java 誕生之初就曾宣揚過它的初衷,「一次編寫,多處執行」,而它之所以能夠實現跨平臺的一個核心點就在於,Java 引入「位元組碼」遮蔽了與底層作業系統之間的差異

同一段 Java 程式在編譯後生成的位元組碼檔案是唯一的,不會因為平臺的不同而產生任何的變化。而同一段位元組碼跑在不同實現的 JVM 上,會產生不同的機器指令。於底層而言,其實 Sun 公司針對不同的作業系統開發了不同版本的 JVM,而這些 JVM 則通過識別上層的位元組碼並向下解釋給作業系統執行。因此,你的同一段位元組碼在不同平臺下的 JVM 上執行,會對應到不同的機器指令,以此實現了跨平臺執行。

而理解這個「位元組碼」檔案結構就顯得十分重要了,理解它是如何儲存我們程式中的欄位、方法、屬性、區域性變數、各種常量值等等,是學習虛擬機器工作原理的基礎。

那麼,本文就來分析一下這個「位元組碼」檔案,解開它的神祕面紗。

Class 檔案的總體概況

我們的 Java 檔案被編譯器編譯成 Class 檔案之後,整個 Class 檔案由若干個 0 和 1 組成為一個超長的「二進位制串」。各個專案按照嚴格的規範儲存並順序的排在一起,每個專案佔幾個位元組幾乎固定,所以 JVM 在解析的時候,只需要按照我們制定的規範一項一項的拆分解析即可。

整個 Class 檔案的各個專案以及它們之前的排列順序都是固定的,如圖:

image

其中 u2 表示當前的專案總共佔兩個位元組,當然,u4 表示佔四個位元組。以 _info 結尾的專案表述為一張表,具體佔多少位元組數需要參見該表的內部結構。其實,巨集觀上來看,整個 Class 檔案也可以被看做是一張表。

魔數與 Class 檔案的版本

Class 檔案開頭的四個位元組儲存的是當前檔案的「魔數」,所謂的「魔數」就是用於標識當前的檔案是一個由 Java 檔案編譯過來的 Class 檔案。不是什麼檔案拿過來,我虛擬機器都接受並執行的,因為檔案的副檔名是可以隨意更改的,所以有些檔案可能就不是 Java 檔案編譯而來的。

不同型別的檔案有著不同的魔數值,圖片格式有圖片格式的的魔數值,視訊格式有視訊格式的魔數值,而我們 Class 檔案的魔數值為:0xCAFEBABE 。我們使用 UltraEdit 任意開啟一個 Class 檔案,會發現前四個位元組都是一樣的。

image

參見 Class 檔案的結構圖,接下來的 minor_version 和 major_version 用於表述當前 Class 檔案的版本號。前者佔兩個位元組,描述的是 Class 檔案的「次版本號」,後者也佔兩個位元組,描述的是 Class 檔案的「主版本號」。

jdk1.1 之後的每個較大的版本都基於 jdk1.1 的主版本號加一,而 jdk1.1 的主版本號是從 45 開始的。所以,jdk1.2 的主版本號為 46,jdk1.3 的主版本號為 47 。當然,對於每個 jdk 版本中較小的變化而言,主版本號的值就不會發生變化,變化的是次版本號的值。

例如:jdk1.1.8 的版本號為 45.3,其中 45 是主版本號,3 是次版本號。

其實,基本上 jdk1.2 以後的版本就只使用主版本號了,次版本號全為 0 。我電腦上的 jdk 版本是 1.8 的,於是得到它的版本號為 52(45+7) 。

image

那這個版本號有什麼用呢?

虛擬機器規範中指明,低版本 jdk 中的虛擬機器不能執行高版本的 Class 檔案,而高版本 jdk 中的虛擬機器則可以執行低版本的 Class 檔案。話可能有點繞,但主要意思就是,JVM 拒絕執行比自己版本低的 Class 檔案。

常量池

常量池算是類檔案中比較繁瑣的一塊內容了,在解析它之前我們先看一段 Java 程式碼。

public class Person implements Serializable {
	private int num;
	private String name = "Yang";
	
	public void sayHello() {
		System.out.println("hello,my name is:" + this.name);
	}
}
複製程式碼

這是一段再簡單不過的 Java 程式碼,我們開啟它編譯後的 Class 檔案。

image

根據我們的 Class 檔案格式,第 9,10 兩個位元組表述 constant_pool_count,它代表了常量池中的容量。從圖中我們也可以看出來,constant_pool_count = 0x0035 = 53 。由於 Class 檔案格式規定常量池中的項從 1 開始計數,而不是從我們習慣的 0 開始的。所以整個 Class 檔案中共有 52([1,53)) 個常量項,0 這個位置用於表述「不引用任何一個常量池專案」。

接下來的一項,Class 檔案格式中並沒有明確指明它總共佔據多少個位元組,而只是宣告它是一張表。常量池中可以被定義的專案型別:

image

每一項又都是一張表,我們 52 個常量項就是這些專案的組合。因為每個常量項所對應的表結構都不盡相同,所每個常量項的表結構中第一個位元組儲存的就是一個標誌,用於區分當前項的型別。例如:

image

這個值是 7,對應的我們的常量項是 CONSTANT_Class_info。於是調來 CONSTANT_Class_info 表的結構:

image

CONSTANT_Class_info 總共佔三個位元組,第一個位元組儲存的標誌,不再多說。name_index 佔兩個位元組,它是一個偏移地址,我們從上圖可以得到它的值是:0x0002,即它指向常量池中第二項常量。

我們去看看第二項常量是什麼,0x01 是它的標誌,表明它是 CONSTANT_Utf-8_info 型別的常量。

image

length 佔兩個位元組,本例中的值為:0x0011 = 17 。所以該常量項還有 17 個 bytes 儲存的是該常量的 utf-8 編碼值。可以看到:

image

這 17 個位元組表述的 utf-8 字串為:com/single/Person

我們手動的「翻譯」了常量池中前兩項,其實 Sun 公司為我們提供了工具幫我們計算位元組碼檔案中各個專案,這些工具都是非常好用的。

image

這裡我們只分析了兩種常量項的表結構,其餘 12 種大家可以自行搜尋瞭解。我們常量池所有的常量都是有用的,Class 檔案結構中其他專案幾乎都會引用這裡面的常量,待會再解釋。

訪問標誌

訪問標誌用於描述類檔案的一些詳細資訊,這個 Class 是類還是介面,修飾為 public 或 protected,是否修飾為 final 等。Class 檔案格式定義了訪問標誌佔兩個位元組,總共 16 個位元位。

image

很簡單,一共 16 個位元位,這裡只使用了 8 個位元位,如果最低位為 1 說明該 Class 被修飾為 public,為 0 則說明沒有被修飾為 public。一個標誌佔了一個位,有兩個狀態,1 為被修飾了某個狀態,0 表示沒有被修飾為某個狀態。

例如:

0x0011(0000 0000 0001 0001):public + final

0x0201(0000 0010 0000 0001):public + 介面

類、父類、父介面索引的集合

這三個專案用於描述 Class 檔案的繼承相關資訊,它們按順序排列在訪問標誌後。根據我們的 Class 檔案格式,this_class 佔兩個位元組,存放的是相對於常量池的偏移值,同理 super_class 是其父類的符號引用。Java 除了 Object 類沒有父類,其他任何類都是有且僅有一個類,所以 Object 類的 super_class 的值為 0,表示未引用常量池中任何一項。

以我們上述的例子來說:

image

this_class 指向常量池中第一項,super_class 指向常量池中第三項。通過檢視常量池中的內容,發現他們所對應的常量項型別是 CONSTANT_Class_info ,繼續深入得到類的全限定名分別是:com/single/Person 和 java/lang/Object

介面項有稍許不同,因為 Java 中允許介面的多繼承,所以表述介面需要使用兩項,interfaces_count 佔兩個位元組,計數了 Class 檔案實現的介面數量,interfaces 佔兩個位元組,儲存的是相對於常量池的偏移值。

這裡,interfaces_count 的值為:0x0001 ,interfaces 的值為:0x0005。於是得到該 Class 檔案所實現的介面的名稱為:java/io/Serializable

欄位表集合

欄位其實就是介面或者類中定義的變數,有例項變數和類變數之分。當然,方法中定義的區域性變數肯定不能算欄位的,欄位特指那些定義在方法之外,類或介面之中的變數。

每個欄位表只能描述一個欄位的資訊,一個 Class 檔案中往往又有多個欄位,所以 Class 檔案格式在欄位表之前定義了兩個位元組的項 fields_count 來計數字段的數量。

欄位表的標準結構如下:

image

access_flags 佔兩個位元組,它描述了該欄位的基本訪問標誌,主要包括:欄位的作用域,例項或類變數(static),可否序列化(transient),可變性(final)等等。這個屬性的儲存形式和我們之前介紹的類的訪問標識儲存的思想是類似的,每種狀態使用一個位元位來標識對於該狀態的修飾與否。

image

參見我們上述的例子:

image

第一個 0x0002 表示欄位表數量為 2,即當前 Class 檔案中有兩個欄位。第二個 0x0002 表示當前欄位被 「private」 關鍵字修飾。

我們接著看這個欄位表。

name_index 佔兩個位元組,它儲存的是當前欄位的名稱在常量池中的偏移量值。

descriptor_index 佔兩個位元組,它是對當前欄位基本資料型別的描述,儲存的也是一個字元常量在常量池中的偏移值。但是你如果對應到常量池中去看的話,你會發現這個描述符的的值是: I

image

基本資料型別與實際儲存的符號之間有這麼一種對映關係,為的是簡單儲存。其中,如果欄位是陣列型別的話,需要前置一個 『 』,多維陣列就前置多個該符號進行描述。

接著看欄位表。

接下來的 attributes_count 和 attributes 描述的是當前欄位的「屬性」。所謂「屬性」也即欄位的額外資訊描述。我們的第一個欄位沒有額外的屬性,所以 attributes_count 為 0 。

下面我們完整分析一下第二個欄位的位元組碼:

image

access_flags 的值為 0x0002,對應的訪問修飾符是:private 。name_index 的值對應於欄位名稱在常量池中的偏移值。

image

descriptor_index 的值為:0x000A ,對應的常量值是:Ljava/lang/String 。同樣,它也沒有屬性描述。

方法表集合

理解了欄位表,方法表的內容就很容易理解了。下面是方法表的標準結構:

image

針對我們上述的示例,簡單分析一下:

image

首先,0x0002 表示整個 Class 檔案中有兩個方法(一個是我們自己編寫的 sayHello 方法,還有一個是編譯器增加的例項構造器《init》方法)。

然後,0x0001 指明瞭該方法的訪問標誌:public,0x000B 指明瞭該方法名稱在常量池中的偏移值,對應到常量池中的常量:

接下來是這個 descriptor_index,欄位表中該屬性儲存的是欄位的資料型別,而在方法表中,這個屬性儲存的「東西」要稍微多一些,它儲存了方法的引數個數,引數型別,返回值等資訊。例如我們此示例中,descriptor_index 對應於常量池中的常量:()V(0x000C)。

當然,這個方法比較簡單,沒有引數,返回值型別為 void。我們再看一個稍微複雜點的例子:

public int executeNum(int a,String b,char[] x)

對應的精簡版儲存形式:

(IL/java/lang/String[C)I

接著就是屬性表,顯然從我們的位元組碼錶中可以看出來,attributes_count 的值為 1,說明該方法存在一個屬性,下面我們來看看屬性表有哪些嚴格的「約束」。

虛擬機器規範中定義的屬性有很多,並且每種屬性都有不同於其他屬性的表結構,但是所有的屬性都必須包含以下三個項。

image

通過前兩個位元組可以辨別當前的屬性型別。於我們這裡的示例而言,attrubute_name_index 的值為 0x000D(Code),所以虛擬機器可以調來 Code 表結構繼續完成解析,Code 表結構如下:

image

接著分析

然後的四個位元組表明該屬性所佔用的總位元組數,attribute_length 等於 0x0000003D(61),然後一步一步分析即可,我們這裡不再繼續分析了。其實 Code 屬性表最主要的一個作用是,儲存當前方法在編譯後所生成的所有位元組碼指令,並記錄所需區域性變數表的大小等有關方法執行的資訊。

還有一些其他屬性表我們這裡為了不使篇幅過長,將在後續文章中繼續分析。

總體上而言,所謂的位元組碼檔案,或者說 Class 檔案就是編譯器嚴格按照虛擬機器規範生成的一串二進位制,虛擬機器在進行解析的時候也是嚴格按照虛擬機器規範進行解析,這樣就使得 Class 檔案中所有的資訊都能夠被虛擬機器讀取解析。


文章中的所有程式碼、圖片、檔案都雲端儲存在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:撲在程式碼上的高爾基,所有文章都將同步在公眾號上。

image

相關文章