字節碼層面分析class類文件結構
1. 思考:Java中的String字符串的長度有限制嗎?
平時項目的開發中,我們經常會用到String來聲明字符串,比如String str = ”abc“,但是你可能從來沒有想過等於號之後的字符串常量到底有沒有長度限制。要徹底答對這道題,就需要了解-class文件。
2. class 的來龍去脈
Java能夠實現”一次編譯,到處運行“,這其中class文件要佔大部分功勞。爲了讓java語言具有良好的跨平臺能力,Java獨具匠心的提供了一種可以在所有平臺上都能使用的一種中間代碼——字節碼類文件(.class)。有了字節碼,無論是哪種平臺,只要安裝了虛擬機都可以直接運行字節碼。
並且,有了字節碼,也解除了Java虛擬機和Java語言之間的耦合。
其實,Java虛擬機當初被設計出來的目的就不單單只是運行Java這一種語言。目前Java虛擬機已經可以支持很多除Java語言以外的其他語言了,如Groovy,JRuby,Jython,Scala等。之所以可以支持其他語言,是因爲這些語言經過編譯之後也可以生成能夠被JVM解析並執行的字節碼問價。而虛擬機並不關心字節碼是由哪種語言編譯過來的。如下圖所示:
3. 上帝視角看class文件
如果從縱觀的角度來看class文件,class文件裏只有兩種數據結構:無符號數和表。
- 無符號數:屬於基本的數據類型,以u1,u2,u4,u8來分別表示1個字節,2個字節,4個字節,8個字節的無符號數,無符號數可以用來描述數字,索引引用,數量值或者字符串(UTF-8編碼)。
- 表:表是由多個無符號數或者其他表作爲數據項構成的複合數據類型,class文件中所有的表都以”_info“結尾。其實,整個class文件本質上就是一張表。
這兩者之間的關係可以用下面這張圖來表示:
可以看出,在一張表中可以包含其他無符號數和其他表格。僞代碼可以如下圖所示:
//無符號數
u1=byte[1];
u2=byte[2];
u4=byte[4];
u8=byte[8];
//表
class_table{
u1 tag;
u2 index2;
...
// 表中也可以引用其它表
method_table mt;
...
}
4. class文件結構
剛纔我們說在class文件中只存在無符號數和表這兩種數據結構。而這些無符號數和表就組成了class中的各個結構。這些結構按照預先規定好的順序緊密的從前向後排列,相鄰的項之間沒有任何間隙。如下圖所示:
當JVM加載某個class文件時,JVM就是根據上圖中的結構去解析class文件,加載class文件到內存中,並在內存中分配相應的空間。具體某一種結構需要佔用多大的空間。如下圖所示:
5. 實例分析
理清這些概念之後,接下來通過一個Java代碼實例,來看一下上面這幾個結構的詳細情況。首先編寫一個簡單的Java源代碼Test.java,如下圖所示:
import java.io.Serializable;
public class Test implements Serializable,Cloneable{
private int num = 1;
private String str = "abc";
public int add(int i){
int j = 10;
num = num + i;
return num;
}
}
通過javac將其編譯,生成Test.class字節碼文件。然後使用16進制編輯器打開class文件,顯示內容如下:
上圖中都是一些16進制數字,每兩個字符代表一個字節。乍看一下各個字符之間毫無規律,但是在JVM的視角里這些16進制字符是按照嚴格的規律排列的。接下來就一步一步看下JVM是如何解析它們的。
1. 魔數 magic number
如上圖所示,在class文件開頭的四個字節是class文件的魔數,它是一個固定的值–0xCAFEBABE。魔數是class文件的標誌,也就是說它是判斷一個文件是不是class格式文件的標準,如果開頭四個字節不是0xCAFEBABE,那麼就說明它不是class文件,不能被JVM識別或加載。
2. 版本號
緊跟在魔數後面的四個字節代表當前class文件的版本號。前兩個字節0000代表次版本號(minor_version),後面兩個字節0034是主版本號(major_version),對應的十進制值爲52,也就是說當前的主版本號是52,次版本號是0.所以綜合版本號是52.0
3. 常量池(重點)
緊跟在版本號之後的是一個叫做常量池的表(cp_info)。在常量池中保存了類的各種相關信息,比如類的名稱,父類的名稱,類中的方法名,參數名稱,參數類型等,這些信息都是以各種表的形式保存在常量池中的。
常量池中的每一項都是一個表,其項目類型共有14種,如下表所示:
可以看出,常量池中的每一項都會有一個u1大小的tag值。tag值是表的標識,JVM解析class文件時,通過這個值來判斷當前的數據結構是哪一種表。以上14種表都有自己的結構,這裏不再一一介紹,就以CONSTANT_Class_info和CONSTANT_Utf8_info這兩張表舉例說明,因爲其他表也基本類似。
首先,CONSTANT_Class_info表具體結構如下:
table CONSTANT_Class_info{
u1 tag = 7;
u2 name_index;
}
解釋說明
- tag:佔用一個字節大小。比如值爲7,說明是CONSTANT_Class_info類型表。
- name_index:是一個索引值,可以將它理解爲一個指針,指向常量池中索引爲name_index的常量表。比如name_index = 2,則它指向常量池中第二個常量。
接下來再看CONSTANT_Utf8_info表具體結構如下:
table CONSTANT_Utf8_info{
u1 tag;
u2 length;
u1[] bytes;
}
解釋說明:
- tag:值爲1,表示是CONSTANT_Utf8_info類型表。
- length:length表示u1[] 的長度,比如length = 5,則表示接下來的數據是5個連續的u1類型數據。
- bytes:u1類型數組,長度爲上面第二個參數length的值。
而我們在Java代碼中聲明的String字符串最終在class文件中的存儲格式就是CONSTANT_Utf8_info。因此
一個字符串最大長度也就是u2所能代表的最大值65536個,但是需要使用2字節來保存null值,因此一個字符串的最大長度爲65536 - 2 = 65534。
上述解釋中說的是字符串最大長度爲65534個字節,並不代表一個字符串中就可以保存65534個字符。因爲在utf-8編碼下,一個數字和一個英文字母佔一個字節,但是一個漢字卻可以佔用2~4個字節。因此如果使用字面量的方式聲明中文字符串的長度會遠遠小於65534。
不難看出,在常量池內部的表中也有相互之間的引用。用一張圖來理解CONSTANT_Class_info和CONSTANT_Utf8_info表格之間的關係。如下圖所示:
理解了常量池內部的數據結構之後,接下來就看一下實例代碼的解析過程。因爲開發者平時定義的Java類各式各樣,類中的方法與參數也不盡相同。所以常量池的元素數量也就無法固定,因此class文件在常量池的前面使用2個字節的容量計數器,用來代表當前類中常量池的大小。如下圖所示:
紅色框中的001d轉化爲十進制就是29,也就是說常量計數器的值爲29.其中下標爲0的常量被JVM留作其他特殊用途,因此Test.class中實際的常量池大小爲這個計數器的值減1,也就是28個。
第一個常量,如下所示:
0a轉化爲十進制後爲10,通過查看常量池14種表格圖中,可以查到tag = 10的表類型爲CONSTANT_Methodref_info,因此常量池中的第一個常量類型爲方法引用表。其結構如下:
table CONSTANT_Methodref_info{
u1 tag = 10;
u2 class_index; 指向此方法的所屬類
u2 name_type_index; 指向此方法的名稱和類型
}
也就是說在”0a“之後的兩個字節指向這個方法是屬於哪個類,緊接的兩個字節指向這個方法的名稱和類型。它們的值分別是:
- 0006:十進制6,表示指向常量池中的第6個常量。
- 0015:十進制21,表示指向常量池種的第21個常量。
至此第一個常量就解答完畢了。緊接着的就是第2個常量,如下所示:
tag 09表示是字段引用表CONSTANT_Fieldref_info,其結構如下:
table CONSTANT_Fieldref_info{
u1 tag;
u2 class_index; 指向此字段的所屬類
u2 name_type_index; 指向此字段的名稱和類型
}
同樣也是4個字節,前後都是兩個索引:
- 0005:指向常量池中第五個常量。
- 0016:指向常量池中第22個常量。
到現在爲止我們已經解析除了常量池中的兩個常量。剩下的常量的解析過程大同小異,這裏就不一一解析了。實際上我們可以用javap命令來幫助我們查看class常量池中的內容:
javap -v Test
上述命令執行後,顯示結果如下:
正如我們剛纔分析的一樣,常量池中第一個常量是Methodref類型,指向下標6和下標21的常量。其中下標21的常量類型爲NameAndType,它對應的數據結構如下:
table CONSTANT_NameAndType_info{
u1 tag;
u2 name_index; 指向某字段或方法的名稱字符串
u2 type_index; 指向某字段或方法的類型字符串
}
而下標在21的NameAndType的name_index和type_index分別指向了13和14,也就是"< init>“和”()V"。因此最終解析下來常量池中第一個常量的解析過程以及最終值如下圖所示:
仔細解析層層引用,最後可以看出,Test.class文件中常量池的第一個常量保存的是Object中的默認構造方法。
4. 訪問標誌(access_flags)
緊跟在常量池之後的常量是訪問標誌,佔用兩個字節,如下圖所示;
訪問標誌代表類或者接口的訪問信息,比如:該class文件是類還是接口,是否被定義成public,是否是abstract,如果是類,是否被聲明成final等等。各種訪問標誌如下所示:
我們定義的Test.java是一個普通的Java類,不是接口,枚舉,或註解。並且被public修飾但沒有被聲明爲final和abstract,因此它所對應的access_flags爲0021(0x0001和0x0020相結合)。
5. 類索引,父類索引與接口索引計數器
在訪問標誌後的2個字節就是類索引,類索引後的2個字節就是父類索引,父類索引後的2個字節則是接口索引計數器。如下圖所示:
可以看出類索引指向常量池的第5個常量,父類索引指向常量池中的第6個常量,並且實現接口的個數爲2個。再回顧下常量池中的數據:
從圖中可以看出,第5個常量和第6個常量均爲CONSTANT_Class_info表類型,並且代表的類分別爲”Test“和”Object“。再看接口計數器,因爲接口計數器的值爲2,代表這個類實現了2個接口。查看在接口計數器後的4個字節,分別爲:
- 0007:指向常量池中的第7個常量,從圖中可以看出第7個常量值爲”Serializable“。
- 0008:指向常量池中的第7個常量,從圖中可以看出第7個常量值爲”Cloneable“。
綜上所述,可以得出兩個結論:當前類爲Test繼承自Object類,並實現了”Serializable“和”Cloneable“這兩個接口。
6. 字段表
緊跟在接口索引集合後面的就是字段表了,字段表的主要功能是用來描述類或者接口種聲明的變量。這裏的字段包含了類級別變量以及實例變量,但不包括方法內部聲明的局部變量。
同樣,一個類中的變量個數是不固定的,因此在字段表集合之前還是使用一個計數器來表示變量的個數,如下所示:
0002表示類中聲明瞭2個變量(在class文件中叫字段),字段計數器之後會緊跟着2個字段表的數據結構。
字段表的具體結構如下:
table CONSTANT_Fieldref_info{
u2 access_flags; 字段的訪問標誌
u2 name_index; 字段的名稱索引
u2 descriptor_index; 字段的描述索引
u2 attributes_count; 屬性計數器
attribute_info attributes;
}
繼續解析Test.class中的字段表,其結構如下圖所示:
字段訪問標誌
對於Java類中的個變量,也可以使用public,private,final,static等標識符進行標識。因此解析字段時,需要先判斷它的訪問標誌,字段的訪問標誌如下所示:
字段表結構圖中的訪問標誌的值爲0002,代表它是private類型。變量名索引指向常量池中的第9個常量,變量名類型索引指向常量池中的第10個常量。第9個和第10個常量分別爲”num“和”I“,如下所示:
因此可以得知類中有一個名爲num,類型爲int類型的變量。對於第二個變量的解析過程也是一樣。
注意事項:
- 字段表集合中不會列出從父類或者父接口中繼承而來的字段。
- 內部類中爲了保持對外部類的訪問性,會自動添加指向外部類實例的字段。
7. 方法表
字段表之後跟着的就是方法表常量。方法表常量也是從一個計數器開始,因爲一個類中的方法數量也是不固定的,如圖所示:
上圖表示Test.class中有兩個方法,但是我們只在Test.java中聲明瞭一個add方法,這是因爲默認構造器方法也被包含在方法常量表內。
方法表的結構如下所示:
table CONSTANT_Methodref_info{
u2 access_flags; 方法的訪問標誌
u2 name_index; 指向方法名的索引
u2 descriptor_index; 指向方法類型的索引
u2 attributes_count; 方法屬性計數器
attribute_info attributes;
}
可以看到,方法也是有自己的訪問標誌,具體如下:
我們主要看add方法:
從圖中我們可以看到add方法的以下字段的具體值:
- access_flags = 0X0001 也就是訪問權限爲public
- name_index = 0X0011 指向常量池中的第17個常量,也就是”add“。
- type_index = 0X0012 指向常量池中的第18個常量,也就是(I)I。這個方法接收int類型參數,並返回int類型參數
8. 屬性表
在之前解析字段和方法的時候,在它們的具體結構中我們都能看到有一個叫做attributes_info的表,這就是屬性表。
屬性表並沒有一個固定的結構,各種不同的屬性只要滿足以下結構即可:
table CONSTANT_Attribute_info{
u2 name_index;
u2 attribute_length length;
u1[] info;
}
JVM中預定義了很多屬性表,這裏重點看一下Code屬性表。
-
Code屬性表
我們可以接着剛纔解析方法表的思路繼續往下分析:
可以看到,在方法類型索引之後跟着的就是”add“方法的屬性。0X0001是屬性計數器,代表只有一個屬性。0X000f是屬性表類型索引,通過常量池可以看出它是一個Code屬性表,如下所示:
Code屬性表中,最主要的就是一系列的字節碼。通過javap -v Test.class之後,可以看到方法的字節碼,如下圖顯示的是add方法的字節碼指令:
JVM執行add方法時,就是通過這一系列指令來做相應的操作。