一文詳解Java中的類加載機制
一、前言
Java虛擬機把描述類的數(shù)據(jù)從Class文件加載到內(nèi)存,并對數(shù)據(jù)進行校驗、轉(zhuǎn)換解析和初始化,最 終形成可以被虛擬機直接使用的Java類型,這個過程被稱作虛擬機的類加載機制。
通俗點講:new 一個對象的時候,首先得需要保證,new的這個對象的class已經(jīng)加載到內(nèi)存,疑問來了,為什么非得要加載到內(nèi)存呢?我們在寫程序的時候,new一個對象也沒說非得加到內(nèi)存當中啊,原因是JVM自帶了類加載器這個功能,也就是在new的時候,會判斷內(nèi)存是否存在這個class類,如果有的話就不用加載,沒有的話類加載器會自動加載,將class文件讀到內(nèi)存當中,所以我們不深層去了解,根本感知不到類加載器的作用。
在Java語言里面,類型的加載、連接和初始化過程都是在程序運行期間完成 的,這種策略讓Java語言進行提前編譯會面臨額外的困難,也會讓類加載時稍微增加一些性能開銷, 但是卻為Java應用提供了極高的擴展性和靈活性,Java天生可以動態(tài)擴展的語言特性就是依賴運行期動態(tài)加載和動態(tài)連接這個特點實現(xiàn)的。
例如,編寫一個面向接口的應用程序,可以等到運行時再指定其實際的實現(xiàn)類,從最基礎的Applet、JSP到相對復雜的OSGi技術,都依賴著Java語言運行期類加載才 得以誕生。
二、類加載的時機
2.1 類加載過程
一個類型從被加載到虛擬機內(nèi)存中開始,到卸載出內(nèi)存為止,它的整個生命周期將會經(jīng)歷加載 (Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、準備、解析三個部分統(tǒng)稱 為連接(Linking)。這七個階段的發(fā)生順序如下圖所示。
加載、驗證、準備、初始化和卸載這五個階段的順序是確定的,類型的加載過程必須按 照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始, 這是為了支持Java語言的運行時綁定特性(也稱為動態(tài)綁定或晚期綁定)。
2.2 什么時候類初始化
關于在什么情況下需要開始類加載過程的第一個階段“加載”,《Java虛擬機規(guī)范》中并沒有進行 強制約束,這點可以交給虛擬機的具體實現(xiàn)來自由把握。但是對于初始化階段,《Java虛擬機規(guī)范》 則是嚴格規(guī)定了有且只有六種情況必須立即對類進行“初始化”(而加載、驗證、準備自然需要在此之 前開始):
1.遇到new、getstatic、putstatic或invokestatic這四條字節(jié)碼指令時,如果類型沒有進行過初始 化,則需要先觸發(fā)其初始化階段。能夠生成這四條指令的典型Java代碼場景有:
- 使用new關鍵字實例化對象的時候。
- 讀取或設置一個類型的靜態(tài)字段(被final修飾、已在編譯期把結(jié)果放入常量池的靜態(tài)字段除外) 的時候。
- 調(diào)用一個類型的靜態(tài)方法的時候。
2.使用java.lang.reflect包的方法對類型進行反射調(diào)用的時候,如果類型沒有進行過初始化,則需 要先觸發(fā)其初始化。
3.當初始化類的時候,如果發(fā)現(xiàn)其父類還沒有進行過初始化,則需要先觸發(fā)其父類的初始化。
4.當虛擬機啟動時,用戶需要指定一個要執(zhí)行的主類(包含main()方法的那個類),虛擬機會先 初始化這個主類。
5.當使用JDK 7新加入的動態(tài)語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解 析結(jié)果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句 柄,并且這個方法句柄對應的類沒有進行過初始化,則需要先觸發(fā)其初始化。
6.當一個接口中定義了JDK 8新加入的默認方法(被default關鍵字修飾的接口方法)時,如果有 這個接口的實現(xiàn)類發(fā)生了初始化,那該接口要在其之前被初始化。
這六種場景中的行為稱為對一個類型進行主動引用。
2.3 被動引用不會初始化
除此之外,所有引用類型的方 式都不會觸發(fā)初始化,稱為被動引用。下面舉三個例子來說明何為被動引用。
代碼示例一:
package org.fenixsoft.classloading; /** * 被動使用類字段演示一: * 通過子類引用父類的靜態(tài)字段,不會導致子類初始化 */ public class SuperClass { static { System.out.println("父類 init!"); } public static int value = 123; } class SubClass extends SuperClass { static { System.out.println("子類 init!"); } } /** * 非主動使用類字段演示 */ class NotInitialization { public static void main(String[] args) { System.out.println(SubClass.value); } }
上述代碼運行之后,只會輸出“父類 init!”,而不會輸出“子類 init!”。
得出結(jié)論:訪問靜態(tài)屬性的時候,不管是通過子類還是父類來訪問這個靜態(tài)屬性,只有靜態(tài)屬性所呆的類會被初始化。至于是否要觸發(fā)子類的加載和驗證階段,在《Java虛擬機規(guī)范》中并未明確規(guī)定。
代碼示例二:
/** * 被動使用類字段演示二: * 通過數(shù)組定義來引用類,不會觸發(fā)此類的初始化 */ class NotInitialization { public static void main(String[] args) { SuperClass[] sca = new SuperClass[10]; } }
為了節(jié)省版面,這段代碼復用了代碼示例一的SuperClass,運行之后發(fā)現(xiàn)沒有輸出“父類 init!”,說明并沒有觸發(fā)類org.fenixsoft.classloading.SuperClass的初始化階段。
但是這段代碼里面觸發(fā)了 另一個名為“[Lorg.fenixsoft.classloading.SuperClass”的類的初始化階段,對于用戶代碼來說,這并不是 一個合法的類型名稱,它是一個由虛擬機自動生成的、直接繼承于java.lang.Object的子類,創(chuàng)建動作由 字節(jié)碼指令newarray觸發(fā)。
這個類代表了一個元素類型為org.fenixsoft.classloading.SuperClass的一維數(shù)組,數(shù)組中應有的屬性 和方法(用戶可直接使用的只有被修飾為public的length屬性和clone()方法)都實現(xiàn)在這個類里。Java語 言中對數(shù)組的訪問要比C/C++相對安全,很大程度上就是因為這個類包裝了數(shù)組元素的訪問。
代碼示例三:
/** * 被動使用類字段演示三: * 常量在編譯階段會存入調(diào)用類的常量池中,本質(zhì)上沒有直接引用到定義常量的類,因此不會觸發(fā)定義常量的 類的初始化 */ public class ConstClass { static { System.out.println("ConstClass init!"); } public static final String HELLOWORLD = "hello world"; } /** * 非主動使用類字段演示 */ class NotInitialization { public static void main(String[] args) { System.out.println(ConstClass.HELLOWORLD); } }
上述代碼運行之后,也沒有輸出“ConstClass init!”,原因是常量在編譯階段存入了常量池,已經(jīng)徹底和類脫離了關系,也就是常量和類的關系 在編譯成 Class文件后就已不存在任何聯(lián)系了。常量已經(jīng)不再屬于這個類。
接口的加載過程與類加載過程稍有不同,針對接口需要做一些特殊說明:接口也有初始化過程, 這點與類是一致的,上面的代碼都是用靜態(tài)語句塊“static{}”來輸出初始化信息的,而接口中不能使 用“static{}”語句塊,但編譯器仍然會為接口生成“<clinit>()”類構造器,用于初始化接口中所定義的 成員變量。接口與類真正有所區(qū)別的是前面講述的六種“有且僅有”需要觸發(fā)初始化場景中的第三種: 當一個類在初始化時,要求其父類全部都已經(jīng)初始化過了,但是一個接口在初始化時,并不要求其父 接口全部都完成了初始化,只有在真正使用到父接口的時候(如引用接口中定義的常量)才會初始 化(被default關鍵字修飾的接口方法這種情況除外)。
三、類加載的過程
接下來我們會詳細了解Java虛擬機中類加載的全過程,即加載、驗證、準備、解析和初始化這五 個階段所執(zhí)行的具體動作。
3.1 加載
“加載”(Loading)階段是整個“類加載”(Class Loading)過程中的一個階段,希望讀者沒有混淆 這兩個看起來很相似的名詞。在加載階段,Java虛擬機需要完成以下三件事情:
1.通過一個類的全限定名來獲取定義此類的二進制字節(jié)流。
2.將這個字節(jié)流所代表的靜態(tài)存儲結(jié)構轉(zhuǎn)化為方法區(qū)的運行時數(shù)據(jù)結(jié)構。
3.在內(nèi)存中生成一個代表這個類的java.lang.Class對象,作為方法區(qū)這個類的各種數(shù)據(jù)的訪問入 口。
“通過一個類的全限定名來獲取定義此類的二進制字節(jié)流”這條規(guī)則,它并沒有指明二 進制字節(jié)流必須得從某個Class文件中獲取,確切地說是根本沒有指明要從哪里獲取、如何獲取。許多舉足輕重的Java技術都建立在這 一基礎之上,例如:
1.從ZIP壓縮包中讀取,這很常見,最終成為日后JAR、EAR、WAR格式的基礎。
2.運行時計算生成,這種場景使用得最多的就是動態(tài)代理技術,在java.lang.reflect.Proxy中,就是用 了ProxyGenerator.generateProxyClass()來為特定接口生成形式為“*$Proxy”的代理類的二進制字節(jié)流。
3.由其他文件生成,典型場景是JSP應用,由JSP文件生成對應的Class文件。
4.可以從加密文件中獲取,這是典型的防Class文件被反編譯的保護措施,通過加載時解密Class文 件來保障程序運行邏輯不被窺探。
加載階段既可以使用Java虛擬機里內(nèi)置的引導類加 載器來完成,也可以由用戶自定義的類加載器去完成,開發(fā)人員通過定義自己的類加載器去控制字節(jié) 流的獲取方式(重寫一個類加載器的findClass()或loadClass()方法),實現(xiàn)根據(jù)自己的想法來賦予應用 程序獲取運行代碼的動態(tài)性。
對于數(shù)組類而言,情況就有所不同,數(shù)組類本身不通過類加載器創(chuàng)建,它是由Java虛擬機直接在內(nèi)存中動態(tài)構造出來的。但數(shù)組類與類加載器仍然有很密切的關系,因為數(shù)組類的元素類型最終還是要靠類加載器來完成加載,一個數(shù)組類(下面簡稱 為C)創(chuàng)建過程遵循以下規(guī)則:
1.如果數(shù)組的組件類型是引用類型,那就遞歸采用本節(jié)中定義的加載過程去加載這個組件類型,數(shù)組C將被標 識在加載該組件類型的類加載器的類名稱空間上。
2.如果數(shù)組的組件類型不是引用類型(例如int[]數(shù)組的組件類型為int),Java虛擬機將會把數(shù)組C 標記為與引導類加載器關聯(lián)。
加載階段與連接階段的部分動作(如一部分字節(jié)碼文件格式驗證動作)是交叉進行的,加載階段 尚未完成,連接階段可能已經(jīng)開始,但這些夾在加載階段之中進行的動作,仍然屬于連接階段的一部 分,這兩個階段的開始時間仍然保持著固定的先后順序。
3.2 驗證
驗證是連接階段的第一步,這一階段的目的是確保Class文件的字節(jié)流中包含的信息符合《Java虛擬機規(guī)范》的全部約束要求,保證這些信息被當作代碼運行后不會危害虛擬機自身的安全。
編譯器驗證: Java語言本身是相對安全的編程語言(起碼對于C/C++來說是相對安全的),將一個對象轉(zhuǎn)型為它并未實現(xiàn)的類型、跳轉(zhuǎn)到不存在的代碼 行之類的事情,如果嘗試這樣去做了,編譯器會毫不留情地拋出異常、拒絕編譯。
字節(jié)碼驗證: 但前面也曾說過, Class文件并不一定只能由Java源碼編譯而來,Java代碼無法做到的事情在字節(jié)碼層面上都是可以實現(xiàn)的,至少 語義上是可以表達出來的。Java虛擬機如果不檢查輸入的字節(jié)流,對其完全信任的話,很可能會因為 載入了有錯誤或有惡意企圖的字節(jié)碼流而導致整個系統(tǒng)受攻擊甚至崩潰,所以驗證字節(jié)碼是Java虛擬 機保護自身的一項必要措施。
驗證階段的工作量在虛擬機的類加載過程中占了相當大 的比重。驗證階段大致上會完成下面四個階段的檢驗動作:文件格式驗證、元數(shù)據(jù)驗證、字節(jié) 碼驗證和符號引用驗證。
文件格式驗證:該驗證階段的主要目的是保證輸入的字節(jié)流能正確地解析并存儲于方法區(qū)之內(nèi),格式上符 合描述一個Java類型信息的要求。這階段的驗證是基于二進制字節(jié)流進行的,只有通過了這個階段的 驗證之后,這段字節(jié)流才被允許進入Java虛擬機內(nèi)存的方法區(qū)中進行存儲,所以后面的三個驗證階段 全部是基于方法區(qū)的存儲結(jié)構上進行的,不會再直接讀取、操作字節(jié)流了。
元數(shù)據(jù)驗證:這個階段可能包括的驗證點如下(內(nèi)容比較多,只列了以下幾點):
1.這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
2.這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
3.如果這個類不是抽象類,是否實現(xiàn)了其父類或接口之中要求實現(xiàn)的所有方法。
字節(jié)碼驗證:第三階段是整個驗證過程中最復雜的一個階段,主要目的是通過數(shù)據(jù)流分析和控制流分析,確定 程序語義是合法的、符合邏輯的。
符號引用驗證:主要作用是驗證該類是否缺少或者被禁止訪問它依賴的某些外部 類、方法、字段等資源。類、字段、方法的可訪問性(private、protected、public、)是否可被當 前類訪問。
如果 程序運行的全部代碼(包括自己編寫的、第三方包中的、從外部加載的、動態(tài)生成的等所有代碼)都 已經(jīng)被反復使用和驗證過,在生產(chǎn)環(huán)境的實施階段就可以考慮使用-Xverify:none參數(shù)來關閉大部分的 類驗證措施,以縮短虛擬機類加載的時間。
3.3 準備
準備階段是正式為類中定義的變量(即靜態(tài)變量,被static修飾的變量)分配內(nèi)存并設置類變量初 始值的階段,從概念上講,這些變量所使用的內(nèi)存都應當在方法區(qū)中進行分配,但必須注意到方法區(qū) 本身是一個邏輯上的區(qū)域,在JDK 7及之前,HotSpot使用永久代來實現(xiàn)方法區(qū)時,實現(xiàn)是完全符合這 種邏輯概念的;而在JDK 8及之后,類變量則會隨著Class對象一起存放在Java堆中。
關于準備階段,還有兩個容易產(chǎn)生混淆的概念筆者需要著重強調(diào),首先是這時候進行內(nèi)存分配的 僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中。其 次是這里所說的初始值“通常情況”下是數(shù)據(jù)類型的零值,假設一個類變量的定義為:
public static int value = 123;
那變量value在準備階段過后的初始值為0而不是123,因為這時尚未開始執(zhí)行任何Java方法,而把 value賦值為123的putstatic指令是程序被編譯后,存放于類構造器()方法之中,所以把value賦值 為123的動作要到類的初始化階段才會被執(zhí)行。下圖列出了Java中所有基本數(shù)據(jù)類型的零值。
3.4 解析
解析階段是Java虛擬機將常量池內(nèi)的符號引用替換為直接引用的過程
- 符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可
- 直接引用(Direct References):直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄
符號引用與虛擬機實現(xiàn)的內(nèi)存布局無關,直接引用是和虛擬機實現(xiàn)的內(nèi)存布局直接相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。
如果有了直接引用,那引用的目標必定已經(jīng)在虛擬機的內(nèi)存中存在。
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調(diào)用點限定符這7類符號引用進行
符號引用要轉(zhuǎn)換成直接引用才有效,這也說明直接引用的效率要比符號引用高。那為什么要用符號引用呢?這是因為類加載之前,javac會將源代碼編譯成.class文件,這個時候javac是不知道被編譯的類中所引用的類、方法或者變量他們的引用地址在哪里,所以只能用符號引用來表示。
3.5 初始化
類的初始化階段是類加載過程的最后一個步驟,除了在加載階 段用戶應用程序可以通過自定義類加載器的方式局部參與外,其余動作都完全由Java虛擬機來主導控 制。直到初始化階段,Java虛擬機才真正開始執(zhí)行類中編寫的Java程序代碼,將主導權移交給應用程 序。
進行準備階段時,變量已經(jīng)賦過一次系統(tǒng)要求的初始零值,而在初始化階段,則會根據(jù)程序員通 過程序編碼制定的主觀計劃去初始化類變量和其他資源。
初始化階段就是執(zhí)行類構造器<clinit>()方法的過程。<clinit>()并不是程序員在Java代碼中直接編寫 的方法,它是Javac編譯器的自動生成物。
<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態(tài)語句塊(static{}塊)中的 語句合并產(chǎn)生的,編譯器收集的順序是由語句在源文件中出現(xiàn)的順序決定的,靜態(tài)語句塊中只能訪問 到定義在靜態(tài)語句塊之前的變量,定義在它之后的變量,在前面的靜態(tài)語句塊可以賦值,但是不能訪 問,如下所示。
<clinit>()方法與類的構造函數(shù)(即在虛擬機視角中的實例構造器<init>()方法)不同,它不需要顯 式地調(diào)用父類構造器,Java虛擬機會保證在子類的<clinit>()方法執(zhí)行前,父類的<clinit>()方法已經(jīng)執(zhí)行 完畢。因此在Java虛擬機中第一個被執(zhí)行的<clinit>()方法的類型肯定是java.lang.Object。
由于父類的<clinit>()方法先執(zhí)行,也就意味著父類中定義的靜態(tài)語句塊要優(yōu)先于子類的變量賦值 操作,如下代碼示例,輸出2。
public class Test { 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); } }
接口中不能使用靜態(tài)語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成 <clinit>()方法。但接口與類不同的是,執(zhí)行接口的<clinit>()方法不需要先執(zhí)行父接口的<clinit>()方法, 因為只有當父接口中定義的變量被使用時,父接口才會被初始化。此外,接口的實現(xiàn)類在初始化時也 一樣不會執(zhí)行接口的<clinit>()方法。
Java虛擬機必須保證一個類的<clinit>()方法在多線程環(huán)境中被正確地加鎖同步,如果多個線程同 時去初始化一個類,那么只會有其中一個線程去執(zhí)行這個類的<clinit>()方法,其他線程都需要阻塞等 待,直到活動線程執(zhí)行完畢<clinit>()方法。如果在一個類的<clinit>()方法中有耗時很長的操作,那就 可能造成多個進程阻塞,在實際應用中這種阻塞往往是很隱蔽的。代碼如下演示了這種場景。
public class Test { static class DeadLoopClass { static { // 如果不加上這個if語句,編譯器將提示“Initializer does not complete normally” 并拒絕編譯 if (true) { System.out.println(Thread.currentThread() + "init DeadLoopClass"); while (true) { } } } } public static void main(String[] args) { Runnable script = new Runnable() { @Override 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(); } }
運行結(jié)果:
需要注意,其他線程雖然會被阻塞,但如果執(zhí)行<clinit>()方法的那條線程退出
<clinit>()方法后,其他線程喚醒后則不會再次進入<clinit>()方法。同一個類加載器下,一個類型只會被初始化一 次。
四、父類和子類初始化過程中的執(zhí)行順序
代碼示例:
public class InitDemo { public static void main(String[] args) { System.out.println("第一次實例化子類:"); new sub(); System.out.println("第二次實例化子類:"); new sub(); } } class Super{ static { System.out.println("父類中的靜態(tài)塊"); } { System.out.println("父類中的非靜態(tài)塊"); } Super(){ System.out.println("父類中的構造方法"); } } class sub extends Super{ static { System.out.println("子類中的靜態(tài)塊"); } { System.out.println("子類中的非靜態(tài)塊"); } sub(){ System.out.println("子類中的構造方法"); } }
運行結(jié)果:
五、類加載器
Java虛擬機設計團隊有意把類加載階段中的“通過一個類的全限定名來獲取描述該類的二進制字節(jié) 流”這個動作放到Java虛擬機外部去實現(xiàn),以便讓應用程序自己決定如何去獲取所需的類。實現(xiàn)這個動 作的代碼被稱為“類加載器”(Class Loader)。
5.1 類與類加載器
對于 任意一個類,都必須由加載它的類加載器和這個類本身一起共同確立其在Java虛擬機中的唯一性,兩個類來源于同一個 Class文件,被同一個Java虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。
這里所指的“相等”,包括代表類的Class對象的equals()方法、isAssignableFrom()方法、isInstance() 方法的返回結(jié)果,也包括了使用instanceof關鍵字做對象所屬關系判定等各種情況。如果沒有注意到類 加載器的影響,在某些情況下可能會產(chǎn)生具有迷惑性的結(jié)果,如下代碼演示了不同的類加載器對 instanceof關鍵字運算的結(jié)果的影響。
代碼示例:
package com.gzl.cn; import java.io.IOException; import java.io.InputStream; public class ClassLoaderTest { public static void main(String[] args) throws Exception { ClassLoader myLoader = 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 (IOException e) { throw new ClassNotFoundException(name); } } }; Object obj = myLoader.loadClass("com.gzl.cn.ClassLoaderTest").newInstance(); System.out.println(obj.getClass()); System.out.println(obj instanceof com.gzl.cn.ClassLoaderTest); } }
上面示例當中構造了一個簡單的類加載器,盡管它極為簡陋,但是對于這個演示來說已經(jīng)足夠。它可以加載與自己在同一路徑下的Class文件,我們使用這個類加載器去加載了一個名 為“com.gzl.cn.ClassLoaderTest”的類,并實例化了這個類的對象。但在第二行的輸出中卻發(fā)現(xiàn)這個對象與類com.gzl.cn.ClassLoaderTest做所屬 類型檢查的時候返回了false。
這是因為Java虛擬機中同時存在了兩個ClassLoaderTest類,一個是由虛擬 機的應用程序類加載器所加載的,另外一個是由我們自定義的類加載器加載的,雖然它們都來自同一 個Class文件,但在Java虛擬機中仍然是兩個互相獨立的類,做對象所屬類型檢查時的結(jié)果自然為 false。
注意:同一個類加載器下,一個類型只會被初始化一 次。
5.2 雙親委派模型
站在Java虛擬機的角度來看,只存在兩種不同的類加載器:
1.一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實現(xiàn),是虛擬機自身的一部分;
2.另外一種就是由Java語言實現(xiàn)的類加載器,獨立存在于虛擬機外部,并且全都繼承自抽象類 java.lang.ClassLoader。
站在Java開發(fā)人員的角度來看,類加載器就應當劃分得更細致一些。自JDK 1.2以來,Java一直保 持著三層類加載器、雙親委派的類加載架構。
下圖中展示的各種類加載器之間的層次關系被稱為類加載器的“雙親委派模型(Parents Delegation Model)”。雙親委派模型要求除了頂層的啟動類加載器外,其余的類加載器都應有自己的父類加載器。不過這里類加載器之間的父子關系一般不是以繼承(Inheritance)的關系來實現(xiàn)的,而是通常使用 組合(Composition)關系來復用父加載器的代碼。
雙親委派模型的工作過程是:
如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加 載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的 加載請求最終都應該傳送到最頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請 求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去完成加載。
為什么使用雙親委派模型?
簡單的來說:一個是安全性,另一個就是性能;(避免重復加載 和 避免核心類被篡改)
用戶自定義一個java.lang.String類,該String類具有系統(tǒng)的String類一樣的功能,只是在某個函數(shù)稍作修改。比如equals函數(shù),這個函數(shù)經(jīng)常使用,如果在這這個函數(shù)中,黑客加入一些“病毒代碼”。并且通過自定義類加載器加入到JVM中。此時,如果沒有雙親委派模型,那么JVM就可能誤以為黑客自定義的java.lang.String類是系統(tǒng)的String類,導致“病毒代碼”被執(zhí)行。
而有了雙親委派模型,黑客自定義的java.lang.String類永遠都不會被加載進內(nèi)存。因為首先是最頂端的類加載器加載系統(tǒng)的java.lang.String類,最終自定義的類加載器無法加載java.lang.String類。
雙親委派模型對于保證Java程序的穩(wěn)定運作極為重要,但它的實現(xiàn)卻異常簡單,用以實現(xiàn)雙親委 派的代碼只有短短十余行,全部集中在java.lang.ClassLoader的loadClass()方法之中,代碼如下:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 首先,檢查請求的類是否已經(jīng)被加載過了 Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 如果父類加載器拋出ClassNotFoundException // 說明父類加載器無法完成加載請求,那就繼續(xù)往下走 } if (c == null) { // 在父類加載器無法加載時 // 再調(diào)用本身的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; } }
啟動類加載器(Bootstrap Class Loader):引導類加載器,又叫啟動類加載器。這個類加載器負責加載存放在 <JAVA_HOME>\lib目錄,或者被-Xbootclasspath參數(shù)所指定的路徑中存放的,而且是Java虛擬機能夠 識別的(按照文件名識別,如rt.jar、tools.jar,名字不符合的類庫即使放在lib目錄中也不會被加載)類庫加載到虛擬機的內(nèi)存中。
擴展類加載器(Extension Class Loader):
Java語言編寫,由sun.misc.Launcher類的內(nèi)部類ExtClassLoader類實現(xiàn),派生于ClassLoader類,父加載器為引導類加載器。
從java.ext.dirs系統(tǒng)屬性所指定的目錄中加載類庫,或從JDK的安裝目錄的jre/lib/ext子目錄(擴展目錄)下加載類庫。如果用戶創(chuàng)建的jar包放在此目錄下,也會自動由擴展類加載器加載。
應用程序類加載器(Application Class Loader):
這個類加載器由 sun.misc.Launcher$AppClassLoader來實現(xiàn)。由于應用程序類加載器是ClassLoader類中的getSystemClassLoader()方法的返回值,所以有些場合中也稱它為“系統(tǒng)類加載器”。
它負責加載用戶類路徑 (ClassPath)上所有的類庫,開發(fā)者同樣可以直接在代碼中使用這個類加載器。如果應用程序中沒有 自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。
5.3 破壞雙親委派模型
上文提到過雙親委派模型并不是一個具有強制性約束的模型,而是Java設計者推薦給開發(fā)者們的 類加載器實現(xiàn)方式。在Java的世界中大部分的類加載器都遵循這個模型,但也有例外的情況,直到Java 模塊化出現(xiàn)為止,雙親委派模型主要出現(xiàn)過3次較大規(guī)模“被破壞”的情況。
這里所說的破壞指的是沒有遵循自下而上的規(guī)則。也就是剛剛提到的雙親委派模型的工作過程。
第一次破壞:
在 jdk 1.2 之前,那時候還沒有雙親委派模型,不過已經(jīng)有了 ClassLoader 這個抽象類,所以已經(jīng)有人繼承這個抽象類,重寫 loadClass 方法來實現(xiàn)用戶自定義類加載器。
而在 1.2 的時候要引入雙親委派模型,為了向前兼容, loadClass 這個方法還得保留著使之得以重寫,新搞了個 findClass 方法讓用戶去重寫,并呼吁大家不要重寫 loadClass 只要重寫 findClass。
這就是第一次對雙親委派模型的破壞,因為雙親委派的邏輯在 loadClass 上,但是又允許重寫 loadClass,重寫了之后就可以破壞委派邏輯了。
第二次破壞:
第二次破壞指的是 JNDI、JDBC 之類的情況。
首先得知道什么是 SPI(Service Provider Interface),它是面向拓展的,也就是說我定義了個規(guī)矩,就是 SPI ,具體如何實現(xiàn)由擴展者實現(xiàn)。
像我們比較熟的 JDBC 就是如此。
MySQL 有 MySQL 的 JDBC 實現(xiàn),Oracle 有 Oracle 的 JDBC 實現(xiàn),我 Java 不管你內(nèi)部如何實現(xiàn)的,反正你們這些數(shù)據(jù)庫廠商都得統(tǒng)一按我這個來,這樣我們 Java 開發(fā)者才能容易的調(diào)用數(shù)據(jù)庫操作,所以在 Java 核心包里面定義了這個 SPI。
而核心包里面的類都是由啟動類加載器去加載的,但它的手只能摸到<JAVA_HOME>\lib或Xbootclasspath指定的路徑中,其他的它鞭長莫及。
而 JDBC 的實現(xiàn)類在我們用戶定義的 classpath 中,只能由應用類加載器去加載,所以啟動類加載器只能委托子類來加載數(shù)據(jù)庫廠商們提供的具體實現(xiàn),這就違反了自下而上的委托機制。
具體解決辦法是搞了個線程上下文類加載器 (Thread Context ClassLoader)。這個類加載器可以通過java.lang.Thread類的setContext-ClassLoader()方 法進行設置,如果創(chuàng)建線程時還未設置,它將會從父線程中繼承一個,如果在應用程序的全局范圍內(nèi) 都沒有設置過的話,那這個類加載器默認就是應用程序類加載器。
如下圖就是JDBC加載驅(qū)動當中用到的上下文類加載器。
其實這里說的打破雙親規(guī)則,就是說用來加載spi實現(xiàn)用的是線程中的加載器(其實就是AppclassLoader),當加載spi實現(xiàn)類時就沒有繼續(xù)調(diào)用父類加載器了,因為它知道父類就是找不到才找它去加載的,說這里違反了雙親規(guī)則。
第三次破壞:
這次破壞是為了滿足熱部署的需求,不停機更新這對企業(yè)來說至關重要,畢竟停機是大事。
OSGI 就是利用自定義的類加載器機制來完成模塊化熱部署,而它實現(xiàn)的類加載機制就沒有完全遵循自下而上的委托,有很多平級之間的類加載器查找,具體就不展開了,有興趣可以自行研究一下。
六、Java模塊化系統(tǒng)
在JDK 9中引入的Java模塊化系統(tǒng)(Java Platform Module System,JPMS)是對Java技術的一次重 要升級,此前,如果類路徑中缺失了運行時依賴的類型,那就只能等程序運行到發(fā)生該類型的加載、鏈接 時才會報出運行的異常。而在JDK 9以后,如果啟用了模塊化進行封裝,模塊就可以聲明對其他模塊 的顯式依賴,這樣Java虛擬機就能夠在啟動時驗證應用程序開發(fā)階段設定好的依賴關系在運行期是否 完備,如有缺失那就直接啟動失敗,從而避免了很大一部分由于類型依賴而引發(fā)的運行時異常。
JDK 9中 的public類型不再意味著程序的所有地方的代碼都可以隨意訪問到它們,模塊提供了更精細的可訪問性 控制,必須明確聲明其中哪一些public的類型可以被其他哪一些模塊訪問,這種訪問控制也主要是在類 加載過程中完成的。
其次是是擴展類加載器(Extension Class Loader)被平臺類加載器(Platform Class Loader)取代。由于咱們一時半會也不會用JDK9,更多具體的我就不整理了。
以上就是一文詳解Java中的類加載機制的詳細內(nèi)容,更多關于Java類加載機制的資料請關注腳本之家其它相關文章!
相關文章
Java import static及import原理區(qū)別解析
這篇文章主要介紹了Java import static及import原理區(qū)別解析,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-10-10