深入理解JVM虛擬機6:深入理解JVM類加載機制

深入理解JVM類加載機制

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

下面我們具體來看類加載的過程:

類的生命週期類的生命週期


類從被加載到內存中開始,到卸載出內存,經歷了加載、連接、初始化、使用四個階段,其中連接又包含了驗證、準備、解析三個步驟。這些步驟總體上是按照圖中順序進行的,但是Java語言本身支持運行時綁定,所以解析階段也可以是在初始化之後進行的。以上順序都只是說開始的順序,實際過程中是交叉進行的,加載過程中可能就已經開始驗證了。

類加載的時機

首先要知道什麼時候類需要被加載,Java虛擬機規範並沒有約束這一點,但是卻規定了類必須進行初始化的5種情況,很顯然加載、驗證、準備得在初始化之前,下面具體來說說這5種情況:

類加載時機類加載時機


其中情況1中的4條字節碼指令在Java裏最常見的場景是:
1 . new一個對象時
2 . set或者get一個類的靜態字段(除去那種被final修飾放入常量池的靜態字段)
3 . 調用一個類的靜態方法

類加載的過程

下面我們一步一步分析類加載的每個過程

1. 加載

加載是整個類加載過程的第一步,如果需要創建類或者接口,就需要現在Java虛擬機方法區創建於虛擬機實現規定相匹配的內部表示。一般來說類的創建是由另一個類或者接口觸發的,它通過自己的運行時常量池引用到了需要創建的類,也可能是由於調用了Java核心類庫中的某些方法,譬如反射等。

一般來說加載分爲以下幾步:

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

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

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

創建名字爲C的類,如果C不是數組類型,那麼它就可以通過類加載器加載C的二進制表示(即Class文件)。如果是數組,則是通過Java虛擬機創建,虛擬機遞歸地採用上面提到的加載過程不斷加載數組的組件。

Java虛擬機支持兩種類加載器:

  • 引導類加載器(Bootstrap ClassLoader)

  • 用戶自定義類加載器(User-Defined Class Loader)

用戶自定義的類加載器應該是抽象類ClassLoader的某個子類的實例。應用程序使用用戶自定義的類加載器是爲了擴展Java虛擬機的功能,支持動態加載並創建類。比如,在加載的第一個步驟中,獲取二進制字節流,通過自定義類加載器,我們可以從網絡下載、動態產生或者從一個加密文件中提取類的信息。

關於類加載器,會新開一篇文章描述。

2.驗證

驗證作爲鏈接的第一步,用於確保類或接口的二進制表示結構上是正確的,從而確保字節流包含的信息對虛擬機來說是安全的。Java虛擬機規範中關於驗證階段的規則也是在不斷增加的,但大體上會完成下面4個驗證動作。

驗證驗證


1 . 文件格式驗證:主要驗證字節流是否符合Class文件格式規範,並且能被當前版本的虛擬機處理。
主要驗證點:

  • 是否以魔數0xCAFEBABE開頭

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

  • 常量池的常量是否有不被支持的類型 (檢查常量tag標誌)

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

  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據

  • Class文件中各個部分及文件本身是否有被刪除的或者附加的其他信息
    ...
    實際上驗證的不僅僅是這些,關於Class文件格式可以參考我的深入理解JVM類文件格式,這階段的驗證是基於二進制字節流的,只有通過文件格式驗證後,字節流纔會進入內存的方法區中進行存儲。

2 . 元數據驗證:主要對字節碼描述的信息進行語義分析,以保證其提供的信息符合Java語言規範的要求。
主要驗證點:

  • 該類是否有父類(只有Object對象沒有父類,其餘都有)

  • 該類是否繼承了不允許被繼承的類(被final修飾的類)

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

  • 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,出現不符合規則的方法重載,例如方法參數都一致,但是返回值類型卻不同)
    ...

3 . 字節碼驗證:主要是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型做完校驗後,字節碼驗證將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件。
主要有:

  • 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似的情況:操作數棧裏的一個int數據,但是使用時卻當做long類型加載到本地變量中

  • 保證跳轉不會跳到方法體以外的字節碼指令上

  • 保證方法體內的類型轉換是合法的。例如子類賦值給父類是合法的,但是父類賦值給子類或者其它毫無繼承關係的類型,則是不合法的。

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

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

  • 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段

  • 符號引用中的類、方法、字段的訪問性(private,public,protected、default)是否可被當前類訪問
    符號引用驗證的目的是確保解析動作能夠正常執行,如果無法通過符號引用驗證,那麼將會拋出一個java.lang.IncompatibleClassChangeError異常的子類,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

驗證階段非常重要,但不一定必要,如果所有代碼極影被反覆使用和驗證過,那麼可以通過虛擬機參數-Xverify: none來關閉驗證,加速類加載時間。

3.準備

準備階段的任務是爲類或者接口的靜態字段分配空間,並且默認初始化這些字段。這個階段不會執行任何的虛擬機字節碼指令,在初始化階段纔會顯示的初始化這些字段,所以準備階段不會做這些事情。假設有:

public static int value = 123;

value在準備階段的初始值爲0而不是123,只有到了初始化階段,value纔會爲0。
下面看一下Java中所有基礎類型的零值:

數據類型零值
int0
long0L
short(short)0
char'\u0000'
byte(byte)0
booleanfalse
float0.0f
double0.0d
referencenull

一種特殊情況是,如果字段屬性表中包含ConstantValue屬性,那麼準備階段變量value就會被初始化爲ConstantValue屬性所指定的值,比如上面的value如果這樣定義:

public static final int value = 123;

編譯時,value一開始就指向ConstantValue,所以準備期間value的值就已經是123了。

4.解析

解析階段是把常量池內的符號引用替換成直接引用的過程,符號引用就是Class文件中的CONSTANT_Class_info CONSTANT_Fieldref_infoCONSTANT_Methodref_info等類型的常量。下面我們看符號引用和直接引用的定義。

符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要可以唯一定位到目標即可。符號引用於內存佈局無關,所以所引用的對象不一定需要已經加載到內存中。各種虛擬機實現的內存佈局可以不同,但是接受的符號引用必須是一致的,因爲符號引用的字面量形式已經明確定義在Class文件格式中。

直接引用(Direct References):直接引用時直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用和虛擬機實現的內存佈局相關,同一個符號引用在不同虛擬機上翻譯出來的直接引用一般不會相同。如果有了直接引用,那麼它一定已經存在於內存中了。

以下Java虛擬機指令會將符號引用指向運行時常量池,執行任意一條指令都需要對它的符號引用進行解析:

引起解析的命令引起解析的命令


對同一個符號進行多次解析請求是很常見的,除了invokedynamic指令以外,虛擬機基本都會對第一次解析的結果進行緩存,後面再遇到時,直接引用,從而避免解析動作重複。

對於invokedynamic指令,上面規則不成立。當遇到前面已經由invokedynamic指令觸發過解析的符號引用時,並不意味着這個解析結果對於其他invokedynamic指令同樣生效。這是由invokedynamic指令的語義決定的,它本來就是用於動態語言支持的,也就是必須等到程序實際運行這條指令的時候,解析動作纔會執行。其它的命令都是“靜態”的,可以再剛剛完成記載階段,還沒有開始執行代碼時就解析。

下面來看幾種基本的解析:
類與接口的解析: 假設Java虛擬機在類D的方法體中引用了類N或者接口C,那麼會執行下面步驟:

  1. 如果C不是數組類型,D的定義類加載器被用來創建類N或者接口C。加載過程中出現任何異常,可以被認爲是類和接口解析失敗。

  2. 如果C是數組類型,並且它的元素類型是引用類型。那麼表示元素類型的類或接口的符號引用會通過遞歸調用來解析。

  3. 檢查C的訪問權限,如果D對C沒有訪問權限,則會拋出java.lang.IllegalAccessError異常。

字段解析
要解析一個未被解析過的字段符號引用,首先會對字段表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,這邊記不清的可以繼續回顧深入理解JVM類文件格式,也就是字段所屬的類或接口的符號引用。如果在解析這個類或接口符號引用的過程中出現了任何異常,都會導致字段解析失敗。如果解析完成,那將這個字段所屬的類或者接口用C表示,虛擬機規範要求按照如下步驟對C進行後續字段的搜索。

1 . 如果C本身包含了簡單名稱和字段描述符都與目標相匹配的字段,則直接返回這個字段的直接引用,查找結束。
2 . 否則,如果在C中實現了接口,將會按照繼承關係從下往上遞歸搜索各個接口和它的父接口,如果接口中包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
3 . 再不然,如果C不是java.lang.Object的話,將會按照繼承關係從下往上遞歸搜索其父類,如果在類中包含
了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。
4 . 如果都沒有,查找失敗退出,拋出java.lang.NoSuchFieldError異常。如果返回了引用,還需要檢查訪問權限,如果沒有訪問權限,則會拋出java.lang.IllegalAccessError異常。

在實際的實現中,要求可能更嚴格,如果同一字段名在C的父類和接口中同時出現,編譯器可能拒絕編譯。

類方法解析
類方法解析也是先對類方法表中的class_index項中索引的方法所屬的類或接口的符號引用進行解析。我們依然用C來代表解析出來的類,接下來虛擬機將按照下面步驟對C進行後續的類方法搜索。
1 . 首先檢查方法引用的C是否爲類或接口,如果是接口,那麼方法引用就會拋出IncompatibleClassChangeError異常
2 . 方法引用過程中會檢查C和它的父類中是否包含此方法,如果C中確實有一個方法與方法引用的指定名稱相同,並且聲明是簽名多態方法(Signature Polymorphic Method),那麼方法的查找過程就被認爲是成功的,所有方法描述符所提到的類也需要解析。對於C來說,沒有必要使用方法引用指定的描述符來聲明方法。
3 . 否則,如果C聲明的方法與方法引用擁有同樣的名稱與描述符,那麼方法查找也是成功。
4 . 如果C有父類的話,那麼按照第2步的方法遞歸查找C的直接父類。
5 . 否則,在類C實現的接口列表及它們的父接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在相匹配的方法,說明類C時一個抽象類,查找結束,並且拋出java.lang.AbstractMethodError異常。

  1. 否則,宣告方法失敗,並且拋出java.lang.NoSuchMethodError
    最後的最後,如果查找過程成功返回了直接引用,將會對這個方法進行權限驗證,如果發現不具備對此方法的訪問權限,那麼會拋出 java.lang.IllegalAccessError異常。

接口方法解析
接口方法也需要解析出接口方法表的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功,依然用C表示這個接口,接下來虛擬機將會按照如下步驟進行後續的接口方法搜索。
1 . 與類方法解析不同,如果在接口方法表中發現class_index對應的索引C是類而不是接口,直接拋出java.lang.IncompatibleClassChangeError異常。
2 . 否則,在接口C中查找是否有簡單名稱和描述符都與目標匹配的方法,如果有則直接返回這個方法的直接引用,查找結束。
3 . 否則,在接口C的父接口中遞歸查找,直到java.lang.Object類爲止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。
4 . 否則,宣告方法失敗,拋出java.lang.NoSuchMethodError異常。

由於接口的方法默認都是public的,所以不存在訪問權限問題,也就基本不會拋出java.lang.IllegalAccessError異常。

5.初始化

初始化是類加載的最後一步,在前面的階段裏,除了加載階段可以通過用戶自定義的類加載器加載,其餘部分基本都是由虛擬機主導的。但是到了初始化階段,纔開始真正執行用戶編寫的java代碼了。

在準備階段,變量都被賦予了初始值,但是到了初始化階段,所有變量還要按照用戶編寫的代碼重新初始化。換一個角度,初始化階段是執行類構造器<clinit>()方法的過程。

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

public class Test {
  static {
    i=0;  //可以賦值
    System.out.print(i); //編譯器會提示“非法向前引用”
  }
  static int i=1;
}

<clinit>()方法與類的構造函數<init>()方法不同,它不需要顯示地調用父類構造器,虛擬機會寶成在子類的<clinit>()方法執行之前,父類的<clinit>()已經執行完畢,因此在虛擬機中第一個被執行的<clinit>()一定是java.lang.Object的。

也是由於<clinit>()執行的順序,所以父類中的靜態語句塊優於子類的變量賦值操作,所以下面的代碼段,B的值會是2。

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

<clinit>()方法對於類來說不是必須的,如果一個類中既沒有靜態語句塊也沒有靜態變量賦值動作,那麼編譯器都不會爲類生成<clinit>()方法。

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

虛擬機會保證一個類的<clinit>()方法在多線程環境中能被正確的枷鎖、同步。如果多個線程初始化一個類,那麼只有一個線程會去執行<clinit>()方法,其它線程都需要等待。

6.Java虛擬機退出

Java虛擬機退出的一般條件是:某些線程調用Runtime類或System類的exit方法,或者時Runtime類的halt方法,並且Java安全管理器也允許這些exit或者halt操作。
除此之外,在JNI(Java Native Interface)規範中還描述了當使用JNI API來加載和卸載(Load & Unload)Java虛擬機時,Java虛擬機退出過程。



JVM系列之類加載流程-自定義類加載器

JVM系列之類加載流程-自定義類加載器

老實說,類加載流程作者還是比較熟悉而且有實戰經驗的,因爲有過一次自定義類加載器的實戰經驗(文章最後會和大家分享),雖然大部分小夥伴覺得這部分對coding沒什麼實際意義,如果你一直寫CRUD並且用現有的高級語言業務框架,我可以告訴你,確實沒什麼用。但話說回來,你如果想多瞭解底層,並且在類加載時做一些手腳,那麼這一塊就很有必要學了。很多框架都是利用了類加載機制裏的動態加載特性來搞事情,像比較出名的OSGI模塊化(一個模塊一個類加載器),JSP(運行時轉換爲字節流讓加載器動態加載),Tomcat(自定義了許多類加載器用來隔離不同工程)...這裏就不一一列舉了。本文還是先把類加載流程先講一講,然後分享一下作者的一次自定義類加載的經驗心得,概要如下:

文章結構
1 類加載的各個流程講解
2 自定義類加載器講解
3 實戰自定義類加載器

1. 類加載的各個流程講解

作者找了下網上的圖,參考着自己畫了一張類生命週期流程圖:

類的生命週期圖類的生命週期圖


注意點:圖中各個流程並不是嚴格的先後順序,比如在進行1加載時,其實2驗證已經開始了,是交叉進行的。

加載

加載階段說白了,就是把我們編譯後的.Class靜態文件轉換到內存中(方法區),然後暴露出來讓程序員能訪問到。具體展開:

  • 通過一個類的全限定名來獲取定義此類的二進制字節流(可以是.class文件,也可以是網絡上的io,也可以是zip包等)

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

  • 在內存中(HotSpot的實現其實就是在方法區)生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口。

驗證

加載階段獲得的二進制字節流並不一定是來自.class文件,比如網絡上發來的,那麼如果不進行一定的格式校驗,肯定是不能加載的。所以驗證階段實際上是爲了保護JVM的。對於一般Javaer來說,俺們都是.java文件編譯出來的.class文件,然後轉換成相應的二進制流,沒啥危害。所以不用太關心這一部分。

準備

準備階段主要是給static變量分配內存(方法區中),並設置初始值。
比如: public static Integer value =1;在準備階段的值其實是爲0的。需要注意的是常量是在準備階段賦值的:
public static final Integer value =1 ;在準備階段value就被賦值爲了1;

解析

解析階段就更抽象了,稍微說一下,因爲不太重要,有兩個概念,符號引用直接引用。說的通俗一點但是不太準確,比如在類A中調用了new B();大家想一想,我們編譯完成.class文件後其實這種對應關係還是存在的,只是以字節碼指令的形式存在,比如 "invokespecial #2" 大家可以猜到#2其實就是我們的類B了,那麼在執行這一行代碼的時候,JVM咋知道#2對應的指令在哪,這就是一個靜態的傢伙,假如類B已經加載到方法區了,地址爲(#f00123),所以這個時候就要把這個#2轉成這個地址(#f00123),這樣JVM在執行到這時不就知道B類在哪了,就去調用了。(說的這麼通俗,我都懷疑人生了).其他的,像方法的符號引用,常量的符號引用,其實都是一個意思,大家要明白,所謂的方法,常量,類,都是高級語言(Java)層面的概念,在.class文件中,它纔不管你是啥,都是以指令的形式存在,所以要把那種引用關係(誰調用誰,誰引用誰)都轉換爲地址指令的形式。好了。說的夠通俗了。大家湊合理解吧。這塊其實不太重要,對於大部分coder來說,所以我就通俗的講了講。

初始化

這一塊其實就是調用類的構造方法,注意是類的構造方法,不是實例構造函數,實例構造函數就是我們通常寫的構造方法,類的構造方法是自動生成的,生成規則:
static變量的賦值操作+static代碼塊
按照出現的先後順序來組裝。
注意:1 static變量的內存分配和初始化是在準備階段.2 一個類可以是很多個線程同時併發執行,JVM會加鎖保證單一性,所以不要在static代碼塊中搞一些耗時操作。避免線程阻塞。

使用&卸載

使用就是你直接new或者通過反射.newInstance了.
卸載是自動進行的,gc在方發區也會進行回收.不過條件很苛刻,感興趣可以自己看一看,一般都不會卸載類.

2. 自定義類加載器講解

2.1 類加載器

類加載器,就是執行上面類加載流程的一些類,系統默認的就有一些加載器,站在JVM的角度,就只有兩類加載器:

  • 啓動類加載器(Bootstrap ClassLoader):由C++語言實現(針對HotSpot),負責將存放在<JAVA_HOME>/lib目錄或-Xbootclasspath參數指定的路徑中的類庫加載到內存中。

  • 其他類加載器:由Java語言實現,繼承自抽象類ClassLoader。如:

    • 擴展類加載器(Extension ClassLoader):負責加載<JAVA_HOME>/lib/ext目錄或java.ext.dirs系統變量指定的路徑中的所有類庫。

    • 應用程序類加載器(Application ClassLoader)。負責加載用戶類路徑(classpath)上的指定類庫,我們可以直接使用這個類加載器。一般情況,如果我們沒有自定義類加載器默認就是用這個加載器。

    • 自定義類加載器,用戶根據需求自己定義的。也需要繼承自ClassLoader.

2.2 雙親委派模型

如果一個類加載器收到類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器完成。每個類加載器都是如此,只有當父加載器在自己的搜索範圍內找不到指定的類時(即ClassNotFoundException),子加載器纔會嘗試自己去加載。見下圖:

雙親委派模型雙親委派模型


需要注意的是,自定義類加載器可以不遵循雙親委派模型,但是圖中紅色區域這種傳遞關係是JVM預先定義好的,誰都更改不了。雙親委派模型有什麼好處呢?舉個例子,比如有人故意在自己的代碼中定義了一個String類,包名類名都和JDK自帶的一樣,那麼根據雙親委派模型,類加載器會首先傳遞到父類加載器去加載,最終會傳遞到啓動類加載器,啓動加載類判斷已經加載過了,所以程序員自定義的String類就不會被加載。避免程序員自己隨意串改系統級的類。

2.3 自定義類加載器

上面說了半天理論,我都有點迫不及待的想上代碼了。下面看看如何來自定義類加載器,並且如何在自定義加載器時遵循雙親委派模型(向上傳遞性).其實非常簡單,在這裏JDK用到了模板的設計模式,向上傳遞性其實已經幫我們封裝好了,在ClassLoader中已經實現了,在loadClass方法中:

protected Class<?> loadClass(String name, boolean resolve)    throws ClassNotFoundException
{    synchronized (getClassLoadingLock(name)) {        // 1. 檢查是否已經加載過。
        Class c = findLoadedClass(name);        if (c == null) {            long t0 = System.nanoTime();            try {                if (parent != null) {                //2 .如果沒有加載過,先調用父類加載器去加載
                    c = parent.loadClass(name, false);
                } else {                // 2.1 如果沒有加載過,且沒有父類加載器,就用BootstrapClassLoader去加載
                c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }            if (c == null) {                //3. 如果父類加載器沒有加載到,調用findClass去加載
                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(String, boolean)函數即實現了雙親委派模型!整個大致過程如下:

  1. 檢查一下指定名稱的類是否已經加載過,如果加載過了,就不需要再加載,直接返回。

  2. 如果此類沒有加載過,那麼,再判斷一下是否有父加載器;如果有父加載器,則由父加載器加載(即調用parent.loadClass(name, false);).或者是調用bootstrap類加載器來加載。

  3. 如果父加載器及bootstrap類加載器都沒有找到指定的類,那麼調用當前類加載器的findClass方法來完成類加載。默認的findclass毛都不幹,直接拋出ClassNotFound異常,所以我們自定義類加載器就要覆蓋這個方法了。

  4. 可以猜測:ApplicationClassLoader的findClass是去classpath下去加載,ExtentionClassLoader是去java_home/lib/ext目錄下去加載。實際上就是findClass方法不一樣罷了

由上面可以知道,抽象類ClassLoader的findClass函數默認是拋出異常的。而前面我們知道,loadClass在父加載器無法加載類的時候,就會調用我們自定義的類加載器中的findeClass函數,因此我們必須要在loadClass這個函數裏面實現將一個指定類名稱轉換爲Class對象.
如果是是讀取一個指定的名稱的類爲字節數組的話,這很好辦。但是如何將字節數組轉爲Class對象呢?很簡單,Java提供了defineClass方法,通過這個方法,就可以把一個字節數組轉爲Class對象啦~

defineClass:將一個字節數組轉爲Class對象,這個字節數組是class文件讀取後最終的字節數組.

protected final Class<?> defineClass(String name, byte[] b, int off, int len)        throws ClassFormatError  {        return defineClass(name, b, off, len, null);

上面介紹了自定義類加載器的原理和幾個重要方法(loadClass,findClass,defineClass),相信大部分小夥伴還是一臉矇蔽,沒關係,我先上一副圖,然後上一個自定義的類加載器:

自定義類加載器方法調用流程圖自定義類加載器方法調用流程圖
樣例自定義類加載器:


import java.io.InputStream;public class MyClassLoader extends ClassLoader{    public MyClassLoader()
    {
    }    public MyClassLoader(ClassLoader parent)
    {        //一定要設置父ClassLoader不是ApplicationClassLoader,否則不會執行findclass
        super(parent);
    }    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException
    {    //1. 覆蓋findClass,來找到.class文件,並且返回Class對象
        try
        {
            String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
            InputStream is = getClass().getResourceAsStream(fileName);            if (is == null) {            //2. 如果沒找到,return null
                return null;
            }            byte[] b = new byte[is.available()];
            is.read(b);            //3. 講字節數組轉換成了Class對象
            return defineClass(name, b, 0, b.length);
        }        catch (Exception e)
        {
            e.printStackTrace();
        }        return null;
    }
}

稍微說一下:
其實很簡單,繼承ClassLoader對象,覆蓋findClass方法,這個方法的作用就是找到.class文件,轉換成字節數組,調用defineClass對象轉換成Class對象返回。就這麼easy..
演示下效果:

        MyClassLoader mcl = new MyClassLoader();
        Class<?> c1 = Class.forName("Student", true, mcl);
        Object obj = c1.newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println(obj instanceof Student);

返回結果:
sun.misc.Launcher$AppClassLoader@6951a712
true

        MyClassLoader mcl = new MyClassLoader(ClassLoader.getSystemClassLoader().getParent());
        Class<?> c1 = Class.forName("Student", true, mcl);
        Object obj = c1.newInstance();
        System.out.println(obj.getClass().getClassLoader());
        System.out.println(obj instanceof Student);

返回結果:
MyClassLoader@3918d722
false

重點分析:
第一個代碼和第二個代碼唯一一點不同的就是在new MyClassLoader()時,一個傳入的ClassLoader.getSystemClassLoader().getParent();(這個其實就是擴展類加載器)

  1. 當不傳入這個值時,默認的父類加載器爲Application ClassLoader,那麼大家可以知道,在這個加載器中已經加載了Student類(ClassPath路徑下的Student類),我們在調用Class.forName時傳入了自定義的類加載器,會調用自定義類加載器的loadClass,判斷自己之前沒有加載過,然後去調用父類的(ApplicationClassLoader)的loadClass,判斷結果爲已經加載,所以直接返回。所以打印ClassLoader爲AppClassLoader.
    驗證默認父類加載器爲ApplicationClassLoader:

         MyClassLoader mcl = new MyClassLoader();
         System.out.println(mcl.getParent().getClass());

    打印結果:class sun.misc.Launcher$AppClassLoader

  2. 當我們傳入父類加載器爲擴展類加載器時,當調用父類(擴展類加載器)的loadeClass時,由於擴展類加載器只加載java_home/lib/ext目錄下的類,所以classpath路徑下的它不能加載,返回null,根據loadClass的邏輯,接着會調用自定義類加載器findClass來加載。所以打印ClassLoader爲MyClassLoader.

  3. instanceof返回true的條件是(類加載器+類)全部一樣,雖然這裏我們都是一個Student類,一個文件,但是由兩個類加載器加載的,當然返回false了。

  4. 在JVM中判斷一個類唯一的標準是(類加載器+.class文件)都一樣.像instanceof和強制類型轉換都是這樣的標準。

  5. 注意,這裏所說的父類類加載器,不是以繼承的方式來實現的,而是以成員變量的方式實現的。當調用構造函數傳入時,就把自己的成員變量parent設置成了傳入的加載器。

  • 課外衍生:這裏作者是遵循了雙親委託模型,所以覆蓋了findClass,沒有覆蓋loadClass,其實loadClass也是可以覆蓋的,比如你覆蓋了loadClass,實現爲"直接加載文件,不去判斷父類是否已經加載",這樣就打破了雙親委託模型,一般是不推薦這樣乾的。不過小夥伴們可以試着玩玩.

自定義類加載器就給大家說完了,雖然作者感覺已經講清楚了,因爲無非就是幾個方法的問題(loadClass,findClass,defineClass),但還是給大家幾個傳送門,可以多閱讀閱讀,相互參閱一下:
www.cnblogs.com/xrq730/p/48…
www.importnew.com/24036.html

3. 實戰自定義類加載器

其實上面基本已經把自定義類加載器給講清楚了,這裏和大家分享一下作者一次實際的編寫自定義類加載器的經驗。背景如下:
我們在項目裏使用了某開源通訊框架,但由於更改了源碼,做了一些定製化更改,假設更改源碼前爲版本A,更改源碼後爲版本B,由於項目中部分代碼需要使用版本A,部分代碼需要使用版本B。版本A和版本B中所有包名和類名都是一樣。那麼問題來了,如果只依賴ApplicationClassLoader加載,它只會加載一個離ClassPath最近的一個版本。剩下一個加載時根據雙親委託模型,就直接返回已經加載那個版本了。所以在這裏就需要自定義一個類加載器。大致思路如下圖:

雙版本設計圖雙版本設計圖


這裏需要注意的是,在自定義類加載器時一定要把父類加載器設置爲ExtentionClassLoader,如果不設置,根據雙親委託模型,默認父類加載器爲ApplicationClassLoader,調用它的loadClass時,會判定爲已經加載(版本A和版本B包名類名一樣),會直接返回已經加載的版本A,而不是調用子類的findClass.就不會調用我們自定義類加載器的findClass去遠程加載版本B了。

順便提一下,作者這裏的實現方案其實是爲了遵循雙親委託模型,如果作者不遵循雙親委託模型的話,直接自定義一個類加載器,覆蓋掉loadClass方法,不讓它先去父類檢驗,而改爲直接調用findClass方法去加載版本B,也是可以的.大家一定要靈活的寫代碼。

結語

好了,JVM類加載機制給大家分享完了,希望大家在碰到實際問題的時候能想到自定義類加載器來解決 。Have a good day .

關注下面的標籤,發現更多相


打破雙親委派模型



   上文提到過雙親委派模型並不是一個強制性的約束模型,而是 Java設計者推薦給開發者的類加載器實現方式。在Java 的世界中大部分的類加載器都遵循這個模型,但也有例外

   雙親委派模型的被破壞是由這個模型自身的缺陷所導致的,雙親委派很好地解決了各個類加載器的基礎類的統一問題(越基礎的類由越上層的加載器進行加載) 基礎類之所以稱爲基礎,是因爲它們總是作爲被用戶代碼調用的API ,但世事往往沒有絕對的完美,如果基礎類又要調用回用戶的代碼,那該怎麼辦這並非是不可能的事情,一個典型的例子便是JNDI 服務,JNDI現在已經是Java的標準服務,它的代碼由啓動類加載器去加載(在 JDK 1.3時放進去的rt.jar),但JNDI 的目的就是對資源進行集中管理和查找,它需要調用由獨立廠商實現並部署在應用程序的Class Path下的JNDI 接口提供者(SPI,Service Provider Interface)的代碼,但啓動類加載器不可能認識” 這些代碼 ,因爲啓動類加載器的搜索範圍中找不到用戶應用程序類,那該怎麼辦?爲了解決這個問題,Java設計團隊只好引入了一個不太優雅的設計:線程上下文類加載器(Thread Context ClassLoader)。這個類加載器可以通過java.lang.Thread類的setContextClassLoader()方法進行設置,如果創建線程時還未設置,它將會從父線程中繼承一個,如果在應用程序的全局範圍內都沒有設置過的話,那這個類加載器默認就是應用程序類加載器(Application ClassLoader)。

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

   雙親委派模型的另一被破壞是由於用戶對程序動態性的追求而導致的,這裏所說的“ 動態性指的是當前一些非常熱門的名詞:代碼熱替換(HotSwap)、模塊熱部署(HotDeployment)等 ,說白了就是希望應用程序能像我們的計算機外設那樣,接上鼠標、U盤,不用重啓機器就能立即使用,鼠標有問題或要升級就換個鼠標,不用停機也不用重啓。對於個人計算機來說,重啓一次其實沒有什麼大不了的,但對於一些生產系統來說,關機重啓一次可能就要被列爲生產事故,這種情況下熱部署就對軟件開發者,尤其是企業級軟件開發者具有很大的吸引力Sun 公司所提出的JSR-294JSR-277規範在與 JCP組織的模塊化規範之爭中落敗給JSR-291(即 OSGi R4.2),雖然Sun不甘失去Java 模塊化的主導權,獨立在發展 Jigsaw項目,但目前OSGi已經成爲了業界“ 事實上” Java模塊化標準,而OSGi實現模塊化熱部署的關鍵則是它自定義的類加載器機制的實現。每一個程序模塊( OSGi 中稱爲Bundle)都有一個自己的類加載器,當需要更換一個Bundle 時,就把Bundle連同類加載器一起換掉以實現代碼的熱替換。

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

1)將以java.*開頭的類委派給父類加載器加載。

2)否則,將委派列表名單內的類委派給父類加載器加載。

3)否則,將Import列表中的類委派給 Export這個類的Bundle的類加載器加載。

4)否則,查找當前Bundle Class Path,使用自己的類加載器加載。

5)否則,查找類是否在自己的Fragment Bundle中,如果在,則委派給 Fragment Bundle的類加載器加載。

6)否則,查找Dynamic Import列表的 Bundle,委派給對應Bundle的類加載器加載。

7)否則,類查找失敗。

   上面的查找順序中只有開頭兩點仍然符合雙親委派規則,其餘的類查找都是在平級的類加載器中進行的。

   只要有足夠意義和理由,突破已有的原則就可認爲是一種創新。正如OSGi中的類加載器並不符合傳統的雙親委派的類加載器,並且業界對其爲了實現熱部署而帶來的額外的高複雜度還存在不少爭議,但在Java 程序員中基本有一個共識:OSGi中對類加載器的使用是很值得學習的,弄懂了OSGi的實現,就可以算是掌握了類加載器的精髓


Tomcat的類加載器架構


   

   主流的Java Web服務器(也就是Web容器) ,如Tomcat、Jetty、WebLogicWebSphere 或其他筆者沒有列舉的服務器,都實現了自己定義的類加載器(一般都不止一個)。因爲一個功能健全的 Web容器,要解決如下幾個問題:

   1)部署在同一個Web容器上 兩個Web應用程序使用的Java類庫可以實現相互隔離。這是最基本的需求,兩個不同的應用程序可能會依賴同一個第三方類庫的不同版本,不能要求一個類庫在一個服務器中只有一份,服務器應當保證兩個應用程序的類庫可以互相獨立使用。

   2)部署在同一個Web容器上 兩個Web應用程序所使用的Java類庫可以互相共享 。這個需求也很常見,例如,用戶可能有10個使用spring 組織的應用程序部署在同一臺服務器上,如果把10Spring分別存放在各個應用程序的隔離目錄中,將會是很大的資源浪費——這主要倒不是浪費磁盤空間的問題,而是指類庫在使用時都要被加載到Web容器的內存,如果類庫不能共享,虛擬機的方法區就會很容易出現過度膨脹的風險

   3)Web容器需要儘可能地保證自身的安全不受部署的Web應用程序影響。目前,有許多主流的Java Web容器自身也是使用Java語言來實現的。因此,Web容器本身也有類庫依賴的問題,一般來說,基於安全考慮,容器所使用的類庫應該與應用程序的類庫互相獨立。

   4)支持JSP應用的Web容器,大多數都需要支持 HotSwap功能。我們知道,JSP文件最終要編譯成Java Class才能由虛擬機執行,但JSP文件由於其純文本存儲的特性,運行時修改的概率遠遠大於第三方類庫或程序自身的Class文件 。而且ASP、PHP 和JSP這些網頁應用也把修改後無須重啓作爲一個很大的“優勢”來看待 ,因此“主流”的Web容器都會支持JSP生成類的熱替換 ,當然也有“非主流”的,如運行在生產模式(Production Mode)下的WebLogic服務器默認就不會處理JSP文件的變化。

   由於存在上述問題,在部署Web應用時,單獨的一個Class Path就無法滿足需求了,所以各種 Web容都“不約而同”地提供了好幾個Class Path路徑供用戶存放第三方類庫,這些路徑一般都以“lib”或“classes ”命名。被放置到不同路徑中的類庫,具備不同的訪問範圍和服務對象,通常,每一個目錄都會有一個相應的自定義類加載器去加載放置在裏面的Java類庫 。現在,就以Tomcat 容器爲例,看一看Tomcat具體是如何規劃用戶類庫結構和類加載器的。

   在Tomcat目錄結構中,有3組目錄(“/common/*”、“/server/*”和“/shared/*”)可以存放Java類庫,另外還可以加上Web 應用程序自身的目錄“/WEB-INF/*” ,一共4組,把Java類庫放置在這些目錄中的含義分別如下:

   ①放置在/common目錄中:類庫可被Tomcat所有的 Web應用程序共同使用

   ②放置在/server目錄中:類庫可被Tomcat使用,對所有的Web應用程序都不可見。

   ③放置在/shared目錄中:類庫可被所有的Web應用程序共同使用,但對Tomcat自己不可見。

   ④放置在/WebApp/WEB-INF目錄中:類庫僅僅可以被此Web應用程序使用,對 Tomcat和其他Web應用程序都不可見。

   爲了支持這套目錄結構,並對目錄裏面的類庫進行加載和隔離,Tomcat自定義了多個類加載器,這些類加載器按照經典的雙親委派模型來實現,其關係如下圖所示。





   上圖中灰色背景的3個類加載器是JDK默認提供的類加載器,這3個加載器的作用已經介紹過了。而CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader則是Tomcat自己定義的類加載器,它們分別加載/common/*、/server/*、/shared/*和/WebApp/WEB-INF/*中的Java類庫。其中WebApp類加載器和Jsp類加載器通常會存在多個實例每一個Web應用程序對應一個WebApp類加載器每一個JSP文件對應一個Jsp類加載器

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

   對於Tomcat的6.x版本,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和share.loader項後纔會真正建立Catalina ClassLoader和Shared ClassLoader的實例,否則在用到這兩個類加載器的地方都會用Common ClassLoader的實例代替,而默認的配置文件中沒有設置這兩個loader項,所以Tomcat 6.x順理成章地把/common、/server和/shared三個目錄默認合併到一起變成一個/lib目錄,這個目錄裏的類庫相當於以前/common目錄中類庫的作用。這是Tomcat設計團隊爲了簡化大多數的部署場景所做的一項改進,如果默認設置不能滿足需要,用戶可以通過修改配置文件指定server.loader和share.loader的方式重新啓用Tomcat 5.x的加載器架構

    Tomcat加載器的實現清晰易懂,並且採用了官方推薦的“正統”的使用類加載器的方式。如果讀者閱讀完上面的案例後,能完全理解Tomcat設計團隊這樣佈置加載器架構的用意,那說明已經大致掌握了類加載器“主流”的使用方式,那麼筆者不妨再提一個問題讓讀者思考一下:前面曾經提到過一個場景,如果有10個Web應用程序都是用Spring來進行組織和管理的話,可以把Spring放到Common或Shared目錄下讓這些程序共享。Spring要對用戶程序的類進行管理,自然要能訪問到用戶程序的類,而用戶的程序顯然是放在/WebApp/WEB-INF目錄中的,那麼被CommonClassLoader或SharedClassLoader加載的Spring如何訪問並不在其加載範圍內的用戶程序呢?如果研究過虛擬機類加載器機制中的雙親委派模型,相信讀者可以很容易地回答這個問題。

  分析:如果按主流的雙親委派機制,顯然無法做到讓父類加載器加載的類 訪問子類加載器加載的類,上面在類加載器一節中提到過通過線程上下文方式傳播類加載器。

  答案是使用線程上下文類加載器來實現的,使用線程上下文加載器,可以讓父類加載器請求子類加載器去完成類加載的動作。看spring源碼發現,spring加載類所用的Classloader是通過Thread.currentThread().getContextClassLoader()來獲取的,而當線程創建時會默認setContextClassLoader(AppClassLoader)即線程上下文類加載器被設置爲 AppClassLoaderspring中始終可以獲取到這個AppClassLoader(  Tomcat裏就是WebAppClassLoader)子類加載器來加載bean ,以後任何一個線程都可以通過 getContextClassLoader()獲取到WebAppClassLoadergetbean  


本篇博文內容取材自《深入理解Java虛擬機:JVM高級特性與最佳實踐》

微信公衆號【Java技術江湖】一位阿里 Java 工程師的技術小站。(關注公衆號後回覆”Java“即可領取 Java基礎、進階、項目和架構師等免費學習資料,更有數據庫、分佈式、微服務等熱門技術學習視頻,內容豐富,兼顧原理和實踐,另外也將贈送作者原創的Java學習指南、Java程序員面試指南等乾貨資源)



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