手繪圖解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分享文章。線上地址:http://www.processon.com/view/link/5e7eed6ce4b0ffc4ad43fda8

歡迎點選該連結報名參加讀書會,一起成長學習和交流!報名連結

 

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