深入理解Java虛擬機-第七章 虛擬機類加載機制

第七章 虛擬機類加載機制

7.1 概述

在 Java 語言裏面,類型的加載、鏈接和初始化過程都是在程序運行期間完成的,這種策略雖然會使類加載增加一些性能開銷,但是會提供高度的靈活性。例如編寫一個接口,可以等到運行的時候再指定其實際的實現類。用戶可以通過 Java 預定義的和自定義類加載器,讓一個本地的應用程序可以再運行時從網絡或其他地方加載一個二進制流作爲程序代碼的一部分。

7.2 類加載的時機

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括以下幾步:加載、驗證、準備、解析、初始化、使用、卸載。其中檢查、準備、解析三大步驟被稱爲連接(所謂動態連接說的也是這一部分的東西):
類的生命週期
圖中,加載、驗證、準備、初始化和卸載這 5 個步驟的順序是確定的,類的加載過程必須按照這五個步驟按部就班的開始。而解析不一定,它在某些情況下可以在初始化後開始(即動態綁定或晚期綁定)。這裏作者說的是按部就班的開始而不是進行,這是因爲這些階段通常都是相互交叉的混合式進行的。通常會在一個階段執行的過程中調用、激活另外一個階段(後面的文章應該會提到,這裏留個疑點)。
那麼加載、驗證等 5 個步驟一定是以加載爲開始。那麼加載什麼時候開始呢,虛擬機並沒有強制約束,有規定了有且只有 5 種情況必須立即執行類的初始化(而加載、驗證、準備自然在他之前):

  1. 遇到了new、getstatic、putstatic或invokestatic這4個字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候
  2. 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  3. 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化
  4. 當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main方法的類),虛擬機會先初始化這個主類。
  5. 當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化。

對於這 5 類能觸發初始化的操作,虛擬機規範使用了一個強限定詞“有且只有”,這 5 種場景中的行爲稱爲對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,叫被動引用。下面有幾個例子,以此證明:

public class SuperClass {
    public static String value = "3";

    static {
        System.out.println("Super Class Init!");
    }
}

public class SubClass extends SuperClass {
    static {
        System.out.println("Sub Class Init!");
    }
}

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

}

加上 -XX:+TraceClassLoading 參數並執行,執行結果在預期之中:
執行結果
細心的朋友可能發現了,這裏 SubClass 不是已經加載了嗎,爲什麼沒打印 init 呢,你代碼肯定出 bug 了!不是哦,static 塊中的方法,只有在類進行到 初始化 步驟時纔會執行,而且我們實驗的也是 初始化 步驟,老鐵沒毛病哦~
也許還有朋友發現,我的代碼跟書上的不太一致(誰特麼看你的博客還去跟書對比去,不要臉),書上的 value 變量是 int 型,而我這是 String 型。不知道大家對上文提到過的 被final修飾、已在編譯期把結果放入常量池的靜態字段除外 這句話有沒有印象,我起了興趣證實了一下並找到了原理,爲什麼是 String 去驗證後面再說,先上代碼。

public class SuperClass {
    public static final String value = "3";

    static {
        System.out.println("Super Class Init!");
    }
}

其實就是給父類變量加了個 final 修飾詞,期望證明被 final 關鍵字修飾後的常量可直接使用,不再調用類加載。執行結果依舊在意料之中:
執行結果
這裏不知道大家有沒有發現,不光父類沒有初始化,父類和子類連加載也沒加載。無獎問答,有沒有人知道原理~
這裏其實通過 javap -verbose 看一眼反編譯代碼就懂了:
反編譯
ldc 的意思是將 int、float 或 String 類型常量從常量池推至棧頂,這裏直接引用了一個常量 3 (如果這裏是 int 的話,會是 iconst_3 ,不太直觀,所以此處用 String )。什麼意思呢,就是說在編譯的時候,這個 final 修飾的常量就會被編譯到調用類(InvokeClass)的常量池中,所以調用的時候不需要加載定義類(SuperClass)。
這裏會有一個坑大家可能會踩,就是說當更新 定義類 的時候,調用類也要同步更新,否則會造成數據不一致的問題。舉個例子:A 類裏面有個 final 修飾的整形變量 value 值爲1 。B 類打印了 A.value;這時打印出來的是 1 。但是,將 A 類中的 value 修改爲 2 後,僅編譯更新 A 類後再次執行 B 時,打印出來的還是 1 。這就是僅更新定義類未更新調用類的坑。原理同上,B 在編譯的時候就把 1 編譯進去了,A 再怎麼修改只要不重新編譯 B,B 打印出來的 A.value 永遠是 1。

類和接口真正有所區別的是 當一個類在初始化時要求其父類全部都已經初始化了,但是一個接口在初始化時,並不要求其父接口全部都完成了初始化,只有在真正使用到父接口的時候(例如引用接口中定義的常量)纔會初始化。

7.3 類加載的過程

7.3.1 加載

加載 是類加載的一個過程,兩者不可混爲一談。加載大致需要完成下列三件事情:

  1. 通過一個類的全限定名來獲取定義此類的二進制字節流
  2. 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構
  3. 在內存中生成代表這個類的 java.lang.Class 對象,作爲方法區中這個類的各種數據訪問入口

虛擬機規範中這三條實際上要求並不算具體,因此虛擬機實現與具體應用的靈活度都是很大的,例如第一條,並沒有限制從哪讀。也可以從 .class 文件中,也可以在 jar 包中,甚至可以從網絡中、運行時、數據庫、其餘類型文件中獲取。
相對於累加在過程的其他階段,一個非數組類的加載階段是開發人員可控性最強的,因爲加載階段既可以使用系統提供的引導類加載器進行加載,也可以由開發人員自定義類加載器進行加載(重寫 loadClass() 方法)。
對於數組類來說,數組類本身不通過類加載器創建,而是由 Java 虛擬機直接創建的。但是數組類與類加載器仍然有很密切的關係,因爲數組類的元素類型(指的是數組去掉所有維度的類型)最終是要靠類加載器去加載。一個數組類的創建過程遵循以下規則:

  • 如果數組的組件類型(指的是數組去掉一個維度的類型)是引用類型,那就遞歸採用本節中定義的加載過程區加載這個組件類型。數組將在加載該組件類型的類加載器的類名稱空間上被標識(7.4節詳細介紹)
  • 數組的組件類型不是引用類型,Java 虛擬機將會把數組標記爲與引導類加載器(Bootstrap ClassLoader)關聯。
  • 數組類的可見性與他組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認爲 public 。

加載完成後,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區中。方法區中的數據存儲格式由 虛擬機實現 自行 定義。然後再內存中實例化一個 java.lang.Class 對象(並不一定就在 Java 堆中,對於 HotSpot 虛擬機而言,它存在方法區中)
加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段未完成連接階段已經開始,但是這些夾在加載階段中進行的連接動作,仍屬於連接階段的內容。這兩個階段的開始時間仍然保持着固定的先後順序。這就是最開始說的 按部就班的開始而不是進行

7.3.2 驗證

驗證是連接階段的第一步驟,這一階段的目的是保證 Class 文件的字節流中包含的信息符合當前虛擬機的需求,並且不會危害虛擬機自身的安全。Java 語言本身是相對安全的語言,使用純粹的 Java 代碼無法做到如訪問數組邊界以外的數據等事情,但是虛擬機加載的不只是純粹的 Java 代碼編譯而成的 class 文件,他有可能是任何途徑生成的字節流。虛擬機如果不檢查輸入的字節流,而是對其完全信任的話,很可能會因爲載入了有害的字節流而導致系統崩潰,所以驗證是虛擬機對自身保護的一種措施
驗證階段是非常重要的,大致會分爲 4 個階段:

  1. 文件格式驗證:這個階段主要是校驗字節流是否是符合 Class 文件格式的規範。例如:

    • 是否是以魔數 0xCAFEBABE 開頭
    • 主、次版本號是否在當前虛擬機處理範圍之內(只能小於等於當前虛擬機版本)
    • 常量池中的常量裏是否有不被支持的常量類型(檢查常量 tag 標誌)
    • 指向常量的各種索引值中是否有指向不存在的長了兩或不符合類型的常量
    • CONSTANT_Utf8_info 型的長兩種是否有不符合 UTF8 編碼的數據。
    • Class 文件中各個部分及文件本身是否有被刪除的或附加的其他信息
      ……

    實際上,第一階段的驗證點遠不止這些,這只是摘取了部分,這個階段的驗證是基於二進制字節流進行的驗證,只有通過了這個階段的驗證後,字節流纔會進入內存的方法區中進行存儲。所以後面三個階段的驗證都是基於方法區的存儲結構進行的,不再直接操作字節流。

  2. 元數據驗證:這個階段可能包括的驗證點如下:

    • 這個類是否有父類(除了 java.lang.Object 之外,其餘類都應當有父類)
    • 這個類的父類是否繼承了不允許被繼承的類(被 final 修飾的類)
    • 如果這個類不是抽象類,那麼是否實現了其父類或接口之中要求實現的所有方法。
    • 類中的字段是否字段、方法等是否與父類產生矛盾(例如覆蓋了父類的 final 字段,出現不和規則的方法重載等)。
      ……

    第二階段的主要目的是對壘的元數據信息進行語義校驗,保證不存在不符合 Java 語言規範的元數據信息

  3. 字節碼驗證:這個階段是整個驗證過程中最複雜的一個步驟,主要目的是通過數據流和控制流分析,確定程序語義是合法、符合邏輯的,例如:

    • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,不會出現類似操作棧中放了一個 int 類型的數據卻在使用時按照 long 類型載入本地變量表的。
    • 保證跳轉指令不會跳轉到方法體以外的字節碼指令上。
    • 保證方法體重的類型轉換是有效的,例如上轉型對象。

    如果一個類方法體沒通過字節碼驗證,那麼他一定是不安全的,但是通過了卻不代表他一定安全(Halting Problem,有興趣的同學可自行了解)。
    由於數據流驗證的高複雜性,虛擬機設計團隊爲避免過多的時間消耗在字節碼驗證階段,在 JDK 1.6 之後的 Javac 編譯器和 Java 虛擬機中進行了一項優化,給方法體的 Code 屬性的屬性表中增加了一項名爲 StackMapTable 的屬性。這項屬性描述了方法體中所有的基本塊(Basic Block,按照控制流拆分的代碼塊)開始時本地變量表和操作棧應有的狀態,在字節碼驗證期間,僅需檢查 StackMapTable 屬性中的記錄是否合法即可。這樣將字節碼驗證的類型推到轉變爲類型檢查從而節省一些時間。
    爲了說明這個 StackMapTable 是個啥,寫了一個簡單的 demo 我們來反編譯看看:

    public class ClassCheck {
        public void demo(int param) {
            if (1 == param) {
                System.out.println(1);
            } else {
                System.out.println(2);
            }
        }
    }
    

    反編譯的結果是:
    反編譯
    詳細說明他具體是個啥可能要再開篇博客才能講清楚,這裏先引用一篇別人的:《JVM StackMapTable 屬性的作用及理解》

  4. 符號引用驗證:最後一個階段發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證主要看的是堆類自身以外(常量池中的各類符號引用)的信息進行匹配性校驗:

    • 符號引用中通過字符串描述的全限定性名是否能找到對應類。
    • 在指定類中是否存在符合方法的字段描述符以及簡單命名成所描述的方法和字段
    • 符號引用中的類、字段、方法的訪問性(public、private等)是否能被當前類調用

7.3.3 準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這裏強調下兩個概念,類變量指的是被 static 修飾過的變量,而不是實例變量。初始值通常情況下指的是該數據類型的 0 值(如果是 final 修飾的,則編譯的時候 Javac 會生成 ConstantValue 屬性,在準備階段虛擬機就會根據 ConstantValue 的設置將 value 賦值爲 123),例如:

public static int value = 123;

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

7.3.4 解析

解析階段就是將符號引用變爲直接引用:

  • 符號引用:符號引用就是以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要能無歧義的定位到目標即可。他跟內存佈局無關,引用的目標不一定已經加載到內存中了
  • 直接引用:直接引用可以使直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄 ,直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不同的虛擬機上翻譯出來的直接引用一般不一樣。如果有了直接引用,那麼引用的目標一定已經在內存中存在。

虛擬機規範中沒有規定解析階段發生的具體時間,只要求了在執行 Java 虛擬機指令 anewarray、checkcast、getfield、getstatic、instanceof、nvokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield 和 putstatic 這 16 條指令前都需要對它的符號引用進行解析。
對於同一個符號引用進行多次解析請求是很常見的事情,除了 invokedynamic 指令外,虛擬機實現可以對第一次解析的結果進行緩存(在運行時常量池中記錄直接引用並把常量標註爲已解析狀態)從而避免解析動作重複進行。虛擬機需要保證如果一個符號引用已經成功解析過,那麼後續的引用解析請求就應當一直成功;同樣的,如果第一次失敗了那麼其他指令對這個符號的解析請求也應該受到相同的異常(此處對於 invokedynamic 指令不成立,而目前僅使用 Java 語言不會生成這條字節碼指令)。
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符 7 類符號引用進行。

  1. 類或接口的解析:假設當前代碼所處類爲 D,如果要把一個從未解析過的符號引用 N 解析爲一個接口 C 需要經過以下幾個步驟:

    • 如果 C 不是一個數組,那麼虛擬機就會將代表 N 的全限定名傳給 D 類的類加載器去加載 C 類。
    • 如果 C 是一個數組(例如 [java.lang.Integer ),那麼虛擬機先將數組的元素對象交由 D 類的類加載器去加載,然後由 JVM 生成一個代表此數組維度和元素的數組對象。
    • 如果上面的步驟都沒報錯,那麼就算解析成功,不過還要經過符號引用驗證,校驗是否有訪問權限
  2. 字段解析:如果要解析一個字段,首先需要對字段表中 class_index(詳情可參看本欄文章 《深入理解Java虛擬機-附件1 常量池中的 14 種常量項的結構總表》中 CONSTANT_Fieldref_info 相關內容)項中索引的 CONSTANT_Class_info 符號引用進行解析,即字段所屬類、接口的符號引用(下稱 C ),解析成功後進行如下幾步:

    • 如果 C 本身包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
    • 否則,如果在 C 中實現了接口或繼承了父類,則按照繼承關係從下往上遞歸查找,如果找到包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回此字段的直接引用
    • 如果都沒找到,那麼就直接拋出 java.lang.NoSuchFieldError 異常。
    • 查找到後,對此字段進行權限驗證,看是否有資格訪問。

    注意,如果父類(包括父類的父類等)和實現的接口中都有此字段,那麼編譯器會提示 The Field Sub.A is ambiguous 並拒絕編譯這段代碼。如下所示:

    public class FieldResolution {
        interface Interface0{
            int A = 0;
        }
    
        interface Interface1 extends Interface0{
            int A = 1;
        }
    
        interface Interface2{
            int A = 2;
        }
    
        static class Parent implements Interface1 {
            static int A = 3;
        }
    
        static class Sub extends Parent implements Interface2 {
            static int A = 4;
        }
    
        public static void main(String[] args) {
            System.out.println(Sub.A);
    
        }
    
    }
    

    到此爲止可以正常編譯,但是如果將 Sub 中的變量 A 刪掉的話,編譯器就會報錯如下圖所示
    在這裏插入圖片描述

  3. 類方法解析:類方法解析只比字段解析多了一步預檢查,他會先看 class_index 中索引的 C 是個接口還是類,如果不是類的話,直接拋出 java.lang.IncompatibleClassChangeError 異常。

  4. 接口方法解析:跟類方法解析一致,先檢查 class_index 重索引的 C 是不是個接口,不是的話直接拋出 java.lang.IncompatibleClassChangeError 異常。

剩餘的三種解析,等學完 invokedynamic 指令再說。

7.3.5 初始化

類初始化是類加載過程的最後一步。前面的加載、驗證、準備、解析四大過程,除了加載時可以通過用戶自定義類加載器參與之外,其餘的動作都是完全由虛擬機主導和控制的。到了初始化階段纔開始執行類中定義的 Java 程序代碼(或者說字節碼)。
初始化階段是執行類構造器 <clinit>() 方法的過程。我們來看一下方法執行過程中的特點和細節:

  • <clinit>() 方法是由編譯器自動收集類中的所有 類變量的複製動作 和 靜態語句塊(static{})中的語句 合併產生的。編譯器收集的順序是按照語句在原文件中出現的順序決定的,所以靜態語句塊只能使用定義在他後面的靜態變量,當然,這裏賦值是可以給後面的變量賦值的,因爲在解析期就把這些字段初始化好了,但是解析期還沒賦值所以用不了。
  • <clinit>() 方法與構造方法(<init>())不同,他不需要顯式的調用父類構造器,虛擬機會保證子類 <clinit>() 方法執行前,父類的 <clinit>() 方法已經執行完畢。這裏要特別說明一下接口類,因爲接口類沒有靜態塊語句,但是也有變量賦值,所以也有 <clinit>() 方法。但是與正常類不同的是,接口的 <clinit>() 方法執行不需要先執行其父類接口的 <clinit>() 方法,只有當用到父類接口中定義的變量的時候,父類接口的 <clinit>() 方法纔會被執行。同理,接口實現類的 <clinit>() 方法執行的時候,也不會執行接口的 <clinit>() 方法。
  • 父類的 <clinit>() 方法先執行,所以父類的靜態塊要比子類先執行
  • <clinit>() 方法對類或接口不是必須的,如果沒有靜態方法塊和對類變量的賦值操作,那麼編譯器可以不爲這個類生成 <clinit>() 方法。
  • 虛擬機會保證一個類的 <clinit>() 方法在多線程環境中被正確的上鎖、同步。如果多個線程同時去訪問,只有一個線程可以進去執行 <clinit>() 方法,剩餘的線程都會被阻塞直到活動線程將 <clinit>() 方法執行完畢。

7.4 類加載器

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

7.4.1 類與類加載器

對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在 Java 虛擬機中的唯一性,每一個類加載器都擁有一個獨立的類名稱空間。也就是說,即使兩個類是來源於同一個 Class 文件,被同一個 JVM 加載,如果他們的類加載器不同,這兩個類就必定不相等。
這裏所指的相等包括了代表類的 Class 對象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回結果。例如如下代碼所示:

public class ClassLoaderDemo {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        ClassLoader myLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                InputStream inputStream = null;
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    inputStream = getClass().getResourceAsStream(fileName);
                    if (null == inputStream) {
                        return super.loadClass(name);
                    }

                    byte[] bytes = new byte[inputStream.available()];
                    inputStream.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException();
                } finally {
                    if (null != inputStream) {
                        try {
                            inputStream.close();
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        };

        Object o = myLoader.loadClass("com.simon.classload.ClassLoaderDemo").newInstance();
        System.out.println(o.getClass());
        System.out.println(o instanceof com.simon.classload.ClassLoaderDemo);
        System.out.println(o.getClass().equals(com.simon.classload.ClassLoaderDemo.class));

    }
}

大家猜下結果是什麼~~~~公佈答案:
運行結果
雖然我們的實例確實是類 ClassLoaderDemo 實例化出來的,但是因爲類加載器的不同,我們自寫的類加載器和系統類加載加載進來的類對象就是不一樣。雖然都是一個 class 文件加載進來的,但依然是兩個獨立的類。
對於 JVM 來說,只存在兩種不同的類加載器:一種是啓動類加載(Bootstrap ClassLoader),這個類加載器使用 C++ 語言實現,是虛擬機自身的一部分;另一種就是所有其他的類加載器,這些類加載器都由 Java 語言是吸納,獨立於虛擬機外部並全部繼承自 java.lang.ClassLoader。
但是從 Java 開發人員看,類加載器還可以分爲以下四種:

  • 啓動類加載器(Bootstrap ClassLoader):這個類加載器器負責將存放在 <JAVA_HOME>/lib 目錄中的,或者被 -Xbootclasspath 參數所制定的路徑中的,並且被虛擬機識別的(僅按照文件名識別,例如 rt.jar)。此加載器無法被 Java 程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器,那直接使用 null 代替即可。
  • 擴展類加載器(Extension ClassLoader):這個加載器由 sun.misc.Launcher$ExtClassLoader 實現,他負責加載 <JAVA_HOME>/lib/ext 目錄中的或者被 java.ext.dirs 系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。
  • 應用程序類加載器(Application ClassLoader):這個加載器由 sun.misc.Launcher$AppClassLoader 實現。由於這個類加載器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也稱他爲系統類加載器(注意區分 啓動類加載器 )。他負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器(默認加載器)
  • 自定義類加載器,用戶自己實現 java.lang.ClassLoader 接口並重寫 loadClass() 方法。

7.4.2 雙親委派模型(Parents Delegation Model)

上文介紹了四種類加載器,我們的應用程序都是用這四種加載器互相配合進行加載的。雙親委派模型要求除了頂層的啓動類加載器外,其餘的類加載器都應帶有自己的父類加載器。如圖:
雙親委派模型
雙親委派模型的概念其實很簡單:如果一個類加載器收到了類加載的過程,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,沒一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啓動類加載器其中,只有當父類反應自己無法完成這個家在請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會去嘗試自己去加載。
可以用網上舉的一個例子來簡單概述:小明想買一個玩具車,他只能先去問爸爸(Application ClassLoader):“爸爸你有玩具車嗎?”,爸爸回覆:“等會兒,我去問問你爺爺”,然後轉身問爺爺(Extension ClassLoader):“爸爸你有玩具車嗎?”,爺爺回覆:“等會兒,我去問問你爺爺”,然後轉身問太爺爺(Bootstrap ClassLoader):“爸爸你有玩具車嗎?”。太爺爺說:“我這沒有,你自己買去吧!”,爺爺說:“好嘞” 然後轉身對爸爸說:“我這沒有,你自己買去吧!”,爸爸說:“好嘞” 然後轉身對小明說:“我這沒有,你自己買去吧!”。這時,小明纔可以自己去買玩具車,但凡祖上三輩有一個人有玩具車,小明都只能玩舊的不能買新的。
使用雙親委派模型來阻止類加載器之間的關係有個顯而易見的好處就是 Java 類隨着他的類加載器一起具備了一種帶優先級的層次關係。例如 java.lang.Object,它存放在 rt.jar 中,無論哪一個類加載器要加載這個類,最終都是委派給處於模型最頂端的啓動類加載器進行加載。因此 Object 類在程序的各種累加載器環境中都是同一個類。如果沒使用雙親委派模型,用戶自己寫了個 Object 類加載到 JVM 中,應用程序就亂套了。
雙親委派模型的代碼都集中在 java.lang.ClassLoader 的 loadClass() 中,如下所示:

  /**
    * Loads the class with the specified <a href="#name">binary name</a>.  The
    * default implementation of this method searches for classes in the
    * following order:
    *
    * <ol>
    *
    *   <li><p> Invoke {@link #findLoadedClass(String)} to check if the class
    *   has already been loaded.  </p></li>
    *
    *   <li><p> Invoke the {@link #loadClass(String) <tt>loadClass</tt>} method
    *   on the parent class loader.  If the parent is <tt>null</tt> the class
    *   loader built-in to the virtual machine is used, instead.  </p></li>
    *
    *   <li><p> Invoke the {@link #findClass(String)} method to find the
    *   class.  </p></li>
    *
    * </ol>
    *
    * <p> If the class was found using the above steps, and the
    * <tt>resolve</tt> flag is true, this method will then invoke the {@link
    * #resolveClass(Class)} method on the resulting <tt>Class</tt> object.
    *
    * <p> Subclasses of <tt>ClassLoader</tt> are encouraged to override {@link
    * #findClass(String)}, rather than this method.  </p>
    *
    * <p> Unless overridden, this method synchronizes on the result of
    * {@link #getClassLoadingLock <tt>getClassLoadingLock</tt>} method
    * during the entire class loading process.
    *
    * @param  name
    *         The <a href="#name">binary name</a> of the class
    *
    * @param  resolve
    *         If <tt>true</tt> then resolve the class
    *
    * @return  The resulting <tt>Class</tt> object
    *
    * @throws  ClassNotFoundException
    *          If the class could not be found
    */
   protected Class<?> loadClass(String name, boolean resolve)
       throws ClassNotFoundException
   {
       synchronized (getClassLoadingLock(name)) {
           // First, check if the class has already been loaded
           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 thrown if class not found
                   // from the non-null parent class loader
               }

               if (c == null) {
                   // If still not found, then invoke findClass in order
                   // to find the class.
                   long t1 = System.nanoTime();
                   c = findClass(name);

                   // this is the defining class loader; record the stats
                   sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                   sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                   sun.misc.PerfCounter.getFindClasses().increment();
               }
           }
           if (resolve) {
               resolveClass(c);
           }
           return c;
       }
   }

上述代碼邏輯清晰易懂,先檢查是否已被加載過,若沒有加載則調用父加載器的 loadClass() 方法,如果父加載器爲空則默認使用啓動類加載器作爲父加載器。如果父加載器加載失敗,拋出 ClassNotFoundException 異常後,在調用自己的 findClass() 方法進行加載。

7.4.3 破壞雙親委派模型

書中講了三個被破壞的階段,分別是

  • JDK 1.2 發佈之前
  • 類似 JNDI 這種底層服務調用用戶代碼
  • 用戶對程序動態性的追求,爲了實現熱插拔,熱部署,模塊化,說白了就是添加一個功能或減去一個功能不用重啓,只需要把這模塊連同類加載器一起換掉就實現了代碼的熱替換。

比較常見的是 JNDI 這種 SPI 的加載動作(JNDI、JDBC、JCE、JAXB、JBI)。是怎麼解決的呢,Java 設計團隊引入了一個不太優雅的設計:線程上下文類加載器(Thread Context ClassLoader)。這個類加載器可以通過 java.lang.Thread 類的 setContextClassLoaser() 方法進行設置,如果創建線程時還未設置,它將會從父線程中繼承一個,如果在應用程序的全局範圍內都沒有設置過的話,那這個類加載器默認就是應用程序類加載器。
有了線程上下文類加載器,就可以做一些“舞弊”的事情了,JNDI 服務使用這個線程上下文類加載器去加載所需要的 SPI 代碼,也就是父類加載器請求子類加載器去完成類加載的動作。
再補充一種比較常見的 - Tomcat 類的容器也是破壞雙親委派模型的經典例子:
我們想一下 Tomcat 或 Weblogic 等容器類的應用需要滿足哪幾點呢:

  • 可以加載不同應用的同一個版本或不同版本的同一個類
  • 公共的類應該可以只加載一次
  • 容器類應用本身的類應該與其他應用的類有隔離
  • JSP 等文件編譯出來的類,應該有即時更新的能力

這時我們看雙親委派模型第一點就不符合。全限定名相同的類就只會被加載一次,那麼是無法加載兩個相同類庫的不同版本的。
那麼怎麼辦呢,只有破壞雙親委派模型了。於是 Tomcat 設計團隊就設計瞭如下圖所示的結構。
在這裏插入圖片描述
此處引用 @莫那一魯道 的一篇博客《深入理解 Tomcat(四)Tomcat 類加載器之爲何違背雙親委派模型》中的解釋:

我們看到,前面3個類加載和默認的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader則是Tomcat自己定義的類加載器,它們分別加載/common/、/server/、/shared/*(在tomcat 6之後已經合併到根目錄下的lib目錄下)和/WebApp/WEB-INF/*中的Java類庫。其中WebApp類加載器和Jsp類加載器通常會存在多個實例,每一個Web應用程序對應一個WebApp類加載器,每一個JSP文件對應一個Jsp類加載器。

  • commonLoader:Tomcat最基本的類加載器,加載路徑中的class可以被Tomcat容器本身以及各個Webapp訪問;
  • catalinaLoader:Tomcat容器私有的類加載器,加載路徑中的class對於Webapp不可見;
  • sharedLoader:各個Webapp共享的類加載器,加載路徑中的class對於所有Webapp可見,但是對於Tomcat容器不可見;
  • WebappClassLoader:各個Webapp私有的類加載器,加載路徑中的class只對當前Webapp可見

從圖中的委派關係中可以看出:

  • CommonClassLoader能加載的類都可以被Catalina ClassLoader和SharedClassLoader使用,從而實現了公有類庫的共用,而CatalinaClassLoader和Shared ClassLoader自己能加載的類則與對方相互隔離。
  • WebAppClassLoader可以使用SharedClassLoader加載到的類,但各個WebAppClassLoader實例之間相互隔離。
  • 而JasperLoader的加載範圍僅僅是這個JSP文件所編譯出來的那一個.Class文件,它出現的目的就是爲了被丟棄:當Web容器檢測到JSP文件被修改時,會替換掉目前的JasperLoader的實例,並通過再建立一個新的Jsp類加載器來實現JSP文件的HotSwap功能。

好了,至此,我們已經知道了tomcat爲什麼要這麼設計,以及是如何設計的,那麼,tomcat 違背了java 推薦的雙親委派模型了嗎?答案是:違背了。 我們前面說過:
雙親委派模型要求除了頂層的啓動類加載器之外,其餘的類加載器都應當由自己的父類加載器加載。
很顯然,tomcat 不是這樣實現,tomcat 爲了實現隔離性,沒有遵守這個約定,每個webappClassLoader加載自己的目錄下的class文件,不會傳遞給父類加載器。

讀書越多越發現自己的無知,Keep Fighting!

本文僅是在自我學習 《深入理解Java虛擬機》這本書後進行的自我總結,有錯歡迎友善指正。

歡迎友善交流,不喜勿噴~
Hope can help~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章