【深入理解JVM】:Class類文件結構

概述

我們平時在DOS界面中往往需要運行先運行javac命令,這個命令的直接結果就是產生相應的class文件,然後基於這個class文件纔可以真正運行程序得到結果。自然。這是Java虛擬機的功勞,那麼是不是Java虛擬機只能編譯.java的源文件呢?答案是否定的。時至今日,Java虛擬機已經實現了語言無關性的特點。而實現語言無關性的基礎是虛擬機和字節碼的存儲格式,Java虛擬機已經不和包括Java語言在內的任何語言綁定。它只與“class”文件這種特定的二進制文件相關聯。在class文件中包含了Java虛擬機指令集和符號表以及若干輔助信息。可以很容易想到Java(本質上不是Java語言本身的平臺無關性,而是其底層的Java虛擬機的平臺無關性使然。)的跨平臺,因爲任何一門功能性語言都可以表示爲能被Java虛擬機接受的有效的class文件。比如,除了Java虛擬機可以將Java源文件直接編譯爲class文件外,使用JRuby等其他語言的編譯器一樣可以把程序代碼編譯成class文件,由此可見,Java虛擬機並不關心class文件是由何種語言編譯來的。

Class類文件結構

Class文件是一組以8字節爲基礎單位的二進制流,各個數據項目嚴格按照順序緊湊排列在class文件中,中間沒有任何分隔符,這使得class文件中存儲的內容幾乎是全部程序運行的程序。Java虛擬機規範規定,Class文件格式採用類似C語言結構體的僞結構來存儲數據,這種結構只有兩種數據類型:無符號數和表。

無符號數屬於基本數據類型,主要可以用來描述數字、索引符號、數量值或者按照UTF-8編碼構成的字符串值,大小使用u1、u2、u4、u8分別表示1字節、2字節、4字節和8字節。

表是由多個無符號數或者其他表作爲數據項構成的複合數據類型,所有的表都習慣以“_info”結尾。那麼表是幹嘛的呢?表主要用於描述有層次關係的複合結構的數據,比如方法、字段。需要注意的是class文件是沒有分隔符的,所以每個的二進制數據類型都是嚴格定義的。具體的順序定義如下:

Class文件

在class文件中,主要分爲魔數、Class文件的版本號、常量池、訪問標誌、類索引(還包括父類索引和接口索引集合)、字段表集合、方法表集合、屬性表集合。

魔數與Class文件版本號

頭4個字節是魔數,魔數的唯一作用在於確定這個Class文件是否是Java虛擬機接受的Class文件。如gif和jpeg等在文件頭中都存在魔術,使用魔術而不是使用擴展名是基於安全性考慮的——擴展名可以隨意被改變。Class文件的魔術值爲“0xCAFEBABE”(咖啡寶貝?)。

緊接着魔數的4個字節是Class文件版本號:版本號又分爲次版本號和主版本號。其中前兩個字節用於表示次版本號,後兩個字節用於表示主版本號。這個的版本號是隨着jdk版本的不同而表示不同的版本範圍的。如果Class文件的版本號超過虛擬機版本,將被拒絕執行。

常量池

常量池可以簡單理解爲class文件的資源從庫,這種數據類型是Class文件結構中與其他項目關聯最多的數據類型,也是佔用Class文件空間最大的項目之一。在常量池中主要存放字面量符號引用。字面量比較接近Java語言層面的常量概念,比如文本字符串、聲明爲final的常量值等(百度百科的解釋是字面量是用雙引用號引住的一系列字符)。符號引用則主要包括三類常量:

  • 類和接口的全限定名
  • 字段的名稱和描述符
  • 方法的名稱和描述符。

符號引用與直接引用的關聯

  • 符號引用是一組符號,用來描述所引用的目標,符號是以任何形式存在的字面量。對於符號引用Java虛擬機並沒有嚴格的限制。規定只需要使用的時候能夠無歧義定位到目標就可以。常量池存在於Class文件中,而Class文件是必須首先通過Java虛擬機的類加載機制加載到內存中(確切的說是方法區這個內存區域,回顧一下,方法區存放的主要是對象的實例,這個Class文件是虛擬機對外接受訪問的接口)。符號引用屬於常量池中的內容,那麼是不是說符號引用的目標已經加載到內存中了呢?答案是否定的,因爲符號引用與虛擬機的內存佈局無關,符號引用的目標並不一定已經加載到內存中了。

  • 直接引用可以是直接指向引用目標的指針、相對偏移量或者是一個能夠間接定位到目標的句柄。直接引用是和虛擬機的內存佈局有關的,同一個符號引用在不同的虛擬機上翻譯的直接引用一般是不同的。如果有了直接引用,那麼引用的目標必定是存在內存中的。

在常量池中每一項常量都是一個表,在jdk1.7中共有14中常量類型,所以常量池的項目就對應14張表,這14張表的每種類型都不一樣。但是有一個共同特點:表開始的第一位都是一個u1類型的標誌位,代表這個常量屬於哪種類型。

常量池項目類型

需要注意的是,在Class文件中,方法、字段都需要引用CONSTANT-Utf8_info類型的常量,所以這種類型的常量的長度有一定的限制,也就是Java中方法、字段的最大長度。在CONSTANT-Utf8_info中,其length的值u2,說明Java虛擬機只能編譯最大大約64KB的變量或者方法名。超過的話將不會進行編譯。

訪問標誌

常量池之後的數據結構是訪問標誌(access_flags),這個標誌主要用於識別一些類或者接口層次的訪問信息,主要包括:這個Class是類還是接口、是否定義public、是否定義abstract類型;如果是類的話是否被聲明爲final等。具體的標誌訪問如下:

訪問標誌

類索引、父類索引和接口索引集合

這個數據項主要用於確定這個類的繼承關係

其中類索引和父類索引都是一個u2類型的數據,而接口索引集合是一組u2類型的數據。在Java中由於不允許多繼承,所以父類索引是唯一的,但是一個類可以實現多個接口,所以得到的接口索引是一個集合,表示這個類實現了哪些接口。

字段表集合

字段表用於描述接口或者類中聲明的變量。

字段包括類級變量和實例級變量,但是不包括方法內部聲明的局部變量(這些變量是存儲在Java虛擬機棧中的局部變量表中的)。自然,描述一個字段的信息包括:字段的作用域(public、protected、private)、實例變量與否(static)、可變性(final)、併發可見性(volatile)、可否被序列化(transient)、字段數據類型(基本數據類型、對象、數組)、字段名稱。字段的信息也被存放在一張表中,其字段表包括三種類型:

  • u2類型訪問標誌(access_flags),其訪問標誌在access_flags中
  • u2類型的name_index(字段的簡單名稱)
  • u2類型的描述符(descriptor_index)

上面出現了簡單名稱,上文中出現了全限定名,以及這裏出現的描述符,三者有什麼區別呢?其中全限定名稱比較好理解,就是類的完整路徑信息。而簡單名稱則是指沒有類型和參數修飾的方法或者字段名稱,比如一個方法如下:

public void inc(int a,int b){
    System.out.println(a+b);
}

那麼這個方法的簡單名稱就是inc。

相對於以上兩者,描述符相對複雜一些。描述符的主要的作用是描述字段的數據類型、方法的參數列表和返回值。其中我們熟悉的void,在Class文件中用V表示。下面是完整的描述符標誌的含義:

描述符標識字符含義

對於數組類型,每一維度使用一個前置的“[”字符描述,如果是二維數組,那麼就有兩個“[”符號。比如“java.lang.String[][]”會被記錄成“[[Ljava.lang.String;”

對於方法,則是按照縣參數列表後返回值的順序進行描述的。比如方法int inc(int a,int[] b,char[][] c,int d)的描述符是“(I[I[[CI)I”。

方法表集合

JVM中堆方法表的描述與字段表是一致的,包括了:訪問標誌、名稱索引、描述符索引、屬性表集合。方法表的結構與字段表是一致的,區別在於訪問標誌的不同。在方法中不能用volatile和transient關鍵字修飾,所以這兩個標誌不能用在方法表中。在方法中添加了字段不能使用的訪問標誌,比如方法可以使用synchronized、native、strictfp、abstract關鍵字修飾,所以在方法表中就增加了相應的訪問標誌。

要注意的是,如果父類方法沒有在子類中重寫,那麼在方法中不會自動出現來自父類的方法信息。同樣的,有可能添加編譯器自動增加的方法,比如方法。

屬性表集合

前面的Class文件、字段表和方法表都可以攜帶自己的屬性信息,這個信息用屬性表進行描述,用於描述某些場景專有的信息。在屬性表中沒有類似Class文件的數據項目類型和順序的嚴格要求,只要新的屬性不與現有的屬性名重複,任何人都可以向屬性表中寫入自己定義的屬性信息。

Code屬性

Java程序方法體中的代碼經過javac編譯最終編譯成的字節碼指令就保存在Code屬性中。但是並非所有的方法表都必須存在這個屬性。Code屬性是Class文件中最重要的一個屬性,如果把一個Java程序中的信息分爲代碼(Code)和元數據(Metadata,包括類、字段、方法定義及其其他信息)兩部分,那麼在整個Class文件中,Code屬性用於描述代碼,所有其他的數據項目都用於描述元數據。

Exceptions屬性

這個屬性的作用是列舉出方法中可能拋出的受查異常(Checked Exception),也就是描述throws 後的列舉的異常

LineNumberTable屬性

主要用於描述Java源代碼行號與字節碼行號之間的對應關係。這個屬性也不是必須的。如果沒有這個屬性,對程序的直接影響就是當拋出異常的時候無法顯示對應的行號;並且在調試的時候無法通過設置斷點的方法是調試程序。

LocalVariableTable屬性

用於描述棧幀中局部變量表中的變量與Java源碼中定義的變量的之間的關係。也不屬於必須的屬性。如果沒有這個屬性,產生的直接影響就是當別人引用這個方法的時候,所有的參數名稱都會丟失,IDE將會使用諸如args0、args1之類的參數進行顯示。自然,當調試程序的時候,顯示的參數名稱是不可知的。

SourceFile屬性

用於記錄這個Class文件的源碼文件名稱。如果不使用這個屬性,那麼當拋出異常的時候,堆棧中將不會顯示出錯代碼所屬的文件名。

ConstantValue屬性

作用是通知虛擬機自動爲靜態變量賦值。要注意的是,只有被static關鍵字修飾的額變量纔可以使用這個屬性(類變量)。對於非類變量,初始化是在方法中進行的;對於類變量可以選擇兩種方式進行變量的初始化:一是在類構造器方法中使用;二是是ConstantValue屬性。目前Sun Hotspot的選擇原則是:如果一個變量同時使用static和final關鍵字修飾,並且這個變量是基本數據類型或者java.lang.String類型的話,就使用ConstantValue屬性進行初始化。如果沒有被final修飾或者並非是基本數據類型,那麼將會選擇使用方法進行初始化。

InnerClass屬性

這個屬性主要用於記錄內部類與宿主類之間的關聯關係。

Deprecated以及Synthetic屬性

這兩個屬性都屬於標誌類型的布爾屬性,只存在有沒有的區別。

Deprecated屬性用於表示某個類、字段或者方法,已經被程序作者定爲不再推薦使用,可以通過註解@deprecated實現

Synthetic屬性代表此字段並不是由Java源碼產生的,而是通過編譯器自行添加的。

StackMapTable屬性

該屬性的目的在於代替以前比較消耗性能的基於數據流分析的類型推導驗證器。

Signature屬性

這個屬性是專門用來記錄泛型類型的,因爲在Java語言採用的是擦除法實現的泛型,在字節碼(Code屬性)中,泛型信息編譯之後會被擦除。擦除法的優點是能夠節省泛型所佔的內存空間,缺點是在運行期間無法通過反射得到泛型信息,而Signature屬性則彌補了這一缺陷。現在的Java反射API已經能夠得到泛型信息,功勞就在於這個屬性。

BootstrapMethods屬性

這個屬性用於保存invokedynamic指令引用的引導方法限定符。該指令用於在運行時動態解析出調用點限定符所引用的方法,並執行該方法。

參考
1、周志明,深入理解Java虛擬機:JVM高級特性與最佳實踐,機械工業出版社

發佈了117 篇原創文章 · 獲贊 184 · 訪問量 44萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章