本文基于周志明的《深入理解java虛擬機 JVM高級特性與最佳實踐》所寫。特此推薦。
類加載的時機
類的生命周期包括了:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(using)、和卸載(Unloading)七個階段。其中驗證、準備和解析三個部分統稱為連接(Linking),這七個階段的發生順序如下圖所示:
如上圖所示,加載、驗證、準備、初始化和卸載這五個階段的順序是確定的,類的加載過程必須按照這個順序來按部就班地開始,而解析階段則不一定,它在某些情況下可以在初始化階段后再開始。類的生命周期的每一個階段通常都是互相交叉混合式進行的,通常會在一個階段執行的過程中調用或激活另外一個階段。
虛擬機規范嚴格規定了有且只有5種情況必需立即對類進行“初始化”(而加載、驗證、準備階段則必需在此之前開始):
- 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令最常見的Java代碼場景是:使用new關鍵字實例化對象時、讀取或者設置一個類的靜態字段(被final修飾、已在編譯器把結果放入常量池的靜態字段除外)時、以及調用一個類的靜態方法的時候。
- 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
- 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要觸發父類的初始化。
- 當虛擬機啟動時,用戶需要指定一個執行的主類(包含main()方法的類),虛擬機會先初始化這個類。
- 當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發氣初始化。
類加載的過程
全過程:加載、驗證、準備、解析、初始化。
加載
在加載階段,虛擬機需要完成以下三件事情:
- 通過一個類的權限定名稱來獲取定義此類的二進制字節流。
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
- 在java堆中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。
相對于類加載過程的其他階段,一個非數組類加載階段是開發人員可控性最強的,該階段既可以使用系統提供的類加載器完成,也可以由用戶自定義的類加載器來完成,開發人員可以通過定義自己的類加載器去控制字節流的獲取方式。
對于數組類而言,數組類本身不通過類加載器創建,由java虛擬機直接創建的。
驗證
這一階段的目的是為了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。
不同的虛擬機對類驗證的實現可能會有所不同,但大致上都會完成下面四個階段的檢驗過程:文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證。
- 文件格式驗證:該階段主要是驗證字節流是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理。
- 元數據驗證:這一階段主要是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規范的要求。
- 字節碼驗證:主要工作是通過數據流和控制流分析,確定語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗后,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的行為。
- 符號引用驗證:主要是在虛擬機將符號引用轉化為直接引用的時候進行校驗,這個轉化動作是發生在解析階段。符號引用可以看做是對類自身以外(常量池的各種符號引用)的信息進行匹配性的校驗。
準備
準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些內存都將在方法區中進行分配。這個時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨著對象一起被分配在Java堆中。這里所說的初始值“通常情況”下是數據類型的零值。
解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。
- 符號引用(Symbolic Reference):符號引用以一組符號來描述所引用的目標,符號引用可以是任何形式的字面量,符號引用與虛擬機實現的內存布局無關,引用的目標并不一定已經在內存中。
- 直接引用(Direct Reference):直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機實現的內存布局相關的,同一個符號引用在不同的虛擬機實例上翻譯出來的直接引用一般都不相同,如果有了直接引用,那引用的目標必定已經在內存中存在。
對于同一個符號引用可能會出現多次解析,虛擬機可能會對第一次解析的結果進行緩存。 解析動作分為四類:包括類或接口的解析、字段解析、類方法解析、接口方法解析。
初始化
類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了加載(Loading)階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼。初始化階段是執行類構造器<clinit>()方法的過程。以下是它的生成步驟:
- <clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合并產生的,編譯器收集的順序由語句在源文件中出現的順序所決定。
- <clinit>()方法與類的構造函數不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢,因此在虛擬機中第一個執行的<clinit>()方法的類一定是java.lang.Object。
- 由于父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先于子類的變量賦值操作。
- <clinit>()方法對于類或者接口來說并不是必需的,如果一個類中沒有靜態語句塊也沒有對變量的賦值操作,那么編譯器可以不為這個類生成<clinit>()方法。
- 接口中可能會有變量賦值操作,因此接口也會生成<clinit>()方法。但是接口與類不同,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現類在初始化時也不會執行接口的<clinit>()方法。
- 虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖和同步。如果有多個線程去同時初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法,其它線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,那么就可能造成多個進程阻塞。
類加載器
在類加載階段,有一步是“通過類的全限定名來獲取描述此類的二進制字節流”,而所謂的類加載器就是實現這個功能的一個代碼模塊,這個動作是在Java虛擬機外部實現的,這樣做可以讓應用程序自己決定如何去獲取所需要的類。
對于任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機中唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。通俗的講:比較兩個類是否“相等”,只有這兩個類是由同一個類加載器加載才有意義。
雙親委派模型
從虛擬機的角度來說,只存在兩種不同的類加載器:一種是啟動類加載器(Bootstrap ClassLoader),該類加載器使用C++語言實現,屬于虛擬機自身的一部分。另外一種就是所有其它的類加載器,這些類加載器是由Java語言實現,獨立于JVM外部,并且全部繼承自抽象類java.lang.ClassLoader。
從Java開發人員的角度來看,大部分Java程序一般會使用到以下三種系統提供的類加載器:
- 啟動類加載器(Bootstrap ClassLoader):負責加載JAVA_HOME\lib目錄中的,或者被-Xbootclasspath參數所指定的路徑中的,并且能被虛擬機識別的類庫加載到JVM內存中,如果名稱不符合的類庫即使放在lib目錄中也不會被加載。該類加載器無法被Java程序直接引用。用戶編寫自定義加載器時,如果需要把加載請求委派給引導類加載器,直接使用null即可。
- 擴展類加載器(Extension ClassLoader):該加載器主要是負責加載JAVA_HOME\lib\ext目錄中的,或者被java.ext.dirs系統變量所指定的路徑中的所有類庫。該加載器可以被開發者直接使用。
- 應用程序類加載器(Application ClassLoader):該類加載器也稱為系統類加載器,它負責加載用戶類路徑(Classpath)上所指定的類庫,開發者可以直接使用該類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。
我們的應用程序都是由這三類加載器互相配合進行加載的,我們也可以加入自己定義的類加載器。這些類加載器之間的關系如下圖所示:
如上圖所示的類加載器之間的這種層次關系,就稱為類加載器的雙親委派模型(Parent Delegation Model)。該模型要求除了頂層的啟動類加載器外,其余的類加載器都應當有自己的父類加載器。子類加載器和父類加載器不是以繼承(Inheritance)的關系來實現,而是通過組合(Composition)關系來復用父加載器的代碼。
雙親委派模型的工作過程為:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的加載器都是如此,因此所有的類加載請求都會傳給頂層的啟動類加載器,只有當父加載器反饋自己無法完成該加載請求(該加載器的搜索范圍中沒有找到對應的類)時,子加載器才會嘗試自己去加載。