一、前言
我們知道我們寫完的Java程序經過javac xxx.java
編譯后生成了xxx.class
文件,可是你是否想過xxx.class
文件到底是什么?這個文件中到底包含了什么內容?那么現在我們就一起通過解析一個.class
文件來深入的學習一下類文件結構,通過這次的學習,我想你會對class
文件了如指掌。
二、Class類文件結構
在解析一個class文件之前,我們需要先學習一下Class類文件的結構,這個類文件結構相當于一個總綱,我們馬上就會對照著這個類文件結構解析真正的class文件。
- Class文件是一組以8個字節為基礎單位的二進制流(可能是磁盤文件,也可能是類加載器直接生成的),各個數據項目嚴格按照順序緊湊地排列,中間沒有任何分隔符;
- Class文件格式采用一種類似于C語言結構體的偽結構來存儲數據,其中只有兩種數據類型:無符號數和表;
- 無符號數屬于基本的數據類型,以u1、u2、u4和u8來分別代表1個字節、2個字節、4個字節和8個字節的無符號數,可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字符串值;
- 表是由多個無符號數獲取其他表作為數據項構成的復合數據類型,習慣以“_info”結尾;
- 無論是無符號數還是表,當需要描述同一個類型但數量不定的多個數據時,經常會使用一個前置的容量計數器加若干個連續的數據項的形式,這時稱這一系列連續的某一類型的數據未某一類型的集合。
類文件結構圖:
三、類文件分析
我們就以一個非常經典的代碼作為例子進行分析,代碼如下:
package temp;
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello,World");
}
}
我們通過16進制編輯器打開編譯后的HelloWorld.class
文件,其十六進制的文件內容如下:
1、魔數和版本
- Class文件的頭4個字節,唯一作用是確定文件是否為一個可被虛擬機接受的Class文件,固定為“0xCAFEBABE”。
- 第5和第6個字節是次版本號,第7和第8個字節是主版本號(0x0034為52,對應JDK版本1.8);Java的版本號是從45開始的,JDK1.1之后的每一個JDK大版本發布主版本號向上加1,高版本的JDK能向下兼容低版本的JDK。
對應到class文件中就是:
2、常量池
緊接著主版本號的就是常量池,常量池可以理解為class文件的資源倉庫,它是class文件結構中與其它項目關聯最多的數據類型,也是占用class文件空間最大的數據項目之一,也是class文件中第一個出現的表類型數據項目。
由于常量池中常量的數量不是固定的,所以常量池入口需要放置一項u2類型的數據,代表常量池中的容量計數。不過,這里需要注意的是,這個容器計數是從1開始的而不是從0開始,也就是說,常量池中常量的個數是這個容器計數-1。將0空出來的目的是滿足后面某些指向常量池的索引值的數據在特定情況下需要表達“不引用任何一個常量池項目”的含義。class文件中只有常量池的容量計數是從1開始的,對于其它集合類型,比如接口索引集合、字段表集合、方法表集合等的容量計數都是從0開始的。
常量池中主要存放兩大類常量:字面量和符號引用。字面量比較接近Java語言的常量概念,如文本字符串、聲明為final的常量等。而符號引用則屬于編譯原理方面的概念,它包括三方面的內容:
- 類和接口的全限定名(Fully Qualified Name);
- 字段的名稱和描述符(Descriptor);
- 方法的名稱和描述符;
Java代碼在進行javac編譯的時候并不像C和C++那樣有連接這一步,而是在虛擬機加載class文件的時候進行動態連接。也就是說,在class文件中不會保存各個方法、字段的最終內存布局信息,因此這些字段、方法的符號引用不經過運行期轉換的話無法得到真正的內存入口地址,虛擬機也就無法使用。當虛擬機運行時,需要從常量池獲得對應的符號引用,再在類創建時或運行時解析、翻譯到具體的內存地址中。
常量池中的每一項都是一個表,在JDK1.7之前有11中結構不同的表結構,在JDK1.7中為了更好的支持動態語言調用,又增加了3種(CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info)。不過這里不會介紹這三種表數據結構。
這14個表的開始第一個字節是一個u1類型的tag,用來標識是哪一種常量類型。這14種常量類型所代表的含義如下:
由class文件結構圖可知:
常量池的開頭兩個字節0x0022是常量池的容量計數,這里是34,也就是說,這個常量池中有33個常量項。
我們可以看一下這33個常量:
藍色部分的內容就是33個常量,我們可以發現圖片右邊用UTF-8編碼后已經把常量翻譯成了英文字母??梢钥吹竭@部分的內容非常多。因為常量池中的常量比較多,每一中常量還有自己的結構,導致常量池的結構非常復雜,這里只解析第一個常量作為示例:
看看這個例子的第一項,容量計數后面的第一個字節標識這個常量的類型,是0x0A,即10,查表可知是類方法的符號引用,這個常量表的結構如下:
類型 | 名稱 | 數量 |
---|---|---|
U1 | tag | 1 |
U2 | name_index | 1 |
U2 | descriptor_index | 1 |
按照這個結構,可以知道name_index
是6(0x0006),descriptor_index是20(0x0014)。這都是一個索引,指向常量池中的其他常量,其中name描述了這個方法的名稱,descriptor描述了這個方法的訪問標志(比如public、private等)、參數類型和返回類型。(這里因為手工解析常量池確實是一件很坑爹的工作,而且后面會介紹自動解析的工具,所以這里就不去管name和descriptor的內容了)
我們可以看到手工解析常量池是一件非常痛苦的事情,這里還只是一個特別簡單的例子生成的class文件,我們可以自己想想如果是自己寫的一個程序編譯為class文件后,它的常量池會非常大,所以Java已經為我們提供了一個解析常量池的工具javap
,我們可以通過javap -verbose class文件名
,就可以自動幫我們解析了,下面是這個程序的解析結果:
I:\work\out\production\work\temp>javap -verbose HelloWorld
警告: 二進制文件HelloWorld包含temp.HelloWorld
Classfile /I:/work/out/production/work/temp/HelloWorld.class
Last modified 2018-8-3; size 543 bytes
MD5 checksum 5eeb0ca06c253d3206781e81895bd4a4
Compiled from "HelloWorld.java"
public class temp.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // Hello,World
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // temp/HelloWorld
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Ltemp/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 HelloWorld.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 Hello,World
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 temp/HelloWorld
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
{
public temp.HelloWorld();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 2: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ltemp/HelloWorld;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello,World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 4: 0
line 5: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "HelloWorld.java"
這里我們先不要管后面的內容,我們只看常量池部分,很明顯,33個常量已經被解析完畢,現在我們可以看一下第一個常量的內容:
[圖片上傳失敗...(image-8b7582-1533291668097)]
我們可以發現第一個常量指向了第6個和第20個常量,經過分析其指向的常量,最終的結果是后面顯示的java/lang/Object."<init>":()V
,我們現在對這個字符串所表示的內容大概有自己的猜測,不過也有自己的疑惑之處,這都不要緊,因為后面我們就會分析相似的字符串的意思。而且我們會發現<init>
并沒有在Java程序中出現,還有一些內容也沒有在Java程序中出現,比如“[”、“V”、“LineNumberTable”等。這是自動生成的常量,但它們會被后面即將介紹到的字段表、方法表和屬性表引用到,用來描述一些不方便使用固定字節表示的內容。譬如描述方法的返回值是什么?有幾個參數?每個參數的類型是什么?
最后,給出14種常量項的結構:
HelloWorld這個類的訪問標志就是ACC_PUBLIC
和ACC_SUPER
,這一點我們可以在javap得到的結果中驗證:
這篇博客就先分析到訪問標志,因為后面的內容還有很多,考慮到一篇很長的文章會極大的降低閱讀體驗,所以類文件結構這篇文章就分為兩章。