手繪圖解java類加載原理

語言: CN / TW / HK
摘要:這也許是全網”最大“、”最細“、“最深”的java類加載原理圖解了。

本文分享自華為雲社區《【讀書會第12期】這也許是全網”最大“、”最細“、“最深”的java類加載原理圖解了》,作者: breakDawn。

關於類初始化的時機和誤區

書籍的第一步部分上來就先講了類初始化的時機,整理成圖片如下:

看起來非常多,很難記住,很折磨。

個人認為,書籍把這一部分放到章節的最前面不太合理,曾經一度讓我把上面的這些事件,理解成了類加載的時機,也不懂這些規則的緣由(根本原因還是此時讀者對類加載的理解不夠深。)

先貼一下類加載和類初始化的區別:

  • 類加載概念:將class文件加載到jvm中並生成class對象,並根據情況做初始化。
  • 類初始化概念:調用類class文件中默認存在的<cinit>類初始化方法。

而我們容易產生誤解的原因,是因為書中沒有這句話:所謂的類初始化時機,只是針對cinit類初始化方法的調用,並不是指的類加載時機!

以上圖中紅色的部分為例:

這裏書籍中沒有解釋這3個規則的原因,在沒理解原理前,強行記憶這3條是沒有任何意義的。我認為是作者的失誤。

在這裏我挑其中一個做補充:

“使用類裏的static final 常量,不會觸發初始化”
想要理解這個規則,需要先理解class文件原理。
對於類的static final常量字段,它的常量值是存放在字段的constanValue屬性中。

正因為如此,static final常量並不需要通過cinit方法中的指令來完成賦值。

所以也就沒有必要在這時候調用<cinit>方法了。

因此對於“兒子類調用父類的靜態成員,不用對兒子類做類初始化”也是一個道理,兒子類的類靜態成員沒有被使用到,沒必要做cinit。

對於上面的分析,可以濃縮為一句話:

“如果我們急需使用static成員,且這個成員的值是要通過cinit方法賦值的,那麼我們才做cinit初始化”

新的疑問:那為什麼僅僅是new一個對象時,也一定要做cinit類初始化呢?
假設此時我還沒用到static成員,那麼new一個對象時,是否可以省去cinit,等用到靜態成員的時候,再去觸發cinit?

這涉及到了類初始化的另一個容易被忽視的點:“cinit類初始化方法,並不僅僅是做類成員的賦值,其實還可能包含一些初始化行為調用”,這可以是資源的啟動或者加載等類對象必須要用到的內容。

因此在一切可能觸發類對象實際行為前,必須觸發cinit避免出錯。

所以剛才的長篇大論,可以再次進行優化,濃縮為:
“當需要用到static成員的初始賦值,或者對類對象進行正式使用時,才會觸發cinit類初始化,目的是為了保證類對象或者類成員的正確使用”
拿着這一句話,去回看前面的類初始化時機的觸發時機和不觸發的時機時,相信你就會有更深的理解了,甚至也不需要強行去記憶每一條規則了。

有誤導的“加載三部曲”

有一個很經典的回答,叫做類加載三部曲:加載、連接、初始化
好像類加載過程就是這三步按照順序串行拼裝起來的。

實際上這3個過程是存在交叉的!
只能説,“最早發生”的時機,是按照這個順序發生,但是中間加載過程是有很多的,具體後面會結合我畫的圖以及原理解釋進行呈現。

加載:不僅僅是讀取字節流

對於加載,很容易只理解成只是“從文件里加載二進制字節到內存”。

這個過程顯然是必須最先執行的,否則連類的基本信息都獲取不到。

可以看到這個過程很靈活,只要你從你能想到的地方拿到字節流即可,任意形式都行。

然而,對於“加載”,除了獲取字節流,實際上還包含了“把字節流轉成方法區裏的數據結構,進行存儲defineClass”、“生成一個class對象,存儲在堆中”這兩步。

這2步是穿插在連接過程中的。

比如字節流轉數據結構的過程,必須在確認字節流的正確性之後完成。

而生成class對象同理,符合一個class對象的條件時,才能將其在堆中生成。

連接

連接過程可以説是最難記住的一個過程, 裏面包含了各種校驗啊之類的,讓人摸不清頭腦。這裏會通過更細緻的解釋和圖解,讓你明白連接過程究竟做了什麼。

首先連接過程分為 驗證、準備和解析,“解析”並不是連接的最後一步,而是在驗證過程中實時發生的!。 下文會為你詳細解釋為什麼。

驗證

文件格式校驗(class文件對不對)

注意這裏的校驗,都是一些最簡單的校驗,相當於無需做太多的語法分析操作等操作, 都是基於class文件格式定義進行的基礎校驗。

然而如果對加載的文件有充分的自信,來源可靠,那麼確實可以省去這個步驟,提升連接效率,因此會有一個-Xverify:none的選項供使用。

元數據驗證(我的父親對不對)

這裏驗證了class文件裏面繼承特性相關的重要信息,例如繼承關係是否合理、是否實現了抽象類或接口的方法

注意,這個元數據驗證的過程,會觸發父類或者接口的解析(加載)操作!

書上提到了4個解析情況以及流程:

  • 類解析
  • 字段解析
  • 類方法解析
  • 接口方法解析
    卻沒有解釋這4個解析過程是在哪裏發生的。後面我會逐一提到,來真正理解這4個解析過程。

元數據驗證中的類解析

還記得class文件中,父類是指向一個constant_class_info嗎?這個東西當時看就是一個utf字符串,沒什麼意義。你沒法知道父類究竟有什麼方法,是不是抽象類。因此必須拿到父類的類信息,要麼是已經在方法區中,要麼需要重新加載。

而類解析的過程如下:

可以看到這個過程中也會發生加載,甚至好多次加載。

字節碼驗證(我的指令對不對)

這個驗證不要和前面的“文件格式驗證”搞混了。
前面的“元數據驗證”都只是針對類、方法、字段等和父類進行確認、校驗。
但是還沒有涉及到每個方法裏的code屬性。

code屬性雖然在編譯出來時是正確的,但是無法保證傳輸過程中被人篡改。

如果發生操作操作數棧時,棧裏沒東西,或者試圖在局部變量表邊界外寫入局部變量,就可能導致不可估量的後果。

因此此刻會進行最基本的指令分析,確認對操作數棧、局部變量表的操作是安全、正確的。

但是,逐個指令分析,會不會太慢了?如果代碼很長的話。

還記得class文件的code屬性中,還包含了一個stackMapTable屬性麼,估計很多人都跳過了這個屬性。

這個屬性就是用在字節碼驗證這個過程,可以立即讓編譯器編譯出class時,提前把各位置的情況寫入stackMap中,jvm加載時只對這個stackMap做校驗確認是對的即可。

但代價就是可能不安全了,因為這個stackMap是可以被篡改的。

符號引用驗證(我的指令調用的目標對不對)

注意前面的“字節碼驗證”是簡單的確認,但不會持有過多的其他類的信息。但是方法肯定會涉及對其他類的調用。

此時就會涉及到符號引用驗證,確認自己是否擁有對方方法的訪問權限。
那麼你就需要找到目標類的類信息存放地址,確認方法權限,或者字段權限。
於是會在這裏觸發字段解析、類方法解析或者接口解析!

書上只提到了這3個解析過程的流程,卻沒有詳細解釋其中的一些緣由,我會做更詳細的補充。

符號引用驗證中的字段解析

class中的constant_filed_info終於露出了它的真面目,原來是用在這個地方,即和字段相關的指令會用到它,並通過字段符號引用, 解析到這個字段真正的定義位置。


像經常遇到的NoSuchFieldError報錯,就是在這個過程中爆出來的。而且接口字段的優先級是大於父類的字段的。

符號引用驗證中的類方法解析

當調用方法前,需要先確認對象方法是否有權限訪問。那麼就必須這個類的信息進行確認。

注意:這個過程並不是動態分派的那個過程,此刻並沒有觸發任何的方法調用!僅僅是確認代碼中靜態類型的訪問權限是否正確之類的!

  • 對類方法做解析的時候,會判斷此時是類還是接口。如果是接口,竟然會報“IncompatibleClassChangeError”。
  • 還有如果是抽象類,也會報“AbstractMethodError”,因為正常情況下,你的jvm指令調用的方法,必須是實例化的對象所對應的方法,不可能直接調用抽象類方法的。

符號引用驗證中的接口方法解析

看起來像是將類方法解析中的接口和方法互換了位置。

疑問1:為什麼接口方法還要解析?接口不是沒有代碼嗎?

因為接口類裏每個interface方法,本身也是一個方法,只不過沒有詳細的code屬性。但方法的訪問修飾符之類的都存在,因此驗證階段還是需要進行校驗。

疑問2:為什麼要區分類的方法和接口方法?不能用同一種思路去解析麼?

我理解的幾個原因:

  1. 向上搜索時的邏輯不同,對於類方法,直接找父類即可, 而接口則需要遍歷所有父接口。而且類方法還要考慮抽象類的問題,接口不需要。
  2. 類方法和接口方法本身就是兩個不同的符號引用, 一個是constant_method_ref,另一個是constant_interface_ref,用2套邏輯沒什麼毛病
  3. 如果硬要問為什麼要區分這2個符號引用,明明內容都是類索引+描述符索引?
    這是因為後面在實際調用方法時,二者有顯著區別,具體見下文的“方法表的準備”。

準備

類靜態成員默認值的準備

對於準備階段,大家一般只記得需要對一些非final的類靜態成員做默認初始值操作。

方法表的準備

除了這個默認值賦值,還有一個動作,是準備方法表。

方法表就是為了多態而生,簡化動態分派時頻繁的迭代循環帶來的不必要消耗:

通過前面的驗證過程,我們已經獲知了父類信息。因此可以準備一個方法表,把父類方法堆到最前面,自己的方法堆到後面,後面直接根據索引獲取方法調用地址即可!

重要問題:interface的接口方法,會有方法表嗎?

intefacer接口是不具有方法表的!
因此這可能也是jvm特地區分了class_inteface_info和class_method_info這2個常量,以及特地用invoke_inteface和invoke_virtual指令來區分2類方法的調用。因為他們的調用邏輯可能大相徑庭。

為什麼接口不能有方法表?

這是由於Java可以實現多個接口,不同的類可能會實現了多個或者不同的接口,在虛表裏該接口所實現方法的索引會不一致。

假設有A、B、C三個接口類

  • 類X實現了A、B兩個接口,假設A和B接口放在虛表裏,那麼調用A接口方法我們假設它是在t位置。
  • 類T實現了B、C、A接口,按照實現順序,先放B的方法,再放A的方法,最後放C的方法。這樣調用接口A時,就不一定是t位置了,我們無法直接確定A裏面方法的位置,因為一個類可以實現多個接口,而且順序可以隨意更改!

這樣每次解析的虛表索引都可能會不同,因此不能進行緩存,需要每次都進行重新的解析。因此,接口的方法調用會比普通的子類繼承的虛函數調用要慢。

解析

解析其實分為“靜態解析”和“動態解析”。
因此將解析説成是“連接”中的一部分是不嚴謹的, 只有靜態解析,才是“連接”的一部分。
靜態解析用於解析私有方法、父類構造器、final方法等不存在多態可能的方法。

而動態解析則會在類加載的範圍外去使用。

初始化

cinit方法細節解析

關於初始化時機的解釋,在開頭就已經闡述過了,這裏不再重複解釋。

疑問1:cinit方法中的代碼是如何生成的?

cinit方法 是編譯器收集所有類靜態變量的賦值動作和靜態語句塊static{}中的語句合併產生,按照順序收集。
因此類加載賦值的順序和類定義順序有關,原理就取決於cinit生成的原理。

疑問2:cinit類初始化是線程安全的嗎?

是線程安全的,虛擬機會保證一個類的加載和cinit方法會被正確的加鎖、同步。

因此多線程場景下,同時使用一個之前沒初始化過的類,且類初始化過程耗時非常久的話, 且可能會造成線程阻塞。

而這也是可以利用類初始化+內部類的方式,來做單例模式的實現的原理:

初始化中的動態解析

而初始化過程中,可能會涉及其他對象實例方法的調用,因此是可能發生動態解析過程的!
類方法和接口方法的解析過程如下
類方法的解析可以藉助虛方法表簡化解析過程。

擴展:invoke_dynamic是什麼

對於invoke_dynamic指令做什麼的?涉及動態分派、類加載和解析嗎?

我們首先看下invoke_dynamic指令調用的dynamic_info常量長什麼樣的:

可以看到它只包含了一個方法索引和描述,但似乎沒包含方法屬於哪個類。

它的作用是用java實現一些類似於腳本語言的邏輯,腳本語言不關心靜態類型,不做編譯檢查,只關心運行期的內容。所以invoke_dynamic以及constant_dynamic_info應運而生。但書本和工作中對這塊的接觸都不是太深,因此我的理解也只能侷限於此了。

最後的完整大圖

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

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

圖解筆記系列也會持續更新下去,爭取做全網最細又最大的java分享文章。在線地址:https://www.processon.com/view/link/5e7eed6ce4b0ffc4ad43fda8

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

 

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