Java位元組碼結構剖析一:常量池
這篇部落格開始,我打算帶大家去解讀一下JVM平臺下的位元組碼檔案(熟悉而又陌生的感覺)。眾所周知,Class檔案包含了我們定義的類或介面的資訊。然後位元組碼又會被JVM載入到記憶體中,供JVM使用。那麼,類資訊到了位元組碼檔案裡,它們如何表示的,以及在位元組碼裡是怎麼分佈的呢?帶著這些問題,讓我們去深入瞭解位元組碼檔案吧。
Class檔案的結構
Class檔案是一組以8位位元組為基礎單位的二進位制流,各個資料專案嚴格按照順序緊湊地排列在Class檔案之中,中間沒有新增任何分隔符,這使得整個Class檔案中儲存的內容幾乎全部是程式執行的必要資料,沒有空隙存在。當遇到需要佔用8位位元組以上空間地資料項時,則會按照高位在前的方式分割成若干個8位位元組進行儲存。
每一個 Class 檔案對應於一個如下所示的 ClassFile 結構體。
ClassFile { u4 magic; u2 minor_version; u2 major_version; u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; u2 access_flags; u2 this_class; u2 super_class; u2 interfaces_count; u2 interfaces[interfaces_count]; u2 fields_count; field_info fields[fields_count]; u2 methods_count; method_info methods[methods_count]; u2 attributes_count; attribute_info attributes[attributes_count]; }
這種資料結構,類似C語言結構體。這個結構體中只有兩種資料型別:無符號數和表,後面的解析都要以這兩種資料型別為基礎,所以這裡要先介紹這兩個概念。
無符號數屬於基本的資料型別,以u1,u2,u4,u8來分別代表1個位元組,2個位元組,4個位元組和8個位元組的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字串值。
表是由多個無符號數或者其他表作為資料項構成的複合資料型別,所有表都習慣性地以“_info”結尾。表用於描述有層次關係的複合結構的資料,整個Class檔案本質就是一張表。
下面是我的案例程式碼,本章將以此程式碼生成的位元組碼檔案作為例子來分析。
public class MyTest2 { String str = "Welcome"; private int x = 5; public static Integer in = 10; public static void main(String[] args) { MyTest2 myTest2 = new MyTest2(); myTest2.setX(8); in = 20; } public void setX(int x) { this.x = x; } }
對應生成的位元組碼檔案格式如下:(資料內容較多,只是截了部分)
上面的數字是以16進製表示的。我們可以按照之前的結構一項項去解讀它。
Class檔案解析
magic
魔數,u4型別的資料,佔4個位元組。魔數的唯一作用是確定這個檔案是否為一個能被虛擬機器所接受的 Class 檔案。魔數值固定為 0xCAFEBABE (咖啡寶貝),不會改變。
minor_version、major_version
緊接著魔數之後的4個位元組為Java版本資訊:第5和第6個位元組是次版本號(minor_version),第7和第8個位元組是主版本號(major_version)。
就看當前這個位元組碼,次版本號是0×0000=0,主版本號是0×0034=52。我本地機器用的是JDK1.8,所以可生成的Class檔案主版本號最大值為52.0。
下面給出了Java各個主版本號,以供參考。
constant_pool_count
常量池計數器,u2型別的資料。它是常量池的入口,表示緊跟著它後面的常量池的元素個數。算一下,0x002F=47,即常量池裡的元素有47個。這裡我用jdk的內建工具javap,反編譯一下,可以輸出常量池的資訊以及元素個數。執行命令:javap -verbose。輸出結果如下:
Constant pool: #1 = Methodref#10.#34// java/lang/Object."<init>":()V #2 = String#35// Welcome #3 = Fieldref#5.#36// com/shengsiyuan/jvm/bytecode/MyTest2.str:Ljava/lang/String; #4 = Fieldref#5.#37// com/shengsiyuan/jvm/bytecode/MyTest2.x:I #5 = Class#38// com/shengsiyuan/jvm/bytecode/MyTest2 #6 = Methodref#5.#34// com/shengsiyuan/jvm/bytecode/MyTest2."<init>":()V #7 = Methodref#5.#39// com/shengsiyuan/jvm/bytecode/MyTest2.setX:(I)V #8 = Methodref#40.#41// java/lang/Integer.valueOf:(I)Ljava/lang/Integer; #9 = Fieldref#5.#42// com/shengsiyuan/jvm/bytecode/MyTest2.in:Ljava/lang/Integer; #10 = Class#43// java/lang/Object #11 = Utf8str #12 = Utf8Ljava/lang/String; #13 = Utf8x #14 = Utf8I #15 = Utf8in #16 = Utf8Ljava/lang/Integer; #17 = Utf8<init> #18 = Utf8()V #19 = Utf8Code #20 = Utf8LineNumberTable #21 = Utf8LocalVariableTable #22 = Utf8this #23 = Utf8Lcom/shengsiyuan/jvm/bytecode/MyTest2; #24 = Utf8main #25 = Utf8([Ljava/lang/String;)V #26 = Utf8args #27 = Utf8[Ljava/lang/String; #28 = Utf8myTest2 #29 = Utf8setX #30 = Utf8(I)V #31 = Utf8<clinit> #32 = Utf8SourceFile #33 = Utf8MyTest2.java #34 = NameAndType#17:#18// "<init>":()V #35 = Utf8Welcome #36 = NameAndType#11:#12// str:Ljava/lang/String; #37 = NameAndType#13:#14// x:I #38 = Utf8com/shengsiyuan/jvm/bytecode/MyTest2 #39 = NameAndType#29:#30// setX:(I)V #40 = Class#44// java/lang/Integer #41 = NameAndType#45:#46// valueOf:(I)Ljava/lang/Integer; #42 = NameAndType#15:#16// in:Ljava/lang/Integer; #43 = Utf8java/lang/Object #44 = Utf8java/lang/Integer #45 = Utf8valueOf #46 = Utf8(I)Ljava/lang/Integer;
可是,我們得到的常量池裡的元素個數是46。我們看常量池第一個元素,它的索引是從1開始的。所以索引值範圍是1~46。設計者將第0項常量空出來是有特殊考慮的,這樣做的目的在於滿足後面某些指向常量池的索引值的資料在特定情況下需要表達“不引用任何一個常量池專案”的含義,這種情況就可以把索引值置為0來表示。根本原因在於,索引為0也是一個常量(保留常量),只不過它不位於常量表中。這個常量就對應Null值,所以常量池的索引從1而非0開始。
常量池結構剖析
緊接其後的就是常量池了。一個Java類中定義的很多資訊都是由常量池維護和描述的。可以將常量池看作是Class檔案的資源庫。比如:Java類中定義的方法與變數資訊,都是儲存在常量池中。常量池中主要儲存兩類常量:字面常量和符號引用。字面量,如文字字串,Java中宣告為常量值,而符號引用如類和介面的全侷限定名,欄位的名稱和描述符,方法的名稱和描述符等。
注:常量池中儲存的不一定是不變的量!如, private int x = 5 ,x是變數,但“x”這個變數名字依然存在常量池中。
我們也可以把常量池當做一個數組(常量池中的每一項常量都是一個表),與一般陣列不同的是,常量池陣列中不同的元素型別,結構都是不同的,長度當然也不相同;但是每一個元素的第一個資料都是u1型別,該位元組是個標誌位,佔一個位元組。JVM在解析長量池時,會根據這個u1型別來獲取元素的具體型別。目前,常量池中出現的常量型別有14種,如下表:
有了這張表就可以繼續剖析常量池的內容了,常量池第一個位元組就是一個標誌位,0x000A=10,說明第一個常量型別是CONSTANT_Methodref_info。這是一個表型別,它對應的結構是:
CONSTANT_Methodref_info { u1 tag; u2 class_index; u2 name_and_type_index; }
可知,該型別常量佔1+2+2=5個位元組。所以我們從常量池前5個位元組就是第一個常量元素了。緊接後面就是第二個常量,同樣的,開始是一個標誌位,即0x008=8。可知,第二個常量是CONSTANT_String_info型別。CONSTANT_String_info 用於表示 java.lang.String 型別的常量物件,格式如下:
CONSTANT_String_info { u1 tag; u2 string_index; }
所以常量池的第二個元素佔3個位元組。按照這個套路,我們就可以找出每一個常量了。一直數到第46個常量,常量池就結束了。此處是常量池中的 ofollow,noindex" target="_blank">14種常量項的結構總表 。感興趣的可以對照這個表,去把剩下的常量對照出來。
常量項分析
第一個常量是CONSTANT_Methodref_info型別的,它描述了類中方法的符號引用。class_index 項的值必須是對常量池的有效索引,常量池在該索引處的項必須是CONSTANT_Class_info結構,表示一個類或介面。
class_index表示的索引值是0x000A=10。根據之前 javap -verbose 輸出的常量池資訊,我們可以知道常量池的#10項是CONSTANT_Class_info型別的常量。該型別常量用於表示類或介面,格式如下:
CONSTANT_Class_info { u1 tag; u2 name_index; }
name_index 項的值,必須是對常量池的一個有效索引。常量池在該索引處的項必須是CONSTANT_Utf8_info結構,代表一個有效的類或介面二進位制名稱的內部形式。
name_index 表示的索引值是43(這裡我直接從上面的量池資訊讀出,如果從位元組碼裡看,此處的值為0x002B=43)。所以接著找常量池第43項的常量型別,是CONSTANT_utf8_info型別,用於表示字串常量的值,結構如下:
CONSTANT_Utf8_info { u1 tag; u2 length; u1 bytes[length]; }
其中,length 項的值指明瞭 bytes[]陣列的長度,bytes[]是表示字串值的byte陣列。在這裡,我把位元組碼常量池中#43處常量的16進位制值單獨拿出來來看。下圖有背景色的部分就是完整的CONSTANT_Utf8_info型別常量表示。
第一個位元組是標誌位,0×0001=1。說明此常量型別是CONSTANT_Utf8_info。後面2個位元組是0×0010=16,表示後面bytes[]長度為16。所以往後數16個位元組就是整個它表示的字串常量。
bytes[]第一個位元組值,0x006A。根據 ASCII碼對照表 ,代表的字串是”j”。依次的,第二個位元組0×0061,代表“a”,等等。把16個位元組看完你就得到了字串常量表示“java/lang/Object”。好了這表示一個類的全限定名。饒了一大圈,終於找到最終要表示的常量資訊了。
到此,我們把第一個常量的結構中的class_index就解析完了,還剩一個name_and_type_index。它表示了常量池在該索引處的項必須是 CONSTANT_NameAndType_info結構,它表示當前欄位或方法的名字和描述符。後面大家可以根據 常量池中的14種常量項的結構總表 ,並結合javap得到的常量池資訊,自己去分析每個常量在常量池裡是怎麼個回事。
總結
這篇文章介紹了,位元組碼檔案的結構組成,並分析了魔數、次主版本號和常量池。尤其帶大家深入分析了常量池的組成結構,並拿例子中的常量池第一個常量作為案例,完整解析它在常量池中的各項引用。套路都是一樣的,常量池後面的常量,大家可以自己去分析了。你會發現類中有用的資訊都存在了我們的常量池裡,然後以索引的形式,給程式碼使用。這也就是常量池作為class檔案的資源倉庫的原因了。