《深入理解Java虛擬機》虛擬機類加載機制


 

上節學習回顧

 

上一節,我們深入到類文件去了解其結構細節,也大概對類文件的編寫規則略知一二了,解析來我們就得學習這個類文件是如何被加載到Java虛擬機的,看看有什么引人入勝的奧秘。

 

本節學習重點

 

大部分計算機類科生都應該有接觸過C語言,C語言的編譯過程會有預處理、編譯、匯編、鏈接四個步驟,經過了這四個步驟就生成了可以執行文件(二進制機器碼)。Java同樣如此,

有Java使用經驗的伙伴都應該知道,類加載有加載、連接、初始化三個階段,這就是虛擬機類加載的三大步驟,只有經過了這三大步驟才能在Java虛擬機上跑。唯一不同的是類文件是在程序運行期間執行的,而我們本章學習的重點就是深入這三大步驟,去發掘每一步驟的詳細操作和原理。

 

概述

 

在Java語言里面,類的加載、連接、初始化過程都是在程序運行期間完成的,這種策略雖然會令類加載時稍微增加一些性能開銷,但是會為Java應用程序提供高度的靈活性,Java里天生可以動態擴展的語言特性就是依賴運行期動態加載和動態連接這個特點實現的

 

類加載時機

 

類從被加載到虛擬機內存中開始,到卸載出內存為止,它的整個生命周期如下圖所示:

如上圖,加載、驗證、准備、初始化和卸載這5個階段的順序是確定的,類的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,這是為了支持Java語言的運行時綁定(也稱為動態綁定或晚期綁定)。這一點會在“解析”階段深入學習並說明。

 

在Java虛擬機規范中並沒有進行強制約束,至於什么時候開始實行類加載的“加載”階段,這都就由虛擬機的具體實現來決定,但虛擬機規范嚴格規定了有且只有5種情況必須對類進行“初始化”,當然初始化前的三個階段(加載、驗證、准備)就必須在此之前開始執行了。關於這5種必須初始化的場景如下:

1)遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有初始化,則需要先觸發其初始化;這4條指令對應的的常見場景分別是:使用new關鍵字實例化對象、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。

注:靜態內容是跟類關聯的而不是類的對象。

2)使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。

注:反射機制是在運行狀態中,對於任意一個類,都能夠知道這個類的所有屬性和方法;對於任意一個對象,都能夠調用它的任意一個方法和屬性;這種動態獲取的信息以及動態調用對象的方法的功能稱為java語言的反射機制,這相對好理解為什么需要初始化類。

3)當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

注:子類執行構造函數前需先執行父類構造函數。

4)當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。

注:main方法是程序的執行入口

5)當使用JDK1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最后的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化。則需要先觸發其初始化。

注:JDK1.7的一種新增的反射機制,都是對類的一種動態操作。

 

在虛擬機規范中使用了一個很強烈的限定語:“有且僅有”,這5種場景中的行為稱為對類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。看看以下三個例子:

演示一:通過子類引用父類的靜態字段,不會導致子類初始化

通過運行以上代碼可以看到,此測試代碼只會輸出“SuperClass init!”而不會輸出“SubClass init!”。對於靜態字段,只有直接定義這個字段的類才會被初始化。

 

演示二:通過數組定義來引用類,不會觸發此類的初始化

通過運行以上代碼,發現沒有任何輸出,說明並沒有觸發SuperClass的初始化。但是這段代碼觸發了另一個名為“[SuperClass”的類初始化階段,對於用戶代碼來說,這並不是一個合法的類名稱,它是一個由虛擬機自動生成的、直接繼承於java.lang.Object的子類,創建動作由字節碼指令newarray觸發。這個類代表了一個元素類型為SuperClass的一維數組,數組中應該有的屬性和方法(如length屬性和clone()方法)都實現在這個類里。

 

演示三:常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化

運行以上代碼后並沒有輸出“SuperClass init!”,這是因為雖然在Java源碼中引用了SuperClass類中的常量value,但其實在編譯階段通過常量傳播優化,已經將此常量的值“hello world!”存儲到Test類的常量池中,以后Test類對常量SuperClass.value的引用實際上都轉化為Test類對自身常量池的引用了。也就是說,實際上Test的class文件之中並沒有SuperClass類的符號引用入口,這兩個類在編譯成Class之后就不存在任何聯系了。

 

類加載的過程:加載

 

“加載”和“類加載”是不同概念的,“加載”是“類加載”過程的一個階段,最好不要被字眼迷惑。在加載階段,虛擬機需要完成以下3件事情:

“加載”和“類加載”是不同概念的,“加載”是“類加載”過程的一個階段,最好不要被字眼迷惑。在加載階段,虛擬機需要完成以下3件事情:

1)通過一個類的全限定名來獲取此類的二進制字節流;

注:虛擬機規范並沒有明確說明類的二進制字節流從何而來,所以這里可以有非常靈活的實現空間,例如可以用過ZIP包(如JAR、EAR、WAR格式)讀取,從網絡中獲取,運行時計算生成(如ASM框架),從數據庫中讀取等等。例如我常用的一個Websphere中間件跟tomcat中間件的類加載器就有所不同

2)將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構;

注:回顧本書“2.2.5方法區”章節介紹:“方法區域Java堆一樣,是各線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯后的代碼等數據”。而方法區中的數據存儲結構格式虛擬機自行定義。

3)在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

注:加載階段完成后,虛擬機在內存中實例化一個java.lang.Class類的對象(Class是一個實實在在的對象,是記錄着類成員、接口等信息的對象)。還有一點是,我們都知道對象肯定是存放在堆中的,但Class對象比較特殊,對於HotSpot虛擬機而言,Class對象是存放在方法區中的。

 

非數組類和數據類的加載階段有有所不同,從以上“被動引用例子3”我們就知道,數組類的應用是不會對該類進行初始化,而是虛擬機通過字節碼指令“newarray”去創建一個“[Object”對象。“初始化階段”是在“加載階段”之后,但不代表該類不會被加載。接下來,看看數組類加載過程要遵循的規則:

1)如果數組的組件類型是引用類型(非基礎類型),那就遞歸去加載這個組件類型(本章后續學習筆記會學習到類與類加載器的相關知識)。

2)如果數組組件類型不是引用類型(例如int[]數組),Java虛擬機將會把該數組標記為與引導類加載器關聯。

3)數組類的可見性與他的組件類型可見性一致,如果組件類型不是引用類型,那數組的可見性將默認為public

加載階段與連接階段的部分內容(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段尚未完成,連接階段可能已經開始,但這些在加載階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持着固定的先后順序,也就是必須先加載才能驗證。

 

類加載的過程:驗證

 

加載階段可以說是字節碼進入Java虛擬機的入口,換個角度去想,如果我們的API接受外來請求數據時,我應該要做些什么樣的事情,那當然是對入參數據的驗證了。沒做,類加載過程的“驗證階段”同樣是對類文件的字節碼進行驗證,才能確保Java虛擬機不受惡意代碼的攻擊。從性能上講,這無疑是給虛擬機帶來額外的性能消耗,但這也是無可厚非要付出的代價。一開始《Java虛擬機規范(第2版)》對這個階段的限制、指導還是比較籠統而粗糙的,直到《Java虛擬機規范(Java SE 7版)》才大幅細化了驗證過程的篇幅。從整體上看,驗證階段大致上會完成下面4個階段的檢查動作:

1)驗證字節流是否符合Class文件格式的規范,並且能被當前版本的虛擬機處理。細節如下:

l  是否以魔數0xCAFEBABE開頭;

l  主、次版本號是否在當前虛擬機處理范圍內;

l  常量池的常量中是否有不被支持的常量類型(檢查常量tag標志);

l  指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量;

l  CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的數據

l  Class文件中各個部分及文件是否有被刪除的或附加的其他信息;

l  ……

2)元數據驗證,保證不存在不符合Java語言規范的元數據信息

l  這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類);

l  這個類的父類是否繼承了不允許被繼承的類(被final修飾的類);

l  如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法;

l  類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等)。

l  ……

3)字節碼驗證,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件

l  保證任意時刻操作棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放置了一個int類型的數據,使用時卻按long類型來加載如本地變量中;

l  保證跳轉指令不會跳轉到方法體以外的字節碼指令上;

l  保證方法體中的類型轉換是有效的;

l  ……

“字節碼驗證”是整個驗證階段最消耗時間的,雖然如此但也不能保證絕對安全。

4)符號引用驗證,確保在后續的“解析”階段能正常執行

l  符號引用中通過字符串描述的全限定名是否能找到對應的類;

l  在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段;

l  在符號引用中的類、字段、方法的訪問性(private、protected、public、default)是否可被當前類訪問;

l  ……

其實我們的IDE也虛擬機規范的檢查,所以我們的代碼加載幾乎沒有不通過的。

 

類加載的過程:准備

 

准備階段是正式為類變量分配內存設置類變量初始化值的階段,這些變量所使用的內存都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下。首先,這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在Java堆中。其次,這里所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義為:

public static int value = 123

那變量value在准備階段過后的初始化值為0而不是123,因為這是尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程序被編譯后存放在類構造器<clinit>()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。以下表格列出了所有基本數據類型的零值:

數據類型

零值

int

0

long

0L

short

(short)0

char

‘\u0000’

byte

(byte)0

boolean

false

float

0.0f

double

0.0d

reference

null

上面提到的在“通常情況”下初始值為零值,但還是會有一些特殊情況,如下:

public static final int value = 123

類字段的字段屬性表中存在ConstantValue屬性,那在准備階段變量value就會被初始化微ConstantValue屬性所指定的值。編譯時Javac將會為vaue生成ConstantValue屬性,在准備階段虛擬機就會根據ConstantValue的設置將value賦值為123。

 

類加載的過程:解析

 

解析階段是虛擬機將常量池的符號引用直接替換為直接引用的過程,看看前一章節的常量池例子:

看看截圖紅色框框的就是常量池的符號引用,再來解釋一下常量引用和符號引用的區別:

 

符號引用(Symbolic References):

符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標並不一定已經加載到內存中。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機規范的Class文件格式中。

 

直接引用(Direct References):

直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接點位到目標的句柄。直接引用是和虛擬機實現的內存布局相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在內存中存在。

 

就拿以上截圖的紅色框框的例子來舉例吧,框住的常量池語意大概是常量池中的第三個常量為類或接口的符號引用,這個符號的值為第四個常量池的值,也就是“java/lang/Object;”這是我們熟知的Object類的全限定名。解析階段就是要把這個“class”的字符引用換成直接指向這個Object類在內存中的地址(如指針 )。那就說明,這個Object類必須同時也需要加載到內存中來。

 

對同一個符號引用進行多次解析請求是很常見的事情,虛擬機實現可以對第一次解析的結果進行緩存(在運行時常量池中記錄直接引用,並把常量標識為已解析狀態)從而避免解析動作重復進行。但對於invokedynamic指令,上面規則則不成立。當碰到某個前面已經由invokedynamic指令觸發過解析的符號引用時,並不意味着這個解析結果對其他invokedynamic指令也同樣生效。因為invokedynamic指令是JDK1.7新加入的指令,目的用於動態語言支持,它所對應的引用稱為“動態調用點限定符”(Dynamic Call Site Specifier),這里“動態”的含義就是必須等到程序實際運行到這條指令的時候,解析動作才能進行。相對的,其余可觸發解析的指令都是“靜態”的,可以在剛剛完成加載階段,還沒有執行代碼時就進行解析。解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號進行引用,下面只對前4種引用的解析過程進行介紹,對於后面3種與JDK1.7新增的動態語言支持息息相關,后續章節將會學習到:

 

類或接口的解析:

 

假設當前代碼所處的類為D,如果要把一個從未解析過的符號引用M解析為 一個類或接口C的直接引用,那虛擬機完成整個解析過程需要一個3個步驟:

1)如果C不是一個數組類型,那虛擬機將會把代表N的全限定名傳遞給D的類加載器去加載這個類C。在加載過程中,由於元數據驗證、字節碼驗證的需要,又可能觸發其他相關類的加載動作,例如加載這個類的父類或實現接口。一旦這個加載過程出現了任何異常,解析過程宣布失敗。

2)如果C是一個數組類型,並且數組的元素類型是對象,也就是N的描述符會是類似“[Ljava/lang/Integer”的形式,那將會按照第1點的規則加載數組元素類型。如果N的描述如前面所假設的形式,需要加載的元素類型就是“Java.lang.Integer”,接着有虛擬機生成一個代表此數組維度和元素的數組對象:“[Ljava/lang/Integer”(數組引用可回顧上文“類加載時機-被動引用演示二”)。

3)如果上述步驟沒有出現任何異常,那么C在虛擬機中實際上已經成為一個有效的類或接口了,但在解析完成之前還要進行符號引用驗證,確認D是否具備對C的訪問權限。如果發現不具備訪問權限,將拋出java.lang.IllegalAccessError異常。

 

字段解析:

 

要解析一個未被解析過的字段符號引用,首先將會對字段表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,也就是字段所屬的類或接口的符號引用。看看以下例子可能會更明白:

 

 

 

 

我們對javap工具打印出Test.class的常量池看看:

行解析(也就是上圖的#14),那首先對t2字段所屬的Class進行解析,也就是#15的Test2。如果我們在解析這個Test2類都失敗的話,那么對Test的字段t2解析同樣失敗。如果解析Test2成功了那么以上截圖紅色框框部分就是Test對Test2.t2字段的符號引用。如果我們要對Test2.t2字段進我們將這個這段所屬的類或接口用C(也就是以上例子的Test2)表示。虛擬機規范要求按照如下步驟對C進行后續字段的搜索:

1)如果C本身就包含了簡單名稱和字段描述符都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。

2)否則,如果在C中實現了接口,將會安裝繼承關系從上往下遞歸搜索各個接口和它的父接口,如果接口中包含了簡單名稱和字段描述符都與目標匹配的字段,則返回這個字段的直接引用,查找結束。

3)否則,如果C不是java.lang.Object的話,將會按照繼承關系從上往下遞歸搜索其父類,如果在父類中包含了簡單名稱和字段描述都與目標相匹配的字段,則返回這個字段的直接引用,查找結束。

4)否則,查找失敗,拋出java.lang.NoSuchFieldError異常。

 

類方法解析:

 

類方法解析的第一個步驟與字段解析一樣,也需要解析出類方法表的class_index項中索引的方法所屬的類或接口的符號引用,如果解析成功我們依然用C標識這個類,接下來虛擬機將會按照如下步驟進行后續的類方法搜索:

1)類方法和接口方法符號引用的常量類型定義是分開的,如果在類方法表中發現class_index中索引的C是個接口,那直接拋出java.lang.IncompatibleClassChangeError異常。

2)如果通過了第1步,在類C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。

3)否則,在類C的父類中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。

4)否則,在類C實現的接口列表及它們的父類接口之中遞歸查找是否有簡單名稱和描述符都與目標相匹配的方法,如果存在匹配方法,說明類C是一個抽象類,這是查找結束,拋出java.lang.AbstractMethodError異常。

5)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError異常。

 

接口方法解析:

 

接口方法也需要先解析出接口方法表的class_index項中索引的方法屬性的類或接口的符號引用,如果解析成功,依然用C表示這個接口,接下來虛擬機將會按照如下步驟進行后續的接口方法搜索。

1)與類方法解析不同,如果在接口方法表中發現class_index中的索引C是個類而不是接口,那就直接拋出java.lang.IncompatibleClassChangeError異常。

2)否則,在接口C中查找是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。

3)否則,在接口C的父接口中遞歸查找,知道java.lang.Object類(查找范圍會包Object類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查找結束。

4)否則,宣告方法查找失敗,拋出java.lang.NoSuchMethodError異常。

 

類加載的過程:初始化

 

類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器之外,其余動作完全是由虛擬機主導和控制。在准備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計划其余初始化變量和其他資源,或者從另一個角度來表達,初始化階段是執行類構造器<clinit>()方法的過程:

■<clinit>()方法是有編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合並產生的,編譯器收集的順序是有語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句之前的變量,定義在它之后的變量,在前面的靜態語句塊可以賦值,但不能訪問,如下例子所示:

變量聲明前賦值例子

變量聲明前訪問例子

■<clinit>()方法與類的構造函數(或者說實例構造器<init>()方法)不同,它不需要顯示地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢;

 

■由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操作;

 

■<clinit>()方法對於類和接口來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那么編譯器可以不為這個類生成<clinit>()方法;

 

■接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成<clinit>()方法。但接口與類不同的是,執行接口的<clinit>()方法不需要先執行父接口的<clinit>()方法。只有當父接口中定義的變量使用時,父接口才會初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

 

■虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那么只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個進程阻塞。

 

注:我通過javap工具把class文件反編譯但找不到<clinit>()方法,只看到<init>()方法,我暫且偷懶通過網上資料得到比較靠譜的答案是“因為這個特殊初始化方法是不能被Java代碼調用的,沒有任何一條invoke-*字節碼可以調用它。它只能作為類加載過程的一部分由JVM直接調用”,具體實現可以參考JVM源碼。

 

類加載器

 

提到Java虛擬機加載器,肯定會聯想到它的雙親委派機制,具體如下圖所示(因為懶得畫所以就網上借了個圖):

雙親委派機制圖

先來大概的解釋一下各個加載器的情況:

 

■啟動類加載器(Bootstrap ClassLoader):這個類加載器負責將<JAVA_HOME>\lib目錄中的,或被-Xbootclasspath參數所指定的路徑中的,並且是虛擬機識別的(如rt.jar)類庫加載到虛擬機內存中。

Bootstrap ClassLoader是JVM系統級別的類加載器,應用是無法使用的,例如Object類是由這個類加載器加載的,我們嘗試去打印Object類的類加載器,得到結果如下:

這就是JVM為了保護Bootstrap ClassLoader所做的限制。

 

■擴展類加載器(Extension ClassLoader):這個加載器由sun.misc.Launcher$ExtClassLoader實現的,它負責加載<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器,如下示例:

圖(1)是我的ClassLoaderTest類在classpath下打印的結果:

圖(1)

圖(2)是我的ClassLoaderTest類打包放在<JAVA_HOME>/lib/ext目錄下的打印結果:

圖(2)

從以上實驗可以看出,Extension ClassLoader確實可以被用戶利用。

 

■應用程序類加載器(Application ClassLoader):從上面的測試可以看到,這個類加載器由sun.misc.Launcher$AppClassLoader實現,由於這個類加載器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱為系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義自己的類加載器,一般情況下這個就是程序中默認的類加載器。

 

■自定義類加載器(User ClassLoader):所有自定義的類加載器必須繼承ClassLoader抽象類(嚴格說所有類加載器都繼承於它,除了Bootstrap ClassLoader,因它是由C/C++實現的),那先來看看ClassLoader有哪些重點方法:

方法

說明

getParent()

返回該類加載器的父類加載器。

loadClass(String name)

加載名稱為 name的類,返回的結果是 java.lang.Class類的實例。

findClass(String name)

查找名稱為 name的類,返回的結果是 java.lang.Class類的實例。

findLoadedClass(String name)

查找名稱為 name的已經被加載過的類,返回的結果是 java.lang.Class類的實例。

defineClass(String name, byte[] b, int off, int len)

把字節數組 b中的內容轉換成 Java 類,返回的結果是 java.lang.Class類的實例。這個方法被聲明為 final的。

resolveClass(Class<?> c)

鏈接指定的 Java 類,調用的是本地方法。

除了以上ClassLoader抽象類的一些主要方法介紹,在自己寫自定義類加載器前還是非常有必要講解一下類加載器的“雙親委派機制”。就如以上的“雙親委派機制圖”所示,它的工作過程是這樣的:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載器請求最終都是應該傳送到頂層的啟動類加載器中,只有當父加載器反饋自己無法完成這個加載請求(它的搜索范圍中沒有找到所需要的類)時,子加載器才會嘗試自己去加載。如ClassLoader類的loadClass方法所示:

以上ClassLoader的loadClass方法的實現就是“雙親委派機制”的原型。除了“雙親委派機制”外,我們還需要知道一點的是:對於任意一個類,都需要由它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性也就是說,同一個class文件,由不同的加載器去加載,都不相等。所以“雙親委派機制”有一個顯而易見的好處就是Java類隨着它的類加載器一起具備了一種帶有優先級的層次關系,例如java.lang.Object,它存放於rt.jar之中,無論哪一個類加載器要加載這個類,最終都是委派給處於模型最頂端的啟動類去加載,因此Object類在程序的各種類加載器環境中都是同一個類。除此之外,這種設計模式的其它優缺點需要各自腦補了。當然,這種設計默認並不是必須的(后面會提到)。學習以上的知識,那么就可以自定義動手寫一個屬於自己的類加載器了,如下所示:

 1 public class MyClassLoader extends ClassLoader{  2     
 3     public MyClassLoader(){}  4       
 5     public MyClassLoader(ClassLoader parent){  6         super(parent);  7  }  8     
 9  @Override 10     protected Class<?> findClass(String name) throws ClassNotFoundException{ 11         
12         byte[] bytes = null; 13         
14         /*獲取類字節,自定義類我默認在G盤*/
15         FileInputStream fis = null; 16         try { 17             fis = new FileInputStream("G:\\"+name+".class"); 18             ByteArrayOutputStream baos = new ByteArrayOutputStream(); 19             byte[] buff = new byte[100]; 20             int rc = 0; 21             while ((rc = fis.read(buff, 0, 100)) > 0) { 22                 baos.write(buff, 0, rc); 23  } 24             bytes = baos.toByteArray(); 25             
26         } catch (Exception e) { 27  e.printStackTrace(); 28             
29         }finally{ 30             if(fis != null){ 31                 try { 32  fis.close(); 33                 } catch (IOException e) { 34  e.printStackTrace(); 35  } 36  } 37  } 38         
39         /*生產Class對象*/
40         try{ 41             Class<?> c = this.defineClass(name, bytes, 0, bytes.length); 42             return c; 43         }catch (Exception e){ 44  e.printStackTrace(); 45  } 46         
47         return super.findClass(name); 48  } 49     
50     public static void main(String[] args) throws Exception{ 51         MyClassLoader mlc = new MyClassLoader(MyClassLoader.getSystemClassLoader()); 52         Class c = mlc.loadClass("MyClassLoaderTest"); 53  System.out.println(c.newInstance().toString()); 54  } 55 
56 }

自定義一個類加載器的原因有很多,例如應用需要加載不在ClassPath路徑下的類(重寫findClass方法),又或者不同插件容器需要不同加載器加載同一個類文件(重寫loadClass方法)等等。就像以上例子,我自定義了一個MyClassLoader重寫了findClass方法專門去加載我本地G盤的類。其實,只要得到類文件的二進制流(甚至可以通過ASM字節碼操作框架動態生成class二進制),就可以初始化類對象,所以無論本地還是遠程,都可以通過實現類加載。

 

總結

 

類加載確實是Java虛擬機的一大亮點,在本章也學習了類加載器委托、可見性以及單一性原理特性,許多人可能還是會把類加載器跟“雙親委派機制”緊關聯甚至畫上等號,“雙親委派機制”是一種設計模式(代理模式),這種模式帶來的好處顯而易見,但是不同場景可能會有不同的場景需求而去破壞這種設計模式,例如許多WEB容器都有自己的類加載器,如Tomcat,它的自定義加載器首先會嘗試自己加載應用的類文件再交給父類加載器嘗試加載,這一點已經打破了雙親委派模型,但有些WEB容器又有自己的自定義規則,例如Websphere,所有本章重點在於理解類加載器原理,才能更好的掌控“格局”。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM