如果計算機的 CPU 只有「x86」這一種,或者操作系統只有 Windows 這一類,那么或許 Java 就不會誕生。Java 誕生之初就曾宣揚過它的初衷,「一次編寫,多處運行」,而它之所以能夠實現跨平台的一個核心點就在於,Java 引入「字節碼」屏蔽了與底層操作系統之間的差異。
同一段 Java 程序在編譯后生成的字節碼文件是唯一的,不會因為平台的不同而產生任何的變化。而同一段字節碼跑在不同實現的 JVM 上,會產生不同的機器指令。於底層而言,其實 Sun 公司針對不同的操作系統開發了不同版本的 JVM,而這些 JVM 則通過識別上層的字節碼並向下解釋給操作系統執行。因此,你的同一段字節碼在不同平台下的 JVM 上運行,會對應到不同的機器指令,以此實現了跨平台運行。
而理解這個「字節碼」文件結構就顯得十分重要了,理解它是如何存儲我們程序中的字段、方法、屬性、局部變量、各種常量值等等,是學習虛擬機工作原理的基礎。
那么,本文就來分析一下這個「字節碼」文件,解開它的神秘面紗。
Class 文件的總體概況
我們的 Java 文件被編譯器編譯成 Class 文件之后,整個 Class 文件由若干個 0 和 1 組成為一個超長的「二進制串」。各個項目按照嚴格的規范存儲並順序的排在一起,每個項目占幾個字節幾乎固定,所以 JVM 在解析的時候,只需要按照我們制定的規范一項一項的拆分解析即可。
整個 Class 文件的各個項目以及它們之前的排列順序都是固定的,如圖:
其中 u2 表示當前的項目總共占兩個字節,當然,u4 表示占四個字節。以 _info 結尾的項目表述為一張表,具體占多少字節數需要參見該表的內部結構。其實,宏觀上來看,整個 Class 文件也可以被看做是一張表。
魔數與 Class 文件的版本
Class 文件開頭的四個字節存儲的是當前文件的「魔數」,所謂的「魔數」就是用於標識當前的文件是一個由 Java 文件編譯過來的 Class 文件。不是什么文件拿過來,我虛擬機都接受並運行的,因為文件的擴展名是可以隨意更改的,所以有些文件可能就不是 Java 文件編譯而來的。
不同類型的文件有着不同的魔數值,圖片格式有圖片格式的的魔數值,視頻格式有視頻格式的魔數值,而我們 Class 文件的魔數值為:0xCAFEBABE 。我們使用 UltraEdit 任意打開一個 Class 文件,會發現前四個字節都是一樣的。
參見 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) 。
那這個版本號有什么用呢?
虛擬機規范中指明,低版本 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 文件。
根據我們的 Class 文件格式,第 9,10 兩個字節表述 constant_pool_count,它代表了常量池中的容量。從圖中我們也可以看出來,constant_pool_count = 0x0035 = 53 。由於 Class 文件格式規定常量池中的項從 1 開始計數,而不是從我們習慣的 0 開始的。所以整個 Class 文件中共有 52([1,53)) 個常量項,0 這個位置用於表述「不引用任何一個常量池項目」。
接下來的一項,Class 文件格式中並沒有明確指明它總共占據多少個字節,而只是聲明它是一張表。常量池中可以被定義的項目類型:
每一項又都是一張表,我們 52 個常量項就是這些項目的組合。因為每個常量項所對應的表結構都不盡相同,所每個常量項的表結構中第一個字節存儲的就是一個標志,用於區分當前項的類型。例如:
這個值是 7,對應的我們的常量項是 CONSTANT_Class_info。於是調來 CONSTANT_Class_info 表的結構:
CONSTANT_Class_info 總共占三個字節,第一個字節存儲的標志,不再多說。name_index 占兩個字節,它是一個偏移地址,我們從上圖可以得到它的值是:0x0002,即它指向常量池中第二項常量。
我們去看看第二項常量是什么,0x01 是它的標志,表明它是 CONSTANT_Utf-8_info 類型的常量。
length 占兩個字節,本例中的值為:0x0011 = 17 。所以該常量項還有 17 個 bytes 存儲的是該常量的 utf-8 編碼值。可以看到:
這 17 個字節表述的 utf-8 字符串為:com/single/Person
我們手動的「翻譯」了常量池中前兩項,其實 Sun 公司為我們提供了工具幫我們計算字節碼文件中各個項目,這些工具都是非常好用的。
這里我們只分析了兩種常量項的表結構,其余 12 種大家可以自行搜索了解。我們常量池所有的常量都是有用的,Class 文件結構中其他項目幾乎都會引用這里面的常量,待會再解釋。
訪問標志
訪問標志用於描述類文件的一些詳細信息,這個 Class 是類還是接口,修飾為 public 或 protected,是否修飾為 final 等。Class 文件格式定義了訪問標志占兩個字節,總共 16 個比特位。
很簡單,一共 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,表示未引用常量池中任何一項。
以我們上述的例子來說:
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 來計數字段的數量。
字段表的標准結構如下:
access_flags 占兩個字節,它描述了該字段的基本訪問標志,主要包括:字段的作用域,實例或類變量(static),可否序列化(transient),可變性(final)等等。這個屬性的存儲形式和我們之前介紹的類的訪問標識存儲的思想是類似的,每種狀態使用一個比特位來標識對於該狀態的修飾與否。
參見我們上述的例子:
第一個 0x0002 表示字段表數量為 2,即當前 Class 文件中有兩個字段。第二個 0x0002 表示當前字段被 「private」 關鍵字修飾。
我們接着看這個字段表。
name_index 占兩個字節,它存儲的是當前字段的名稱在常量池中的偏移量值。
descriptor_index 占兩個字節,它是對當前字段基本數據類型的描述,存儲的也是一個字符常量在常量池中的偏移值。但是你如果對應到常量池中去看的話,你會發現這個描述符的的值是: I
基本數據類型與實際存儲的符號之間有這么一種映射關系,為的是簡單存儲。其中,如果字段是數組類型的話,需要前置一個 『[ 』,多維數組就前置多個該符號進行描述。
接着看字段表。
接下來的 attributes_count 和 attributes 描述的是當前字段的「屬性」。所謂「屬性」也即字段的額外信息描述。我們的第一個字段沒有額外的屬性,所以 attributes_count 為 0 。
下面我們完整分析一下第二個字段的字節碼:
access_flags 的值為 0x0002,對應的訪問修飾符是:private 。name_index 的值對應於字段名稱在常量池中的偏移值。
descriptor_index 的值為:0x000A ,對應的常量值是:Ljava/lang/String 。同樣,它也沒有屬性描述。
方法表集合
理解了字段表,方法表的內容就很容易理解了。下面是方法表的標准結構:
針對我們上述的示例,簡單分析一下:
首先,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,說明該方法存在一個屬性,下面我們來看看屬性表有哪些嚴格的「約束」。
虛擬機規范中定義的屬性有很多,並且每種屬性都有不同於其他屬性的表結構,但是所有的屬性都必須包含以下三個項。
通過前兩個字節可以辨別當前的屬性類型。於我們這里的示例而言,attrubute_name_index 的值為 0x000D(Code),所以虛擬機可以調來 Code 表結構繼續完成解析,Code 表結構如下:
接着分析,
然后的四個字節表明該屬性所占用的總字節數,attribute_length 等於 0x0000003D(61),然后一步一步分析即可,我們這里不再繼續分析了。其實 Code 屬性表最主要的一個作用是,存儲當前方法在編譯后所生成的所有字節碼指令,並記錄所需局部變量表的大小等有關方法運行的信息。
還有一些其他屬性表我們這里為了不使篇幅過長,將在后續文章中繼續分析。
總體上而言,所謂的字節碼文件,或者說 Class 文件就是編譯器嚴格按照虛擬機規范生成的一串二進制,虛擬機在進行解析的時候也是嚴格按照虛擬機規范進行解析,這樣就使得 Class 文件中所有的信息都能夠被虛擬機讀取解析。
文章中的所有代碼、圖片、文件都雲存儲在我的 GitHub 上:
(https://github.com/SingleYam/overview_java)
歡迎關注微信公眾號:撲在代碼上的高爾基,所有文章都將同步在公眾號上。