1. 程式人生 > 程式設計 >JVM—【03】認識Java類的生命週期、類載入的過程、雙親委派機制

JVM—【03】認識Java類的生命週期、類載入的過程、雙親委派機制

1. 初識類載入

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

  • 在Java語言中,型別的載入、連線和初始化過程都是在程式執行期間完成的。

    這種策略在類載入時稍微會增加一些效能開銷,但是提高了Java應用程式的靈活性。

    Java天生可以動態擴充套件的語言特性就是依賴執行期動態載入和動態連線這個特點實現的。


2. 類的生命週期

JVM 類的宣告週期

  • 類從載入到虛擬機器器記憶體開始到解除安裝出記憶體為止的生命週期包括7個階段:

    載入、驗證、準備、初始化和解除安裝這5個順序是固定的,類載入過程必須按照這種順序按部就班地開始。

    解析階段在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結。

    這些階段通常都是相互交叉地混合式進行的,通常會在一個階段執行的過程中呼叫、啟用另一個階段。

  • 有且只有5種必須進行類初始化的情況(主動引用):

    遇到newgetstaticputstaticinvokestatic這4個位元組碼指令時,最常見的Java程式碼是場景是:使用new關鍵字例項化物件的時候、讀取或設定一個類的靜態欄位(被final修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候、呼叫一個類的靜態方法的時候.

    使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要觸發其初始化。

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

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

    當使用jdk的動態語言支援時,如果一個java.lang.invoke.Methodhandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行初始化,則需要先進行初始化。

  • 被動引用:

    通過子類引用父類的靜態欄位,不會導致子類初始化。對於靜態欄位,只有直接定義這個欄位的類才會被初始化

    ,因此通過其子類類引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。至於是否要觸發子類的載入和驗證,在JVM規範中並沒有明確規定,這點取決於虛擬機器器的具體實現.示例程式碼如下:

    package io.ilss.main;
    
    /**
    * @author yiren
    * @date 2019-08-20
    **/
    public class SuperClass {
        static {
            System.out.println("SuperClass init!");
        }
    
        public static int value = 123;
    
    }
    
    
    package io.ilss.main;
    
    /**
    * @author yiren
    * @date 2019-08-20
    **/
    public class SubClass extends SuperClass {
        static {
            System.out.println("SubClass init!");
        }
    }
    
    package io.ilss.main;
    
    /**
    * @author yiren
    * @date 2019-08-20
    **/
    public class NotInitialization {
        public static void main(String[] args) { 
            System.out.println(SubClass.value);
        }
    }
    
    複製程式碼

    通過陣列定義來引用類,不會觸發此類的初始化。而是觸發這個陣列元素類對應的陣列類的初始化。如一個io.ilss.Demo類則對應[io.ilss.Demo的一個類,對於使用者程式碼來說這不是一個合法的類名稱,它是由虛擬機器器自動生成的、直接繼承與java.lang.Object的子類,建立動作由位元組碼指令newarray觸發。[io.ilss.Demo這個類代表了io.ilss.Demo對應的一位陣列,陣列中應有的屬性和方法都實現在這個類中,Java的陣列訪問相對於C/C++來說更安全,因為這個類封裝了陣列元素的訪問(封裝在了陣列訪問指令xaload、xastore中)。

    package io.ilss.main;
    
    /**
    * @author yiren
    * @date 2019-08-20
    **/
    public class NotInitialization {
        public static void main(String[] args) {
            SuperClass[] superClasses = new SuperClass[10];
        }
    }
    
    複製程式碼

    常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量類的初始化。這裡有個需要說明的地方,如果是在本類如第一處使用呼叫,是會載入ConstClass這個類的,這裡說的是在非本類的中呼叫這個常量不會初始化ConstClass,這是因為Java在編譯階段通過常量傳播優化,已經將hello world的值存到了NotInitialization類的常量池中,NotInitialization對“常量HELLO_WORLD的引用”都變成了對自身常量池的引用,實際上NotInitialization中不會有任何ConstClass類的符號引用,這兩個類在編譯成Class之後就不存在任何聯絡了。

    package io.ilss.main;
    
    /**
    * @author yiren
    * @date 2019-08-20
    **/
    public class ConstClass {
        static {
            System.out.println("ConstClass init");
        }
    
        public static final String HELLO_WORLD = "hello world";
        
        public static void main(String[] args) { // 1
            System.out.println(HELLO_WORLD);
        }
    }
    
    
    package io.ilss.main;
    
    import static io.ilss.main.ConstClass.HELLO_WORLD;
    
    /**
    * @author yiren
    * @date 2019-08-20
    **/
    public class NotInitialization {
        public static void main(String[] args) { // 2
            System.out.println(HELLO_WORLD);
        }
    }
    複製程式碼

    介面和類的載入略有不同,介面也有初始化過程,介面中沒有static{}程式碼塊,但是編譯器仍會為介面生成<clinit>()類構造器,用於初始化介面中定義的成員變數。介面與類真正的區別的是有且僅有的類初始化場景的第三種:當一個類在初始化時,要求其父類全部都已初始化過了,但是在介面中,並不會要求其父介面全部都完成了初始化,只有在真正使用到父介面的時候才會初始化(如去引用介面中定義的常量)


3. 類的載入過程

  • 類的載入全過程:載入、驗證、準備、解析和初始化

3.1. 載入

  • 載入階段JVM需要完成以下三件事情:

    通過一個類的全限定名來獲取定義此類的二進位制位元組流。

    將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。

    在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流,並沒有指明要從哪裡獲取、怎樣獲取。可以從:

    zip包中讀取,這很常見,最終變成了現在的:JAR、EAR、WAR格式基礎

    從網路中獲取,以前有個叫Applet(已過時)就是這樣做的

    執行時計算生成,見得最多的就是動態代理技術,在java.lang.reflect.Proxy中,就是用了ProxyGenerator.generateProxyClass來為特定的介面生成形式為"*$Proxy"的代理類的二進位制位元組流。

    由其他檔案生成:如JSP應用,由JSP生成對應的Class類

    從資料庫中獲取,這種場景相對少。

  • 載入階段中獲取類的二進位制位元組流的動作是開發人員可控性最強的,載入階段既可以使用系統提供的引導類載入器來完成,也可以由使用者自定義的類載入器去控制位元組流的獲取方式。(即重寫一個類載入器的loadClass()方法)

  • 對於陣列,有不同,陣列類本身不通過類載入器建立,他是由Java虛擬機器器直接建立的。但是陣列類與類載入器仍然關係密切,因為陣列的元素型別是由類載入器去建立,一個陣列類建立過程遵循以下規則:

    如果陣列的元件型別(Component Tyep,指的是陣列中去掉一個維度的型別)是引用型別,那就遞迴採用本節中定義的類載入過程去載入這個元件型別,陣列將在載入該元件型別的類載入器的類名稱空間上被標識。

    如果陣列的元件型別不是引用型別(如int[])JVM將會把陣列標記為與引導類載入器關聯

    陣列類的可見性與它的元件型別的可見性一致,如果元件型別不是引導型別,那陣列類的可見性預設為public。

  • 載入階段完成後,虛擬機器器外部的二進位制流就按照虛擬機器器所需的格式儲存在方法中,方法區中的資料儲存格式由虛擬機器器實現自行定義,虛擬機器器規範未規定此區域的具體資料結構。然後在記憶體中例項化一個java.lang.Class類的物件,這個物件將作為程式訪問方法區中的這些型別資料的外部介面

    對於HotSpot而言,Class物件比較特殊,它雖然是物件,但是存在了方法區中

  • 載入階段與連線階段是交叉進行的,載入階段未完成可能連線階段已經開始了,但是這兩個階段的先後順序是固定的。


3.2. 驗證

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

  • 檔案格式驗證: 主要目的是保證輸入的位元組流能正確地解析並儲存於方法區之內格式上符合描述一個Java型別資訊的要求。這個階段的驗證是基於二進位制位元組流進行的,只有通過了額這個階段的驗證後,位元組流才會進入到記憶體的方法區中進行儲存,後面的3個驗證階段全部是基於方法區的儲存結構進行的,不會再直接操作位元組流。

    是否以魔數0xCAFEBABE開頭

    主次版本號是否在當前虛擬機器器處理範圍之內

    常量池的常量中是否有不被支援的常量型別(檢查異常tag標誌)

    指向常量的各種索引值是否有指向不存在的常量或不符合型別的常量

    CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的資料

    Class檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊。

    .....

  • 元資料驗證: 對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求;主要目的是堆類的元資料資訊進行語義校驗,保證不存在不符合Java語言規範的元資料資訊。

    這個類是否有父類(除java.lang.Object之外所有類都應當有父類)

    這個類的父類是否繼承了不允許被繼承的類(被final修飾)

    如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法

    類的欄位、方法是否與父類產生矛盾(如:覆蓋了父類的final欄位,或者出現不符合規則的方法過載,例如方法引數都一致,但返回值型別卻不同等)

    ......

  • 位元組碼校驗: 通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。這個階段對類的方法體進行校驗分析,保證被叫眼淚的方法在執行時不會做出危害虛擬機器器安全的時間

    保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放了一個int型別的資料,使用時卻按long型別來載入入本地變量表中,

    確保跳轉指令不會跳轉到方法體以外的位元組碼指令上。

    保證方法體中的型別轉換是有效的。如:子類轉父類是安全的,但是把父類賦給子類,甚至是把物件賦給把毫無繼承關係的、毫不相干的資料型別,則是危險和不合法的。

  • 符號引用驗證: 這個校驗發生在符號引用轉換成直接引用的時候,在連線的第三個階段解析中發生。可以看做是堆類自身以外的資訊進行匹配性校驗。需要校驗以下內容:

    符號引用中拖過字串描述的全限定名是否能找到對應類

    在指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位。

    符號引用中的類、欄位、方法的訪問性(private、protected、public、default)是否可以被當前類訪問。

    ......

    符號引用的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,
    就會丟擲java.lang.IncompatiableClassChangeError異常的子類,
    如:IllegalAccessError、NoSuchFieldError、NoSuchMethodError
    複製程式碼

3.3. 準備

  • 正式為類變數分配記憶體以及設定類變數(不是例項變數)初始值的階段,這些變數所用到的記憶體都將分配在方法區。初始值通常情況下是該型別的零值

    public static int value = 123;

    這裡的初始值並不是值123,而是值int的預設值0,而把value賦值123的putstatic指令,是需要在類構造器<clinit>()方法中,所以賦值會在初始化階段才會執行。

  • 通常情況之外的特殊情況: 如果類欄位的欄位屬性表中存在ConstantValue屬性,那麼在準備階段value就會被初始化為ConstantValue如:

    public static final int value = 123; 注意final

    上面的程式碼,編譯時javac會將value生成ConstantValue屬性,在準備階段就會根據ConstantValue的設定將value設定成123.


3.4. 解析

  • 解析階段是JVM將常量池內的符號引用替換為直接引用的過程;符號引用在Class檔案中以CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info等型別的常量出現。

    符號引用(Symbolic References): 符號引用以一組符號來描述所描述引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與記憶體佈局無關,引用的目標不一定載入到記憶體中。符號引用的字面量形式明確定義在JVM規範的Class檔案格式中。

    直接引用(Direct Reference): 直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制程式碼。直接引用是和JVM記憶體佈局相關的,同一個符號引用在不同JVM中翻譯出來的直接引用一般會不同。如果有了直接引用,那引用的目標必定已經在記憶體中存在。

  • JVM規範沒有規定解析階段發生的時間,只要求在執行16個操作符號引用的位元組碼之前,先對他們所使用的符號引用進行解析。所以JVM可以根據需要選擇是在類被類載入器載入時還是符號引用要被使用的時去解析。

  • 對同一個符號引用多次解析是很常見的事情,除invokedynamic外,續集你可以對第一次解析的結果進行快取從而避免重複解析。

    在執行時常量池中記錄直接引用,並把常量標識為已解析狀態。

  • JVM需要保證在同一個實體中,一個符號引用之前已經被成功解析後,後續的引用解析請求就應當一直成功;同樣的如果第一次解析失敗,那麼其他指令對這個符號的解析請求也應當收到相同的異常。

  • 對於invokedynamic上面規則不成立。

  • 解析動作主要針對:類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼、呼叫點限定符 7類 符號引用進行。

    1. 類或介面的解析

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

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

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

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

    如果查詢過程成功返回了引用,將會對這個欄位進行許可權驗證,如果發現不具備對欄位的訪問許可權,將丟擲java.lang.Ille-galAccessError異常。

    1. 類方法解析

    先解析出類方法表的class_index項中索引的方法所屬的類或介面的符號引用,如果解析成功,用C表示這個類,接下來JVM將會按照如下步驟進行後續的類方法搜尋。

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

    最後,如果查詢過程成功返回了直接引用,將會對這個方法進行許可權驗證,如果發現不具備對此方法的訪問許可權,將丟擲java.lang.IllegalAccessError異常。

    1. 介面方法解析

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

    1. 與類方法解析不同,如果在介面方法表中發現class_index中的索引C是個類而不是介面,那就直接丟擲java.lang.Incom-patibleClassChangeError異常。
    2. 否則,在介面C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
    3. 否則,在介面C的父介面中遞迴查詢,直到java.lang.Object類(查詢範圍會包括Object類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
    4. 否則,宣告方法查詢失敗,丟擲java.lang.NoSuchMethodError異常。

    在介面中,所有方法預設就是public所以不存在訪問許可權問題,因此介面方法的符號解析應當不會丟擲java.lang.IllegalAccessError


3.5. 初始化

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

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

    <clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問

    public class Test{
        static {
            i = 0;
            System.out.print(i)
        }
        static int i = 1;
    }
    複製程式碼

    <clinit>()方法與類的建構函式(或者說例項構造器<clinit>()方法)不同,它不需要顯式地呼叫父類構造器,虛擬機器器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機器器中第一個被執行的<clinit>()方法的類肯定是java.lang.Object

    由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作

    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(Strintg[] args) {
        System.out.println(Sub.b) // 結果為2
    }
    複製程式碼

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

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

    虛擬機器器會保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化類,只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個程式阻塞。(需要注意的是,其他執行緒雖然會被阻塞,但如果執行<clinit>()方法的那條執行緒退出<clinit>()方法後,其他執行緒喚醒之後不會再次進入<clinit>()方法同一個類載入器下,一個型別只會初始化一次)

    static class DeadLoopClass {
        static {
            // 如果不加上這個if語句,編譯器將提示“Initializer does not complete normally”並拒絕編譯
        if (true) {
                System.out.println(Thread.currentThread() + "init DeadLoopClass");
                while (true) {
                }
            }
        }
    }
    
    public static void main(String[] args) {
        Runnable script = () -> {
                System.out.println(Thread.currentThread() + "start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            };
    
        Thread thread1 = new Thread(script);
        Thread thread2 = new Thread(script);
        thread1.start();
        thread2.start();
    }
    複製程式碼
    Thread[Thread-0,5,main]start
    Thread[Thread-1,main]start
    Thread[Thread-0,main]init DeadLoopClass
    複製程式碼

4. 類載入器

  • “通過一個類的全限定名來獲取描述此類的二進位制位元組流”這個動作放到Java虛擬機器器外部去實現,以便讓程式自己決定如何獲取所需的類。實現這個動作的程式碼模組稱為“類載入器”。
  • 類載入器在類層次劃分、OSGi、熱部署、程式碼加密等領域大放異彩,成為了java體系中的一塊重要的基石。

4.1 類與類載入器

  • 對於任意一個類,都需要由它的類載入器和這個類本身一同確立其在Java虛擬機器器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間,
  • 比較兩個類是否“相等”,是由同一個類載入器載入的前提下才有意義,否則即使兩個類是同一個class檔案,被同一個虛擬機器器載入,只要載入他們的類載入器不一樣,那這兩個類就必定不相等。

    這裡的相等,包括代表類的Class物件的equalsisAssignableFromisInstance方法返回的結果。也包括instanceof做的所屬關係判定情況。


4.2. 雙親委派模型

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

  • 從Java開發人員的角度來看,絕大部分Java程式都會使用以下3種系統提供的類載入器。

    1. 啟動類載入器(Bootstrap ClassLoader):這個類載入器負責將存放在<JAVA_HOME>\lib目錄中,並且是虛擬機器器識別的(名字不符合的類庫即使放在lib目錄中也不會被載入)類庫載入到虛擬機器器記憶體中。啟動類載入器無法被Java程式直接引用,使用者編寫自定義類載入器時,需要把載入請求委派給引導類載入器,那就直接使用null代替即可。
    2. 擴充套件類載入器(Extension ClassLoader):這個載入器由sun.misc.Launcher$ExtClassLoader實現,他負責載入**<JAVA_HOME>\lib\ext目錄中的類庫**,或者java.ext.dirs系統變數所指定的路徑中的所有類庫,開發者可以直接使用擴充套件類載入器。
    3. 應用程式類載入器(Application ClassLoader):這個類載入器由sun.misc.Launcher$AppClassLoader實現。由於這個類載入器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱他為系統類載入器。他負責載入使用者類路徑上所指定的類庫,開發者可以直接使用這個類載入器,如果應用程式中沒有自定義自己的類載入器,一般情況下這個就是程式中的預設類載入器
  • 雙親委派模型(Parents Delegation Model):雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應當有自己的父類載入器。

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

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

    類載入 - 雙親委派模型

  • 使用雙親委派模型來組織類載入器之間的關係,好處就是Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。

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

    雙親委派的實現程式碼:

    protected Class<?> loadClass(String name,boolean resolve)
            throws ClassNotFoundException
        {
            synchronized (getClassLoadingLock(name)) {
                // 首先,檢查類是否已經載入
                Class<?> c = findLoadedClass(name);
                if (c == null) {
                    long t0 = System.nanoTime();
                    try {
                        if (parent != null) {
                            c = parent.loadClass(name,false);
                        } else {
                            c = findBootstrapClassOrNull(name);
                        }
                    } catch (ClassNotFoundException e) {
                        // 如果沒有從非空父類載入器中找到類,
                        // 則丟擲ClassNotFoundException
                    }
    
                    if (c == null) {
                        // 如果仍然沒有找到該類,那麼呼叫findClass來找到該類。
                        long t1 = System.nanoTime();
                        c = findClass(name);
    
                        // 這是定義類裝入器;記錄資料
                        sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                        sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                        sun.misc.PerfCounter.getFindClasses().increment();
                    }
                }
                if (resolve) {
                    resolveClass(c);
                }
                return c;
            }
        }
    複製程式碼

4.3. 破壞雙親委派

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

  • 由於雙親委派模型在JDK 1.2之後才被引入,為了向前相容,JDK 1.2之後的java.lang.ClassLoader添加了一個新的protected方法findClass(),在此之前,使用者去繼承java.lang.ClassLoader的唯一目的就是為了重寫loadClass()方法,因為虛擬機器器在進行類載入的時候會呼叫載入器的私有方法loadClassInternal(),而這個方法的唯一邏輯就是去呼叫自己的loadClass()

    JDK 1.2之後已不提倡使用者再去覆蓋loadClass()方法,而應當把自己的類載入邏輯寫到findClass()方法中,在loadClass()方法的邏輯裡如果父類載入失敗,則會呼叫自己的findClass()方法來完成載入,這樣就可以保證新寫出來的類載入器是符合雙親委派規則的。

  • 雙親委派很好的解決各個類載入器的基礎類的統一問題(越基礎的類由越上層的載入器進行載入),基礎類之所以稱為“基礎”,是因為他們總是作為被使用者程式碼呼叫的API,但事實往往沒有絕對的完美,如果基礎類又要呼叫回使用者的程式碼該怎麼解決。

    一個典型的例子便是JNDI服務,JNDI現在已經是Java的標準服務,他的程式碼由啟動類載入器去載入(在JDK1.3時放進去的rt.jar),但JNDI的目的就是對資源進行集中管理和查詢,他需要呼叫由獨立廠商實現並部署在應用程式的ClassPath下的JNDI介面提供者(SPI,Service Provider Interface)的程式碼,

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

    有了執行緒上下文類載入器,就可以做一些“舞弊”的事情了,JNDI服務使用這個執行緒上下文類載入器去載入所需要的SPI程式碼,也就是父類載入器請求子類載入器去完成類載入的動作,這彙總行為實際上就是打通了雙親委派模型的層次結構來逆向使用類載入器,實際上已經違背了雙親委派模型的一般性原則,但這也是無可奈何的事情。Java中所有涉及SPI的載入動作基本上都採用這種方式,例如JNDI、JDBC、JCE、JAXB和JBI等

  • 第三次“被破壞”是由於使用者對程式動態性的追求而導致的,“動態性”指的是:程式碼熱替換(HotSwap)、模組熱部署(HotDeployment)等,但對於一些生產系統來說,關機重啟一次可能就要被列為生產事故,這種情況下熱部署就對軟體開發者,尤其是企業級軟體開發者具有很大的吸引力。

    Sun公司所提出的JSR-294、JSR-277規範在與JCP組織的模組化規範之爭中落敗給JSR-291(即OSGI R4.2),目前OSGi已經稱為了業界“事實上”的Java模組話標準,而OSGi實現模組化熱部署的關鍵則是他自定義的類載入器機制的實現。每一個程式模板(OSGi中稱為Bundle)都有一個自己的類載入器,當需要更換一個Bundle時,就把Bundle連同類載入器一起換掉以實現程式碼的熱替換。

    在OSGi環境下,類載入器不再是雙親委派模型中的樹狀結構,而是進一步發展為更加複雜的網狀結構,當收到類載入請求時,OSGi將按照下面的順序進行類搜尋:

    1. 將以java.*開頭的類委派給父類載入器載入。
    2. 否則,將委派列表名單內的類委派給父類載入器載入。
    3. 否則,將Import列表中的類委派給Export這個類的Bundle的類載入器載入。
    4. 否則,查詢當前Bundle的ClassPath,使用自己的類載入器載入。
    5. 否則,查詢類是否在自己的Fragment Bundle中,如果在,則委派給Fragment Bundle的類載入器載入。
    6. 否則,查詢Dynamic Import列表的Bundle,委派給對應Bundle的類載入器載入。
    7. 否則,類查詢失敗。

關於我

  • 座標杭州,普通本科在讀,電腦科學與技術專業,20年畢業,目前處於實習階段。
  • 主要做Java開發,會寫點Golang、Shell。對微服務、大資料比較感興趣,預備做這個方向。
  • 目前處於菜鳥階段,各位大佬輕噴,小弟正在瘋狂學習。
  • 歡迎大家和我交流鴨!!!