寫在前面:
- 你好,歡迎你的閱讀!
- 我熱愛技術,熱愛分享,熱愛生活, 我始終相信:技術是開源的,知識是共享的!
- 博客裏面的內容大部分均爲原創,是自己日常的學習記錄和總結,便於自己在後面的時間裏回顧,當然也是希望可以分享自己的知識。目前的內容幾乎是基礎知識和技術入門,如果你覺得還可以的話不妨關注一下,我們共同進步!
- 除了分享博客之外,也喜歡看書,寫一點日常雜文和心情分享,如果你感興趣,也可以關注關注!
- 微信公衆號:傲驕鹿先生
目錄
Java程序實際上是將。class文件放入JVM中運行。虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗,轉換,解析和初始化,最終形成可以被虛擬機直接使用的Java類型,這就是JVM的類加載機制
一、類加載機制概述
Java類編譯成Class字節碼的相關描述和信息,但是java虛擬機如何才能按照class字節碼中描述的內容進行運用和使用呢?這個就需要JVM的類加載機制對其進行規範和約束;所以虛擬機把類的數據從Class文件(這裏的Class文件可以是javac編譯成的class文件,也可以是反射或者動態代理生成的class二進制流,或者網絡傳輸的二進制流等等)加載到內存,並對數據進行校驗,轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型的過程就是虛擬機的類加載機制。
JVM類加載機制主要包括兩個問題:類加載的時機與步驟和類加載的方式。
與那些在編譯時需要進行連接工作的語言不同,在Java語言裏面,類型的加載、連接和初始化過程都是在程序運行期間完成的,這種策略雖然會令類加載時稍微增加一些性能開銷,但是會爲Java應用程序提供高度的靈活性,Java裏天生可以動態擴展的語言特性就是依賴運行期動態加載和動態鏈接這個特點實現的。例如,如果編寫一個面向接口的應用程序,可以等到運行時再指定其實際的實現類;用戶可以通過Java預定義的和自定義類加載器,讓一個本地的應用程序可以再運行時從網絡或其他地方加載一個二進制流作爲程序代碼的一部分,這種組裝應用程序的方式目前已廣泛應用於Java程序之中。那麼,對於Java的類加載會產生如下問題:
-
虛擬機什麼時候纔會加載Class文件並初始化類呢?(類加載和初始化時機)
-
虛擬機如何加載一個Class文件呢?(Java類加載的方式:類加載器、雙親委派機制)
-
虛擬機加載一個Class文件要經歷那些具體的步驟呢?(類加載過程與步驟)
二、類加載的時機
Java類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)七個階段。其中準備、驗證、解析三個部分統稱爲連接(Linking),這7個階段的發生順序如下圖所示。
加載、驗證、準備、初始化和卸載5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以再初始化階段後再開始,這是爲了支持Java語言的運行時綁定(也稱動態綁定或晚期綁定)。注意:類的加載過程必須按照這種順序按部就班地開始,而不是按部就班地進行或完成,因爲這些階段通常都是相互交叉地混合式進行的,通常會在一個階段執行的過程中調用、激活另外一個階段。
2.1、類加載的時機
Java虛擬機規範中並沒有進行強制約束,這點可以交給虛擬機的具體實現來自由把握。但是對於初始化階段,虛擬機規範則是嚴格規定了有且只有5種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之前開始),具體有5種情況:
-
遇到new、getstatic、putstatic或invokestatic這四條字節碼指令
-
使用java.lang.reflect包的方法對類進行反射調用的時候
-
初始化類時,父類沒有被初始化,先初始化父類
-
虛擬機啓動時,用戶指定的主類(包含main)
-
當使用jdk1.7動態語言支持時,如果一個java.lang.invoke.MethodHandle實例解析出REF__pubstatic,REF_invokestatic方法句柄,並且這個方法句柄所對應的類沒有進行初始化
其中第一點主要解釋爲以下四個方面:
1、使用 new 關鍵字實例化對象時;
2、讀取類的靜態變量時(被 final修飾,已在編譯期把結果放入常量池的靜態字段除外);
3、設置類的靜態變量時;
4、調用一個類的靜態方法時。需要注意:newarray指令觸發的只是數組類型本身的初始化,而不會導致其相關類型的初始化,比如,new String[]只會直接觸發String[]類的初始化,也就是觸發對類java.lang.String的初始化,而直接不會觸發String類的初始化。生成這四條指令最常見的Java代碼場景是:
-
使用new關鍵字實例化對象的時候;
-
讀取或設置一個類的靜態字段(被final修飾,已在編譯器把結果放入常量池的靜態字段除外)的時候;
-
調用一個類的靜態方法的時候;
對於這5種會觸發類進行初始化的場景,虛擬機規範中使用了一個很強烈的限定語:“有且只有”,這5種場景中的行爲稱爲對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱爲被動引用。
需要特別指出的是,類的實例化和類的初始化是兩個完全不同的概念:
-
類的實例化是指創建一個類的實例(對象)的過程;
-
類的初始化是指爲類各個成員賦初始值的過程,是類生命週期中的一個階段。
2.2 被動引用常見的三種場景
1、通過子類引用父類的靜態字段,不會導致子類初始化
/**
* <Description> 輸出:Initialize class Dgrandpa
Initialize class Dfather
* 對於靜態字段,只有直接定義這個字段的類纔會被初始化,因此通過其子類來引用父類中定義的靜態字段,
* 只會觸發父類的初始化而不會觸發子類的初始化。至於是否要觸發子類的加載和驗證,在虛擬機中並未明確規定,
* 這點取決於虛擬機的具體實現。對於Sun HotSpot虛擬機來說,可通過-XX:+TraceClassLoading參數觀察到此操作
* 會導致子類的加載。
*/
public class PrTest1 {
public static void main(String[] args) {
int x = Dson.count;
}
}
class Dgrandpa {
static {
System.out.println("Initialize class Dgrandpa");
}}class Dfather extends Dgrandpa{
static int count = 1;
static{
System.out.println("Initialize class Dfather");
}
}
class Dson extends Dfather{
static{
System.out.println("Initialize class Dson");
}
}
2、通過數組定義來引用類,不會觸發此類的初始化
/**
* <Description> 沒有任何輸出
* 通過數組來定義引用類,不會觸發此類的初始化
*/
public class PrTest2 {
public static void main(String[] args) {
E[] e = new E[10];
}
}
class E{
static{
System.out.println("Initialize class E");
}
}
3、常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化
/**
* <Description> 輸出:1
* 常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,
* 因此不會觸發定義常量的類的初始化
*/
public class PrTest3 {
public static void main(String[] args) {
System.out.println(ConstClass.COUNT );
}
}
class ConstClass{
static final int COUNT = 1;
static{
System.out.println("Initialize class ConstClass");
}
}
上述代碼運行之後,只輸出“1”,這是因爲雖然在Java源碼中引用了ConstClass類中的常量COUNT,但是編譯階段將此常量的值“1”存儲到了PrTest3常量池中,對常量ConstClass.COUNT的引用實際都被轉化爲PrTest3類對自身常量池的引用了。也就是說,實際上PrTest3的Class文件之中並沒有ConstClass類的符號引用入口,這兩個類在編譯爲Class文件之後就不存在關係了。
三、類加載過程
類從加載虛擬機內存中開始到卸載出內存爲止,生命週期包括:加載、驗證、準備、解析、初始化、使用、卸載。
3.1、加載(Loading)
在加載階段,虛擬機需要完成以下三件事情:
a、通過一個類的全限定名來獲取定義此類的二進制字節流(並沒有指明要從一個Class文件中獲取,可以從其他渠道,如:網絡、動態生成、數據庫等);
b、將這個字節流所代表的的靜態存儲結構轉化爲方法區的運行時數據結構;
c、在內存中(對於HotSpot虛擬機而言就是方法區)生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據訪問入口;
加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在夾在階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持着固定的先後順序。
3.2 、驗證(Verification)
驗證是連接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會尾號虛擬機自身的安全。驗證階段大致會完成資格階段的檢驗動作:
-
文件格式驗證:驗證字節流是否符合Class文件格式的規範(如:是否以魔數0xCAFEBABE開頭,主次版本號是否在當前虛擬機的處理範圍之內、常量池中是否有不被支持的類型)
-
元數據驗證:對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求(如:這個類是否有父類,除了java.lang.Object之外)
-
字節碼驗證:通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的;
-
符號引用驗證:確保解析動作能正確執行。
驗證階段是非常重要的,但不是必須的,它對程序運行期沒有影響。如果所引用的類經過反覆驗證,那麼可以考慮採用-Xverifynone參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
3.3、 準備(Preparation)
準備階段是正式爲類變量(static成員變量)分配內存並設置類變量初始值(零值)的階段,這些變量所使用的內存都將在方法區中進行分配。這時候進行內存分配的僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在堆中。
其次,這裏所說的初始值通常情況下是數據類型的零值,假設一個類變量定義爲:
public static int value = 123;
那麼,變量value在準備階段過後的值爲0而不是123。因爲這時候尚未開始執行任何java方法,而把value複製爲123的putstatic指令時程序被變異後,存放於類構造器方法<clinit>()之中,所以把value賦值爲123的動作將在初始化階段纔會執行。至於“特殊情況”是指:當類字段的字段屬性是ConstantValue時,會在準備階段初始化爲指定的值,所以標註爲final之後,value的值在準備階段初始化爲123而非0;
public static final int value = 123;
3.4 、解析(Resolution)
解析階段是把常量池內的符號引用替換成直接引用的過程,符號引用就是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,那麼會執行下面步驟:
-
如果C不是數組類型,D的定義類加載器被用來創建類N或者接口C。加載過程中出現任何異常,可以被認爲是類和接口解析失敗。
-
如果C是數組類型,並且它的元素類型是引用類型。那麼表示元素類型的類或接口的符號引用會通過遞歸調用來解析。
-
檢查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異常。
接口方法解析
接口方法也需要解析出接口方法表的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異常。
3.5 初始化(Initialization)
類初始化階段是類加載過程的最後一步。在前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的java程序代碼(字節碼)。
在準備階段,變量已經賦過一次系統要求的初始值(零值);而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源,或者更直接地說:初始化階段是執行類構造器<clinit>()方法的過程。<clinit>()方法時由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊static{}中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句塊可以賦值,但是不能訪問。如下:
public class Test{
static{
i=0;
System.out.println(i);//Error:Cannot reference a field before it is defined(非法向前應用)
}
static int i=1;
}
那麼註釋報錯的那行代碼,改成下面情形,程序就可以編譯通過並可以正常運行了。
public class Test{
static{
i=0;
//System.out.println(i);
}
static int i=1;
public static void main(String args[]){
System.out.println(i);
}
}
類構造器<clinit>()與實例構造器<init>()不同,它不需要程序員進行顯式調用,虛擬機會保證在子類類構造器<clinit>()執行之前,父類的類構造<clinit>()執行完畢。由於父類的構造器<clinit>()先執行,也就意味着父類中定義的靜態語句塊/靜態變量的初始化要優先於子類的靜態語句塊/靜態變量的初始化執行。特別地,類構造器<clinit>()對於類或者接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對類變量的賦值操作,那麼編譯器可以不爲這個類生產類構造器<clinit>()。
虛擬機會保證一個類的類構造器<clinit>()在多線程環境中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的類構造器<clinit>(),其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。特別需要注意的是,在這種情形下,其他線程雖然會被阻塞,但如果執行<clinit>()方法的那條線程退出後,其他線程在喚醒之後不會再次進入/執行<clinit>()方法,因爲在同一個類加載器下,一個類型只會被初始化一次。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個線程阻塞,在實際應用中這種阻塞往往是隱藏的,如下所示:
public class DealLoopTest {
static{
System.out.println("DealLoopTest...");
}
static class DeadLoopClass {
static {
if (true) {
System.out.println(Thread.currentThread()
+ "init DeadLoopClass");
while (true) { // 模擬耗時很長的操作
}
}
}
}
public static void main(String[] args) {
Runnable script = new Runnable() { // 匿名內部類
public void run() {
System.out.println(Thread.currentThread() + " start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + " run over");
}
};
Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
}/
* Output:
DealLoopTest...
Thread[Thread-1,5,main] start
Thread[Thread-0,5,main] start
Thread[Thread-1,5,main]init DeadLoopClass
*/
四、類加載器和雙親委派機制
我們對java.lang.ClassNotFoundExcetpion這個異常肯定都不陌生,其實,這個異常背後涉及到的是Java技術體系中的類加載。Java類加載機制是技術體系中比較核心的部分,雖然和大部分開發人員直接打交道不多,但是對其背後的機理有一定理解有助於排查程序中出現的類加載失敗等技術問題,對理解Java虛擬機的連接模型和Java語言的動態性都有很大幫助。
4.1、 JVM三種預定義類型類加載器
當JVM啓動的時候,Java缺省開始使用如下三種類型的類加載器:
啓動(Bootstrap ClassLoader)類加載器:引導類加載器是用 本地代碼實現的類加載器,它負責將 <JAVA_HOME>/lib下面的核心類庫 或 -Xbootclasspath選項指定的jar包等虛擬機識別的類庫加載到內存中。由於引導類加載器涉及到虛擬機本地實現細節,開發者無法直接獲取到啓動類加載器的引用,所以不允許直接通過引用進行操作。
擴展(Extension ClassLoader)類加載器:擴展類加載器是由Sun的ExtClassLoader(sun.misc.Launcher$ExtClassLoader)實現的,它負責將<JAVA_HOME >/lib/ext或者由系統變量-Djava.ext.dir指定位置中的類庫 加載到內存中。開發者可以直接使用標準擴展類加載器。
系統(System ClassLoader)類加載器:系統類加載器是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)實現的,它負責將用戶類路徑(java -classpath或-Djava.class.path變量所指的目錄,即當前類所在路徑及其引用的第三方類庫的路徑)下的類庫 加載到內存中。開發者可以直接使用系統類加載器。一般情況下這就是系統默認的類加載器
除了以上列舉的三種類加載器,還有一種比較特殊的類型就是線程上下文類加載器(後文進行學習)
4.2、 類加載雙親委派機制
雙親委派模型是一種組織類加載器之間關係的一種規範。
工作原理:如果一個類加載器收到了類加載的請求,它不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,這樣層層遞進,最終所有的加載請求都被傳到最頂層的啓動類加載器中,只有當父類加載器無法完成這個加載請求(它的搜索範圍內沒有找到所需的類)時,纔會交給子類加載器去嘗試加載。
這樣的好處是:java類隨着它的類加載器一起具備了帶有優先級的層次關係。這是十分必要的,比如java.lang.Object,它存放在\jre\lib\rt。jar中,它是所有java類的父類,因此無論哪個類加載都要加載這個類,最終所有的加載請求都彙總到頂層的啓動類加載器中。Object類會由啓動類加載器來加載,所以加載的都是同一個類,如果不使用雙親委派模型,由各個類加載器自行去加載的話,系統中就會出現不止一個Object類,應用程序就會全亂了。
擴展類加載器和系統類加載器均是繼承自 java.lang.ClassLoader抽象類。我們下面我們就看簡要介紹一下抽象類 java.lang.ClassLoader中幾個最重要的方法:
//加載指定名稱(包括包名)的二進制類型,供用戶調用的接口
public Class<?> loadClass(String name) throws ClassNotFoundException{ … }
//加載指定名稱(包括包名)的二進制類型,同時指定是否解析(但是這裏的resolve參數不一定真正能達到解析的效果),供繼承用
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{ … }
//findClass方法一般被loadClass方法調用去加載指定名稱類,供繼承用
protected Class<?> findClass(String name) throws ClassNotFoundException { … }
//定義類型,一般在findClass方法中讀取到對應字節碼後調用,final的,不能被繼承 //這也從側面說明:JVM已經實現了對應的具體功能,解析對應的字節碼,產生對應的內部數據結構放置到方法區,所以無需覆寫,直接調用就可以了)
protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError{ … }
通過進一步分析標準擴展類加載器和系統類加載器的代碼以及其公共父類(java.net.URLClassLoader和java.security.SecureClassLoader)的代碼可以看出,都沒有覆寫java.lang.ClassLoader中默認的加載委派規則 — loadClass(…)方法。既然這樣,我們就可以從java.lang.ClassLoader中的loadClass(String name)方法的代碼中分析出虛擬機默認採用的雙親委派機制到底是什麼模樣:
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
// 首先判斷該類型是否已經被加載
Class c = findLoadedClass(name);
if (c == null) {
//如果沒有被加載,就委託給父類加載或者委派給啓動類加載器加載
try {
if (parent != null) {
//如果存在父類加載器,就委派給父類加載器加載
c = parent.loadClass(name, false);
} else { // 遞歸終止條件
// 由於啓動類加載器無法被Java程序直接引用,因此默認用 null 替代
// parent == null就意味着由啓動類加載器嘗試加載該類,
// 即通過調用 native方法 findBootstrapClass0(String name)加載
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父類加載器不能完成加載請求時,再調用自身的findClass方法進行類加載,若加載成功,findClass方法返回的是defineClass方法的返回值
// 注意,若自身也加載不了,會產生ClassNotFoundException異常並向上拋出
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
通過上面的代碼分析,我們可以對JVM採用的雙親委派類加載機制有了更感性的認識,下面我們就接着分析一下啓動類加載器、標準擴展類加載器和系統類加載器三者之間的關係。
上面圖片給人的直觀印象是:系統類加載器的父類加載器是標準擴展類加載器,標準擴展類加載器的父類加載器是啓動類加載器,下面我們就用代碼具體測試一下:
public class LoaderTest {
public static void main(String[] args) {
try {
System.out.println(ClassLoader.getSystemClassLoader());
System.out.println(ClassLoader.getSystemClassLoader().getParent());
System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());
} catch (Exception e) {
e.printStackTrace();
}
}
}
/* Output:
sun.misc.Launcher$AppClassLoader@6d06d69c
sun.misc.Launcher$ExtClassLoader@70dea4e
null
*/
通過以上的代碼輸出,我們知道:通過java.lang.ClassLoader.getSystemClassLoader()可以直接獲取到系統類加載器,並且可以判定系統類加載器的父加載器是標準擴展類加載器,但是我們試圖獲取標準擴展類加載器的父類加載器時卻得到了null。事實上,由於啓動類加載器無法被Java程序直接引用,因此JVM默認直接使用 null 代表啓動類加載器。我們還是藉助於代碼分析一下,首先看一下java.lang.ClassLoader抽象類中默認實現的兩個構造函數:
protected ClassLoader() {
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkCreateClassLoader();
}
//默認將父類加載器設置爲系統類加載器,getSystemClassLoader()獲取系統類加載器
this.parent = getSystemClassLoader();
initialized = true;
}
protected ClassLoader(ClassLoader parent) {
SecurityManager security = System.getSecurityManager();
if (security != null) {
security.checkCreateClassLoader();
}
//強制設置父類加載器
this.parent = parent;
initialized = true;
}
緊接着,我們再看一下ClassLoader抽象類中parent成員的聲明:
// The parent class loader for delegation
private ClassLoader parent;
聲明爲私有變量的同時並沒有對外提供可供派生類訪問的public或者protected設置器接口(對應的setter方法),結合前面的測試代碼的輸出,我們可以推斷出:
1.系統類加載器(AppClassLoader)調用ClassLoader(ClassLoader parent)構造函數將父類加載器設置爲標準擴展類加載器(ExtClassLoader)。(因爲如果不強制設置,默認會通過調用getSystemClassLoader()方法獲取並設置成系統類加載器,這顯然和測試輸出結果不符。)
2.擴展類加載器(ExtClassLoader)調用ClassLoader(ClassLoader parent)構造函數將父類加載器設置爲null(null 本身就代表着引導類加載器)。(因爲如果不強制設置,默認會通過調用getSystemClassLoader()方法獲取並設置成系統類加載器,這顯然和測試輸出結果不符。)
事實上,這就是啓動類加載器、標準擴展類加載器和系統類加載器之間的委派關係。
4.3、類加載雙親委派實例
以上已經簡要介紹了虛擬機默認使用的啓動類加載器、標準擴展類加載器和系統類加載器,並以三者爲例結合JDK代碼對JVM默認使用的雙親委派類加載機制做了分析。下面我們就來看一個綜合的例子,首先在IDE中建立一個簡單的java應用工程,然後寫一個簡單的JavaBean如下:
package classloader.test.bean;
public class TestBean {
public TestBean() { }
}
在現有當前工程中另外建立一個測試類(ClassLoaderTest.java)內容如下:
測試一:
package classloader.test.bean;
public class ClassLoaderTest {
public static void main(String[] args) {
try {
//查看當前系統類路徑中包含的路徑條目
System.out.println(System.getProperty("java.class.path"));
//調用加載當前類的類加載器(這裏即爲系統類加載器)加載TestBean
Class typeLoaded = Class.forName("classloader.test.bean.TestBean");
//查看被加載的TestBean類型是被那個類加載器加載的
System.out.println(typeLoaded.getClassLoader());
} catch (Exception e) {
e.printStackTrace();
}
}
}/* Output:
I:\AlgorithmPractice\TestClassLoader\bin
sun.misc.Launcher$AppClassLoader@6150818a
*/
測試二:
將當前工程輸出目錄下的TestBean.class打包進test.jar剪貼到/lib/ext目錄下(現在工程輸出目錄下和JRE擴展目錄下都有待加載類型的class文件)。再運行測試一測試代碼,結果如下:
I:\AlgorithmPractice\TestClassLoader\bin
sun.misc.Launcher$ExtClassLoader@15db9742
測試三:
將test.jar拷貝一份到/lib下,運行測試代碼,輸出如下:
I:\AlgorithmPractice\TestClassLoader\bin
sun.misc.Launcher$ExtClassLoader@15db9742
測試三和測試二輸出結果一致。那就是說,放置到/lib目錄下的TestBean對應的class字節碼並沒有被加載,這其實和前面講的雙親委派機制並不矛盾。虛擬機出於安全等因素考慮,不會加載<JAVA_HOME>/lib目錄下存在的陌生類,換句話說,虛擬機只加載<JAVA_HOME>/lib目錄下它可以識別的類。因此,開發者通過將要加載的非JDK自身的類放置到此目錄下期待啓動類加載器加載是不可能的。
五、Java程序動態擴展方式
public static Class<?> forName(String name, boolean initialize, ClassLoader loader) throws ClassNotFoundException
這裏的initialize參數是很重要的,它表示在加載同時是否完成初始化的工作(說明:單參數版本的forName方法默認是完成初始化的)。有些場景下需要將initialize設置爲true來強制加載同時完成初始化,例如典型的就是利用DriverManager進行JDBC驅動程序類註冊的問題。因爲每一個JDBC驅動程序類的靜態初始化方法都用DriverManager註冊驅動程序,這樣才能被應用程序使用。這就要求驅動程序類必須被初始化,而不單單被加載。Class.forName的一個很常見的用法就是在加載數據庫驅動的時候。如 Class.forName(“org.apache.derby.jdbc.EmbeddedDriver”).newInstance()用來加載 Apache Derby 數據庫的驅動。
六、常見的問題分析
1、由不同的類加載器加載的指定類還是相同的類型嗎?
在Java中,一個類用其完全匹配類名(fully qualified class name)作爲標識,這裏指的完全匹配類名包括包名和類名。但在JVM中,一個類用其全名和一個ClassLoader的實例 作爲唯一標識,不同類加載器加載的類將被置於不同的命名空間。我們可以用兩個自定義類加載器去加載某自定義類型(注意不要將自定義類型的字節碼放置到系統路徑或者擴展路徑中,否則會被系統類加載器或擴展類加載器搶先加載),然後用獲取到的兩個Class實例進行java.lang.Object.equals(…)判斷,將會得到不相等的結果,如下所示:
public class TestBean {
public static void main(String[] args) throws Exception {
// 一個簡單的類加載器,逆向雙親委派機制
// 可以加載與自己在同一路徑下的Class文件
ClassLoader myClassLoader = new ClassLoader() {
@Override
public Class<?> loadClass(String name)throws ClassNotFoundException {
try {
String filename = name.substring(name.lastIndexOf(".") + 1)
+ ".class";
InputStream is = getClass().getResourceAsStream(filename);
if (is == null) {
return super.loadClass(name); // 遞歸調用父類加載器
}
byte[] b = new byte[is.available()];
is.read(b);
return defineClass(name, b, 0, b.length);
} catch (Exception e) {
throw new ClassNotFoundException(name);
}
}
};
Object obj = myClassLoader.loadClass("classloader.test.bean.TestBean")
.newInstance();
System.out.println(obj.getClass());
System.out.println(obj instanceof classloader.test.bean.TestBean);
}
}
/* Output:
class classloader.test.bean.TestBean
false
*/
我們發現,obj 確實是類classloader.test.bean.TestBean實例化出來的對象,但當這個對象與類classloader.test.bean.TestBean做所屬類型檢查時卻返回了false。這是因爲虛擬機中存在了兩個TestBean類,一個是由系統類加載器加載的,另一個則是由我們自定義的類加載器加載的,雖然它們來自同一個Class文件,但依然是兩個獨立的類,因此做所屬類型檢查時返回false。
2、在代碼中直接調用Class.forName(String name)方法,到底會觸發那個類加載器進行類加載行爲?
Class.forName(String name)默認會使用調用類的類加載器來進行類加載。我們直接來分析一下對應的jdk的代碼:
//java.lang.Class.java
publicstatic Class<?> forName(String className) throws ClassNotFoundException {
return forName0(className, true, ClassLoader.getCallerClassLoader());
}
//java.lang.ClassLoader.java
// Returns the invoker's class loader, or null if none.
static ClassLoader getCallerClassLoader() {
// 獲取調用類(caller)的類型
Class caller = Reflection.getCallerClass(3);
// This can be null if the VM is requesting it
if (caller == null) {
return null;
}
// 調用java.lang.Class中本地方法獲取加載該調用類(caller)的ClassLoader
return caller.getClassLoader0();
}
//java.lang.Class.java
//虛擬機本地實現,獲取當前類的類加載器,前面介紹的Class的getClassLoader()也使用此方法
native ClassLoader getClassLoader0();
3、在編寫自定義類加載器時,如果沒有設定父加載器,那麼父加載器是誰?
當自定義類加載器沒有指定父類加載器的情況下,默認的父類加載器即爲系統類加載器。同時,我們可以得出如下結論:即使用戶自定義類加載器不指定父類加載器,那麼,同樣可以加載如下三個地方的類:
-
<Java_Runtime_Home>/lib下的類;
-
<Java_Runtime_Home>/lib/ext下或者由系統變量java.ext.dir指定位置中的類;
-
當前工程類路徑下或者由系統變量java.class.path指定位置中的類。
4、在編寫自定義類加載器時,如果將父類加載器強制設置爲null,那麼會有什麼影響?如果自定義的類加載器不能加載指定類,就肯定會加載失敗嗎?
JVM規範中規定如果用戶自定義的類加載器將父類加載器強制設置爲null,那麼會自動將啓動類加載器設置爲當前用戶自定義類加載器的父類加載器(這個問題前面已經分析過了)。同時,我們可以得出如下結論:即使用戶自定義類加載器不指定父類加載器,那麼,同樣可以加載到<JAVA_HOME>/lib下的類,但此時就不能夠加載<JAVA_HOME>/lib/ext目錄下的類了。
5、編寫自定義類加載器時,一般有哪些注意點?
(1)一般儘量不要覆寫已有的loadClass(…)方法中的委派邏輯(Old Generation)
(2)正確設置父類加載器
(3)保證findClass(String name)方法的邏輯正確性
6、如何在運行時判斷系統類加載器能加載哪些路徑下的類?
一是可以直接調用ClassLoader.getSystemClassLoader()或者其他方式獲取到系統類加載器(系統類加載器和擴展類加載器本身都派生自URLClassLoader),調用URLClassLoader中的getURLs()方法可以獲取到。
二是可以直接通過獲取系統屬性java.class.path來查看當前類路徑上的條目信息 :System.getProperty(“java.class.path”)。如下所示:
public class Test {
public static void main(String[] args) {
System.out.println("Rico");
Gson gson = new Gson();
System.out.println(gson.getClass().getClassLoader());
System.out.println(System.getProperty("java.class.path"));
}
}
/* Output:
Rico
sun.misc.Launcher$AppClassLoader@6c68bcef
I:\AlgorithmPractice\TestClassLoader\bin;I:\Java\jars\Gson\gson-2.3.1.jar
*/
7、如何在運行時判斷標準擴展類加載器能加載哪些路徑下的類?
import java.net.URL;
import java.net.URLClassLoader;
public class ClassLoaderTest {
/**
* @param args the command line arguments
*/
public static void main(String[] args) {
try {
URL[] extURLs = ((URLClassLoader) ClassLoader.getSystemClassLoader().getParent()).getURLs();
for (int i = 0; i < extURLs.length; i++) {
System.out.println(extURLs[i]);
}
} catch (Exception e) {
//…
}
}
}
/* Output:
file:/C:/Program%20Files/Java/jdk1.7.0_79/jre/lib/ext/access-bridge-64.jar
file:/C:/Program%20Files/Java/jdk1.7.0_79/jre/lib/ext/dnsns.jar
file:/C:/Program%20Files/Java/jdk1.7.0_79/jre/lib/ext/jaccess.jar
file:/C:/Program%20Files/Java/jdk1.7.0_79/jre/lib/ext/localedata.jar
file:/C:/Program%20Files/Java/jdk1.7.0_79/jre/lib/ext/sunec.jar
file:/C:/Program%20Files/Java/jdk1.7.0_79/jre/lib/ext/sunjce_provider.jar
file:/C:/Program%20Files/Java/jdk1.7.0_79/jre/lib/ext/sunmscapi.jar
file:/C:/Program%20Files/Java/jdk1.7.0_79/jre/lib/ext/zipfs.jar
*/
七、開發自己的類加載器
真正完成類的加載工作是通過調用defineClass來實現的;而啓動類的加載過程是通過調用loadClass來實現的。前者稱爲一個類的定義加載器(defining loader),後者稱爲初始加載器(initiating loader)。在Java虛擬機判斷兩個類是否相同的時候,使用的是類的定義加載器。也就是說,哪個類加載器啓動類的加載過程並不重要,重要的是最終定義這個類的加載器。兩種類加載器的關聯之處在於:一個類的定義加載器是它引用的其它類的初始加載器。
方法 loadClass()拋出的是 java.lang.ClassNotFoundException異常;方法 defineClass()拋出的是 java.lang.NoClassDefFoundError異常。
類加載器在成功加載某個類之後,會把得到的 java.lang.Class類的實例緩存起來。下次再請求加載該類的時候,類加載器會直接使用緩存的類的實例,而不會嘗試再次加載。也就是說,對於一個類加載器實例來說,相同全名的類只加載一次,即 loadClass方法不會被重複調用。
在絕大多數情況下,系統默認提供的類加載器實現已經可以滿足需求。但是在某些情況下,您還是需要爲應用開發出自己的類加載器。比如您的應用通過網絡來傳輸Java類的字節代碼,爲了保證安全性,這些字節代碼經過了加密處理。這個時候您就需要自己的類加載器來從某個網絡地址上讀取加密後的字節代碼,接着進行解密和驗證,最後定義出要在Java虛擬機中運行的類來。
下面將通過兩個具體的實例來說明類加載器的開發。
1、文件系統類加載器
package classloader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
// 文件系統類加載器
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
// 獲取類的字節碼
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name); // 獲取類的字節數組
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] getClassData(String className) {
// 讀取類文件的字節
String path = classNameToPath(className);
try {
InputStream ins = new FileInputStream(path);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead = 0;
// 讀取類文件的字節碼
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
// 得到類文件的完全路徑
return rootDir + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
}
}
如上所示,類 FileSystemClassLoader繼承自類java.lang.ClassLoader。在java.lang.ClassLoader類的常用方法中,一般來說,自己開發的類加載器只需要覆寫 findClass(String name)方法即可。java.lang.ClassLoader類的方法loadClass()封裝了前面提到的代理模式的實現。該方法會首先調用findLoadedClass()方法來檢查該類是否已經被加載過;如果沒有加載過的話,會調用父類加載器的loadClass()方法來嘗試加載該類;如果父類加載器無法加載該類的話,就調用findClass()方法來查找該類。因此,爲了保證類加載器都正確實現代理模式,在開發自己的類加載器時,最好不要覆寫 loadClass()方法,而是覆寫 findClass()方法。
類 FileSystemClassLoader的 findClass()方法首先根據類的全名在硬盤上查找類的字節代碼文件(.class 文件),然後讀取該文件內容,最後通過defineClass()方法來把這些字節代碼轉換成 java.lang.Class類的實例。加載本地文件系統上的類,示例如下:
package com.example;
public class Sample {
private Sample instance;
public void setSample(Object instance) {
System.out.println(instance.toString());
this.instance = (Sample) instance;
}
}
package classloader;
import java.lang.reflect.Method;
public class ClassIdentity {
public static void main(String[] args) {
new ClassIdentity().testClassIdentity();
}
public void testClassIdentity() {
String classDataRootPath = "C:\\Users\\JackZhou\\Documents\\NetBeansProjects\\classloader\\build\\classes";
FileSystemClassLoader fscl1 = new FileSystemClassLoader(classDataRootPath);
FileSystemClassLoader fscl2 = new FileSystemClassLoader(classDataRootPath);
String className = "com.example.Sample";
try {
Class<?> class1 = fscl1.loadClass(className); // 加載Sample類
Object obj1 = class1.newInstance(); // 創建對象
Class<?> class2 = fscl2.loadClass(className);
Object obj2 = class2.newInstance();
Method setSampleMethod = class1.getMethod("setSample", java.lang.Object.class);
setSampleMethod.invoke(obj1, obj2);
} catch (Exception e) {
e.printStackTrace();
}
}
}
/* Output:
com.example.Sample@7852e922
*/
2、網絡類加載器
下面將通過一個網絡類加載器來說明如何通過類加載器來實現組件的動態更新。即基本的場景是:Java 字節代碼(.class)文件存放在服務器上,客戶端通過網絡的方式獲取字節代碼並執行。當有版本更新的時候,只需要替換掉服務器上保存的文件即可。通過類加載器可以比較簡單的實現這種需求。
類 NetworkClassLoader負責通過網絡下載Java類字節代碼並定義出Java類。它的實現與FileSystemClassLoader類似。
package classloader;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URL;
public class NetworkClassLoader extends ClassLoader {
private String rootUrl;
public NetworkClassLoader(String rootUrl) {
// 指定URL
this.rootUrl = rootUrl;
}
// 獲取類的字節碼
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = getClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] getClassData(String className) {
// 從網絡上讀取的類的字節
String path = classNameToPath(className);
try {
URL url = new URL(path);
InputStream ins = url.openStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 4096;
byte[] buffer = new byte[bufferSize];
int bytesNumRead = 0;
// 讀取類文件的字節
while ((bytesNumRead = ins.read(buffer)) != -1) {
baos.write(buffer, 0, bytesNumRead);
}
return baos.toByteArray();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
private String classNameToPath(String className) {
// 得到類文件的URL
return rootUrl + "/" + className.replace('.', '/') + ".class";
}
}
在通過NetworkClassLoader加載了某個版本的類之後,一般有兩種做法來使用它。第一種做法是使用Java反射API。另外一種做法是使用接口。需要注意的是,並不能直接在客戶端代碼中引用從服務器上下載的類,因爲客戶端代碼的類加載器找不到這些類。使用Java反射API可以直接調用Java類的方法。而使用接口的做法則是把接口的類放在客戶端中,從服務器上加載實現此接口的不同版本的類。在客戶端通過相同的接口來使用這些實現類。我們使用接口的方式。示例如下:
客戶端接口:
package classloader;
public interface Versioned {
String getVersion();
}
package classloader;
public interface ICalculator extends Versioned {
String calculate(String expression);
}
網絡上的不同版本的類:
package com.example;
import classloader.ICalculator;
public class CalculatorBasic implements ICalculator {
@Override
public String calculate(String expression) {
return expression;
}
@Override
public String getVersion() {
return "1.0";
}
}
package com.example;
import classloader.ICalculator;
public class CalculatorAdvanced implements ICalculator {
@Override
public String calculate(String expression) {
return "Result is " + expression;
}
@Override
public String getVersion() {
return "2.0";
}
}
在客戶端加載網絡上的類的過程:
package classloader;
public class CalculatorTest {
public static void main(String[] args) {
String url = "http://localhost:8080/ClassloaderTest/classes";
NetworkClassLoader ncl = new NetworkClassLoader(url);
String basicClassName = "com.example.CalculatorBasic";
String advancedClassName = "com.example.CalculatorAdvanced";
try {
Class<?> clazz = ncl.loadClass(basicClassName); // 加載一個版本的類
ICalculator calculator = (ICalculator) clazz.newInstance(); // 創建對象
System.out.println(calculator.getVersion());
clazz = ncl.loadClass(advancedClassName); // 加載另一個版本的類
calculator = (ICalculator) clazz.newInstance();
System.out.println(calculator.getVersion());
} catch (Exception e) {
e.printStackTrace();
}
}
}