1. 程式人生 > >讀書筆記 ---- 《深入理解Java虛擬機器》---- 第6篇:虛擬機器類載入機制

讀書筆記 ---- 《深入理解Java虛擬機器》---- 第6篇:虛擬機器類載入機制

上一篇:類檔案結構:https://blog.csdn.net/pcwl1206/article/details/84197219

第6篇:虛擬機器類載入機制

1、概述

上一篇文章中講訴了Class檔案儲存格式的具體細節,在Class檔案中的描述的各種資訊,最終都要載入到虛擬機器中之後才能執行和使用。那麼虛擬機器如何載入這些Class檔案?Class檔案中的資訊進入到虛擬機器後會發生什麼變化?本篇文章將對這些問題進行解答。

虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。

類從被載入到虛擬機器記憶體中開始,到卸載出記憶體為止,它的整個生命週期包括:載入、驗證、準備、解析、初始化、使用和解除安裝7個階段。其中驗證、準備、解析3個部分統稱為連線,這7個階段發生的順序如下圖所示:

類的生命週期

載入、驗證、準備、初始化和解除安裝這5個階段的順序是確定的,類的載入過程必須按照這樣的順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結。這些階段通常會相互交叉地混合式進行,通常會在一個階段執行過程中呼叫、啟用另外一個階段。


2、類載入的時機

 首先要明確什麼情況下需要開始類載入過程的第一個階段:載入。Java虛擬機器規範中並沒有進行強制約束,但是卻規定了類必須進行初始化的5種情況,那麼載入、驗證和準備階段自然要在初始化之前開始。具體的5種情況如下圖所示:

類載入機制

第一種情況:“遇到new、getstatic、putstatic或invokestatic這4條位元組碼指令”在Java中最常見的場景是:

  1. new一個物件時;
  2. set或get一個類的靜態欄位(除去那些被final修飾放入常量池的靜態欄位);
  3. 呼叫一個類的靜態方法。

說明:上面的這5種會觸發類進行初始化的場景,虛擬機器規範中使用了一個很強烈的限定語:“有且只有”,這5種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用

下面給出幾個被動引用的案例:

【案例1:通過子類引用父類的靜態欄位,不會導致子類初始化

class SuperClass {
	
	static{
		System.out.println("SuperClass init!");
	}
	
	public static int value = 123;
}

class SubClass extends SuperClass{

	static{
		System.out.println("SubClass init!");
	}
}

class NotInitialization{
	
	public static void main(String[] args) {
		System.out.println(SubClass.value);
	}
}

執行結果:

從執行結果可以看出,結果只輸出了“SuperClass init!”而沒有輸出“SubClass init!”。

對於靜態欄位,只有直接定義這個欄位的類才會被初始化,因此通過子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化

【案例2:通過陣列定義來引用類,不會觸發此類的初始化

class SuperClass {
	
	static{
		System.out.println("SuperClass init!");
	}
	
	public static int value = 123;
}

class SubClass extends SuperClass{

	static{
		System.out.println("SubClass init!");
	}
}

class NotInitialization{
	
	public static void main(String[] args) {
		SuperClass[] sca = new SuperClass[10];
	}
}

結果並沒有打印出“SuperClass init!”,說明並沒有出發類Super的初始化階段。

【案例3:常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化

class ConstClass {
	
	static{
		System.out.println("ConstClass init!");
	}
	
	public static final String HELLOWORD = "hello world";
}

class NotInitialization{
	
	public static void main(String[] args) {
		System.out.println(ConstClass.HELLOWORD);
	}
}

執行結果:

結果中並未輸出“ConstClass init!”,這是因為雖然在Java原始碼中引用ConstClass類中的常量HELLOWORLD,但其實通過常量傳播優化,已經將此常量的值“hello  world”儲存到了NotInitialization類的常量池中,以後NotInitialization對常量ConstClass.HELLOWORD的引用實際都轉化為NotInitialization類對自身常量池的引用。也就是說NotInitialization的Class檔案之中並沒有ConstClass類的符號引用入口,在這兩個類在編譯成Class之後就不存在任何聯絡了。

當一個類在初始化時,要求其父類全部都已經初始化過了,但是一個介面在初始化時,並不要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候才會初始化。


3、類載入的過程

下面詳細講解Java虛擬機器中類載入的全過程,也就是載入、驗證、準備、解析和初始化這5個階段所執行的具體動作。

3.1  載入

載入是整個類載入過程的第一步。如果需要建立類或者介面,就需要先在Java虛擬機器方法區建立與虛擬機器實現規定相匹配的內部表示。一般來說,類的建立是由另一個類或者介面觸發的,它通過自己的執行時常量池引用到了需要建立的類,也可能是由於呼叫了Java核心類庫的某些方法,比如:反射等。

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

  1. 通過一個類的全限定名來獲取定義此類的二進位制位元組流;
  2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;
  3. 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問介面。

這裡需要說明的是陣列類和非陣列類的載入階段不同:

  • 非陣列類:可以使用系統提供的引導類載入器或者自定義的類載入器載入類的二進位制表示,即Class檔案;
  • 陣列類:陣列類本身不通過類載入器建立,它是由Java虛擬機器直接建立的,虛擬機器遞迴地採用上訴的載入過程不斷載入陣列的元件。

載入階段完成後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,方法區中的資料儲存格式由虛擬機器實現自行定義。然後在記憶體中例項化一個java.lang.Class類的物件(HotSpot虛擬機器將特殊的Class類的物件放在方法區中),這個物件將作為程式訪問方法區的這些型別資料的外部介面(比如上面案例裡提到的:Class.HEELOWORLD)。

載入階段與連線階段的部分內容(如:一部分位元組碼檔案格式驗證動作)是交叉進行的,載入階段尚未完成,連線階段可能已經開始,但這些夾在載入階段之中進行的動作,仍然屬於連線階段的內容,這兩個階段的開始時間仍然保持著固定的先後順序。

3.2  驗證

驗證是連線階段的第一步,主要目的是:為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全

驗證階段大致上分為4個階段的檢驗動作:檔案格式驗證、元資料驗證、位元組碼驗證、符號引用驗證

3.2.1  檔案格式驗證

第一階段主要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器所處理。主要的驗證點如下:

  • 是否以魔數0xCAFEBABE(java)開頭;
  • 主、次版本號是否在當前虛擬機器處理範圍之內;
  • 常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌);
  • 指向常量的各種索引值是否有指向不存在的常量或不符合型別的常量;
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的資料;
  • Class檔案中各個部分以及檔案本身是否有被刪除的或附加的其他資訊。
  • ...................

該驗證階段的主要目的是:保證輸入的位元組流能正確地解析並存儲於方法區之內,格式上符合描述一個Java型別資訊的要求

這個階段的的驗證是基於二進位制位元組流進行的,只有通過了這個階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存,所以後面的3個驗證階段全部都是基於方法區的儲存結構進行的,不會直接操作位元組流。

3.2.2  元資料驗證

元資料的驗證目的是:對類的元資料資訊(位元組碼中描述的資訊)進行語義校驗,保證不存在不符合Java語言規範的元資料資訊。主要的驗證點如下:

  • 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類);
  • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類);
  • 如果這個類不是抽象類,是否實現了其父類或介面中要求實現的所有方法;
  • 類中的欄位、方法是否與父類產生矛盾,如:覆蓋了父類的final欄位,或者出現不符合規則的方法過載。
  • ....................

3.2.3  位元組碼驗證

位元組碼驗證的目的是:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。在元資料驗證階段對元資料資訊中的資料型別做完校驗後,這個階段將對類的方法體進行檢驗分析,保證被檢驗類的方法在執行時不會做出危害虛擬機器安全的事情。主要的驗證點如下:

  • 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似這樣的情況:在運算元棧放置了一個int型別的資料,使用時卻按照long型別來載入入本地變量表;
  • 保證跳轉指令不會跳轉到方法體以外的位元組碼指令上;
  • 保證方法體中的型別轉換是有效的。例如:子類賦值給父類是合法的,但是父類賦值給子類或者賦值給其他毫無繼承關係的資料型別,則是不合法的。

JDK1.6以後,方法體的Code屬性的屬性表中新增了“StackMapTable”屬性,將位元組碼驗證的型別推導轉變為型別檢查從而節省一些時間。

3.2.4  符號引用驗證

最後一個階段的檢驗發生在虛擬機器將符號引用轉化為直接引用的時候,這個轉化動作將在連線的第三個階段——解析階段中發生。

符號引用驗證的目的是:確保解析動作能正常執行,如果無法通過符號引用驗證,那麼將會丟擲java.lang.IncompatibleClassChangeError異常的子類。

符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗。主要的驗證點如下:

  • 符號引用中通過字串描述的全限定名是否能找到對應的類;
  • 在指定類中是否存在符合方法的欄位描述以及簡單名稱所描述的方法和欄位;
  • 符號引用類中的類、欄位、方法的訪問性是否可被當前類訪問。
  • ...........................

這裡需要說明的是:對於虛擬機器的類載入機制,驗證階段非常重要但不是必要的階段。如果所執行的全部程式碼都已經被反覆使用和驗證過,那麼在實施階段就可以考慮使用-Xverify:none引數來關閉大部分的類驗證措施,以縮短虛擬機器載入的時間。

3.3  準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中。其次,這裡說的初始值“通常情況”下是資料型別的零值。假設一個類變數定義為:

public static int value = 123;

變數value在準備階段過後的初始值為0而不是123,因為這個時候尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器<clinit>()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。

Java所有的基本資料型別的零值如下表所示:

資料型別 零值
int 0
long 0L
short (short)0
char '\u0000'
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

“通常情況”下初始值是零值,那相對的會有一些“特殊情況”:如果類欄位的欄位屬性表中存放在ConstantValue屬性,那在準備階段變數value就會被初始化為ConstantValue屬性所指的值,假設上面類變數value的定義變為:

public static final int value = 123;

編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定將value賦值為123。

3.4  解析

解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。符號引用在Class檔案中以:CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等型別的常量出現。先看符號引用和直接引用的定義:

  • 符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機器實現的記憶體佈局各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機器規範的Class檔案格式中。
  • 直接引用:直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。直接引用是和虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在記憶體中存在。

虛擬機器規範並未規定解析階段發生的具體時間,只要求在執行以下16個用於操作符號引用的位元組碼指令之前,先對它們所使用的符號引用進行解析。

引起解析的命令

對同一個符號進行多次解析請求時,除了invokedynamic指令外,虛擬機器可以對第一次解析的結果進行快取(在執行時常量池中記錄直接引用,並把常量標識為已解析狀態),後面再遇到時,直接引用,從而避免解析動作重複進行。

對於invokedynamic指令,上面的規則不成立。invokedynamic指令的目的是用於動態語言支援,它所對應得引用稱為“動態呼叫點限定符”,動態的含義就是必須等到程式實際執行到這條指令的時候,解析動作才能進行。相對的,其餘可觸發解析的指令都是“靜態”的,可以在剛剛完成載入階段,還沒有開始執行程式碼時就進行解析。

解析動作主要針對下表中的7類符號引用:本文先詳解前4類,後3類,後面的文章再講。

符號引用 常量池中對應的型別
類或介面 CONSTANT_Class_info
欄位 CONSTANT_Fieldref_info
類方法 CONSTANT_Methodref_info
介面方法 CONSTANT_InterfaceMethodref_info
方法型別 CONSTANT_MethodType_info
方法控制代碼 CONSTANT_MethodHandle_info
呼叫點限定符 CONSTANT_InvokeDynamic_info

1、類和介面的解析

假設當前程式碼所處的類為D,如果要把一個從未解析過的符號引用N解析為一個類或介面C的直接引用,那虛擬機器完成整個解析的過程需要以下3個步驟:

  1. 如果C不是一個數組型別,那虛擬機器會把代表N的全限定名傳遞給D的類載入器去載入這個類C。在載入過程中,由於元資料驗證、位元組碼驗證的需要,又可能觸發其他相關類的動作,例如載入這個類的父類或實現的介面。一旦這個載入過程中出現了任何異常,解析過程就宣告失敗;
  2. 如果C是一個數組型別,並且陣列的元素型別為物件,也就是N的描述符會是類似“[Ljava/lang/Integer]”的形式,則按照第1點的規則載入陣列元素型別。(其實是一個遞迴呼叫的過程,直至到一維陣列)。如果N的描述符如前面假設的形式,需要載入的元素型別就是“java.lang.Integer”,接著由虛擬機器生成一個代表此陣列維度和元素的陣列物件;
  3. 如果上面的步驟沒有出現任何異常,那麼C在虛擬機器中實際上已經成為一個有效的類或介面了,但在解析完成之前還要對符號引用驗證,確定D是否具備對C的訪問許可權。如果發現不具備訪問許可權,則丟擲java.lang.IllegalAccessError異常。

2、欄位解析

要解析一個未被解析過的欄位符號引用,首先將會對欄位表內class_index項索引的CONSTANT_Class_info符號引用進行解析也就是欄位所屬的類或介面的符號引用。如果在解析這個類或介面符號引用的過程中出現任何異常,都會導致欄位符號引用解析的失敗。如果解析成功完成,那將這個欄位所屬的類或介面用C表示,虛擬機器規範要求按照如下步驟對C進行後續欄位的搜尋。

  1. 如果C本身就包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束;
  2. 否則,如果在C中實現了介面,將會按照繼承關係從下往上遞迴搜尋各個介面和它的父介面,如果介面中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束;
  3. 否則,如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞迴搜尋其父類,如果在父類中包含了簡單名稱和欄位描述符都與目標相匹配的欄位,則返回這個欄位的直接引用,查詢結束;
  4. 否則,查詢失敗,丟擲java.lang.NoSuchFieldError異常。如果查詢成功,還需要對這個欄位進行許可權驗證,看是否對這個欄位具有訪問許可權。

3、類方法解析

類方法解析的第一步驟與欄位解析一樣,也需要先解析出類方法表的class_index項中索引的方法所屬的類或介面的符號引用,如果解析成功,用C表示這個類,虛擬機器按照如下的步驟進行後續的類方法搜尋。

  1. 類方法和介面方法符號引用的常量型別定義是分開的,如果在類方法表中發現class_index中索引的C是個介面,那就直接丟擲java.lang.IncompatibleClassChangeError異常;
  2. 如果通過了第1步,在類C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束;
  3. 否則,在類C的父類中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束;
  4. 否則,在類C實現的介面列表及它們的父介面之中遞迴查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配的方法,說明類C是一個抽象類,這時查詢結束,丟擲java.lang.AbstractMethodError異常;
  5. 否則,方法查詢失敗,丟擲java.lang.NoSuchMethodError。
  • 如果查詢成功返回了直接引用,需要對這個方法進行許可權驗證。

4、介面方法解析

介面方法也需要先解析出介面方法表的class_index項中索引的方法所屬的類或介面的符號引用,如果解析成功,依然用C表示這個介面,虛擬機器將按照如下步驟進行介面方法的搜尋。

  1. 與類方法解析不同,如果在介面方法表中發現class_index中的索引C是個類而不是介面,那就直接丟擲java.lang.IncompatibleClassChangeError異常;
  2. 否則,在介面C中查詢是否具有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束;
  3. 否則,在介面C的父介面中遞迴查詢,直到java.lang.Object類為止,看是否具有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束;
  4. 否則,方法查詢失敗,丟擲java.lang.NoSuchMethodError。
  • 說明:由於介面中的方法預設都是public的,所以不存在訪問許可權的問題。

3.5  初始化

類初始化階段是類載入過程的最後一步,前面的類載入過程中,除了在載入階段使用者應用程式可以通過自定義類載入器參與之外,其餘動作完全由虛擬機器主導和控制。到了初始化階段,才正在開始執行類中定義的Java程式程式碼(或者說是位元組碼)。

在準備階段,變數已經賦過一次系統要求的初始值了,而在初始化階段,則根據程式設計師通過程式制定的主觀計劃去初始化類變數和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。先看下<clinit>()方法執行過程中可能會影響程式執行行為的特點和細節。

  • <clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊中可以賦值但是不能訪問。
public class Test{
    static{
        i = 0;                 // 給變數i賦值可以正常編譯通過
        System.out.print(i);   // 這句編譯器會提示“非法向前引用”
    }
    
    static int i = 1;
}
  • <clinit>()方法與類構造器函式(或者說例項構造器<init>()方法)不同,它不需要顯式地呼叫父類構造器,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此虛擬機器中第一個被執行<clinit>()方法的類是java.lang.Object。
  • 由於父類的<clinit>方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作,因此下述程式碼中欄位B的值是2而不是1。
static class Parent{
    public static int A = 1;
    static{
        A = 2;
    }
}

static class Sub extends Parent{
    public static int B = A;
}

public static void main(String[] args){
    System.out.println(Sub.B);   // 結果是2
}
  • <clinit>()方法對於類或介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類生成<clinit方法>。
  • 介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因為介面與類一樣都會生成<clinit>()方法。但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>方法,只有當父介面中定義的變數使用時,父接口才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。
  • 虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。

4、類載入器

虛擬機器把類載入階段中的“通過一個類的全限定名來獲取描述此類的二進位制位元組流”這個動作放到Java虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需的類。實現這個動作的程式碼模組稱為“類載入器”。

4.1  類與類載入器

類載入器雖然只用於實現類的載入動作,但它與Java程式中起到的作用卻遠遠不限於類載入階段。對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在Java虛擬機器中的唯一性,每個類載入器,都擁有一個獨立的類名稱空間。換句話說:比較兩個類是否“相等”,只有在這兩個類是由同一個類載入器載入的前提下才有意義,否則,即使這兩個類來源於同一個Class檔案,被同一個虛擬機器載入,只要載入它們的類載入器不同,那麼這兩個類就必定不相等

這裡說的“相等”,包括:equals()、isAssignableFrom()、isInstance()方法的返回結果以及instanceof關鍵字做物件所屬關係判定的情況。

類載入器就是執行上面類載入流程的一些類,系統預設的就有一些類載入器,從JVM的角度看,只有兩種類載入器:

1、啟動類載入器(Bootstrap  ClassLoader):這個類載入器是由C++語言實現的,是虛擬機器自身的一部分。負責將存在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath引數所指定的路徑中的類庫載入到虛擬機器記憶體中。啟動內載入器無法被Java程式直接引用,使用者在編寫自定義類載入器時,如果需要把載入請求委派給引導/啟動類載入器,直接使用null即可;

2、其他類載入器:由Java語言實現,獨立於虛擬機器外部,並且全都繼承自抽象類java.lang.ClassLoader。如擴充套件類載入器和應用程式類載入器;

           2.1  擴充套件類載入器(Extension  ClassLoader):這個類載入器由sun.misc.Launcher$ExtClassLoader實現,它負責載入<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變數所指定的路徑中的所有類庫,開發者可以直接使用擴充套件類載入器。

2.2  應用程式類載入器(Application  ClassLoader):這個類載入器由sun.misc.Launcher$AppClassLoder實現。由於個類載入器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱之為系統類載入器。它負責載入使用者路徑(ClassPath)所指定的類庫,開發者可以直接使用這個類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

4.2  雙親委派模型  ---- 重要

應用程式一般是由上訴的三種類載入器相互配合進行載入的,如果有必要,還可以加入自己定義的類載入器,它們的關係如下圖所示:

類載入器雙親委派模型

上圖展示的類載入器之間的這種層次關係,稱為類載入器的雙親委派模型(Parents  Delegation Model)。雙親委派模型要求除了頂層的啟動類載入器外,其餘的累加器都應當有自己的父類載入器。這裡類載入器之間的父子關係一般不會以繼承的關係來實現,而是都使用組合關係來複用父載入器的程式碼。

雙親委派模型的工作過程:

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

使用雙親委派模型的好處:

Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如:類java.lang.Object,它存放在rt.jar中,無論哪一個類載入器需要載入這個類,最終都是委派給處於模型最頂端的啟動類載入器進行載入,因此Object類在程式的各種類載入器環境中都是同一個類(使用的是同一個類載入器載入的)。相反,如果沒有使用雙親委派模型,由各個類載入器自行去載入的話,如果使用者自己編寫了一個java.lang.Object類,並放在程式的ClassPath中,那麼系統將會出現多個不同的Object類,java型別體系中最基礎的行為也就無法保證,應用程式也將變得一片混亂。

雙親委派模型的程式碼實現:

實現雙親委派的程式碼都集中在java.lang.ClassLoader的loadClass()方法中,邏輯清晰易懂:先檢查是否已經被載入過,若沒有載入則呼叫父載入器的loadClass()方法,若父載入器為空則預設使用啟動類載入器作為父類載入器。如果父類載入失敗,丟擲ClassNotFoundException異常後,再呼叫自己的findClass()方法進行載入。

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

這裡補充一點:若要實現自定義類載入器,只需要繼承java.lang.ClassLoader類,並且重寫其findClass()方法即可。java.lang.ClassLoader類的基本職責就是根據一個指定的類的名稱,找到或者生成其對應的位元組碼,然後從這些位元組碼中定義出一個Java類,即java.lang.Class得一個例項。除此之外,ClassLoader還負責載入Java應用所需的資源,如:影象檔案和配置檔案等。

ClassLoader中與載入類相關的方法如下:

方法 說明
getParent() 返回該類載入器的父類載入器
loadClass(String  name) 載入二進位制名為name的類,返回的結果是java.lang.Class類的例項
findClass(String  name) 查詢名稱為name的類,返回的結果是java.lang.Class類的例項
findLoadedClass(String  name) 查詢名稱為name的已經被載入過的類,返回的結果是java.lang.Class類的例項
resolveClass(Class<?>  c) 連結指定的Java類

4.3  破壞雙親委派模型

上文中的雙親委派模型並不是一個強制性的約束模型,而是Java設計者推薦給開發者的類載入器實現方式。在Java的世界中大部分的類載入器都遵循這個模型,但是也有例外,目前為止,雙親委派模型主要出現過三次較大規模的“被破壞”情況。

第一次:在雙親委派模型釋出之前,即JDK1.2之前。為了相容之前JDK版本中自定義類載入器的實現。

解決辦法:把自己的類載入器邏輯寫到findClass()方法中,在loadClass()方法的邏輯裡如果父類載入失敗,則會呼叫自己寫的findClass()方法來完成載入,這樣就可以保證新寫出來的類載入器是符合雙親委派模型的。

第二次:自身的缺陷所致。

JNDI服務需要呼叫獨立廠商實現並部署在應用程式的ClassPath下的JNDI介面提供者(SPI)的程式碼,但是啟動類載入器不認識這些程式碼。

解決辦法:引入上下文類載入器(Thread  Context  ClassLoader)。這個類載入器可以通過java.lang.Thread類的setContextClassLoaser()方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個,如果在應用程式的全域性範圍都沒有設定過的話,那這個類載入器預設就是應用程式的類載入了。

類似的服務還有:JDBC、JCE、JAXB、JBI等等。

第三次:使用者對動態性的追求而導致的,例如:程式碼熱替換、熱部署

解決辦法:OSGI實現模組化熱部署的關鍵是它自定義的類載入機制實現的。每一個程式模組(OSGI中稱為Bundle)都有一個自己的類載入器,當需要更換一個Bundle時,就把Bundle連同類載入器一起換掉以實現程式碼的熱部署。


上一篇:類檔案結構:https://blog.csdn.net/pcwl1206/article/details/84197219

下一篇:虛擬機器位元組碼執行引擎:https://blog.csdn.net/pcwl1206/article/details/84314002

推薦及參考:

1、深入理解JVM類載入機制:https://blog.csdn.net/a724888/article/details/78396462【必讀】

這篇文章不僅對《深入理解java虛擬機器》的類載入機制做了讀書筆記,而且還根據自己的觀點對每個點做了簡單的總結,還具體講解了自定義類載入器的實現過程以及Tomact的類載入器架構。

2、類載入機制:https://blog.csdn.net/chjttony/article/details/7909502