一圖詳解java-class類檔案原理
本文分享自華為雲社群《【讀書會第十二期】這可能是全網“最大“、“最細“、“最深”的一份java-class類檔案原理圖解了!》,作者: breakDawn。
假期藉著華為雲讀書會的活動,重讀了一遍《深入理解java虛擬機器》, 發現第一遍讀類檔案相關內容的時候,真的是囫圇吞棗,很多細節都被我跳過了,無論是符號引用的含義,還是屬性表的理解,都沒有弄懂,當時想著“反正也用不到,跳過吧”,卻沒注意到他們包含了許多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()了。
- 有個泛型基類Base
,包含一個方法f(T t), 有個子類Sub , 實現了方法f(String s), 兩個f方法的入參並不一致,為什麼還多型的機制還能生效?
```
class Base
class Sub extend Base
這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
class Sub extend Base
那麼也是一樣的道理,橋接了一個父類的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引用時,它有兩件事要做:
- 呼叫A的建構函式。
- 把引用地址賦值給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分享文章。如果感覺不錯,歡迎掃描文末的二維碼,參加社群的活動並抽獎!
圖片線上檢視
- 帶你掌握 C 中三種類成員初始化方式
- 實踐GoF的設計模式:工廠方法模式
- DCM:一個能夠改善所有應用資料互動場景的中介軟體新秀
- 手繪圖解java類載入原理
- 關於加密通道規範,你真正用的是TLS,而非SSL
- 程式碼重構,真的只有複雜化一條路嗎?
- 解讀分散式排程平臺Airflow在華為雲MRS中的實踐
- 透過例項demo帶你認識gRPC
- 帶你聚焦GaussDB(DWS)儲存時遊標使用
- 傳統到敏捷的轉型中,誰更適合做Scrum Master?
- 輕鬆解決研發知識管理難題
- Java中觀察者模式與委託,還在傻傻分不清
- 如何使用Python實現影象融合及加法運算?
- 什麼是強化學習?
- 探索開源工作流引擎Azkaban在MRS中的實踐
- GaussDB(DWS) NOT IN優化技術解密:排他分析場景400倍效能提升
- Java中觀察者模式與委託,還在傻傻分不清
- Java中的執行緒到底有哪些安全策略
- 一圖詳解java-class類檔案原理
- Java中的執行緒到底有哪些安全策略