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

  深刻理解JVM類加載機制

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

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

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

 

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

類加載的時機

首先要知道何時類須要被加載,Java虛擬機規範並無約束這一點,可是卻規定了類必須進行初始化的5種狀況,很顯然加載、驗證、準備得在初始化以前,下面具體來講說這5種狀況:程序員

類加載時機類加載時機

 

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

類加載的過程

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

1. 加載

加載是整個類加載過程的第一步,若是須要建立類或者接口,就須要如今Java虛擬機方法區建立於虛擬機實現規定相匹配的內部表示。通常來講類的建立是由另外一個類或者接口觸發的,它經過本身的運行時常量池引用到了須要建立的類,也多是因爲調用了Java核心類庫中的某些方法,譬如反射等。設計模式

通常來講加載分爲如下幾步:數組

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

建立名字爲C的類,若是C不是數組類型,那麼它就能夠經過類加載器加載C的二進制表示(即Class文件)。若是是數組,則是經過Java虛擬機建立,虛擬機遞歸地採用上面提到的加載過程不斷加載數組的組件。緩存

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

  • 引導類加載器(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 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;

編譯時,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符號引用進行解析,這邊記不清的能夠繼續回顧深刻理解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 、JDBC、JCE、 JAXB 和JBI等。

   雙親委派模型的另外一次「被破壞」是因爲用戶對程序動態性的追求而致使的,這裏所說的「 動態性」指的是當前一些很是「熱門」的名詞:代碼熱替換(HotSwap)、模塊熱部署(HotDeployment)等 ,說白了就是但願應用程序能像咱們的計算機外設那樣,接上鼠標、U盤,不用重啓機器就能當即使用,鼠標有問題或要升級就換個鼠標,不用停機也不用重啓。對於我的計算機來講,重啓一次其實沒有什麼大不了的,但對於一些生產系統來講,關機重啓一次可能就要被列爲生產事故,這種狀況下熱部署就對軟件開發者,尤爲是企業級軟件開發者具備很大的吸引力。Sun 公司所提出的JSR-29四、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高級特性與最佳實踐》

相關文章
相關標籤/搜索