若是計算機的 CPU 只有「x86」這一種,或者操做系統只有 Windows 這一類,那麼或許 Java 就不會誕生。Java 誕生之初就曾宣揚過它的初衷,「一次編寫,多處運行」,而它之因此可以實現跨平臺的一個核心點就在於,Java 引入「字節碼」屏蔽了與底層操做系統之間的差別。java
同一段 Java 程序在編譯後生成的字節碼文件是惟一的,不會由於平臺的不一樣而產生任何的變化。而同一段字節碼跑在不一樣實現的 JVM 上,會產生不一樣的機器指令。於底層而言,其實 Sun 公司針對不一樣的操做系統開發了不一樣版本的 JVM,而這些 JVM 則經過識別上層的字節碼並向下解釋給操做系統執行。所以,你的同一段字節碼在不一樣平臺下的 JVM 上運行,會對應到不一樣的機器指令,以此實現了跨平臺運行。git
而理解這個「字節碼」文件結構就顯得十分重要了,理解它是如何存儲咱們程序中的字段、方法、屬性、局部變量、各類常量值等等,是學習虛擬機工做原理的基礎。github
那麼,本文就來分析一下這個「字節碼」文件,解開它的神祕面紗。數組
咱們的 Java 文件被編譯器編譯成 Class 文件以後,整個 Class 文件由若干個 0 和 1 組成爲一個超長的「二進制串」。各個項目按照嚴格的規範存儲並順序的排在一塊兒,每一個項目佔幾個字節幾乎固定,因此 JVM 在解析的時候,只須要按照咱們制定的規範一項一項的拆分解析便可。bash
整個 Class 文件的各個項目以及它們以前的排列順序都是固定的,如圖:微信
其中 u2 表示當前的項目總共佔兩個字節,固然,u4 表示佔四個字節。以 _info 結尾的項目表述爲一張表,具體佔多少字節數須要參見該表的內部結構。其實,宏觀上來看,整個 Class 文件也能夠被看作是一張表。工具
Class 文件開頭的四個字節存儲的是當前文件的「魔數」,所謂的「魔數」就是用於標識當前的文件是一個由 Java 文件編譯過來的 Class 文件。不是什麼文件拿過來,我虛擬機都接受並運行的,由於文件的擴展名是能夠隨意更改的,因此有些文件可能就不是 Java 文件編譯而來的。學習
不一樣類型的文件有着不一樣的魔數值,圖片格式有圖片格式的的魔數值,視頻格式有視頻格式的魔數值,而咱們 Class 文件的魔數值爲:0xCAFEBABE 。咱們使用 UltraEdit 任意打開一個 Class 文件,會發現前四個字節都是同樣的。this
參見 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)
歡迎關注微信公衆號:撲在代碼上的高爾基,全部文章都將同步在公衆號上。