JVM-類加載機制


類加載機制

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

在Java語言里面,類型的加載、連接和初始化過程都是在程序運行期間完成的,這種策略讓Java語言進行提前編譯會面臨額外的困難,也會讓類加載時稍微增加一些性能開銷,但是卻為Java應用提供了極高的擴展性和靈活性,Java天生可以動態擴展的語言特性就是依賴運行期動態加載和動態連接這個特點實現的。例如,編寫一個面向接口的應用程序,可以等到運行時再指定其實際的實現類,用戶可以通過Java預置的或自定義類加載器,讓某個本地的應用程序在運行時從網絡或其他地方上加載一個二進制流作為其程序代碼的一部分。這種動態組裝應用的方式目前已廣泛應用於Java程序之中,從最基礎的Applet、JSP到相對復雜的OSGi技術,都依賴着Java語言運行期類加載才得以誕生。

一個類型從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期將會經歷加載(Loading)、驗證(Verification)、准備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、准備、解析三個部分統稱為連接(Linking)。

image-20211114162542851

加載、驗證、准備、初始化和卸載這五個階段的順序是確定的,類型的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,這是為了支持Java語言的運行時綁定特性(也稱為動態綁定或晚期綁定)。請注意,這里寫的是按部就班地“開始”,而不是按部就班地“進行”或按部就班地“完成”,強調這點是因為這些階段通常都是互相交叉地混合進行的,會在一個階段執行的過程中調用、激活另一個階段。

初始化

《Java虛擬機規范》中並沒有嚴格規定類加載過程中的“加載”階段,但是規定了有且只有六種情況必須立即對類進行“初始化”(而加載、驗證、准備需要在此之前開始):

  1. 遇到new、getstatic、putstatic或invokestatic這四條字節碼指令時,如果類型沒有進行過初始化,則需要先觸發其初始化階段。能夠生成這四條指令的典型Java代碼場景有:

    • 使用new關鍵字實例化對象的時候。
    • 讀取或設置一個類型的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候。
    • 調用一個類型的靜態方法的時候。
  2. 使用java.lang.reflect包的方法對類型進行反射調用的時候,如果類型沒有進行過初始化,則需要先觸發其初始化。

  3. 當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

  4. 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。

  5. 當使用JDK 7新加入的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句柄,並且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。

  6. 當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有這個接口的實現類發生了初始化,那該接口要在其之前被初始化。

這六種場景中的行為稱為對一個類型進行主動引用。除此之外,所有引用類型的方式都不會觸發初始化,稱為被動引用。

被動引用:(可通過靜態代碼塊進行類是否初始化的觀察)

  1. 當訪問一個靜態域時。僅僅有真正聲明這個域的類才會被初始化。
  2. 通過子類引用父類的靜態變量,不會導致子類初始化。
  3. 通過數組定義類引用,用不會觸發此類的初始化。
  4. 引用常量不會觸發此類的初始化(常量在編譯階段就存入調用類的常量池中了)。

接口的加載過程與類加載過程稍有不同,針對接口需要做一些特殊說明:接口也有初始化過程,這點與類是一致的,上面的代碼都是用靜態語句塊“static{}”來輸出初始化信息的,而接口中不能使用“static{}”語句塊,但編譯器仍然會為接口生成“<clinit>()”類構造器[2],用於初始化接口中所定義的成員變量。接口與類真正有所區別的是前面講述的六種“有且僅有”需要觸發初始化場景中的第三種:當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個接口在初始化時,並不要求其父接口全部都完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量)才會初始化。

加載

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

1)通過一個類的全限定名來獲取定義此類的二進制字節流。

2)將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。

3)在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

相對於類加載過程的其他階段,非數組類型的加載階段(准確地說,是加載階段中獲取類的二進制字節流的動作)是開發人員可控性最強的階段。加載階段既可以使用Java虛擬機里內置的引導類加載器來完成,也可以由用戶自定義的類加載器去完成,開發人員通過定義自己的類加載器去控制字節流的獲取方式(重寫一個類加載器的findClass()或loadClass()方法),實現根據自己的想法來賦予應用程序獲取運行代碼的動態性。

加載階段與連接階段的部分動作(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬於連接階段的一部分,這兩個階段的開始時間仍然保持着固定的先后順序。

驗證

驗證是連接階段的第一步,這一階段的目的是確保Class文件的字節流中包含的信息符合《Java虛擬機規范》的全部約束要求,保證這些信息被當作代碼運行后不會危害虛擬機自身的安全。從整體上看,驗證階段大致上會完成下面四個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證。

  • 文件格式驗證

    第一階段要驗證字節流是否符合Class文件格式的規范,並且能被當前版本的虛擬機處理。這一階段可能包括下面這些驗證點:

    • 是否以魔數0xCAFEBABE開頭。
    • 主、次版本號是否在當前Java虛擬機接受范圍之內。
    • 常量池的常量中是否有不被支持的常量類型(檢查常量tag標志)。
    • 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
    • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8編碼的數據。
    • Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
    • ……
  • 元數據驗證

    第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合《Java語言規范》的要求,這個階段可能包括的驗證點如下:

    • 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
    • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
    • 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法。
    • 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等)。
    • ……

    第二階段的主要目的是對類的元數據信息進行語義校驗,保證不存在與《Java語言規范》定義相悖的元數據信息。

  • 字節碼驗證

    第三階段是整個驗證過程中最復雜的一個階段,主要目的是通過數據流分析和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型校驗完畢以后,這階段就要對類的方法體(Class文件中的Code屬性)進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的行為,例如:

    • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似於“在操作棧放置了一個int類型的數據,使用時卻按long類型來加載入本地變量表中”這樣的情況。
    • 保證任何跳轉指令都不會跳轉到方法體以外的字節碼指令上。
    • 保證方法體中的類型轉換總是有效的,例如可以把一個子類對象賦值給父類數據類型,這是安全的,但是把父類對象賦值給子類數據類型,甚至把對象賦值給與它毫無繼承關系、完全不相干的一個數據類型,則是危險和不合法的。
    • ……

    JDK 7之后,盡管虛擬機中仍然保留着類型推導驗證器的代碼,但是對於主版本號大於50(對應JDK6)的Class文件,使用類型檢查來完成數據流分析校驗則是唯一的選擇,不允許再退回到原來的類型推導的校驗方式。通過檢查檢查StackMapTable屬性中的記錄是否合法。

  • 符號引用驗證

    最后一個階段的校驗行為發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證可以看作是對類自身以外(常量池中的各種符號引用)的各類信息進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某些外部類、方法、字段等資源。

驗證階段對於虛擬機的類加載機制來說,是一個非常重要的、但卻不是必須要執行的階段,因為驗證階段只有通過或者不通過的差別,只要通過了驗證,其后就對程序運行期沒有任何影響了。如果程序運行的全部代碼(包括自己編寫的、第三方包中的、從外部加載的、動態生成的等所有代碼)都已經被反復使用和驗證過,在生產環境的實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。

准備

准備階段是正式為類中定義的變量(即靜態變量,被static修飾的變量)分配內存並設置類變量初始值的階段(比如int默認為0,真正賦值要等到類的初始化階段),從概念上講,這些變量所使用的內存都應當在方法區中進行分配,但必須注意到方法區本身是一個邏輯上的區域,在JDK 7及之前,HotSpot使用永久代來實現方法區時,實現是完全符合這種邏輯概念的;而在JDK 8及之后,類變量則會隨着Class對象一起存放在Java堆中,這時候“類變量在方法區”就完全是一種對邏輯概念的表述了。

這時候進行內存分配的僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在Java堆中。

image-20211116153054620

如果類字段的字段屬性表中存在ConstantValue屬性,那在准備階段變量值就會被初始化為ConstantValue屬性所指定的初始值:

public static final int value = 123;

例如此類,編譯時Java將會為value生成ConstantValue屬性,在准備階段虛擬機就會根據ConstantValue屬性的設置將value賦值為123。

解析

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

  • 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標並不一定是已經加載到虛擬機內存當中的內容。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在《Java虛擬機規范》的Class文件格式中。
  • 直接引用(Direct References):直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存布局直接相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在虛擬機的內存中存在。

關於這兩點如果理解不了的話可以參考這個博客:https://www.zhihu.com/question/30300585

《Java虛擬機規范》之中並未規定解析階段發生的具體時間,只要求了在執行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic這17個用於操作符號引用的字節碼指令之前,先對它們所使用的符號引用進行解析。所以虛擬機實現可以根據需要來自行判斷,到底是在類被加載器加載時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才去解析它。

解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符這7類符號引用進行。這里不再對各種解析進行贅述。

初始化

類的初始化階段是類加載過程的最后一個步驟,之前介紹的幾個類加載的動作里,除了在加載階段用戶應用程序可以通過自定義類加載器的方式局部參與外,其余動作都完全由Java虛擬機來主導控制。直到初始化階段,Java虛擬機才真正開始執行類中編寫的Java程序代碼,將主導權移交給應用程序。

進行准備階段時,變量已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程序員通過程序編碼制定的主觀計划去初始化類變量和其他資源。我們也可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器<clinit>()方法的過程。

<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合並產生的,編譯器收集的順序是由語句在源文件中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量。定義在它之后的變量,在前面的靜態語句塊可以賦值,但是不能訪問。這里如果難以理解的話,可參考https://segmentfault.com/q/1010000002569214。

<clinit>()方法對於類或接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那么編譯器可以不為這個類生成<clinit>()方法。

接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法,因為只有當父接口中定義的變量被使用時,父接口才會被初始化。此外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

Java虛擬機必須保證一個類的<clinit>()方法在多線程環境中被正確地加鎖同步,如果多個線程同時去初始化一個類,那么只會有其中一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行完畢<clinit>()方法。如果在一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個進程阻塞,在實際應用中這種阻塞往往是很隱蔽的。

類加載器

Java虛擬機設計團隊有意把類加載階段中的“通過一個類的全限定名來獲取描述該類的二進制字節流”這個動作放到Java虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需的類。實現這個動作的代碼被稱為“類加載器”(Class Loader)。

對於任意一個類,都必須由加載它的類加載器和這個類本身一起共同確立其在Java虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。比較兩個類是否“相等”(Class對象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回結果,也包括了使用instanceof關鍵字做對象所屬關系判定等各種情況),只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源於同一個Class文件,被同一個Java虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。

雙親委派模型

站在Java虛擬機的角度來看,只存在兩種不同的類加載器:一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實現,是虛擬機自身的一部分;另外一種就是其他所有的類加載器,這些類加載器都由Java語言實現,獨立存在於虛擬機外部,並且全都繼承自抽象類java.lang.ClassLoader。

站在Java開發人員的角度來看,類加載器就應當划分得更細致一些。自JDK 1.2以來,Java一直保持着三層類加載器、雙親委派的類加載架構。后來出現了一些調整變化,但是並未改變其主體結構。

啟動類加載器(Bootstrap Class Loader):這個類加載器負責加載存放在<JAVA_HOME>\lib目錄,或者被-Xbootclasspath參數所指定的路徑中存放的,而且是Java虛擬機能夠識別的(按照文件名識別,如rt.jar、tools.jar,名字不符合的類庫即使放在lib目錄中也不會被加載)類庫加載到虛擬機的內存中。啟動類加載器無法被Java程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器去處理,那直接使用null代替即可,代碼清單7-9展示的就是java.lang.ClassLoader.getClassLoader()方法的代碼片段,其中的注釋和代碼實現都明確地說明了以null值來代表引導類加載器的約定規則。

擴展類加載器(Extension Class Loader):這個類加載器是在類sun.misc.Launcher$ExtClassLoader中以Java代碼的形式實現的。它負責加載<JAVA_HOME>\lib\ext目錄中,或者被java.ext.dirs系統變量所指定的路徑中所有的類庫。根據“擴展類加載器”這個名稱,就可以推斷出這是一種Java系統類庫的擴展機制,JDK的開發團隊允許用戶將具有通用性的類庫放置在ext目錄里以擴展Java SE的功能,在JDK9之后,這種擴展機制被模塊化帶來的天然的擴展能力所取代。由於擴展類加載器是由Java代碼實現的,開發者可以直接在程序中使用擴展類加載器來加載Class文件。

應用程序類加載器(Application Class Loader):這個類加載器由sun.misc.Launcher$AppClassLoader來實現。由於應用程序類加載器是ClassLoader類中的getSystem-ClassLoader()方法的返回值,所以有些場合中也稱它為“系統類加載器”。它負責加載用戶類路徑(ClassPath)上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

image-20211116164524720

JDK 9之前的Java應用都是由這三種類加載器互相配合來完成加載的,如果用戶認為有必要,還可以加入自定義的類加載器來進行拓展,典型的如增加除了磁盤位置之外的Class文件來源,或者通過類加載器實現類的隔離、重載等功能。這些類加載器之間的協作關系“通常”會如圖7-2所示。

雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器。不過這里類加載器之間的父子關系一般不是以繼承(Inheritance)的關系來實現的,而是通常使用組合(Composition)關系來復用父加載器的代碼。

類加載器的雙親委派模型在JDK 1.2時期被引入,並被廣泛應用於此后幾乎所有的Java程序中,但它並不是一個具有強制性約束力的模型,而是Java設計者們推薦給開發者的一種類加載器實現的最佳實踐。

雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到最頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去完成加載。

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;
}

這段代碼的邏輯清晰易懂:先檢查請求加載的類型是否已經被加載過,若沒有則調用父加載器的loadClass()方法,若父加載器為空則默認使用啟動類加載器作為父加載器。假如父類加載器加載失敗,拋出ClassNotFoundException異常的話,才調用自己的findClass()方法嘗試進行加載。

雙親委派機制的優勢:采用雙親委派模式的是好處是Java類隨着它的類加載器一起具備了一種帶有優先級的層次關系,通過這種層級關可以避免類的重復加載,當父親已經加載了該類時,就沒有必要子ClassLoader再加載一次。其次是考慮到安全因素,java核心api中定義類型不會被隨意替換,假設通過網絡傳遞一個名為java.lang.Integer的類,通過雙親委托模式傳遞到啟動類加載器,而啟動類加載器在核心Java API發現這個名字的類,發現該類已被加載,並不會重新加載網絡傳遞的過來的java.lang.Integer,而直接返回已加載過的Integer.class,這樣便可以防止核心API庫被隨意篡改。

本篇博客是對類加載機制初次學習的一些筆記,其中有些內容可能不夠充分也沒有完全理解,有疑問的可以提在評論里,在未來有機會的話將會對本篇進行補充。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM