C/C++在運行前需要完成預處理、編譯、匯編、鏈接;而在Java中,類加載(加載、連接、初始化)是在程序運行期間第一次使用時動態加載的,而不是編譯時期一次性加載。因為如果在編譯時期一次性加載,會占用很多的內存。在程序運行期間進行類加載會稍微增加程序的開銷,但隨之會帶來更大的好處 —— 提高程序的靈活性。
Java語言的靈活性體現在它可以在運行期間動態擴展,所謂動態擴展就是在運行期間動態加載和動態連接。例如,如果編寫一個面向接口的應用程序,可以等到運行時再指定其實際的實現類;用戶可以通過Java預定義的和自定義類加載器,讓一個本地的應用程序可以在運行時從網絡或其他地方加載一個二進制流作為程序代碼的一部分,這種組裝應用程序的方式目前已廣泛應用于Java程序之中。從最基礎的Applet、JSP到相對復雜的OSGi技術,都使用了Java語言運行期類加載的特性。
類的生命周期
一個類從加載進內存到卸載出內存為止,一共經歷7個階段:
- 加載(Loading)
- 驗證(Verification)
- 準備(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸載(Unloading)
其中加載、驗證、準備、初始化的順序是固定的,雖然它們并不一定是嚴格同步串行執行,存在交叉,但開始時間是按序的。但解析過程在某些情況下可以在初始化階段之后再開始,這是為了支持 Java 的動態綁定。
其中,類加載包括5個階段:
- 加載
- 驗證
- 準備
- 解析
- 初始化
在類加載的過程中,以下3個過程稱為連接:
- 驗證
- 準備
- 解析
因此,JVM的類加載過程也可以概括為3個過程:
- 加載
- 連接
- 初始化
類加載的時機
主動引用
虛擬機規范中并沒有強制約束何時進行加載,但是規范嚴格規定了有且只有下列五種情況必須對類進行加載(加載、驗證、準備都會隨之發生),稱為主動引用:
1、遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有被加載,則需要先加載。
生成這4條指令的最常見的Java代碼場景是:
- 使用new關鍵字實例化對象的時候
- 讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候
- 調用一個類的靜態方法的時候
2、使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有被加載,則需要先加載。
3、當加載一個類的時候,如果發現其父類還沒有被加載,則需要先加載其父類。
4、當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先加載這個主類(當然如果主類存在未加載的父類,會先加載父類)。
5、當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應的類沒有被加載,則需要先加載。
被動引用
5種會觸發類加載的主動引用場景之外,所有引用類的方式都不會觸發類加載,稱為被動引用。
被動引用的場景示例
示例一:通過子類引用父類的靜態字段,不會導致子類加載
package cn.habitdiary;
public class SuperClass{
public static int value = 123;
static{
System.out.println("SuperClass init!");
}
}
public class SubClass extends SuperClass{
static{
System.out.println("SubClass init!");
}
}
public class NotInitialization{
public static void main(String[]args){
System.out.println(SubClass.value);
}
}
輸出結果:
SuperClass init!
原因分析:
本示例看似滿足加載時機的第一條:當要獲取某一個類的靜態成員變量的時候如果該類尚未加載,則對該類進行加載。但對于靜態字段,只有直接定義這個字段的類才會被加載,因此通過其子類來引用父類中定義的靜態字段屬于間接引用,只會觸發父類的加載而不會觸發子類的加載。
示例二:通過數組定義來引用類,不會觸發此類的加載
public class NotInitialization{
public static void main(String[]args){
SuperClass[] sca = new SuperClass[10];
}
}
輸出結果:
無輸出
原因分析:
這個過程看似滿足加載時機的第一條:遇到new創建對象時若類沒被加載,則加載該類。
運行之后發現沒有輸出“SuperClass init!”,說明并沒有觸發類cn.habitdiary.SuperClass的加載階段。但是這段代碼里面觸發了另外一個名為 [Lcn.habitdiary.SuperClass 的類的加載階段。對于用戶代碼來說,這并不是一個合法的類名稱,它是一個由虛擬機自動生成的、直接繼承于java.lang.Object的子類,創建動作由字節碼指令newarray觸發。
這個類代表了一個元素類型為cn.habitdiary.SuperClass的一維數組,數組中應有的屬性和方法(用戶可直接使用的只有被修飾為public的length屬性和clone()方法)都實現在這個類里。
簡言之,現在通過new要創建的是一個SuperClass數組對象,而非SuperClass類對象,因此也屬于間接引用,不會加載SuperClass類。
示例三:常量在編譯階段會存入調用類的常量池中,本質上并沒有直接引用到定義常量的類,因此不會觸發定義常量的類的加載
package cn.habitdiary;
public class ConstClass{
public static final String HELLOWORLD="hello world";
static{
System.out.println("ConstClass init!");
}
}
public class NotInitialization{
public static void main(String[]args){
System.out.println(ConstClass.HELLOWORLD);
}
}
輸出結果:
hello world
原因分析:
本示例看似滿足類加載時機的第一個條件:獲取一個類靜態成員變量的時候若類尚未加載則加載類。
這是因為雖然在Java源碼中引用了ConstClass類中的常量HELLOWORLD,但HELLOWORLD是被final修飾的常量,在編譯階段通過常量傳播優化,已經將此常量的值“hello world”存儲到了引用它的類(這里是NotInitialization類)的常量池中,以后NotInitialization對常量ConstClass.HELLOWORLD的引用實際都被轉化為NotInitialization類對自身常量池的引用了,本質上并沒有直接引用到定義常量的類ConstClass,因此不會觸發它的加載。
也就是說,實際上NotInitialization的Class文件之中并沒有ConstClass類的符號引用入口,這兩個類在編譯成Class之后就不存在任何聯系了。
接口的加載
接口和類都需要加載,接口和類的加載過程基本一樣,不同點在于:類加載時,如果發現父類尚未被加載,則先要加載父類,然后再加載自己;但接口加載時,并不要求父接口已經全部加載,有在真正使用到父接口的時候(如引用接口中定義的常量)才會加載。另外,接口中不能使用“static{}”語句塊。
類加載的過程
接下來我們詳細講解一下Java虛擬機中類加載的全過程,也就是加載、驗證、準備、解析和初始化這5個階段所執行的具體動作。
加載
注意:“加載”是“類加載”過程的第一步,千萬不要混淆。
加載時JVM做了什么?
在加載過程中,JVM主要做3件事情:
- 通過一個類的全限定名來獲取這個類的二進制字節流,即class文件
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構,存儲在方法區中。
- 在堆中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。接下來程序在運行過程中所有對該類的訪問都通過這個類對象,也就是這個Class類型的類對象是提供給外界訪問該類的接口。
加載源(二進制字節流可以從以下方式獲取)
- 文件:從 ZIP 包讀取,這很常見,最終成為日后 JAR、EAR、WAR 格式的基礎。
- 網絡:從網絡中獲取,這種場景最典型的應用是 Applet。
- 由其他文件動態生成:典型場景是 JSP 應用,即由 JSP 文件生成對應的 Class 類。
- 數據庫:將二進制字節流存儲至數據庫中,然后在加載時從數據庫中讀取。這種場景相對少見,例如有些中間件服務器(如 SAP Netweaver)可以選擇把程序安裝到數據庫中來完成程序代碼在集群間的分發。
- 計算生成:運行時計算生成,這種場景使用得最多的就是動態代理技術。在 java.lang.reflect.Proxy 中,就是用了ProxyGenerator.generateProxyClass 的代理類的二進制字節流。
類和數組加載過程的區別?
一個非數組類的加載階段(準確地說,是加載階段中獲取類的二進制字節流的動作)是開發人員可控性最強的,因為加載階段既可以使用系統提供的引導類加載器來完成,也可以由用戶自定義的類加載器去完成,開發人員可以通過定義自己的類加載器去控制字節流的獲取方式。
如果數組的組件類型(Component Type,指的是數組去掉一個維度的類型)是引用類型,那就遞歸采用本節中定義的加載過程去加載這個組件類型,數組將在加載該組件類型的類加載器的類名稱空間上被標識(這點很重要,一個類必須與類加載器一起確定唯一性)。
如果數組的組件類型不是引用類型(例如int[]數組),Java虛擬機將會把數組標記為與引導類加載器關聯。
數組類的可見性與它的組件類型的可見性一致,如果組件類型不是引用類型,那數組類的可見性將默認為public。
加載過程的注意點
1、加載階段完成后,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區之中,方法區中的數據存儲格式由虛擬機實現自行定義,虛擬機規范未規定此區域的具體數據結構。然后在堆中實例化一個java.lang.Class類的對象,這個對象將作為程序訪問方法區中的這些類型數據的外部接口。
2、加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬于連接階段的內容,這兩個階段的開始時間仍然保持著固定的先后順序。
驗證
驗證是連接階段的第一步,這一階段的目的是:為確保Class文件的字節流中包含的信息符合當前虛擬機的要求,并且不會危害虛擬機自身的安全。
驗證階段是非常重要的,這個階段是否嚴謹,直接決定了Java虛擬機是否能承受惡意代碼的攻擊。從執行性能的角度上講,驗證階段的工作量在虛擬機的類加載子系統中又占了相當大的一部分。對于虛擬機的類加載機制來說,驗證階段是一個非常重要的、但不是一定必要(因為對程序運行期沒有影響)的階段。如果所運行的全部代碼(包括自己編寫的及第三方包中的代碼)都已經被反復使用和驗證過,那么在實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
為什么需要驗證?
Java語言本身是相對安全的語言(依然是相對于C/C++來說),使用純粹的Java代碼無法做到諸如訪問數組邊界以外的數據、將一個對象轉型為它并未實現的類型、跳轉到不存在的代碼行之類的事情,如果這樣做了,編譯器將拒絕編譯。但前面已經說過,Class文件并不一定要求用Java源碼編譯而來,可以使用任何途徑產生,甚至包括用十六進制編輯器直接編寫來產生Class文件。在字節碼語言層面上,上述Java代碼無法做到的事情都是可以實現的,至少語義上是可以表達出來的。虛擬機如果不檢查輸入的字節流,對其完全信任的話,很可能會因為載入了有害的字節流而導致系統崩潰,所以驗證是虛擬機對自身保護的一項重要工作。
驗證流程
從整體上看,驗證階段大致上會完成下面4個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。
1.文件格式驗證
第一階段要驗證字節流是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理。
這一階段可能包括下面這些驗證點:
- 是否以魔數0xCAFEBABE開頭。
- 主、次版本號是否在當前虛擬機處理范圍之內,如:JDK1.8(52.0)、JDK1.7(51.0)。
- 常量池的常量中是否有不被支持的常量類型(檢查常量tag標志)。
- 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據。
- Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
. . . . . .
實際上,第一階段的驗證點還遠不止這些,上面這些只是從HotSpot虛擬機源碼中摘抄的一小部分內容,該驗證階段的主要目的是保證輸入的字節流能正確地解析并存儲于方法區之內,格式上符合描述一個Java類型信息的要求。這階段的驗證是基于二進制字節流進行的,只有通過了這個階段的驗證后,字節流才會進入內存的方法區中進行存儲,所以后面的3個驗證階段全部是基于方法區的存儲結構進行的,不會再直接操作字節流。
2.元數據驗證
第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規范的要求,這個階段可能包括的驗證點如下:
- 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
- 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
- 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法。
- 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等)。
. . . . . .
第二階段的主要目的是對類的元數據信息進行語義校驗,保證不存在不符合Java語言規范的元數據信息。
3.字節碼驗證
第三階段是整個驗證過程中最復雜的一個階段,主要目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。 在第二階段對元數據信息中的數據類型做完校驗后,這個階段將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件,例如:
- 保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放置了一個int類型的數據,使用時卻按long類型來加載入本地變量表中。
- 保證跳轉指令不會跳轉到方法體以外的字節碼指令上。
- 保證方法體中的類型轉換是有效的,例如可以把一個子類對象賦值給父類數據類型,這是安全的,但是把父類對象賦值給子類數據類型,甚至把對象賦值給與它毫無繼承關系、完全不相干的一個數據類型,則是危險和不合法的。
. . . . . .
如果一個類方法體的字節碼沒有通過字節碼驗證,那肯定是有問題的;但如果一個方法體通過了字節碼驗證,也不能說明其一定就是安全的。即使字節碼驗證之中進行了大量的檢查,也不能保證這一點。這里涉及了離散數學中一個很著名的問題“Halting Problem” :通俗一點的說法就是,通過程序去校驗程序邏輯是無法做到絕對準確的——不能通過程序準確地檢查出程序是否能在有限的時間之內結束運行。
4.符號引用驗證
最后一個階段的校驗發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的信息進行匹配性校驗,通常需要校驗下列內容:
- 符號引用中通過字符串描述的全限定名是否能找到對應的類。
- 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。
- 符號引用中的類、字段、方法的訪問性(private、protected、public、default)是否可被當前類訪問。
. . . . . .
符號引用驗證的目的是確保解析動作能正常執行,如果無法通過符號引用驗證,那么將會拋出一個java.lang.IncompatibleClassChangeError異常的子類,如:
java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
準備
準備階段是正式為類變量分配內存并設置類變量初始值的階段,這些變量所使用的內存都將在堆中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下:首先,這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在堆中。注意,實例化不是類加載的一個過程,類加載發生在所有實例化操作之前,并且類加載只進行一次,實例化可以進行多次!其次,這里所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義為:
public static int value = 123;
變量value在準備階段過后的初始值為0而不是123!因為這時候尚未開始執行任何Java方法,而把value賦值為123的 putstatic 指令是程序被編譯后,存放于類構造器<clinit>()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。
上面提到,在“通常情況”下初始值是零值,那相對的會有一些“特殊情況”:如果類字段的字段屬性表中存在ConstantValue屬性(即該字段被 final static 修飾),那在準備階段變量value就會被初始化為ConstantValue屬性所指定的值,假設上面類變量value的定義變為:
public static final int value = 123;
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機就會根據ConstantValue的設置將value賦值為123。
解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程,符號引用在講解Class文件格式的時候已經出現過多次,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現,那解析階段中所說的直接引用與符號引用又有什么關聯呢?
符號引用(Symbolic References):
符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標并不一定已經加載到內存中。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機規范的Class文件格式中。
直接引用(Direct References):
直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存布局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。
初始化
類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼(或者說是字節碼)。
在準備階段,類變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去顯式初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合并產生的。
初始化過程的注意點:
- <clinit>()方法中編譯器收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中語句的順序是由語句在源文件中出現的順序所決定的。
- 靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊可以賦值,但是不能訪問,如:
public class Test{
static{
i = 0;//給變量賦值可以正常編譯通過
System.out.print(i);//這句編譯器會提示"非法向前引用"
}
static int i=1;
}
- <clinit>()方法與實例構造器<init>()方法不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機中第一個被執行的<clinit>()方法的類肯定是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>()方法在多線程環境中被正確地加鎖、同步,因此當多條線程同時執行某一個類的<clinit>()方法時,那么只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待。直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個進程阻塞,在實際應用中這種阻塞往往是很隱蔽的。并且,只要有一個<clinit>()方法執行完,其它的<clinit>()方法就不會再被執行。因此,在同一個類加載器下,同一個類只會被初始化一次。
類加載器
類加載器的作用
將class文件加載進JVM的方法區,并在堆中創建一個java.lang.Class對象作為外界訪問這個類的接口。
JVM加載類的兩種方式
- 隱式加載:不通過在代碼里調用ClassLoader來加載需要的類,而是通過主動引用的方式來自動加載類,如new一個類的對象,調用一個類的靜態方法等。
- 顯式加載:在代碼中通過ClassLoader來加載一個類,例如調用this.getClass().getClassLoader().loadClass()或者Class.forName()。
類與類加載器的關系
類加載器雖然只用于實現類的加載動作,但它在Java程序中起到的作用卻遠遠不限于類加載階段。對于任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。這句話可以表達得更通俗一些:只有被同一個類加載器加載的類才可能會相等,否則,即使這兩個類來源于同一個Class文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。
這里所指的“相等”,包括代表類的Class對象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回結果為true,也包括使用instanceof關鍵字做對象所屬關系判定結果為true。
類加載器種類
從Java虛擬機的角度來講,只存在兩種不同的類加載器:
- 啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實現,是虛擬機自身的一部分
- 所有其他的類加載器,這些類加載器都由Java語言實現,獨立于虛擬機外部,并且全都繼承自抽象類java.lang.ClassLoader
從Java開發人員的角度來看,類加載器還可以劃分得更細致一些,絕大部分Java程序都會使用到以下3種系統提供的類加載器:
啟動類加載器(Bootstrap ClassLoader):
采用native code實現,是JVM的一部分,主要加載JVM自身工作需要的類,如java.lang.*、java.util.*等,這些類位于$JAVA_HOME/jre/lib/rt.jar。Bootstrap ClassLoader不繼承自ClassLoader,因為它不是一個普通的Java類,底層由C++編寫,已嵌入到了JVM內核當中,當JVM啟動后,Bootstrap ClassLoader也隨著啟動,負責加載完核心類庫后,并構造Extension ClassLoader和AppClassLoader類加載器。
擴展類加載器(Extension ClassLoader):
這個類加載器是由ExtClassLoader(sun.misc.Launcher$ExtClassLoader)實現的。擴展的class loader,加載位于$JAVA_HOME/jre/lib/ext目錄下的擴展jar。
應用程序類加載器(Application ClassLoader):
這個類加載器是由AppClassLoader(sun.misc.Launcher$AppClassLoader)實現的。由于這個類加載器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般稱為系統類加載器。它負責加載$CLASSPATH所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。
自定義類加載器:
實現步驟
1.定義一個類,繼承 ClassLoader
2.重寫findClass()方法(不打破雙親委派模型)
自定義類加載器的優勢
加密:Java代碼很容易被反編譯,可以先將編譯后的代碼用某種加密算法加密,然后實現自己的類加載器,負責將這段加密后的代碼還原。
從非標準來源加載代碼:例如字節碼位于數據庫甚至云端,就可以通過自定義的類加載器,從指定的來源加載類。
雙親委派模型
什么是雙親委派模型?
我們的應用程序都是由上述前3種類加載器互相配合進行加載的,如果有必要,還可以加入自己定義的類加載器。這些類加載器之間的關系如圖所示:
上圖展示的類加載器之間的這種層次關系,稱為類加載器的雙親委派模型(Parents Delegation Model)。雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應當有自己的父類加載器。這里類加載器之間的父子關系一般不會以繼承(Inheritance)的關系來實現,而是都使用組合(Composition)關系來復用父加載器的代碼。
類加載器的雙親委派模型在JDK 1.2期間被引入并被廣泛應用于之后幾乎所有的Java程序中,但它并不是一個強制性的約束模型,而是Java設計者推薦給開發者的一種類加載器實現方式。
工作過程
如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。
好處
使得 Java 類隨著它的類加載器一起具有一種帶有優先級的層次關系,從而使得基礎類得到統一。例如類java.lang.Object,它存放在rt.jar之中,無論哪一個類加載器要加載這個類,最終都是委派給處于模型最頂端的啟動類加載器進行加載,因此Object類在程序的各種類加載器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個稱為java.lang.Object的類,并放在程序的ClassPath中,那系統中將會出現多個不同的Object類,Java類型體系中最基礎的行為也就無法保證,應用程序也將會變得一片混亂。
實現
雙親委派模型對于保證Java程序的穩定運作很重要,但它的實現卻非常簡單,實現雙親委派的代碼都集中在java.lang.ClassLoader的loadClass()方法之中。邏輯清晰易懂:先檢查是否已經被加載過,若沒有加載則調用父加載器的loadClass()方法,若父加載器為空則默認使用啟動類加載器作為父加載器。如果父類加載失敗,拋出ClassNotFoundException異常后,再調用自己的findClass()方法進行加載。
/**
* 雙親委派模型的實現
*/
public abstract class ClassLoader {
// 父加載器,通過組合而不是繼承實現
private final ClassLoader parent;
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 檢查類是否已被加載過
Class<?> c = findLoadedClass(name);
if (c == null) { //若沒有加載過
try {
if (parent != null) {
//若父加載器不為 null,調用父加載器的loadClass()方法
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
//若父加載器為null,默認使用啟動類加載器作為父加載器
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
//如果父類加載失敗,拋出ClassNotFoundException異常
}
if (c == null) {
//調用自己的findClass()方法進行加載
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
}
自定義類加載器實現
FileSystemClassLoader 是自定義類加載器,繼承自java.lang.ClassLoader,用于加載文件系統上的類。它首先根據類的全名在文件系統上查找類的字節代碼文件(.class 文件),然后讀取該文件內容,最后通過 defineClass() 方法來把這些字節代碼轉換成java.lang.Class 類的實例。
java.lang.ClassLoader 的 loadClass() 實現了雙親委派模型的邏輯,為了不打破雙親委派模型,自定義類加載器一般不去重寫它,但是需要重寫 findClass() 方法
FileSystemClassLoader.java
public class FileSystemClassLoader extends ClassLoader {
private String rootDir;
public FileSystemClassLoader(String rootDir) {
this.rootDir = rootDir;
}
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;
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";
}
}
定義好自定義類加載器,使用Class.forName()帶類加載器的重載方法或者類加載器對象的loadClass()方法就可以使用自定義的類加載器來加載類了。