天天看點

深入了解JVM虛拟機6:深入了解JVM類加載機制深入了解JVM類加載機制JVM系列之類加載流程-自定義類加載器 Tomcat的類加載器架構

https://www.kubernetes.org.cn/1423.html

深入了解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中所有基礎類型的零值:

資料類型 零值
int
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;           

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

4.解析

解析階段是把常量池内的符号引用替換成直接引用的過程,符号引用就是Class檔案中的CONSTANT_Class_info、 CONSTANT_Fieldref_info、CONSTANT_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

符号引用進行解析,這邊記不清的可以繼續回顧

,也就是字段所屬的類或接口的符号引用。如果在解析這個類或接口符号引用的過程中出現了任何異常,都會導緻字段解析失敗。如果解析完成,那将這個字段所屬的類或者接口用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系列之類加載流程-自定義類加載器

老實說,類加載流程作者還是比較熟悉而且有實戰經驗的,因為有過一次自定義類加載器的實戰經驗(文章最後會和大家分享),雖然大部分小夥伴覺得這部分對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

重點分析:

第一個代碼和第二個代碼唯一一點不同的就是在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 、JDBC、JCE、 JAXB 和JBI等。

   雙親委派模型的另一次“被破壞”是由于使用者對程式動态性的追求而導緻的,這裡所說的“ 動态性”指的是目前一些非常“熱門”的名詞:代碼熱替換(HotSwap)、子產品熱部署(HotDeployment)等 ,說白了就是希望應用程式能像我們的計算機外設那樣,接上滑鼠、U盤,不用重新開機機器就能立即使用,滑鼠有問題或要更新就換個滑鼠,不用停機也不用重新開機。對于個人計算機來說,重新開機一次其實沒有什麼大不了的,但對于一些生産系統來說,關機重新開機一次可能就要被列為生産事故,這種情況下熱部署就對軟體開發者,尤其是企業級軟體開發者具有很大的吸引力。Sun 公司所提出的JSR-294、JSR-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、WebLogic、WebSphere 或其他筆者沒有列舉的伺服器,都實作了自己定義的類加載器(一般都不止一個)。因為一個功能健全的 Web容器,要解決如下幾個問題:

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

   2)部署在同一個Web容器上 的兩個Web應用程式所使用的Java類庫可以互相共享 。這個需求也很常見,例如,使用者可能有10個使用

spring

 組織的應用程式部署在同一台伺服器上,如果把10份Spring分别存放在各個應用程式的隔離目錄中,将會是很大的資源浪費——這主要倒不是浪費磁盤空間的問題,而是指類庫在使用時都要被加載到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),即線程上下文類加載器被設定為 AppClassLoader,spring中始終可以擷取到這個AppClassLoader( 在 Tomcat裡就是WebAppClassLoader)子類加載器來加載bean ,以後任何一個線程都可以通過 getContextClassLoader()擷取到WebAppClassLoader來getbean 了 。

本篇博文内容取材自《深入了解Java虛拟機:JVM進階特性與最佳實踐》

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