1. 程式人生 > >深入理解JVM類載入機制

深入理解JVM類載入機制

深入理解JVM類載入機制

轉載自:https://blog.csdn.net/a724888/article/details/78396462

簡述:虛擬機器把描述類的資料從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 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語句塊)中的語句合併生成的,編譯器收集的順序是由語句在原始檔中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊中可以賦值,但是不能訪問。

 
  1.  

    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。

 
  1.  

    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方法中:

 
  1.  

    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檔案讀取後最終的位元組陣列.

 
  1.  

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

     

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

自定義類載入器方法呼叫流程圖自定義類載入器方法呼叫流程圖
樣例自定義類載入器:

 

 
  1.  

    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..
演示下效果:

 
  1.  

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

     

返回結果:
[email protected]
true

 
  1.  

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

     

返回結果:
[email protected]
false

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

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

     
    1.  

    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高階特性與最佳實踐》