JVM学习-类加载机制
文章原文:https://gaoyubo.cn/blogs/4b481fd7.html
一、類加載機制
在JVM學習-Class文件結構中,講了Class文件存儲格式的具體細節。雖然Class文件中描述了各種類信息,但要讓這些信息在虛擬機中運行和使用,就需要加載到內存中。本章將重點介紹虛擬機的類加載機制,包括Class文件如何加載到內存、加載后的信息發生何種變化等方面的內容。
Java虛擬機通過將描述類的數據從Class文件加載到內存中,進行校驗、轉換解析和初始化,最終生成可以被虛擬機直接使用的Java類型。這一過程即為虛擬機的類加載機制。與那些在編譯時需要進行連接的語言不同,Java語言中類型的加載、連接和初始化過程都在程序運行期間完成。盡管這種策略可能導致編譯時的一些困難和類加載時的性能開銷略微增加,但它為Java應用程序提供了極高的擴展性和靈活性。Java天生支持動態擴展的語言特性依賴于運行時的動態加載和動態連接。
例如,編寫一個面向接口的應用程序,可以在運行時指定其實際的實現類。用戶可以通過Java預置的或自定義的類加載器,在運行時從網絡或其他位置加載一個二進制流作為程序代碼的一部分。這種動態組裝應用的方式已廣泛應用于Java程序,涵蓋了從基礎的Applet、JSP到相對復雜的OSGi技術。這一創新的方法使得Java語言能夠適應多樣化的應用需求。
二、類加載時機
一個類型從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期將會經歷加載 (Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、準備、解析三個部分統稱為連接(Linking)。這七個階段的發生順序如下:
類加載過程包括加載、驗證、準備、初始化和卸載這五個階段。這些階段的順序是確定的,必須按部就班地開始,而解析階段則不一定。解析階段在某些情況下可以在初始化階段之后再開始,以支持Java語言的運行時綁定特性(動態綁定或晚期綁定)。值得注意的是,這些階段通常是互相交叉地混合進行的,在一個階段執行的過程中可能調用、激活另一個階段。
關于何時需要開始類加載過程的第一個階段“加載”,《Java虛擬機規范》中并沒有強制約束,這點可以由虛擬機的具體實現*把握。然而,在初始化階段,《Java虛擬機規范》明確規定了六種情況必須立即對類進行“初始化”(加載、驗證、準備自然需要在此之前開始):
- 遇到
new、getstatic、putstatic或invokestatic這四條字節碼指令時,如果類型沒有進行過初始化,則需要先觸發其初始化階段。典型的Java代碼場景包括:- 使用
new關鍵字實例化對象。 - 讀取或設置一個類型的靜態字段(被
final修飾、已在編譯期把結果放入常量池的靜態字段除外)。 - 調用一個類型的靜態方法。
- 使用
- 使用
java.lang.reflect包的方法對類型進行反射調用的時候,如果類型沒有進行過初始化,則需要先觸發其初始化。 - 當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含
main()方法的那個類),虛擬機會先初始化這個主類。 - 當使用JDK 7新加入的動態語言支持時,如果一個
java.lang.invoke.MethodHandle實例最后的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句柄,并且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。 - 當一個接口中定義了JDK 8新加入的默認方法(被
default關鍵字修飾的接口方法)時,如果有這個接口的實現類發生了初始化,那該接口要在其之前被初始化。
這六種會觸發類型進行初始化的場景被稱為主動引用。除此之外,所有引用類型的方式都不會觸發初始化,稱為被動引用。
被動引用
示例一
package algorithmAnalysis;
/**
* 被動使用類字段演示一:
* 通過子類引用父類的靜態字段,不會導致子類初始化
**/
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
/**
* 非主動使用類字段演示
**/
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
上述代碼中,運行后只會輸出“SuperClass init!”而不會輸出“SubClass init!”。
這是因為對于靜態字段,只有直接定義這個字段的類才會被初始化。通過子類引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。至于是否要觸發子類的加載和驗證階段,在《Java虛擬機規范》中并未明確規定,因此這一點取決于虛擬機的具體實現。在HotSpot虛擬機中,可以通過添加參數-XX:+TraceClassLoading觀察到這個操作會導致子類加載,輸出結果如下。
示例二
package algorithmAnalysis;
/**
* 通過數組定義來引用類,不會觸發此類的初始化
**/
public class Test {
public static void main(String[] args) {
SuperClass[] superClasses = new SuperClass[10];
}
}
這段代碼復用了示例一的SuperClass,運行之后發現沒有輸出“SuperClass init!”,說明并沒有觸發類algorithmAnalysis.SuperClass的初始化階段。但是這段代碼里面觸發了另一個名為“[LalgorithmAnalysis.SuperClass”的類的初始化階段。
對于用戶代碼來說,這并不是一個合法的類型名稱,它是一個由虛擬機自動生成的、直接繼承于java.lang.Object的子類,創建動作由
字節碼指令newarray觸發。
這個類代表了一個元素類型為algorithmAnalysis.SuperClass的一維數組,數組中應有的屬性 和方法(用戶可直接使用的只有被修飾為public的length屬性和clone()方法)都實現在這個類里。
Java語 言中對數組的訪問要比C/C++相對安全,很大程度上就是因為這個類包裝了數組元素的訪問(準確地說,越界檢查不是封裝在數組元素訪問的類中,而是封裝在數組訪問的xaload、xastore字節 碼指令中),而 C/C++中則是直接翻譯為對數組指針的移動。在Java語言里,當檢查到發生數組越界時會拋出
java.lang.ArrayIndexOutOfBoundsException異常,避免了直接造成非法內存訪問。
三、類加載過程
3.1加載
在加載階段,Java虛擬機執行以下三個主要任務:
- 獲取二進制字節流: 通過類的全限定名獲取對應的二進制字節流,這是表示類的靜態存儲結構的基礎。
- 轉化為方法區數據結構: 將獲取的字節流表示的靜態存儲結構轉化為方法區的運行時數據結構。這包括對類的字段、方法、接口等信息的整理和組織。
-
生成Class對象: 在內存中創建一個
java.lang.Class對象,用于在方法區中訪問該類的各種數據。這個Class對象是對類的抽象,通過它可以獲取類的各種信息。
《Java虛擬機規范》確實在對類加載的過程中給予了相當大的靈活性,沒有強制指定二進制字節流必須從Class文件中獲取,這為Java虛擬機的實現和應用帶來了廣泛的適用性和可擴展性。開發人員在這個靈活的舞臺上發揮了巨大的創造力,導致了許多重要的Java技術的誕生。以下是一些典型的應用場景:
- 從ZIP壓縮包中讀取: 這為日后JAR、EAR、WAR等格式的應用打下了基礎,這些格式在Java應用中廣泛使用,提供了一種方便的打包和分發方式。
- 從網絡中獲?。?/strong> Web Applet是一個典型的應用場景,它允許在Web瀏覽器中加載并執行Java小程序,通過網絡獲取字節流。
- 運行時計算生成: 動態代理技術是一個重要的應用,它允許在運行時生成代理類的字節流,用于實現動態代理。
- 由其他文件生成: JSP應用是一個例子,其中JSP文件會在運行時被編譯成對應的Class文件,實現了動態生成和加載。
- 從數據庫中讀取: 在一些中間件服務器中,程序代碼可以安裝到數據庫中,通過加載時從數據庫獲取相應的字節流,實現了在集群間的分發。
- 從加密文件中獲?。?/strong> 采用加載時解密Class文件的方式,可以作為一種保護措施,防止Class文件被反編譯。
加載階段相對于類加載過程的其他階段具有更高的可控性,尤其是在非數組類型的加載階段。在這個階段,開發人員可以通過以下方式靈活控制:
-
選擇類加載器: 開發人員可以選擇使用Java虛擬機內置的引導類加載器或自定義的類加載器來完成加載階段。通過自定義類加載器,可以根據需求控制字節流的獲取方式,例如重寫類加載器的
findClass()或loadClass()方法。 - 動態獲取字節流: 在加載階段,開發人員可以通過自定義類加載器來動態獲取類的二進制字節流。這為應用程序提供了獲取運行代碼的動態性,開發人員可以根據自己的需求實現字節流的獲取邏輯。
對于數組類的加載,雖然數組類本身是由Java虛擬機直接在內存中動態構造的,但與類加載器仍然存在密切關系。數組類的創建遵循以下規則:
- 如果數組的組件類型是引用類型,遞歸采用加載過程加載組件類型,數組類將被標識在加載該組件類型的類加載器的類名稱空間上。
- 如果數組的組件類型不是引用類型,數組類將被標記為與引導類加載器關聯。
此外,數組類的可訪問性與其組件類型的可訪問性一致。如果組件類型不是引用類型,數組類的可訪問性默認為public,可被所有的類和接口訪問。
加載階段結束后,二進制字節流按虛擬機設定的格式存儲在方法區中。在方法區中,類型數據會被實例化為一個java.lang.Class對象,這個對象作為程序訪問方法區中類型數據的外部接口。
需要注意的是,加載階段與連接階段的一些動作是交叉進行的,加載階段尚未完成,連接階段可能已經開始。這兩個階段的開始時間保持著固定的先后順序。
3.2驗證
Java語言本身具有相對較高的安全性,相比于C/C++等語言來說更為安全。使用純粹的Java代碼通常無法執行一些危險操作,比如訪問數組邊界以外的數據、將對象轉型為其未實現的類型、跳轉到不存在的代碼行等。在這些情況下,編譯器會嚴格拋出異常并拒絕編譯。
然而,需要注意的是,Class文件并不一定只能由Java源碼編譯而來。任何途徑產生的Class文件,包括直接在二進制編輯器中編寫0和1的方式,都是有效的。因此,驗證字節碼是Java虛擬機保護自身安全的必要措施。
驗證階段在整個類加載過程中具有重要意義,其嚴謹程度直接影響Java虛擬機是否能夠抵御惡意代碼攻擊。驗證階段涵蓋了文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證等四個主要方面。
- 文件格式驗證: 驗證Class文件是否符合Java虛擬機規定的文件格式標準。
- 元數據驗證: 確保類的元數據信息符合規范,包括類的繼承關系、字段和方法的聲明等。
- 字節碼驗證: 對字節碼進行驗證,防止惡意代碼通過字節碼漏洞對系統進行攻擊。
- 符號引用驗證: 確保類在運行時能夠正確鏈接到其他類,并且這些類存在并具有正確的權限。
驗證階段的工作量相當大,涉及到整個類加載過程的安全性和性能。因此,它是保障Java應用程序安全執行的關鍵環節。
文件格式驗證
在驗證階段的第一階段,主要任務是驗證字節流是否符合Class文件格式的規范,并且能夠被當前版本的虛擬機正確處理。以下是包含在這一階段的驗證點:
- 魔數驗證: 檢查Class文件是否以魔數0xCAFEBABE開頭,這是Java Class文件的標識。
- 版本號驗證: 確保主版本號和次版本號是否在當前Java虛擬機接受的范圍之內。
- 常量池驗證: 檢查常量池中的常量類型是否被當前虛擬機支持,包括檢查常量的tag標志。
- 索引值驗證: 確保指向常量的各種索引值沒有指向不存在的常量,而且索引值的類型符合常量的類型。
- UTF-8編碼驗證: 對于CONSTANT_Utf8_info型的常量,檢查其中的數據是否符合UTF-8編碼規范。
- 文件結構驗證: 確保Class文件中各個部分以及文件本身沒有被刪除的或附加的其他信息,保持結構的完整性。
元數據驗證
在驗證階段的第二階段,主要任務是對字節碼描述的信息進行語義分析,以確保其描述的信息符合《Java語言規范》的要求。以下是包含在這一階段的驗證點:
-
父類驗證: 確保每個類除了
java.lang.Object之外都應該有父類。 -
繼承驗證: 檢查父類是否繼承了不允許被繼承的類,即被
final修飾的類。 - 接口實現驗證: 如果一個類不是抽象類,確保它實現了其父類或接口中要求實現的所有方法。
-
字段和方法驗證: 檢查類中的字段和方法是否與父類產生矛盾,例如覆蓋了父類的
final字段,或者出現不符合規則的方法重載(方法參數一致但返回值類型不同等)。
這些驗證點旨在對類的元數據信息進行語義校驗,以確保它們符合Java語言規范的定義。
字節碼驗證
在驗證階段的第三階段,通過數據流分析和控制流分析,目標是確定程序語義是合法的、符合邏輯的。在進行方法體的校驗分析時,主要考慮以下驗證點:
- 操作數棧和指令代碼協同工作: 確保任何時刻操作數棧的數據類型與指令代碼序列配合工作,防止出現操作棧放置了一個數據類型,使用時按不同類型加載入本地變量表的情況。
- 跳轉指令的合法性: 保證任何跳轉指令都不會跳轉到方法體以外的字節碼指令。
- 類型轉換的有效性: 確保方法體中的類型轉換總是有效的,例如可以將子類對象賦值給父類數據類型,而將父類對象賦值給子類數據類型是危險和不合法的。
為了降低在字節碼驗證階段中的執行時間開銷,Java虛擬機設計團隊采用了聯合優化策略。該策略在JDK 6之后實施,主要包括在Javac編譯器中增加了校驗輔助措施,并通過引入名為
StackMapTable的新屬性來描述方法體的基本塊狀態。這一策略的核心思想是通過在編譯期執行盡可能多的校驗輔助措施,從而減輕字節碼驗證期間的負擔。具體而言:
- Javac編譯器中的校驗輔助措施: Javac編譯器在編譯期執行一系列校驗輔助措施,以便在方法體的Code屬性中引入StackMapTable屬性。這樣,編譯器在校驗階段就能夠提供關于基本塊狀態的信息,減輕虛擬機在字節碼驗證期間的工作。
- StackMapTable屬性的引入: StackMapTable屬性是一項新的屬性,用于描述方法體的基本塊狀態。這個屬性記錄了基本塊開始時本地變量表和操作棧應有的狀態。虛擬機在字節碼驗證期間只需檢查StackMapTable屬性中的記錄是否合法,而無需推導這些狀態的合法性,從而減少了驗證的時間開銷。
符號引用驗證
在類加載的最后一個階段,校驗行為發生在虛擬機將符號引用轉化為直接引用的時候,即發生在連接的第三階段——解析階段中。符號引用驗證旨在匹配類自身以外的各類信息,確保類能夠正常訪問其依賴的外部類、方法、字段等資源。此階段通常需要校驗以下內容:
- 符號引用中通過字符串描述的全限定名是否能找到對應的類。
- 指定類中是否存在符合方法的字段描述符及簡單名稱所描述的方法和字段。
- 符號引用中的類、字段、方法的可訪問性(private、protected、public、
)是否可被當前類訪問。
符號引用驗證的主要目的是確保解析行為能夠正常執行。如果無法通過符號引用驗證,Java虛擬機將會拋出一個java.lang.IncompatibleClassChangeError的子類異常,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
驗證階段對于虛擬機的類加載機制是重要但非強制執行的階段,因為驗證階段只有通過或不通過的差別。一旦通過了驗證,其后對程序運行期沒有任何影響。
如果程序運行的全部代碼(包括自己編寫的、第三方包中的、從外部加載的、動態生成的等所有代碼)都經過了反復使用和驗證,那么在生產環境的實施階段可以考慮使用
-Xverify:none參數關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
3.3準備
準備階段是正式為類中定義的變量(即靜態變量,被static修飾的變量)分配內存并設置類變量初始值的階段
這時候進行內存分配的僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中。
初始值通常情況下是數據類型的零值:
-
public static int value = 123;- 準備后為 0,value 的賦值指令 putstatic 會被放在
<clinit>()方法中,<clinit>()方法會在初始化時執行,也就是說,value 變量只有在初始化后才等于 123。
- 準備后為 0,value 的賦值指令 putstatic 會被放在
如果類字段的字段屬性表中存在ConstantValue屬性,那在準備階段變量值就會被初始化為ConstantValue屬性所指定
的初始值:
-
public static final int value = 123;()- 準備后為 123,因為被
static final賦值之后 value 就不能再修改了,所以在這里進行了賦值之后,之后不可能再出現賦值操作,所以可以直接在準備階段就把 value 的值初始化好。
- 準備后為 123,因為被
3.4解析
解析階段是Java虛擬機將常量池內的符號引用替換為直接引用的過程,JVM學習-Class文件結構-符號引用
- 在此之前,常量池中的引用是不一定存在的,解析過之后,可以保證常量池中的引用在內存中一定存在。
- 什么是 “符號引用” 和 “直接引用” ?
- 符號引用:以一組符號描述所引用的對象(如對象的全類名),引用的目標不一定存在于內存中。
- 直接引用:直接指向被引用目標在內存中的位置的指針等,也就是說,引用的目標一定存在于內存中。
對同一個符號引用進行多次解析請求是很常見的事情,除invokedynamic指令以外,虛擬機實現可以對第一次解析的結果進行緩存,譬如在運行時直接引用常量池中的記錄,并把常量標識為已解析狀態,從而避免解析動作重復進行。
invokedynamic指令的目的本來就是用于動態語言支持,它對應的引用稱為“動態調用點限定符 (Dynamically-Computed Call Site Specifier)”,這里“動態”的含義是指必須等到程序實際運行到這條指令時,解析動作才能進行。
相對地,其余可觸發解析的指令都是“靜態”的,可以在剛剛完成加載階段,還沒有開始執行代碼時就提前進行解析。
類或接口的解析
假設當前代碼所處的類為D,解析一個從未解析過的符號引用N為一個類或接口C的直接引用通常涉及以下三個步驟:
- 加載類C: 如果C不是一個數組類型,虛擬機將把代表N的全限定名傳遞給當前類D的類加載器,以加載這個類C。在加載的過程中,可能會觸發元數據驗證和字節碼驗證,也可能導致其他相關類的加載,例如加載C的父類或實現的接口。如果加載過程中出現異常,解析過程失敗。
- 加載數組元素類型(如果C是數組類型): 如果C是一個數組類型,而且數組的元素類型是對象類型,那么N的描述符將是類似于"[Ljava/lang/Integer"的形式。虛擬機將按照第一步的規則加載數組元素類型。如果N的描述符是類似于"java.lang.Integer"的形式,虛擬機生成一個代表該數組維度和元素類型的數組對象。
-
符號引用驗證和訪問權限檢查: 如果前兩步沒有異常,那么C在虛擬機中已經成為一個有效的類或接口。在解析完成前,需要進行符號引用驗證,以確認當前類D是否具有對C的訪問權限。如果訪問權限驗證失敗,將拋出
java.lang.IllegalAccessError異常。在JDK 9及之后的版本中,需要考慮模塊化的因素,即訪問權限驗證還需檢查模塊之間的訪問權限。具體來說,一個D要訪問C,至少滿足以下三條規則之一:- 被訪問的類C是
public的,并且與訪問類D處于同一個模塊。 - 被訪問的類C是
public的,不與訪問類D處于同一個模塊,但是被訪問類C的模塊允許被訪問類D的模塊進行訪問。 - 被訪問的類C不是
public的,但是它與訪問類D處于同一個包中。
- 被訪問的類C是
字段解析
要解析一個未被解析過的字段符號引用,通常需要按照以下步驟進行:
-
解析類或接口符號引用: 對字段表內
class_index項(class_index)中索引的CONSTANT_Class_info符號引用進行解析,即解析字段所屬的類或接口的符號引用。如果在解析這個類或接口符號引用的過程中出現異常,導致字段符號引用解析失敗。 -
后續字段搜索: 如果類或接口符號引用解析成功,用C表示這個字段所屬的類或接口。按照《Java虛擬機規范》的規定,對C進行后續字段搜索:
- 如果C本身包含了簡單名稱和字段描述符都與目標相匹配的字段,返回這個字段的直接引用,搜索結束。
- 否則,如果在C中實現了接口,按照繼承關系從下往上遞歸搜索各個接口和它們的父接口,如果接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,返回這個字段的直接引用,搜索結束。
- 否則,如果C不是
java.lang.Object,按照繼承關系從下往上遞歸搜索其父類,如果在父類中包含了簡單名稱和字段描述符都與目標相匹配的字段,返回這個字段的直接引用,搜索結束。 - 否則,查找失敗,拋出
java.lang.NoSuchFieldError異常。
-
權限驗證: 如果查找成功返回了引用,對這個字段進行權限驗證。如果發現不具備對字段的訪問權限,拋出
java.lang.IllegalAccessError異常。
解析規則確保Java虛擬機能夠獲得字段的唯一解析結果。在實際情況中,Javac編譯器可能會采取比規范更加嚴格的約束,例如,當一個同名字段同時出現在某個類的接口和父類中,或者同時在自己或父類的多個接口中出現時,Javac編譯器可能會拒絕編譯為Class文件。
方法解析
方法解析的步驟與字段解析相似,通常包括以下步驟:
-
解析類或接口符號引用: 首先,需要解析方法表的
class_index項中索引的方法所屬的類或接口的符號引用。使用C表示這個類。如果解析成功,繼續后續的方法搜索。 -
接口檢查: 如果在類的方法表中發現
class_index中索引的C是個接口,直接拋出java.lang.IncompatibleClassChangeError異常。 -
后續方法搜索: 根據以下步驟進行后續的方法搜索:
- 在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,搜索結束。
- 否則,在類C的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,搜索結束。
- 否則,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明類C是一個抽象類,查找結束,拋出
java.lang.AbstractMethodError異常。 - 否則,宣告方法查找失敗,拋出
java.lang.NoSuchMethodError。
-
權限驗證: 如果查找過程成功返回了直接引用,對這個方法進行權限驗證。如果發現不具備對此方法的訪問權限,拋出
java.lang.IllegalAccessError異常。
接口方法解析
解析接口方法和解析類的方法在主要邏輯上是相似的,但由于接口和類在Java中有一些不同的特性,導致在解析過程中存在一些細微的差異:
-
類型檢查: 在解析接口方法時,需要進行接口類型檢查。如果接口方法表中發現所屬的類(
class_index中索引的C)是個類而不是接口,會直接拋出java.lang.IncompatibleClassChangeError異常。這是因為接口方法必須屬于接口,而不能是類的方法。 - 搜索范圍: 解析類的方法時,只需要在類本身及其父類中查找匹配的方法。而解析接口方法時,需要在接口本身及其所有父接口中遞歸查找。這是因為Java接口支持多重繼承,一個接口可以繼承多個父接口的方法。
- 多重繼承處理: 對于可能存在多個父接口中有相匹配的方法的情況,解析接口方法時可以從中選擇一個并返回。這一點與解析類的方法不同,因為類只有一個直接的父類,不存在多重繼承的情況。
在JDK 9之前,Java接口中的所有方法默認都是
public的,且不存在模塊化的訪問約束,因此接口方法的符號解析不會拋出java.lang.IllegalAccessError異常。然而,從JDK 9開始,引入了接口的靜態私有方法以及模塊化的訪問約束,因此在JDK 9及以后的版本中,接口方法的訪問可能會因為訪問權限控制而拋出java.lang.IllegalAccessError異常。
3.5初始化
在Java虛擬機的類加載過程中,初始化階段是加載過程的最后一個步驟。在初始化階段,Java虛擬機執行類構造器<clinit>()方法,該方法是由編譯器自動生成的,用于執行類中的所有類變量的賦值動作和靜態語句塊中的語句。
以下是關于初始化階段和<clinit>()方法的一些重要信息:
-
<clinit>()方法的生成:
<clinit>()方法由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句生成。編譯器根據源文件中語句的順序來確定收集的順序。 - 靜態語句塊的順序: 在靜態語句塊中,只能訪問到定義在靜態語句塊之前的變量。靜態語句塊中可以賦值但不能訪問定義在其后的變量。
-
<clinit>()與<init>()的區別:
<clinit>()方法與類的構造函數(實例構造器<init>()方法)不同。它不需要顯式調用父類構造器,并且Java虛擬機保證在子類的<clinit>()方法執行前,父類的<clinit>()方法已經執行完畢。第一個被執行的<clinit>()方法的類型是java.lang.Object。 -
接口中的<clinit>(): 接口中不能使用靜態語句塊,但仍然會有變量初始化的賦值操作,因此接口也會生成
<clinit>()方法。與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。 -
多線程初始化: Java虛擬機必須保證一個類的
<clinit>()方法在多線程環境中正確地加鎖同步。如果多個線程同時初始化一個類,只會有其中一個線程執行該類的<clinit>()方法,其他線程需要阻塞等待。 - 初始化階段觸發時機: 初始化階段的觸發時機包括對類的主動使用,如創建類的實例、調用類的靜態方法、訪問類或接口的靜態字段等。只有在對類進行主動使用時,初始化階段才會被觸發。
-
線程安全性: 在多線程環境中,如果多個線程同時嘗試初始化同一個類,Java虛擬機會確保只有一個線程執行該類的
<clinit>()方法,其他線程需要等待。
四、類加載器
Java虛擬機設計團隊采用創新的方式將類加載階段中獲取類的二進制字節流的動作放到Java虛擬機外部實現,這個實現被稱為"類加載器"(Class Loader)。這設計的初衷是為了讓應用程序自己決定如何獲取所需的類,為Java語言帶來了靈活性和可擴展性。
4.1類與類加載器
類加載器雖然只用于實現類的加載動作,但它在Java程序中起到的作用卻遠超類加載階段。對于任意一個類,都必須由加載它的類加載器和這個類本身一起共同確立其在Java虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。
如何判斷兩個類 “相等”?
-
“相等” 的要求
- 同一個 .class 文件
- 被同一個虛擬機加載
- 被同一個類加載器加載
-
判斷 “相等” 的方法
-
instanceof關鍵字 - Class 對象中的方法:
equals()isInstance()isAssignableFrom()
-
4.2類加載器分類
Java虛擬機的角度來看,只存在兩種不同的類加載器:
- 啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實現,是虛擬機自身的一部分
- 其他所有的類加載器,這些類加載器都由Java語言實現,獨立存在于虛擬機外部,并且全都繼承自抽象類
java.lang.ClassLoader
Java開發人員的角度來看,類加載器就應當劃分得更細致一些。三層類加載器、雙親委派的類加載架構:
- 啟動類加載器(Bootstrap):
- 是Java虛擬機能夠識別的,按照文件名識別,如rt.jar、tools.jar,名字不符合的類庫即使放在lib目錄中也不會被加載
- <JAVA_HOME>/lib
- -Xbootclasspath 參數指定的路徑
- 擴展類加載器(Extension)
- <JAVA_HOME>/lib/ext
- java.ext.dirs 系統變量指定的路徑
- 應用程序類加載器(Application)/系統類加載器
- 負責加載用戶類路徑 (ClassPath)上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果應用程序中沒有
自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器 - -classpath 參數
- 負責加載用戶類路徑 (ClassPath)上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果應用程序中沒有
4.3雙親委派模型
圖7中展示的各種類加載器之間的層次關系被稱為類加載器的“雙親委派模型(Parents Delegation Model)”。
雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器。不過這里類加載器之間的父子關系一般不是以繼承(Inheritance)的關系來實現的,而是通常使用組合(Composition)關系來復用父加載器的代碼。
工作過程
-
當前類加載器收到類加載的請求后,先不自己嘗試加載類,而是先將請求委派給父類加載器
因此,所有的類加載請求,都會先被傳送到啟動類加載器
-
只有當父類加載器加載失敗時,當前類加載器才會嘗試自己去自己負責的區域加載
實現
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 首先,檢查請求的類是否已經被加載過了
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父類加載器拋出ClassNotFoundException
// 說明父類加載器無法完成加載請求
}
if (c == null) {
// 在父類加載器無法加載時
// 再調用本身的findClass方法來進行類加載
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
- 檢查該類是否已經被加載
- 將類加載請求委派給父類
- 如果父類加載器為 null,默認使用啟動類加載器
parent.loadClass(name, false)
- 當父類加載器加載失敗時
- catch ClassNotFoundException 但不做任何處理
- 調用自己的 findClass() 去加載
- 我們在實現自己的類加載器時只需要
extends ClassLoader,然后重寫findClass()方法而不是loadClass()方法,這樣就不用重寫loadClass()中的雙親委派機制了
- 我們在實現自己的類加載器時只需要
優點
- 避免重復加載: 雙親委派機制通過委派給父類加載器來嘗試加載類,可以避免同樣的類被多次加載。如果一個類已經被一個類加載器加載,那么其父加載器會首先被詢問是否能夠加載這個類,從而避免了重復加載,提高了類加載的效率。
- 安全性: 雙親委派機制可以提高類加載的安全性。由于類加載是從上往下委派的,父加載器加載的類能夠保證在整個加載層次結構中是唯一的,這有助于防止惡意類的加載和替代。
-
保護核心類庫: 雙親委派機制確保核心類庫(如
java.lang、java.util等)由啟動類加載器加載,防止用戶自定義類替代核心類庫,從而保護了Java運行環境的穩定性和一致性。 - 模塊化: 雙親委派機制有助于實現模塊化。通過層級結構和委派機制,類加載器可以根據不同的需求劃分加載的范圍,形成模塊化的結構。
破壞雙親委派機制
在某些情況下,開發者可能會有意或無意地破壞雙親委派機制。以下是一些可能導致雙親委派機制破壞的情況:
-
自定義類加載器: 開發者可以通過自定義類加載器來加載類,而自定義類加載器可以選擇性地打破雙親委派機制。例如,覆蓋
loadClass方法時,可以選擇不調用父類加載器的loadClass方法,從而實現自定義的加載邏輯。 -
線程上下文類加載器: Java中的線程上下文類加載器(Context Class Loader)可以通過
Thread.setContextClassLoader方法進行設置。在一些框架和應用場景中,開發者可能會為線程設置上下文類加載器,以便在特定的情況下改變類加載器的委派行為。 -
Java Instrumentation API: Java提供了 Instrumentation API,允許開發者在類加載的過程中進行字節碼的修改。通過在
premain方法中使用java.lang.instrument.ClassFileTransformer接口,開發者可以修改類的字節碼,從而破壞雙親委派機制。 - 模塊化中平臺類加載器優先委派給負責那個模塊的加載器完成加載
五、Java模塊化系統
在Java 9之前,Java應用程序是以JAR文件的形式組織的,其中包含了一堆類和資源。這種方式存在一些問題:
- 可維護性差:JAR文件可以包含大量的類和資源,這使得應用程序的結構變得混亂,難以維護。
- 可重用性差:在多個應用程序之間共享代碼和資源比較困難。
- 安全性問題:所有的類都在同一個類路徑中,這可能導致意外的訪問和依賴關系。
Java模塊化解決了這些問題。模塊是一種新的編程單元,它可以包含類、資源和其他模塊的依賴關系。模塊化的代碼更容易維護,更容易重用,同時也提供了更好的安全性。
模塊化的基本概念
- 模塊(Module)
一個模塊是一個可重用的單元,它包含了一組相關的類和資源。每個模塊都有一個名字,并可以聲明自己的依賴關系。 - 模塊聲明(Module Declaration)
一個模塊聲明是一個包含在module-info.java文件中的文件,它定義了一個模塊的名稱、依賴關系和其他特性。 - 模塊路徑(Module Path)
模塊路徑是一組目錄和JAR文件,其中包含了模塊的JMOD文件和module-info.class文件。模塊路徑用于告訴JVM哪些模塊可用。 - 模塊化 JAR 文件(Modular JAR File)
模塊化JAR文件是一種特殊類型的JAR文件,它包含了一個模塊的類和資源,以及module-info.class文件。 - 自動模塊(Automatic Module)
如果一個JAR文件沒有module-info.class文件,它被稱為自動模塊。自動模塊的名稱基于JAR文件的文件名,并且具有一些默認的依賴關系。 - 依賴性(Dependency)
一個模塊可以聲明對其他模塊的依賴關系,以便在編譯時和運行時使用其他模塊的類和資源。
模塊化下的類加載器
為了保證兼容性,JDK 9并沒有從根本上動搖從JDK 1.2以來運行了二十年之久的三層類加載器架構以及雙親委派模型。但是為了模塊化系統的順利施行,模塊化下的類加載器仍然發生了一些變化。
-
擴展類加載器的替代: 擴展類加載器(Extension Class Loader)被平臺類加載器(Platform Class Loader)取代。這是因為JDK 9基于模塊化構建,整個Java類庫已經天然滿足可擴展的需求,因此不再需要維護
<JAVA_HOME>\lib\ext目錄。之前通過這個目錄來加載擴展類庫的擴展類加載器也就不再需要,完成了它的歷史使命。 -
取消
<JAVA_HOME>\jre目錄: 在新版的JDK中取消了<JAVA_HOME>\jre目錄。這是因為現在可以根據需要組合構建出程序運行所需的JRE。例如,如果只需要使用java.base模塊中的類型,可以通過jlink命令輕松地打包出一個只包含所需模塊的“JRE”。
最后,JDK 9中雖然仍然維持著三層類加載器和雙親委派的架構,但類加載的委派關系也發生了 變動。
當平臺及應用程序類加載器收到類加載請求,在委派給父加載器加載前,要先判斷該類是否能 夠歸屬到某一個系統模塊中,如果可以找到這樣的歸屬關系,就要優先委派給負責那個模塊的加載器完成加載,也許這可以算是對雙親委派的破壞。
啟動類加載器負責加載的模塊
平臺類加載器負責加載的模塊
應用程序類加載器負責加載的模塊
總結
以上是生活随笔為你收集整理的JVM学习-类加载机制的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 经典数据结构题目-数组
- 下一篇: 如何使用.NET在2.2秒内处理10亿行