如果計(jì)算機(jī)的 CPU 只有「x86」這一種,或者操作系統(tǒng)只有 Windows 這一類,那么或許 Java 就不會誕生。Java 誕生之初就曾宣揚(yáng)過它的初衷,「一次編寫,多處運(yùn)行」,而它之所以能夠?qū)崿F(xiàn)跨平臺的一個(gè)核心點(diǎn)就在于,Java 引入「字節(jié)碼」屏蔽了與底層操作系統(tǒng)之間的差異。
同一段 Java 程序在編譯后生成的字節(jié)碼文件是唯一的,不會因?yàn)槠脚_的不同而產(chǎn)生任何的變化。而同一段字節(jié)碼跑在不同實(shí)現(xiàn)的 JVM 上,會產(chǎn)生不同的機(jī)器指令。于底層而言,其實(shí) Sun 公司針對不同的操作系統(tǒng)開發(fā)了不同版本的 JVM,而這些 JVM 則通過識別上層的字節(jié)碼并向下解釋給操作系統(tǒng)執(zhí)行。因此,你的同一段字節(jié)碼在不同平臺下的 JVM 上運(yùn)行,會對應(yīng)到不同的機(jī)器指令,以此實(shí)現(xiàn)了跨平臺運(yùn)行。
而理解這個(gè)「字節(jié)碼」文件結(jié)構(gòu)就顯得十分重要了,理解它是如何存儲我們程序中的字段、方法、屬性、局部變量、各種常量值等等,是學(xué)習(xí)虛擬機(jī)工作原理的基礎(chǔ)。
那么,本文就來分析一下這個(gè)「字節(jié)碼」文件,解開它的神秘面紗。
Class 文件的總體概況
我們的 Java 文件被編譯器編譯成 Class 文件之后,整個(gè) Class 文件由若干個(gè) 0 和 1 組成為一個(gè)超長的「二進(jìn)制串」。各個(gè)項(xiàng)目按照嚴(yán)格的規(guī)范存儲并順序的排在一起,每個(gè)項(xiàng)目占幾個(gè)字節(jié)幾乎固定,所以 JVM 在解析的時(shí)候,只需要按照我們制定的規(guī)范一項(xiàng)一項(xiàng)的拆分解析即可。
整個(gè) Class 文件的各個(gè)項(xiàng)目以及它們之前的排列順序都是固定的,如圖:
其中 u2 表示當(dāng)前的項(xiàng)目總共占兩個(gè)字節(jié),當(dāng)然,u4 表示占四個(gè)字節(jié)。以 _info 結(jié)尾的項(xiàng)目表述為一張表,具體占多少字節(jié)數(shù)需要參見該表的內(nèi)部結(jié)構(gòu)。其實(shí),宏觀上來看,整個(gè) Class 文件也可以被看做是一張表。
魔數(shù)與 Class 文件的版本
Class 文件開頭的四個(gè)字節(jié)存儲的是當(dāng)前文件的「魔數(shù)」,所謂的「魔數(shù)」就是用于標(biāo)識當(dāng)前的文件是一個(gè)由 Java 文件編譯過來的 Class 文件。不是什么文件拿過來,我虛擬機(jī)都接受并運(yùn)行的,因?yàn)槲募臄U(kuò)展名是可以隨意更改的,所以有些文件可能就不是 Java 文件編譯而來的。
不同類型的文件有著不同的魔數(shù)值,圖片格式有圖片格式的的魔數(shù)值,視頻格式有視頻格式的魔數(shù)值,而我們 Class 文件的魔數(shù)值為:0xCAFEBABE 。我們使用 UltraEdit 任意打開一個(gè) Class 文件,會發(fā)現(xiàn)前四個(gè)字節(jié)都是一樣的。
參見 Class 文件的結(jié)構(gòu)圖,接下來的 minor_version 和 major_version 用于表述當(dāng)前 Class 文件的版本號。前者占兩個(gè)字節(jié),描述的是 Class 文件的「次版本號」,后者也占兩個(gè)字節(jié),描述的是 Class 文件的「主版本號」。
jdk1.1 之后的每個(gè)較大的版本都基于 jdk1.1 的主版本號加一,而 jdk1.1 的主版本號是從 45 開始的。所以,jdk1.2 的主版本號為 46,jdk1.3 的主版本號為 47 。當(dāng)然,對于每個(gè) jdk 版本中較小的變化而言,主版本號的值就不會發(fā)生變化,變化的是次版本號的值。
例如:jdk1.1.8 的版本號為 45.3,其中 45 是主版本號,3 是次版本號。
其實(shí),基本上 jdk1.2 以后的版本就只使用主版本號了,次版本號全為 0 。我電腦上的 jdk 版本是 1.8 的,于是得到它的版本號為 52(45+7) 。
那這個(gè)版本號有什么用呢?
虛擬機(jī)規(guī)范中指明,低版本 jdk 中的虛擬機(jī)不能運(yùn)行高版本的 Class 文件,而高版本 jdk 中的虛擬機(jī)則可以運(yùn)行低版本的 Class 文件。話可能有點(diǎn)繞,但主要意思就是,JVM 拒絕運(yùn)行比自己版本低的 Class 文件。
常量池
常量池算是類文件中比較繁瑣的一塊內(nèi)容了,在解析它之前我們先看一段 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 文件。
根據(jù)我們的 Class 文件格式,第 9,10 兩個(gè)字節(jié)表述 constant_pool_count,它代表了常量池中的容量。從圖中我們也可以看出來,constant_pool_count = 0x0035 = 53 。由于 Class 文件格式規(guī)定常量池中的項(xiàng)從 1 開始計(jì)數(shù),而不是從我們習(xí)慣的 0 開始的。所以整個(gè) Class 文件中共有 52([1,53)) 個(gè)常量項(xiàng),0 這個(gè)位置用于表述「不引用任何一個(gè)常量池項(xiàng)目」。
接下來的一項(xiàng),Class 文件格式中并沒有明確指明它總共占據(jù)多少個(gè)字節(jié),而只是聲明它是一張表。常量池中可以被定義的項(xiàng)目類型:
每一項(xiàng)又都是一張表,我們 52 個(gè)常量項(xiàng)就是這些項(xiàng)目的組合。因?yàn)槊總€(gè)常量項(xiàng)所對應(yīng)的表結(jié)構(gòu)都不盡相同,所每個(gè)常量項(xiàng)的表結(jié)構(gòu)中第一個(gè)字節(jié)存儲的就是一個(gè)標(biāo)志,用于區(qū)分當(dāng)前項(xiàng)的類型。例如:
這個(gè)值是 7,對應(yīng)的我們的常量項(xiàng)是 CONSTANT_Class_info。于是調(diào)來 CONSTANT_Class_info 表的結(jié)構(gòu):
CONSTANT_Class_info 總共占三個(gè)字節(jié),第一個(gè)字節(jié)存儲的標(biāo)志,不再多說。name_index 占兩個(gè)字節(jié),它是一個(gè)偏移地址,我們從上圖可以得到它的值是:0x0002,即它指向常量池中第二項(xiàng)常量。
我們?nèi)タ纯吹诙?xiàng)常量是什么,0x01 是它的標(biāo)志,表明它是 CONSTANT_Utf-8_info 類型的常量。
length 占兩個(gè)字節(jié),本例中的值為:0x0011 = 17 。所以該常量項(xiàng)還有 17 個(gè) bytes 存儲的是該常量的 utf-8 編碼值。可以看到:
這 17 個(gè)字節(jié)表述的 utf-8 字符串為:com/single/Person
我們手動的「翻譯」了常量池中前兩項(xiàng),其實(shí) Sun 公司為我們提供了工具幫我們計(jì)算字節(jié)碼文件中各個(gè)項(xiàng)目,這些工具都是非常好用的。
這里我們只分析了兩種常量項(xiàng)的表結(jié)構(gòu),其余 12 種大家可以自行搜索了解。我們常量池所有的常量都是有用的,Class 文件結(jié)構(gòu)中其他項(xiàng)目幾乎都會引用這里面的常量,待會再解釋。
訪問標(biāo)志
訪問標(biāo)志用于描述類文件的一些詳細(xì)信息,這個(gè) Class 是類還是接口,修飾為 public 或 protected,是否修飾為 final 等。Class 文件格式定義了訪問標(biāo)志占兩個(gè)字節(jié),總共 16 個(gè)比特位。
很簡單,一共 16 個(gè)比特位,這里只使用了 8 個(gè)比特位,如果最低位為 1 說明該 Class 被修飾為 public,為 0 則說明沒有被修飾為 public。一個(gè)標(biāo)志占了一個(gè)位,有兩個(gè)狀態(tài),1 為被修飾了某個(gè)狀態(tài),0 表示沒有被修飾為某個(gè)狀態(tài)。
例如:
0x0011(0000 0000 0001 0001):public + final
0x0201(0000 0010 0000 0001):public + 接口
類、父類、父接口索引的集合
這三個(gè)項(xiàng)目用于描述 Class 文件的繼承相關(guān)信息,它們按順序排列在訪問標(biāo)志后。根據(jù)我們的 Class 文件格式,this_class 占兩個(gè)字節(jié),存放的是相對于常量池的偏移值,同理 super_class 是其父類的符號引用。Java 除了 Object 類沒有父類,其他任何類都是有且僅有一個(gè)類,所以 Object 類的 super_class 的值為 0,表示未引用常量池中任何一項(xiàng)。
以我們上述的例子來說:
this_class 指向常量池中第一項(xiàng),super_class 指向常量池中第三項(xiàng)。通過查看常量池中的內(nèi)容,發(fā)現(xiàn)他們所對應(yīng)的常量項(xiàng)類型是 CONSTANT_Class_info ,繼續(xù)深入得到類的全限定名分別是:com/single/Person 和 java/lang/Object 。
接口項(xiàng)有稍許不同,因?yàn)?Java 中允許接口的多繼承,所以表述接口需要使用兩項(xiàng),interfaces_count 占兩個(gè)字節(jié),計(jì)數(shù)了 Class 文件實(shí)現(xiàn)的接口數(shù)量,interfaces 占兩個(gè)字節(jié),存儲的是相對于常量池的偏移值。
這里,interfaces_count 的值為:0x0001 ,interfaces 的值為:0x0005。于是得到該 Class 文件所實(shí)現(xiàn)的接口的名稱為:java/io/Serializable 。
字段表集合
字段其實(shí)就是接口或者類中定義的變量,有實(shí)例變量和類變量之分。當(dāng)然,方法中定義的局部變量肯定不能算字段的,字段特指那些定義在方法之外,類或接口之中的變量。
每個(gè)字段表只能描述一個(gè)字段的信息,一個(gè) Class 文件中往往又有多個(gè)字段,所以 Class 文件格式在字段表之前定義了兩個(gè)字節(jié)的項(xiàng) fields_count 來計(jì)數(shù)字段的數(shù)量。
字段表的標(biāo)準(zhǔn)結(jié)構(gòu)如下:
access_flags 占兩個(gè)字節(jié),它描述了該字段的基本訪問標(biāo)志,主要包括:字段的作用域,實(shí)例或類變量(static),可否序列化(transient),可變性(final)等等。這個(gè)屬性的存儲形式和我們之前介紹的類的訪問標(biāo)識存儲的思想是類似的,每種狀態(tài)使用一個(gè)比特位來標(biāo)識對于該狀態(tài)的修飾與否。
參見我們上述的例子:
第一個(gè) 0x0002 表示字段表數(shù)量為 2,即當(dāng)前 Class 文件中有兩個(gè)字段。第二個(gè) 0x0002 表示當(dāng)前字段被 「private」 關(guān)鍵字修飾。
我們接著看這個(gè)字段表。
name_index 占兩個(gè)字節(jié),它存儲的是當(dāng)前字段的名稱在常量池中的偏移量值。
descriptor_index 占兩個(gè)字節(jié),它是對當(dāng)前字段基本數(shù)據(jù)類型的描述,存儲的也是一個(gè)字符常量在常量池中的偏移值。但是你如果對應(yīng)到常量池中去看的話,你會發(fā)現(xiàn)這個(gè)描述符的的值是: I
基本數(shù)據(jù)類型與實(shí)際存儲的符號之間有這么一種映射關(guān)系,為的是簡單存儲。其中,如果字段是數(shù)組類型的話,需要前置一個(gè) 『[ 』,多維數(shù)組就前置多個(gè)該符號進(jìn)行描述。
接著看字段表。
接下來的 attributes_count 和 attributes 描述的是當(dāng)前字段的「屬性」。所謂「屬性」也即字段的額外信息描述。我們的第一個(gè)字段沒有額外的屬性,所以 attributes_count 為 0 。
下面我們完整分析一下第二個(gè)字段的字節(jié)碼:
access_flags 的值為 0x0002,對應(yīng)的訪問修飾符是:private 。name_index 的值對應(yīng)于字段名稱在常量池中的偏移值。
descriptor_index 的值為:0x000A ,對應(yīng)的常量值是:Ljava/lang/String 。同樣,它也沒有屬性描述。
方法表集合
理解了字段表,方法表的內(nèi)容就很容易理解了。下面是方法表的標(biāo)準(zhǔn)結(jié)構(gòu):
針對我們上述的示例,簡單分析一下:
首先,0x0002 表示整個(gè) Class 文件中有兩個(gè)方法(一個(gè)是我們自己編寫的 sayHello 方法,還有一個(gè)是編譯器增加的實(shí)例構(gòu)造器《init》方法)。
然后,0x0001 指明了該方法的訪問標(biāo)志:public,0x000B 指明了該方法名稱在常量池中的偏移值,對應(yīng)到常量池中的常量:<init> 。
接下來是這個(gè) descriptor_index,字段表中該屬性存儲的是字段的數(shù)據(jù)類型,而在方法表中,這個(gè)屬性存儲的「東西」要稍微多一些,它存儲了方法的參數(shù)個(gè)數(shù),參數(shù)類型,返回值等信息。例如我們此示例中,descriptor_index 對應(yīng)于常量池中的常量:()V(0x000C)。
當(dāng)然,這個(gè)方法比較簡單,沒有參數(shù),返回值類型為 void。我們再看一個(gè)稍微復(fù)雜點(diǎn)的例子:
public int executeNum(int a,String b,char[] x)
對應(yīng)的精簡版存儲形式:
(IL/java/lang/String[C)I
接著就是屬性表,顯然從我們的字節(jié)碼表中可以看出來,attributes_count 的值為 1,說明該方法存在一個(gè)屬性,下面我們來看看屬性表有哪些嚴(yán)格的「約束」。
虛擬機(jī)規(guī)范中定義的屬性有很多,并且每種屬性都有不同于其他屬性的表結(jié)構(gòu),但是所有的屬性都必須包含以下三個(gè)項(xiàng)。
通過前兩個(gè)字節(jié)可以辨別當(dāng)前的屬性類型。于我們這里的示例而言,attrubute_name_index 的值為 0x000D(Code),所以虛擬機(jī)可以調(diào)來 Code 表結(jié)構(gòu)繼續(xù)完成解析,Code 表結(jié)構(gòu)如下:
接著分析,
然后的四個(gè)字節(jié)表明該屬性所占用的總字節(jié)數(shù),attribute_length 等于 0x0000003D(61),然后一步一步分析即可,我們這里不再繼續(xù)分析了。其實(shí) Code 屬性表最主要的一個(gè)作用是,存儲當(dāng)前方法在編譯后所生成的所有字節(jié)碼指令,并記錄所需局部變量表的大小等有關(guān)方法運(yùn)行的信息。
還有一些其他屬性表我們這里為了不使篇幅過長,將在后續(xù)文章中繼續(xù)分析。
總體上而言,所謂的字節(jié)碼文件,或者說 Class 文件就是編譯器嚴(yán)格按照虛擬機(jī)規(guī)范生成的一串二進(jìn)制,虛擬機(jī)在進(jìn)行解析的時(shí)候也是嚴(yán)格按照虛擬機(jī)規(guī)范進(jìn)行解析,這樣就使得 Class 文件中所有的信息都能夠被虛擬機(jī)讀取解析。
文章中的所有代碼、圖片、文件都云存儲在我的 GitHub 上:
(https://github.com/SingleYam/overview_java)
歡迎關(guān)注微信公眾號:撲在代碼上的高爾基,所有文章都將同步在公眾號上。