你真的懂「類的加載機制」嗎?

作者簡介

高廣超 :多年一線互聯網研發與架構設計經驗,擅長設計與落地高可用、高性能互聯網架構。目前就職於美團網,負責核心業務研發工作。本文首發在 高廣超的簡書博客,歡迎點擊閱讀原文關注作者博客。

正文

Java虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是虛擬機的加載機制。

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括了:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(using)、和卸載(Unloading)七個階段。其中驗證、準備和解析三個部分統稱爲連接(Linking),這七個階段的發生順序如下圖所示:

如上圖所示,加載、驗證、準備、初始化和卸載這五個階段的順序是確定的,類的加載過程必須按照這個順序來按部就班地開始,而解析階段則不一定,它在某些情況下可以在初始化階段後再開始。

類的生命週期的每一個階段通常都是互相交叉混合式進行的,通常會在一個階段執行的過程中調用或激活另外一個階段。

1 類加載的時機

主動引用

一個類被主動引用之後會觸發初始化過程(加載,驗證,準備需再此之前開始)

  • 1)遇到new、get static、put static或invoke static這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令最常見的Java代碼場景是:使用new關鍵字實例化對象時、讀取或者設置一個類的靜態字段(被final修飾、已在編譯器把結果放入常量池的靜態字段除外)時、以及調用一個類的靜態方法的時候。
  • 2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  • 3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要觸發父類的初始化。
  • 4)當虛擬機啓動時,用戶需要指定一個執行的主類(包含main()方法的類),虛擬機會先初始化這個類。
  • 5)當使用jdk7+的動態語言支持時,如果java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發器 初始化。

被動引用

一個類如果是被動引用的話,該類不會觸發初始化過程

  • 1)通過子類引用父類的靜態字段,不會導致子類初始化。對於靜態字段,只有直接定義該字段的類纔會被初始化,因此當我們通過子類來引用父類中定義的靜態字段時,只會觸發父類的初始化,而不會觸發子類的初始化。
  • 2)通過數組定義來引用類,不會觸發此類的初始化。
  • 3)常量在編譯階段會存入調用類的常量池中,本質上沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。

2 類加載過程

1、加載

在加載階段,虛擬機需要完成以下三件事情:

  • 1)通過一個類的全限定名稱來獲取定義此類的二進制字節流。
  • 2)將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
  • 3)在java堆中生成一個代表這個類的java.lang.Class對象,作爲方法區這些數據的訪問入口。 相對於類加載過程的其他階段,加載階段是開發期相對來說可控性比較強,該階段既可以使用系統提供的類加載器完成,也可以由用戶自定義的類加載器來完成,開發人員可以通過定義自己的類加載器去控制字節流的獲取方式。

2、驗證

驗證的目的是爲了確保Class文件中的字節流包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。不同的虛擬機對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:文件格式的驗證、元數據的驗證、字節碼驗證和符號引用驗證。

  • 1)文件格式的驗證:驗證字節流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理,該驗證的主要目的是保證輸入的字節流能正確地解析並存儲 於方法區之內。經過該階段的驗證後,字節流纔會進入內存的方法區中進行存儲,後面的三個驗證都是基於方法區的存儲結構進行的。
  • 2)元數據驗證:對類的元數據信息進行語義校驗(其實就是對類中的各數據類型進行語法校驗),保證不存在不符合Java語法規範的元數據信息。
  • 3)字節碼驗證:該階段驗證的主要工作是進行數據流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在運行時不會做出危害虛擬機安全的行爲。
  • 4)符號引用驗證:這是最後一個階段的驗證,它發生在虛擬機將符號引用轉化爲直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身以外的信息(常量池中的各種符號引用)進行匹配性的校驗。

3、準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些內存都將在方法區中進行分配。

注:

  • 1)這時候進行內存分配的僅包括類變量(static),而不包括實例變量,實例變量會在對象實例化時隨着對象一塊分配在Java堆中。
  • 2)這裏所設置的初始值通常情況下是數據類型默認的零值(如0、0L、、false等),而不是被在Java代碼中被顯式地賦予的值。

4、解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。

符號引用(Symbolic Reference):

符號引用以一組符號來描述所引用的目標,符號引用可以是任何形式的字面量,符號引用與虛擬機實現的內存佈局無關,引用的目標並不一定已經在內存中。

直接引用(Direct Reference):

直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機實現的內存佈局相關的,同一個符號引用在不同的虛擬機實例上翻譯出來的直接引用一般都不相同,如果有了直接引用,那引用的目標必定已經在內存中存在。

  • 1)類或接口的解析:判斷所要轉化成的直接引用是對數組類型,還是普通的對象類型的引用,從而進行不同的解析。
  • 2)字段解析:對字段進行解析時,會先在本類中查找是否包含有簡單名稱和字段描述符都與目標相匹配的字段,如果有,則查找結束;如果沒有,則會按照繼承關係從上往下遞歸搜索該類所實現的各個接口和它們的父接口,還沒有,則按照繼承關係從上往下遞歸搜索其父類,直至查找結束。
  • 3)類方法解析:對類方法的解析與對字段解析的搜索步驟差不多,只是多了判斷該方法所處的是類還是接口的步驟,而且對類方法的匹配搜索,是先搜索父類,再搜索接口。
  • 4)接口方法解析:與類方法解析步驟類似,只是接口不會有父類,因此,只遞歸向上搜索父接口就行了。

5、初始化

類初始化階段是類加載過程的最後一步,前面的類加載過程中,除了加載(Loading)階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼。

初始化階段是執行類構造器<clinit>方法的過程。

  • 1)<clinit>方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序由語句在源文件中出現的順序所決定。
  • 2)<clinit>方法與類的構造函數不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>方法執行之前,父類的<clinit>方法已經執行完畢,因此在虛擬機中第一個執行的<clinit>方法的類一定是java.lang.Object。
  • 3)由於父類的<clinit>方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作。
  • 4)<clinit>方法對於類或者接口來說並不是必需的,如果一個類中沒有靜態語句塊也沒有對變量的賦值操作,那麼編譯器可以不爲這個類生成<clinit>方法。
  • 5)接口中可能會有變量賦值操作,因此接口也會生成<clinit>方法。但是接口與類不同,執行接口的<clinit>方法不需要先執行父接口的<clinit>方法。只有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現類在初始化時也不會執行接口的<clinit>方法。
  • 6)虛擬機會保證一個類的<clinit>方法在多線程環境中被正確地加鎖和同步。如果有多個線程去同時初始化一個類,那麼只會有一個線程去執行這個類的<clinit>方法,其它線程都需要阻塞等待,直到活動線程執行<clinit>方法完畢。如果在一個類的<clinit>方法中有耗時很長的操作,那麼就可能造成多個進程阻塞。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章