一圖詳解java-class類文件原理

摘要:徒手製作一張超大的類文件解析圖,方便通過瀏覽這個圖能馬上回憶起class文件的結構以及內部的指令。

本文分享自華爲雲社區《【讀書會第十二期】這可能是全網“最大“、“最細“、“最深”的一份java-class類文件原理圖解了!》,作者: breakDawn。

藉着華爲雲讀書會的活動,重讀了一遍《深入理解java虛擬機》。在閱讀中, 用processorOn做了一副超大的類文件解析圖,方便自己通過瀏覽這個圖能馬上回憶起class文件的結構以及內部的指令。

下面的內容是拆分後的內容,對於每塊拆分的內容,會有詳細的解釋。

魔數、版本號

  • 每類文件都有一個魔數,用於快速校驗文件類型。
  • 對於高低版本號,只要明確java11\java8這種版本是主版本號
  • 永遠向下兼容, 即高版本jvm可以讀取低版本的class文件, 但是低版本的jvm無法讀取高版本的class文件。

常量池(常量池個數、多個常量項)

大部分文件協議格式中,都會先給定一個某項的數量長度,再決定某項的個數,方便確認遍歷幾次才結束。常量池的設置也是這個原理。

因此學習java的class格式,對我們設計某些文件格式或者協議都是一種不錯的借鑑。

Q:常量池中的常量到底是幹嘛的?和我們理解的static final String xxx常量是一個意思嗎?

A:不對!代碼中定義的final類型字符串常量只是一種用途。更重要的一種用途是符號引用。
而對符號引用的理解,是對java類文件原理最難也最重要的地方。
直接去解釋符號引用的話,還是很難理解的,因此我們按下不表,在第4部分“類索引”部分會給出詳細解釋。

Q:常量池的索引計數爲什麼從1開始(即其他地方要使用常量池的第一個常量時,必須寫成1而不是0)?
A:因爲要留一個0,表示不引用任何常量

  • 舉例:匿名類就是沒有名字的,但是類文件結構中,類名那邊總需要填入類名常量索引,因此可以填入0,表示“沒有類名”的意思。
  • 再來一個例子:object類,是沒有父類的,所以他的父類那一欄填的常量索引也是0
  • 對於常量池的作用,後面會有更詳細的體現和解釋。

類定義的第一行(類訪問標誌、本類、父類、實現接口)

爲什麼叫類定義的第一行,因爲這就來自我們寫每個類時的第一行內容。

例如

public abstract class A extend B implement C,D

這句話對應的所有信息就包含在了上圖中,因此我叫他“類定義的第一行”

CONSTANT_class_info這個類常量到底是幹嘛的?

從圖上可以看到,他其實就是指向了一個表示類名的字符串常量。這裏也可以看到,java文件中的所有名稱例如類名、方法名、字段名,都會以Utf_info的形式,存儲在常量池中。

Q:爲什麼要這樣多走一層?爲什麼不能直接指向一個字符串常量?
A:這個問題我沒找到解釋,但可以理解爲這是最基礎的一層封裝。

字段表(字段數量,各字段(修飾符、名、類型、屬性))

可以看到,字段名、字段類型分別對應了2個字符串常量。特別注意字段類型使用一個字符串來表示的,而不是一個constant_field_info。

那麼constant_field_info是幹嘛的呢?

Q:字段修飾符中的synchetics指的是編譯器自動生成的字段,怎麼理解呢?什麼情況下會用到?
A:找到一個簡單的例子(代碼出處:知乎-不凋花),用枚舉做switch:

enum Foobar {
    FOO,
    BAR;
}
class Test {
    static int test(Foobar var0) {
        switch (var0) {
            case FOO:
                return 1;
            case BAR:
                return 2;
            default:
                return 0;
        }
    }
}

switch的原理,我們應該很容易想到,就是做一次順序檢查,那麼檢查時,肯定程序裏需要有一個列表吧,因此上面switch的背後邏輯代碼是長這樣的:

class Test$1 {
    static final int[] $SwitchMap$Foobar;
    static {
        $SwitchMap$Foobar = new int[Foobar.values().length];
        try {
            $SwitchMap$Foobar[Foobar.FOO.ordinal()] = 1;
        } catch (NoSuchFieldError e) {
            ;
        }
        try {
            $SwitchMap$Foobar[Foobar.BAR.ordinal()] = 2;
        } catch (NoSuchFieldError e) {
            ;
        }
    }
}

可以看到有一個“static final int[] SwitchMapSwitchMapFoobar;”, 這個靜態數組字段,就是編譯器幫忙生成的字段,他會被標記成synchetics

Q:上面可以看到每個字段項的最後包含屬性數量和屬性長度,那麼class中的屬性和上面的“字段名”、“字段類型”有什麼區別呢?
A:屬性是可有可無的,而且提供了高度的“jvm可擴展性”。

換言之,在jvm虛擬機規範中,“字段修飾符”、“字段名”、“字段類型”都是必備的,而屬性則沒有限制。因此我們甚至可以自己實現一個虛擬機,定義新的屬性,在class中加上屬性項然後自己使用

對於屬性作用的更詳細理解,可以看後面的方法章節,方法中的屬性是比較重要且用得最多的。從字段屬性可以看到, 類似於static final int a =10這種常量,就是通過屬性裏的constant屬性來設置的。有個泛型簽名的屬性,可能不太好馬上理解,後面在方法章節中會一併提到這個屬性的作用!

方法表(方法數量、方法項(修飾符、名、描述、屬性))

class文件中,最值得學習的就是常量池和方法表了!

方法修飾符中的橋接

對於方法修飾符,大部分都很好理解,有2個修飾符需要關注:“bridge”和“synthetic”。

其實很多bridge橋接方法本身也是synthetics系統生成的,所以我不太想去區分二者,只要關注他們2個用來做什麼。

思考下面這個問題:

1. 假設有個非公開的類A,A中有個public方法f(),有個繼承自A的公開類B,沒有重寫f(),那麼外部是否可以調用b.f()?

private static class A {
    f() {..}
} 

public static class B extend A{
    // 不重寫任何方法
}
public static void main(String args[]) {
    B b = new B();
   b.f();
}

我們很容易可以得出b.f()可以調用的結論。

但由於B沒有重寫f(), 所以對於編譯後的B.class而言,這意味着不會在class文件中包含f方法。那麼當執行f時,通過多態,會定位到A.f(),此時A是非公開的類,權限就會出錯,因爲不允許直接引用非公開的類的方法,只能間接使用。

如何解決?要修改多態的動態分派校驗機制嗎?

不需要,編譯器爲了方便,直接爲我們在B中重寫了f()來間接調用父類方法,類似於

public void f() {
    super.f()
}

這樣的話就不用擔心外部調用者沒有權限使用A.f()了。

2. 有個泛型基類Base<T>,包含一個方法f(T t), 有個子類Sub<String>, 實現了方法f(String s), 兩個f方法的入參並不一致,爲什麼還多態的機制還能生效?

class Base<T> {
    f(T t);
}

class Sub extend Base<String>{
    f(String t);
}

這2個方法的入參確實不同, 前者的方法簽名是f(Ljava/lang/Object;)V, 後者是f(Ljava/lang/String;)V。 多態(動態分派)的規則也沒有變,確實是要求入參一致。

因此編譯器爲Sub類自動生成了一個f(Ljava/lang/Object;)V,代碼如下:

public void f(Object o) {
    this.f((String)o);
}

這樣多態的機制也能實現了。

可以看到這一切都是爲了適配多態,同時避免過多的特殊邏輯,因此使用橋接方法,來生成了我們看不到的重寫方法

從下面可以看到, 方法描述符是一個**包含“入參和返回值”**的描述符

因此,java是允許 同入參、同方法名、不同返回值的方法存在於同一個class文件中的。

這是不是有點反常識?這種情況我們好像編寫不出來的,編譯器不會通過!

其實這也是橋接+自動生成纔會有這種情況。前文的泛型例子,用泛型T做入參,會生成一個橋接方法,和父類的匹配。

那麼如果泛型T是一個返回值呢:

class Base<T> {
    T f();
}

class Sub extend Base<String>{
    String f();
}

那麼也是一樣的道理,橋接了一個父類的f方法,但僅僅是返回值不同而已。所以會出現只有返回值不同的方法。

方法表的屬性和字段的屬性類似, 也是屬性數量 + N個屬性項。但是方法表屬性裏的乾貨就更多了!

屬性的結構

之前字段屬性中沒提到屬性到底長啥樣,以方法中的throws異常屬性爲例,:

從這裏可以看到,每個屬性都有個屬性名,和常量不同,區分不同常量用的是1個2字節的數字,而屬性則是用一個字符串來表示。

這樣的區別就是因爲常量個數有限,而屬性爲了擴展性,不能存在數量限制。

另外從這也可以知道, 我們在方法名上寫的f() throws IOException 都是存在於異常屬性中的。

最關鍵的Code屬性

Code屬性是方法屬性中最最最重要的屬性。他告訴我們編譯器是怎樣將我們的文本代碼封裝成一個class文件的。

首先,code屬性的屬性名就是一個“Code”

操作數棧、局部變量表大小、指令碼數量

接着會包含3個重要的內容:max_stack、max_local和code_length

從max_stack和max_local我們可以看到,操作數棧和局部變量表的大小,已經在class文件中計算出來了,因此當開闢一個新的棧幀時,jvm便能夠知道給這個方法開闢多大的空間,不用擔心棧上分配不夠的問題。

注意,是操作數棧的大小,而不是程序執行的棧的深度,程序可沒法感知我們能夠遞歸多少次。

指令碼解讀

code_length代表了我們這個方法在編譯後,有多少條字節碼指令,而後面緊跟着的,就是對應數量的java字節碼指令了。

指令碼種類非常多,這裏只列舉關鍵的一些信息。

數據計算用的指令碼

首先,每種涉及基本數據類型的計算指令,都會在指令最前方,攜帶一個T,如圖:

裏面有句話:“不是每種數據類型和每個操作都有指令對應(否則數量太多)”

這句話怎麼理解呢,可以結果圖上右側的表格,從而得知,有些指令是不包含所有類型的,所以可能會借用一些的技巧,比如把byte、short都視爲int在操作上去操作。

對象操作的指令碼

另一個類指令碼是和對象操作有關,例如:

可以看到,當試圖獲取一個類字段時,他指向的是一個class_field_info常量索引,這個常量會提前被放進class文件的常量池中。

Q: 爲什麼它只包含了類引用和名稱呢,我怎麼知道我調用的是哪個對象的字段?
A: 你要調用的對象,已經通過前面提到的操作數棧相關指令,把引用放到了操作數棧的第一個,因此,jvm只要取棧頂對象,然後根據名字進行字段操作即可,後面的方法調用也是一樣的道理。

Q : 另外可以看到,new對象和new數組,用的是2個不同的指令,爲什麼要有區分?不能把數組當成一個java對象嗎
A:這要從對象的內存結構,以及類加載機制上去思考。
因爲數組的對象頭,和普通對象的對象頭是不一樣的。

  • 數組的對象頭中包含了數組長度,而普通對象沒有
  • new一個數組時,數組中包含的類並不會做類加載。
    有這麼多區別,肯定是新增一個單獨針對數組的指令來處理,要簡單很多

操作數棧指令

其他指令好理解, 但操作數棧指令有個dup_x指令,例如dup1_1 就是複製棧頂並再放入1個。爲什麼需要這麼一個指令?

其實當我們調用 A a = new A()時,這一句話生成的指令中就包含了dup指令
因爲當我們new出1個A引用時,它有兩件事要做:

  1. 調用A的構造函數。
  2. 把引用地址賦值給a這個局部變量
    而每件事都會消耗一個A的引用!所以才需要賦值。
    因此可以看到,指令碼很多時候都是基於操作數棧進行操作的,每操作一個數據或引用,就消耗一個

方法調用指令

對於方法調用指令,和前面的類字段調用有點像,也是一個方法常量,方法常量包含類索引和方法描述索引。對於方法究竟是如何觸發調用實現多態的、invokevirtual指令和invokedynamic指令有什麼區別,這個內容就更多了,後面我會放到類加載的圖解筆記中講解。

異常表屬性

指令碼結束後,後面會緊跟着一個異常表。表中的每一行長這樣:

是不是恍然大悟,原來try-catch代碼的邏輯在這邊, 它本質上就是拋異常時,根據try的位置和異常類型,這個異常表中進行查找到對應的catch代碼位置,從而實現異常處理。

Q: 那finally的操作被放到哪了?catch操作完了之後,它怎麼知道要跳轉到哪裏?
A: finally模塊在java語言中是必須執行的,在編譯的時候,通過將finally中代碼塊分別在try模塊的最後和catch模塊的最後都複製了一份,通過這樣來保證finally的必定執行

Q: 有一個問題,對於synchronized關鍵字,它本質是生成了monitorenter和monitorexit兩個指令(上面方法調用指令裏的最後2個)。但如果發生了異常,那會不會無法monitorexit了?
A: 生成code字節碼時,jvm會自動爲synchronized生成1個默認的異常表和throw指令,保證中間同步塊發生異常時,monitorexit能夠正確被指令(類似於放了一個自動生成的try-catch代碼,或者在已有的catch操作後添加)。

Q: 前面提到方法屬性中,已經有一個名叫“Exception”的屬性,和這個code屬性中的異常表有什麼區別?
A: 上面code異常表指的是代碼執行時try-catch的邏輯部分
而方法中的exception屬性則是方法名上所聲明的throws異常。

Code的擴展屬性

在code屬性中,竟然還攜帶了屬性,也就是說,是允許“屬性中的屬性”。畢竟屬性的實現是可以完全自定義的,那麼自己給自己新增額外特性完全是允許的。

裏面有個屬性叫“局部變量描述屬性”,長這樣:

從這裏,你就能明白,爲什麼你從IDEA裏看到反解後的class文件,有時候是var1、var2之類莫名其妙的局部變量,有時候卻又能看到完整的變量名了吧?就是通過這個屬性決定的。畢竟存儲局部變量名的代價還是很高的。

其他的方法屬性

泛型簽名這個屬性很迷惑,不是有泛型擦除嗎,爲什麼還需要這個屬性?

其實泛型簽名屬性是爲了方便反射的。

我們通過前面關於橋接的原理,可以知道編譯時會發生泛型擦除,方法入參都變成了object。

但是反射API可能希望獲取泛型信息因此可通過這個擴展屬性進行獲取。所以會增加這個屬性,從而能感知一些泛型屬性相關的信息。

類屬性

既然方法和字段都有屬性,那麼類肯定也有屬性:

其他屬性都比較好理解或者不重要,重點講一下內部類屬性。

通過內部類屬性,我們可以看到內部類並不是直接包含在這個class文件中,它其實是生成了另一個class文件,所以才需要一個內部類屬性,來確認對應的名字,方便類加載時能找到內部類。

Q: 爲什麼內部類屬性中,要包含宿主類的類名?難道宿主類,不就是它本身嗎?

A: 因爲,內部類中,還可以繼續定義內部類哦!

另外,從上面的一些屬性中可以看到, 很多debug用的調試、展示信息,都會包含在class中

因此,當我們希望調試一些環境上執行的程序時,如果想提供最爲貼近原代碼,那就需要class文件中能有充足的信息,如果想要class文件小,那就去掉,具體怎麼去掉或者添加,肯定就是一些編譯選項的區別了。

最後的完整圖

好累,終於寫完了,感覺能看到最後的人不會太多,但一通詳細地分析和解決中間發現的問題,還是收穫了不少。

最後貼上完整的大圖,歡迎保存和收藏。

圖解筆記系列也會持續更新下去,爭取做全網最細又最大的java分享文章。如果感覺不錯,歡迎掃描文末的二維碼,參加社區的活動並抽獎!

圖片在線查看

歡迎點擊該鏈接報名參加讀書會,一起成長學習和交流!
報名鏈接

 

點擊關注,第一時間瞭解華爲雲新鮮技術~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章