本文將揭開Java Class文件的神秘面紗,帶你了解Class文件的內部結構,并從Class文件結構的視角告訴你:
- 為什么Java Class字節碼文件可以“寫一次,遍地跑”?
- 為什么常量池的計數從1開始,而不是和java等絕大多數語言的習慣一樣從0開始計數?
- 為什么在Java應用運行期間,無法使用反射在普通對象中獲取到泛型信息?
平臺無關性
Java應用之所以能“Write once, Run anywhere”,是因為有JVM虛擬機這個中間媒介來執行Java程序,而JVM虛擬機不和包括Java在內的任何語言綁定,它只和“Class”文件這種約定的二進制文件格式所關聯。虛擬機通過載入和執行平臺無關的字節碼,從而實現了程序的“Write once, Run anywhere”。
什么是Class文件?
“深入理解Java虛擬機”一書中給出了定義,“Class文件是一組以8位字節為基礎單位的二進制流”。各個數據項目按照順序緊湊排列,中間沒有分隔符,整個Class文件沒有一點空間上的浪費。
利用idea插件BinEd打開Class文件,我們可以看到用十六進制表示的Class文件,開頭是固定的0xCAFEBABE(咖啡寶貝)魔數,它的唯一作用是用來驗證此文件是可以被虛擬機接受的Class文件,而不是通過后綴.class來驗證,因為后綴名是可以人為修改的。很多格式如gif或者jpeg等文件頭都存在魔數。
Class文件格式
Class文件格式按照虛擬機規范的約定,采用一種類似于C語言結構體的偽結構來存儲數據,只要各個平臺編譯器能夠嚴格遵守Java虛擬機的規范來生成Class文件,Java虛擬機就能生成它可執行的Class文件。在Class文件中只有兩種數據類型:
- 無符號數。屬于基本的數據類型,u1、u2、u4和u8分別表示1個、2個、4個和8個字節的無符號數,它們可以用來描述數字、索引引用、數量值或者utf-8編碼的字符串。
- 表。是由多個無符號數或其他表構成的復合數據結構。表習慣以“_info”結尾,整個Class文件本質上也是一張表,因為它也是具有層次關系的復合數據類型。
如上圖,當同一個數據類型有多個時,經常會在這個數據類型集合的前面加上一個計數器。例如,fields數據項表示Class文件里的多個field_info字段,其前面的fields_count保存了fields集合的數量。
下面正式開始介紹Class文件的各個數據項的含義和作用。
Class文件的版本
前面已經介紹了Class文件以魔數(magic)開頭,魔數是u4類型,占用了4個字節。緊隨其后的第5和第6個字節是次版本號(Minor Version),第7和第8個字節是主版本號(Major Version)。Java的版本號從45開始,從1.1開始每個JDK大版本的主版本號都向上加1(JDK 1.0~1.1使用了45.0~45.3的版本號)。例如,我的本地JDK版本是1.8,所以編譯的Class文件大版本號是十六進制0x0034,即十進制52。
版本號的作用是保證高版本的JDK向下兼容低版本的Class文件,但必須拒絕超過其版本號的Class文件。
常量池
主版本號之后是常量池入口,常量池是占用Class文件空間最大的數據項目之一,因為其他項目里存放的引用類型,是和常量池里存放的數據進行的關聯。例如,類索引(this_class)是u2類型的數據,里面存放的是引用常量池的地址,常量池對應的區域保存的是類的全限定名。
我們先用一個示例來大致的了解一下常量池的結構。以下是class文件對應的源代碼,我們可以使用idea插件jclasslib,打開這個類的class文件看看它的構造。
public class GuoClass<T> {private int money;public int make() {return money + 1000000000;}
}
在一般信息里,我們看到有一項“本類索引”(this_class),cp_info表示本類索引的數據類型是常量池類型(constant_pool_info),編號#4表示本類索引指向的是常量池的4號位置,它存放的數據是CONSTANT_Class_info類型。
點開常量池編號#4的索引,可以看到它存放的也是一個cp_info的數據,顯然我們可以再次點擊它跳轉到常量池編號25的位置看看。
果然,這里我們看到了常量池編號#25的位置存放的是一個字符串字面量,它保存的就是我們最終要找的類的全限定名“com/examples/test/GuoClass”。
通過上面的示例,我們可以分析出幾個重點:
- 常量池的容量計數是從1開始算起的。這和絕大多數語言習慣包括Java都不太一樣。這樣做的目的在于滿足特定情況下,不引用常量池項目時,將索引值置為0。
- 常量池的常量數量是不固定的。所以在常量池的前面需要放一個類型為u2的數據,代表常量池的容量。上面例子中constant_pool_count存放了十六進制數0x001B,即十進制27,代表常量池有(27-1 = 26)個常量,索引值范圍是1~26。
- 常量池中主要存放兩大類常量:字面量(Literal)和符號引用(Symbolic References)。字面量類似于Java語言的文本或final常量值。符號引用包括:類和接口的全限定名、字段的名稱和描述符以及方法的名稱和描述符。
- 常量池是最繁瑣的數據,其中每一項常量都是一個表,除了第一位是一個u1類型的標志位(tag)以外,每一項常量均有各自的結構。如圖是常量池的項目類型。
- 常量池每一項數據類型依靠標志位(tag)來區分。知道了常量池數據項的類型之后,就可以根據常量項的結構總表來查詢常量項的具體信息。
我們再來看一個例子,從總體上直觀感受一下常量池的結構。還是解析上面的GuoClass.class類文件,圖中紅框1圈出來的是常量池的第一位的數據項,對應的是紅框2圈出來的部分,它的數據類型是CONSTANT_Methodref_info,通過上圖我們知道:
CONSTANT_Methodref_info的第一部分是一個u1類型的標志位(tag),紅框1中的0x0A,即十進制10,正好對應標志位(tag)的枚舉值CONSTANT_Methodref_info。
CONSTANT_Methodref_info的第二部分是一個u2類型的索引項,紅框1中的0x0005,即十進制5,正好對應紅框3中的cp_info#5。
CONSTANT_Methodref_info的第三部分是一個u2類型的索引項,紅框1中的0x0017,即十進制23,正好對應紅框3中的cp_info#23。
訪問標志
如果你能看到這里,說明你已經跨過了學習Class文件格式最困難的部分,堅持下去一定會有收獲!
緊接著常量池的是訪問標志(access_flags),它用于識別類或接口層次的訪問信息,比如這個Class是類還是接口;是否為public類型;有沒有abstract修飾;有沒有final關鍵字等等。具體標志位如圖所示:
以GuoClass這個類為例,它是一個普通的Java類,不是接口、枚舉或者注解,public類型,沒有final和abstract關鍵字修飾,所以它的ACC_PUBLIC、ACC_SUPER標志應當為真,其他的標志位應該為假,所以它的access_flags的值應為:0x0001 | 0x0020=0x0021。從它的十六進制圖可以看出,我們的結果是正確的。
類索引、父類索引與接口索引集合
類索引、父類索引和接口索引集合都按順序排列在訪問標志之后。
- 類索引(this_class),u2類型數據,用于確定類的全限定名。
- 父類索引(super_class),u2類型數據,用于確定類的父類的全限定名。由于Java語言不允許多重繼承,因此父類索引只能有一個。除了java.lang.Object之外,所有的Java類都有父類,所以除了java.lang.Object之外,所有的Java類的父類索引都不為0。
- 接口索引集合(interfaces),一組u2類型數據,用來描述這個類實現的接口。也就是這個類按implements語句后的接口順序排列的集合。如果當前類是一個接口,則應當是extends語句后的接口。
類索引、父類索引和接口索引的查找過程是一樣的,都是用u2類型的索引值表示,指向一個CONSTANT_Class_info類型的類描述符常量,再通過CONSTANT_Class_info類型的常量中的索引值找到CONSTANT_Utf8_info類型的全限定名字符串。
以GuoClass這個類為例,在access_flags之后的兩個字節是this_class,這個u2類型的數據項用十六進制表示為0x0004,它指向常量池中第4個類型為CONSTANT_Class_info的常量,再根據此常量里的索引值找到常量池中第25個位置保存的CONSTANT_Utf8_info類型的字符串,這個字符串就是我們需要找的全限定名“com/examples/test/GuoClass”。
在this_class之后,即0x0004后面的四個字節,0x0005和0x0000分別表示super_class和interfaces集合的入口。super_class和this_class的查找過程一摸一樣,而由于GuoClass沒有實現的接口,所以它的入口值是0x0000,即常量池的0位置,這也是前文提到過的為什么常量池從1開始計數的原因。0在不引用常量值的時候使用。
字段表集合
前文已經介紹了Class文件里的常量池、訪問標志和繼承關系(類索引、父類索引、接口索引集合),那么在一個Java類的還剩下什么信息沒有介紹呢?對!這一部分我們介紹類的字段。
字段(field)包括類變量和實例變量,但不包括方法內部聲明的局部變量。字段數據項的類型是字段表(field_info),它用于描述接口或類中聲明的變量。
字段表(field_info)結構
想象一下在Java里描述一個字段需要包含哪些信息?
- 字段的作用域(public、private、protected)
- 實例變量還是類變量(static)
- 可變性(final)
- 并發可見性(volatile)
- 可否被序列化(transient)
- 字段數據類型(基本類型、對象、數組)
- 字段名稱
字段表結構如圖所示:
字段表的access_flags
字段表里的access_flags與類中的access_flags非常相似,它們都是u2類型,且都是訪問標志。
除了字段數據類型和字段名稱,其他的信息都是修飾符,都可以用布爾值來表示是否有某一個修飾符。字段訪問標志位如圖所示:
顯然,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED只能三選一;ACC_FINAL、ACC_VOLATILE只能二選一;接口中的字段必須有ACC_PUBLIC、ACC_STATIC、ACC_FINAL標志。
以GuoClass為例,緊隨上文中接口索引入口0x0000的是0x0001和0x0002。0x0001是字段表前面的fields_count數據項,fields_count用來對字段的數量計數,因為GuoClass只有一個int類型的字段money,所以fields_count作為一個u2類型的數據項,保存了兩個字節,用十六進制表示數字1就是0x0001。緊隨fields_count之后的就是字段表的access_flags數據項,它的值是0x0002,因為money字段是用private修飾的,所以對應的就是ACC_PRIVATE標志。
字段表的name_index和descriptor_index
緊接著access_flags標志的是兩個索引值:name_index和descriptor_index。
name_index代表字段的簡單名稱,如果是在方法表里代表的是方法的簡單名稱。例如,make()方法的簡單名稱是“make”,money字段的簡單名稱是“money”。
descriptor_index代表字段或方法的描述符。描述符是用來描述字段的數據類型、方法的參數列表和返回值。描述符的標識字符如圖所示:
當使用描述符描述數組類型時,使用一個前置的“[”,例如,一個整型數組可以被表示為“[I”,一個字符串類型的二位數組可以表示為“[[Ljava/lang/String;”。
當使用描述符描述方法時,按照先參數列表,再返回值的順序,參數列表按照參數順序放在一對小括號“()”里。例如,
- 方法int make()的描述符為“()I”
- 方法java.lang.String toString()的描述符為“()Ljava/lang/String;”
- 方法int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)的描述符為“([CII[CIII)I”
以GuoClass為例,緊隨access_flags的是name_index,如圖十六進制0x0006;再來是descriptor_index,十六進制表示為0x0007。
我們可以把索引0x0006和0x0007在常量池中查找一下,0x0006保存的是一個CONSTANT_Utf8_info類型的字面量“money”,即字段的簡單名稱。
0x0007在常量池里保存為一個CONSTANT_Utf8_info類型的字面量“I”,即字段的描述符,表示money字段是int類型的。
至此,通過access_flags、name_index和descriptor_index查找到的信息,我們可以知道GuoClass里的字段信息是“private int money”。
descriptor_index之后還有一個屬性表集合用于保存一些額外的信息。例如,"final static int money = 100000;" 會存在一項名稱為ConstantValue的屬性,其值指向常量100000。但是本例中的字段money沒有額外信息,所以屬性計算器為0。
方法表集合
方法表的內容基本上可以參照字段表的內容,因為它們的結構幾乎一模一樣都包括了訪問標志(access_flags)、名稱索引(name_index)、描述符索引(descriptor_index)、屬性表集合(attributes)這幾項。
方法表的access_flags
方法訪問標志的取值如圖所示。由于volatile和transient關鍵字不能修飾方法,所以在方法訪問標志里去掉了ACC_VOLATILE和ACC_TRANSIENT。因為synchronized、abstract、native和strictfp關鍵字可以修飾方法,所以增加了ACC_SYNCHRONIZED、ACC_ABSTRACT、ACC_NATIVE和ACC_STRICTFP標志。
方法表里只不會保存有具體的代碼信息,方法的java代碼被編譯器編譯成字節碼指令,保存在屬性表集合的“Code”屬性里。
public class GuoClass<T> {private int money;public int make() {return money + 1000000000;}
}
以GuoClass為例,方法表集合的第一個u2類型的數據是計數器容量,它的值為0x0002表示這個類文件有兩個方法,其中一個顯然就是代碼中的make()方法,另外一個比較隱蔽,是實例的構造器方法,構造器方法是編譯器自動添加的方法。構造器方法是public公有的,所以訪問標志是ACC_PUBLIC,對應的十六進制數是0x0001。
方法表的name_index和descriptor_index
緊挨著構造器方法的訪問標志位的是u2類型的方法名稱索引,其值為0x0008,在常量池中我們可以查詢到方法名稱為""。
再往后是u2類型的方法描述符索引,其值為0x0009,在常量池中我們可以查詢到方法描述為"()V"。前面我們已經提到過這個描述符的含義表示方法沒有參數,并且返回空void。
屬性表計數器的值為0x0001,表示屬性表集合有一個屬性。屬性名稱索引為0x000A,在常量池中查詢到其值為“Code”,說明此屬性是方法的字節碼描述。
方法重載
在Java語言中,要重載一個方法,除了方法名要相同外,方法參數的個數或類型不能一樣。但是在Class文件格式中,只要描述符不是完全一致的兩個方法也可以共存,即如果兩個方法具有相同的方法名,方法參數的個數和類型也一樣,但返回值類型不同,這兩個方法也是可以合法共存于同一個Class文件里的。