大家好,我是學富一車的沉默王二,哈哈哈。(有票圈的讀者應該知道這個梗)
今天我拿了一把小刀,準備解剖一下 Java 的 class 文件。
CS 的世界里流行著這么一句話,“計算機科學領域的任何問題都可以通過增加一個中間層來解決”。對于 Java 來說,JVM 就是這么一個產物,“Write once, Run anywhere”之所以能實現,靠得就是 JVM,它能在不同的操作系統下運行同一份源代碼編譯后的 class 文件。
Java 是跨平臺的,JVM 作為中間層,自然要針對不同的操作系統提供不同的實現。拿 JDK 11 來說,它的實現就有上圖中提到的這么多種。
通過不同操作系統的 JVM,我們的源代碼就可以不用根據不同的操作系統編譯成不同的二進制可執行文件了,跨平臺的目標也就實現了。那這個 class 文件到底是什么玩意呢?它是怎么被 JVM 識別的呢?
我們用 IDEA 編寫一段簡單的 Java 代碼,文件名為 Hello.java。
package com.itwanger.jvm;
class Hello {
public static void main(String[] args) {
System.out.println("Hello!");
}
}
點擊編譯按鈕后,IDEA 會幫我們自動生成一個名為 Hello.class 的文件,在 target/classes
的對應包目錄下。直接雙擊打開后長下面這樣子:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//
package com.itwanger.jvm;
class Hello {
Hello() {
}
public static void main(String[] args) {
System.out.println("Hello!");
}
}
看起來和源代碼很像,只是多了一個空的構造方法,對吧?它是 class 文件被 IDEA 自帶的反編譯工具 Fernflower 反編譯后的樣子。那真實的 class 文件長什么樣子呢?
可以在 terminal 面板下用 xxd Hello.class
命令來查看。
咦?完全看不懂的樣子呢。它是 class 文件的一種十六進制形式,xxd
這個命令的神奇之處就是它能將一個給定文件轉換成十六進制形式。
01、魔數
第一行中有一串特殊的字符 cafebabe
,它就是一個魔數,是 JVM 識別 class 文件的標志,JVM 會在驗證階段檢查 class 文件是否以該魔數開頭,如果不是則會拋出 ClassFormatError
。
魔數 cafebabe
的中文意思顯而易見,咖啡寶貝,再加上 Java 的圖標本來就是一個熱氣騰騰的咖啡,可見 Java 與咖啡的淵源有多深。
02、版本號
緊跟著魔數后面的四個字節 0000 0037
分別表示副版本號和主版本號。也就是說,主版本號為 55(0x37 的十進制),也就是 Java 11 對應的版本號,副版本號為 0。
上一個 LTS 版本是 Java 8,對應的主版本號為 52,也就是說 Java 9 是 53,Java 10 是 54,只不過 Java 9 和 Java 10 都是過渡版本,下一個 LTS 版本是 Java 17,預計 2021 年 9 月份推出。
03、常量池
緊跟在版本號之后的是常量池,字符串常量和較大的證書都會存儲在常量池中,當使用這些數值時,會根據常量池中的索引來查找。
Java 定義了 boolean、byte、short、char 和 int 等基本數據類型,它們在常量池中都會被當做 int 來處理。我們來通過一段簡單的 Java 代碼了解下。
public class ConstantTest {
public final boolean bool = true;
public final char aChar = 'a';
public final byte b = 66;
public final short s = 67;
public final int i = 68;
}
布爾值 true 的十六進制是 0x01、字符 a 的十六進制是 0x61,字節 66 的十六進制是 0x42,短整型 67 的十六進制是 0x43,整形 68 的十六進制是 0x44。所以編譯生成的整形常量在 class 文件中的位置如下圖所示。
第一個字節 0x03 表示常量的類型為 CONSTANT_Integer_info,是 JVM 中定義的 14 種常量類型之一,對應的還有 CONSTANT_Float_info、CONSTANT_Long_info、CONSTANT_Double_info,對應的標識分別是 0x04、0x05、0x06。
對于 int 和 float 來說,它們占 4 個字節;對于 long 和 double 來說,它們占 8 個字節。來個 long 型的最大值觀察下。
public class ConstantTest {
public final long ong = Long.MAX_VALUE;
}
來看一下它在 class 文件中的位置。05 開頭,7f ff ff ff ff ff ff ff 結尾,果然占 8 個字節,以前知道 long 型會占 8 個字節,但沒有直觀的感受,現在有了。
接下來,我們再來看一段代碼。
class Hello {
public final String s = "hello";
}
“hello”是一個字符串,它的十六進制為 68 65 6c 6c 6f
,我們來看一下它在 class 文件中的位置。
前面還有 3 個字節,第一個字節 0x01 是標識,標識類型為 CONSTANT_Uft8_info,第二個和第三個自己 0x00 0x05 用來表示第三部分字節數組的長度。
與 CONSTANT_Uft8_info 類型對應的,還有一個 CONSTANT_String_info,用來表示字符串對象(之前代碼中的 s),標識是 0x08。前者存儲了字符串真正的值,后者并不包含字符串的內容,僅僅包含了一個指向常量池中 CONSTANT_Uft8_info 的索引。來看一下它在 class 文件中的位置。
CONSTANT_String_info 通過索引 19 來找到 CONSTANT_Uft8_info。
除此之外,還有 CONSTANT_Class_info,用來表示類和接口,結構和 CONSTANT_String_info 類似,第一個字節是標識,值為 0x07,后面兩個字節是常量池索引,指向 CONSTANT_Utf8_info——字符串存儲的是類或者接口的全路徑限定名。
拿 Hello.java 類來說,它的全路徑限定名為 com/itwanger/jvm/Hello
,對應的十六進制為“636f6d2f697477616e6765722f6a766d2f48656c6c6f”,是一串 CONSTANT_Uft8_info,指向它的 CONSTANT_Class_info 在 class 文件中的什么位置呢?
先不著急,這里給大家介紹一款可視化字節碼的工具 jclasslib bytecode viewer,可以直接在 IDEA 的插件市場安裝。安裝完成后,選中 class 文件,然后在 View 菜單里找到 Show Bytecode With Jclasslib 子菜單,就可以查看 class 文件的關鍵信息了。
從上圖中可以看到,常量池的總大小為 23,索引為 04 的 CONSTANT_Class_info 指向的是是索引為 21 的 CONSTANT_Uft8_info,值為 com/itwanger/jvm/Hello
。21 的十六進制為 0x15,有了這個信息,我們就可以找到 CONSTANT_Class_info 在 class 文件中的位置了。
0x07 是第一個字節,CONSTANT_Class_info 的標識符,然后是兩個字節,標識索引。
還有 CONSTANT_NameAndType_info,用來標識字段或方法,標識符為 12,對應的十六進制是 0x0c。后面還有 4 個字節,前兩個是字段或者方法的索引,后兩個是字段或方法的描述符,也就是字段或者方法的類型。
來看下面這段代碼。
class Hello {
public void testMethod(int id, String name) {
}
}
用 jclasslib 可以看到 CONSTANT_NameAndType_info 包含的索引有兩個。
一個是 4,一個是 5,可以通過下圖來表示 CONSTANT_NameAndType_info 的構成。
對應 class 文件中的位置如下圖所示。
接下來是 CONSTANT_Fieldref_info 、CONSTANT_Methodref_info 和 CONSTANT_InterfaceMethodref_info,它們三個的結構比較類似,可以通過下面的偽代碼來表示。
CONSTANT_*ref_info {
u1 tag;
u2 class_index;
u2 name_and_type_index;
}
學過 C 語言的符號表(Symbol Table)的話,對這段偽代碼并不會陌生。
- tag 為標識符,Fieldref 的為 9,也就是十六進制的 0x09;Methodref 的為 10,也就是十六進制的 0x0a;InterfaceMethodref 的為 11, 也就是十六進制的 0x0b。
- class_index 為 CONSTANT_Class_info 的常量池索引,表示字段 | 方法 | 接口方法所在的類信息。
- name_and_type_index 為 CONSTANT_NameAndType_info 的常量池索引,拿 Fieldref 來說,表示字段名和字段類型;拿 Methodref 來說,表示方法名、方法的參數和返回值類型;拿 InterfaceMethodref 來說,表示接口方法名、接口方法的參數和返回值類型。
還有 CONSTANT_MethodHandle_info 、CONSTANT_MethodType_info 和 CONSTANT_InvokeDynamic_info,我就不再一一說明了,大家也可以拿把小刀去試一試。
啊,class 文件中最復雜的常量池部分就算是解剖完了,真不容易!
04、訪問標記
緊跟著常量池之后的區域就是訪問標記(Access flags),這個標記用于識別類或接口的訪問信息,比如說到底是 class 還是 interface?是 public 嗎?是 abstract 抽象類嗎?是 final 類嗎?等等。總共有 16 個標記位可供使用,但常用的只有其中 7 個。
來看一個簡單的枚舉代碼。
public enum Color {
RED,GREEN,BLUE;
}
通過 jclasslib 可以看到訪問標記的信息有 0x4031 [public final enum]
。
對應 class 文件中的位置如下圖所示。
05、this_class、super_class、interfaces
這三部分用來確定類的繼承關系,this_class 為當前類的索引,super_class 為父類的索引,interfaces 為接口。
來看下面這段簡單的代碼,沒有接口,默認繼承 Object 類。
class Hello {
public static void main(String[] args) {
}
}
通過 jclasslib 可以看到類的繼承關系。
- this_class 指向常量池中索引為 2 的 CONSTANT_Class_info。
- super_class 指向常量池中索引為 3 的 CONSTANT_Class_info。
- 由于沒有接口,所以 interfaces 的信息為空。
對應 class 文件中的位置如下圖所示。
06、字段表
一個類中定義的字段會被存儲在字段表(fields)中,包括靜態的和非靜態的。
來看這樣一段代碼。
public class FieldsTest {
private String name;
}
字段只有一個,修飾符為 private,類型為 String,字段名為 name。可以用下面的偽代碼來表示 field 的結構。
field_info {
u2 access_flag;
u2 name_index;
u2 description_index;
}
- access_flag 為字段的訪問標記,比如說是不是 public | private | protected,是不是 static,是不是 final 等。
- name_index 為字段名的索引,指向常量池中的 CONSTANT_Utf8_info, 比如說上例中的值就為 name。
- description_index 為字段的描述類型索引,也指向常量池中的 CONSTANT_Utf8_info,針對不同的數據類型,會有不同規則的描述信息。
1)對于基本數據類型來說,使用一個字符來表示,比如說 I 對應的是 int,B 對應的是 byte。
2)對于引用數據類型來說,使用 L***;
的方式來表示,L
開頭,;
結束,比如字符串類型為 Ljava/lang/String;
。
3)對于數組來說,會用一個前置的 [
來表示,比如說字符串數組為 [Ljava/lang/String;
。
對應到 class 文件中的位置如下圖所示。
07、方法表
方法表和字段表類似,區別是用來存儲方法的信息,包括方法名,方法的參數,方法的簽名。
就拿 main 方法來說吧。
public class MethodsTest {
public static void main(String[] args) {
}
}
先用 jclasslib 看一下大概的信息。
- 訪問標記是 public static 的。
- 方法名為 main。
- 方法的參數為字符串數組;返回類型為 Void。
對應到 class 文件中的位置如下圖所示。
08、屬性表
屬性表是 class 文件中的最后一部分,通常出現在字段和方法中。
來看這樣一段代碼。
public class AttributeTest {
public static final int DEFAULT_SIZE = 128;
}
只有一個常量 DEFAULT_SIZE,它屬于字段中的一種,就是加了 final 的靜態變量。先通過 jclasslib 看一下它當中一個很重要的屬性——ConstantValue,用來表示靜態變量的初始值。
- Attribute name index 指向常量池中值為“ConstantValue”的常量。
- Attribute length 的值為固定的 2,因為索引只占兩個字節的大小。
- Constant value index 指向常量池中具體的常量,如果常量類型為 int,指向的就是 CONSTANT_Integer_info。
我畫了一副圖,可以完整的表示字段的結構,包含屬性表在內。
對應到 class 文件中的位置如下圖所示。
來看下面這段代碼。
public class MethodCode {
public static void main(String[] args) {
foo();
}
private static void foo() {
}
}
main 方法中調用了 foo 方法。通過 jclasslib 看一下它當中一個很重要的屬性——Code, 方法的關鍵信息都存儲在里面。
- Attribute name index 指向常量池中值為“Code”的常量。
- Attribute length 為屬性值的長度大小。
- bytecode 存儲真正的字節碼指令。
- exception table 表示方法內部的異常信息。
- maximum stack size 表示操作數棧的最大深度,方法執行的任意期間操作數棧深度都不會超過這個值。
- maximum local variable 表示臨時變量表的大小,注意,并不等于方法中所有臨時變量的數量之和,當一個作用域結束,內部的臨時變量占用的位置就會被替換掉。
- code length 表示字節碼指令的長度。
對應 class 文件中的位置如下圖所示。
到此為止,class 文件的內部算是剖析得差不多了,希望能對大家有所幫助。第一次拿刀,手有點顫,如果哪里有不足的地方,歡迎大家在評論區毫不留情地指出來!
超級硬核,解剖了超長時間,累壞了,記得幫我點贊鼓勵下吧~