jdk原始碼解析(七)——Java虛擬機器類載入機制
前面我們講解了class檔案的格式,以及它是什麼樣的。那麼接下來需要了解它怎麼被載入到jvm中呢?jvm的載入機制又是怎麼一個過程呢?本文參考了《Java 虛擬機器規範(Java SE 7 版)》的第五章內容來詳細解釋一下
虛擬機器類載入機制:虛擬機器把描述類的資料從class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別。
1 虛擬機器啟動
Java 虛擬機器的啟動是通過引導類載入器(Bootstrap Class Loader 下面會詳細講解)建立一個初始類(Initial Class)來完成,這個類是由虛擬機器的具體實現指定。緊接著,Java 虛擬機器連結這個初始類,初始化並呼叫它的 public void main(String[])方法。之後的整個執行過程都是由對此方法的呼叫開始。執行 main 方法中的 Java 虛擬機器指令可能會導致Java 虛擬機器連結另外的一些類或介面,也可能會呼叫另外的方法。
可能在某種 Java 虛擬機器的實現上,初始類會作為命令列引數被提供給虛擬機器。當然,虛擬機器實現也可以利用一個初始類讓類載入器依次載入整個應用。初始類當然也可以選擇組合上述的方式來工作。
2類的載入時機
類從被載入到虛擬機器記憶體中到卸載出記憶體,整個生命週期:載入、連線、初始化、使用、解除安裝。其中連線分為驗證、準備、解析。解析的順序不一定,有可能按照上述順序,也有可能在初始化階段之後才開始,這是為了支援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種場景中的行為稱為對類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。
3 類載入的過程
接下來詳細講解java虛擬機器中類載入的全過程,也就是載入、驗證、準備、解析、初始化
3.1 建立和載入
如果要建立標記為 N 的類或介面 C,就需要先在 Java 虛擬機器方法區上為 C 建立與虛擬機器實現規定相匹配的內部表示。C 的建立是由另外一個類或介面 D 所觸發的,它通過自己的執行時常量池引用了 C。當然,C 的建立也可能是由 D 呼叫 Java 核心類庫中的某些方法而觸發,譬如使用反射等。 如果 C 不是陣列型別,那麼它就可以通過類載入器載入 C 的二進位制表示來建立(參見“Class 檔案格式”)。陣列型別沒有外部的二進位制表示;它們都是由 Java 虛擬機器建立,而不是通過類載入器載入的。
Java 虛擬機器支援兩種類載入器:Java 虛擬機器提供的引導類載入器(Bootstrap Class Loader)和使用者自定義類載入器(User-Defined Class Loader)。後面講。
在 Java 虛擬機器執行時,類或介面不僅僅是由它的名稱來確定,而是由一個值對:二進位制名稱(在 Class 檔案結構中出現的類或介面的名稱,都通過全限定形式來表示,這被稱作它們的“二進位制名稱”)和它的定義類載入器共同確定。每個這樣的類或介面都歸屬於獨立的執行時包結構(Runtime Package)。類或介面的執行時包結構由包名及類或介面的定義類載入器來決定。
Java 虛擬機器通過下面三個過程中之一來建立標記為 N 的類或介面 C:
如果 N 表示一個非陣列的類或介面,可以用下面的兩個方法之一來載入並建立 C: 1、如果 D 是由引導類載入器所定義,那麼引導類載入器初始載入 C。 2、如果 D 是由使用者自定義類載入器所定義,那麼此使用者自定義類載入器也用來初始載入 C。這裡不講自定義載入器。 如果 N 表示一個數組類。陣列類是由 Java 虛擬機器而不是類載入器建立。然而,在建立陣列類 C 的過程中,D 的定義類載入器也要被用到。
我們通常使用標識<N,Ld>來表示一個類或介面,這裡的 N 表示類或介面的名稱,Ld 表示類或介面的定義類載入器。我們也可以使用標識LiN來表示一個類或介面,這裡的 N 表示類或介面的名稱,Li 表示類或介面的初始類載入器。
注意:“載入”和“類載入”是不同概念的,“載入”是“類載入”過程的一個階段,最好不要被字眼迷惑。在載入階段,虛擬機器需要完成以下3件事情:
1)通過一個類的全限定名來獲取此類的二進位制位元組流;
注:虛擬機器規範並沒有明確說明類的二進位制位元組流從何而來,所以這裡可以有非常靈活的實現空間,例如可以用過ZIP包(如JAR、EAR、WAR格式)讀取,從網路中獲取,執行時計算生成(如ASM框架),從資料庫中讀取等等。例如我常用的一個Websphere中介軟體跟tomcat中介軟體的類載入器就有所不同。
2)將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;
注:回顧“方法區介紹”方法區域Java堆一樣,是各執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯後的程式碼等資料”。而方法區中的資料儲存結構格式虛擬機器自行定義。
3)在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。
注:載入階段完成後,虛擬機器在記憶體中例項化一個java.lang.Class類的物件(Class是一個實實在在的物件,是記錄著類成員、介面等資訊的物件)。還有一點是,我們都知道物件肯定是存放在堆中的,但Class物件比較特殊,對於HotSpot虛擬機器而言,Class物件是存放在方法區中的。
非陣列類和資料類的載入階段有有所不同,從以上“被動引用”我們就知道,陣列類的應用是不會對該類進行初始化,而是虛擬機器通過位元組碼指令“newarray”去建立個“[Object”物件。“初始化階段”是在“載入階段”之後,但不代表該類不會被載入。接下來,看看陣列類載入過程要遵循的規則:
1)如果陣列的元件型別是引用型別(非基礎型別),那就遞迴去載入這個元件型別(本章後續學習筆記會學習到類與類載入器的相關知識)。
2)如果陣列元件型別不是引用型別(例如int[]陣列),Java虛擬機器將會把該陣列標記為與引導類載入器關聯。
3)陣列類的可見性與他的元件型別可見性一致,如果元件型別不是引用型別,那陣列的可見性將預設為public。
載入階段與連線階段的部分內容(如一部分位元組碼檔案格式驗證動作)是交叉進行的,載入階段尚未完成,連線階段可能已經開始,但這些在載入階段之中進行的動作,仍然屬於連線階段的內容,這兩個階段的開始時間仍然保持著固定的先後順序,也就是必須先載入才能驗證。
3.2 連線-驗證
驗證(Verification,§4.10)階段用於確保類或介面的二進位制表示結構上是正確的。驗證過程可能會導致某些額外的類和介面被載入進來(§5.3),但不應該會導致它們也需要驗證或準備。
載入階段可以說是位元組碼進入Java虛擬機器的入口,類載入過程的“驗證階段”同樣是對類檔案的位元組碼進行驗證,才能確保Java虛擬機器不受惡意程式碼的攻擊。從效能上講,這無疑是給虛擬機器帶來額外的效能消耗,但這也是無可厚非要付出的代價。從整體上看,驗證階段大致上會完成下面4個階段的檢查動作:檔案格式驗證、元資料驗證、位元組碼驗證、符號引用驗證
1>檔案格式驗證
驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。細節如下:
是否以魔數0xCAFEBABE開頭;
主、次版本號是否在當前虛擬機器處理範圍內;
常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌);
指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量;
CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的資料
Class檔案中各個部分及檔案是否有被刪除的或附加的其他資訊; ……
注意:這個階段是通過二進位制位元組流進行的,只有通過這個階段驗證後,位元組流才會進入記憶體方法區進行儲存,所以後面的三個驗證階段全部基於方法區儲存結構進行的,不會在操作位元組流。
2> 元資料驗證
保證不存在不符合Java語言規範的元資料資訊
這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類);
這個類的父類是否繼承了不允許被繼承的類(被final修飾的類);
如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法;
類中的欄位、方法是否與父類產生矛盾(例如覆蓋了父類的final欄位,或者出現不符合規則的方法過載,例如方法引數都一致,但返回值型別卻不同等)。 ……
3> 位元組碼驗證
保證被校驗類的方法在執行時不會做出危害虛擬機器安全的事件
保證任意時刻操作棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放置了一個int型別的資料,使用時卻按long型別來載入如本地變數中;
保證跳轉指令不會跳轉到方法體以外的位元組碼指令上;
保證方法體中的型別轉換是有效的; ……
“位元組碼驗證”是整個驗證階段最消耗時間的,雖然如此但也不能保證絕對安全。
4> 符號引用驗證
確保在後續的“解析”階段能正常執行
符號引用中通過字串描述的全限定名是否能找到對應的類;
在指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位;
在符號引用中的類、欄位、方法的訪問性(private、protected、public、default)是否可被當前類訪問; ……
其實我們的IDE也虛擬機器規範的檢查,所以我們的程式碼載入幾乎沒有不通過的。
3.3 連線-準備
準備階段是正式為類變數分配記憶體設定類變數初始化值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這個階段中有兩個容易產生混淆的概念需要強調一下。首先,這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在Java堆中。其次,這裡所說的初始值“通常情況”下是資料型別的零值,假設一個類變數的定義為:
public static int value = 123;
那變數value在準備階段過後的初始化值為0而不是123,因為這是尚未開始執行任何Java方法,而把value賦值為123的putstatic指令是程式被編譯後存放在類構造器<clinit>()方法之中,所以把value賦值為123的動作將在初始化階段才會執行。以下表格列出了所有基本資料型別的零值:
上面提到的在“通常情況”下初始值為零值,但還是會有一些特殊情況,如下:
public static final int value = 123;
類欄位的欄位屬性表中存在ConstantValue屬性,那在準備階段變數value就會被初始化微ConstantValue屬性所指定的值。編譯時Javac將會為vaue生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定將value賦值為123。
3.3 連線-解析
解析階段是虛擬機器將常量池的符號引用直接替換為直接引用的過程,看看前一章節的常量池例子:
看看截圖紅色框框的就是常量池的符號引用,在Class檔案中它以CONSTANT_Class_info、CONSTANT_Methoder_info等型別的常量出現,再解析階段中直接引用與符號引用又有什麼關聯呢?來解釋一下常量引用和符號引用的區別:
1)符號引用(Symbolic References):
符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標並不一定已經載入到記憶體中。各種虛擬機器實現的記憶體佈局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機器規範的Class檔案格式中。
2)直接引用(Direct References):
直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接點位到目標的控制代碼。直接引用是和虛擬機器實現的記憶體佈局相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在記憶體中存在。
解讀一下:就拿以上截圖的紅色框框的例子來舉例吧,框住的常量池語意大概是常量池中的第三個常量為類或介面的符號引用,這個符號的值為第四個常量池的值,也就是“java/lang/Object;”這是我們熟知的Object類的全限定名。解析階段就是要把這個“class”的字元引用換成直接指向這個Object類在記憶體中的地址(如指標 )。那就說明,這個Object類必須同時也需要載入到記憶體中來。
注意:Java 虛擬機器指令 anewarray、checkcast、getfield、getstatic、instanceof、nvokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield 和 putstatic 這16個用於操作符號的引用的位元組碼指令之前,先對他們所使用的符號引用進行解析。也就是執行上述任何一條指令都需要對它的符號引用的進行解析。所以:虛擬機器實現可以根據需要來判斷到底是在類被載入器載入時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才取解析它。
對同一個符號引用進行多次解析請求是很常見的事情,虛擬機器實現可以對第一次解析的結果進行快取(在執行時常量池中記錄直接引用,並把常量標識為已解析狀態)從而避免解析動作重複進行。但對於invokedynamic指令,上面規則則不成立。當碰到某個前面已經由invokedynamic指令觸發過解析的符號引用時,並不意味著這個解析結果對其他invokedynamic指令也同樣生效。因為invokedynamic指令是JDK1.7新加入的指令,目的用於動態語言支援,它所對應的引用稱為“動態呼叫點限定符”(Dynamic Call Site Specifier),這裡“動態”的含義就是必須等到程式實際執行到這條指令的時候,解析動作才能進行。相對的,其餘可觸發解析的指令都是“靜態”的,可以在剛剛完成載入階段,還沒有執行程式碼時就進行解析。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符7類符號進行引用,下面只對前4種引用的解析過程進行介紹,對於後面3種與JDK1.7新增的動態語言支援息息相關,後續章節將會學習到:
3.3.1 類與介面解析
假設當前程式碼所處的類為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異常。
3.3.2 欄位解析: 要解析一個未被解析過的欄位符號引用,首先將會對欄位表內class_index項中索引的CONSTANT_Class_info符號引用進行解析,也就是欄位所屬的類或介面的符號引用。看看以下例子可能會更明白:
定義兩個java類:
我們對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異常。
注意:如果查詢過程成功返回了引用,將會對這個欄位進行許可權驗證,如果發現不具備對欄位的訪問許可權,將丟擲java.lang.IllegalAccessError異常。
3.3.3 類方法解析:
類方法解析的第一個步驟與欄位解析一樣,也需要解析出類方法表的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異常。
3.3.4 介面方法解析
介面方法也需要先解析出介面方法表的class_index項中索引的方法屬性的類或介面的符號引用,如果解析成功,依然用C表示這個介面,接下來虛擬機器將會按照如下步驟進行後續的介面方法搜尋。
1)與類方法解析不同,如果在介面方法表中發現class_index中的索引C是個類而不是介面,那就直接丟擲java.lang.IncompatibleClassChangeError異常。
2)否則,在介面C中查詢是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
3)否則,在介面C的父介面中遞迴查詢,知道java.lang.Object類(查詢範圍會包Object類)為止,看是否有簡單名稱和描述符都與目標相匹配的方法,如果有則返回這個方法的直接引用,查詢結束。
4)否則,宣告方法查詢失敗,丟擲java.lang.NoSuchMethodError異常。
注意:由於介面中的所有方法預設都是public的,所以不存在訪問許可權的問題,因此介面方法的符號解析應當不會丟擲java.lang.IllegalAccessError異常。
其它的解析以後在解釋:比如方法型別的解析,方法控制代碼的解析,以及許可權的解析等。
3.4 初始化
類初始化階段是類載入過程的最後一步,前面的類載入過程中,除了在載入階段使用者應用程式可以通過自定義類載入器之外,其餘動作完全是由虛擬機器主導和控制。在準備階段,變數已經賦過一次系統要求的初始值,而在初始化階段,則根據程式設計師通過程式制定的主觀計劃其餘初始化變數和其他資源,或者從另一個角度來表達,初始化階段是執行類構造器<clinit>()方法的過程:
■<clinit>()方法是有編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是有語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但不能訪問,如下例子所示:
package com.clazz; public class Test { static{ i=0;//給變數賦值可以正常通過 System.out.println(i);//提示非法向前引用 } static int i =1; }
■<clinit>()方法與類的建構函式(或者說例項構造器<init>()方法)不同,它不需要顯示地呼叫父類構造器,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢;
■由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作;
■<clinit>()方法對於類和介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法;
■介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成<clinit>()方法。但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法。只有當父介面中定義的變數使用時,父接口才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。
■虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。如果一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個程序阻塞。
注:我通過javap工具把class檔案反編譯但找不到<clinit>()方法,只看到<init>()方法,我暫且偷懶通過網上資料得到比較靠譜的答案是“因為這個特殊初始化方法是不能被Java程式碼呼叫的,沒有任何一條invoke-*位元組碼可以呼叫它。它只能作為類載入過程的一部分由JVM直接呼叫”,具體實現可以參考JVM原始碼。3.5 使用 這裡略過: 這裡講解一下:繫結本地方法實現
繫結(Binding)是指將使用 Java 之外的語言編寫的函式整合到 Java 虛擬機器中的過程。此函式需要實現在程式碼中定義好的 native 方法,之後才可以在 Java 虛擬機器中執行。這個過程在傳統編譯原理的表述中被稱“連結”,所以規範裡使用“繫結”這個詞就,就是為了避免與 Java 虛擬機器中連結類或介面的語義發生衝突。
3.6 虛擬機器退出
Java 虛擬機器的退出條件一般是:某些執行緒呼叫 Runtime 類或 System 類的 exit 方法,或是 Runtime 類的 halt 方法,並且 Java 安全管理器也允許這些 exit 或 halt 操作。除此之外,在 JNI(Java Native Interface)規範中還描述了當使用 JNI API 來載入和解除安裝(Load & Unload)Java 虛擬機器時,Java 虛擬機器的退出過程。
4 類載入器
提到Java虛擬機器載入器,肯定會聯想到它的雙親委派機制,具體如下圖所示:
先來大概的解釋一下各個載入器的情況:
■啟動類載入器(Bootstrap ClassLoader):這個類載入器負責將<JAVA_HOME>lib目錄中的,或被-Xbootclasspath引數所指定的路徑中的,並且是虛擬機器識別的(如rt.jar)類庫載入到虛擬機器記憶體中。
Bootstrap ClassLoader是JVM系統級別的類載入器,應用是無法使用的,例如Object類是由這個類載入器載入的,我們嘗試去列印Object類的類載入器,得到結果如下:
package com.clazz; public class Test { public static void main(String[] args) { System.out.println(Object.class.getClassLoader()); } }
列印的結果為null :這就是JVM為了保護Bootstrap ClassLoader所做的限制。 ■擴充套件類載入器(Extension ClassLoader):這個載入器由sun.misc.Launcher$ExtClassLoader實現的,它負責載入<JAVA_HOME>lib/ext目錄中的,或者被java.ext.dirs系統變數所指定的路徑中的所有類庫,開發者可以直接使用擴充套件類載入器,如下示例:
package com.clazz; public class Test { public static void main(String[] args) { System.out.println(Test.class.getClassLoader()); System.out.println(Test.class.getClassLoader().getParent()); System.out.println(Test.class.getClassLoader().getParent().getParent()); } }
執行結果:
若將Test打包放在<JAVA_HOME>/lib/ext下也是可以執行的 ■應用程式類載入器(Application ClassLoader):從上面的測試可以看到,這個類載入器由sun.misc.Launcher$AppClassLoader實現,由於這個類載入器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也稱為系統類載入器。它負責載入使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類載入器,如果應用程式中沒有自定義自己的類載入器,一般情況下這個就是程式中預設的類載入器。
■自定義類載入器(User ClassLoader):所有自定義的類載入器必須繼承ClassLoader抽象類(嚴格說所有類載入器都繼承於它,除了Bootstrap ClassLoader,因它是由C/C++實現的),那先來看看ClassLoader有哪些重點方法:
除了以上ClassLoader抽象類的一些主要方法介紹,在自己寫自定義類載入器前還是非常有必要講解一下類載入器的“雙親委派機制”。就如以上的“雙親委派機制圖”所示,它的工作過程是這樣的:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入器請求最終都是應該傳送到頂層的啟動類載入器中,只有當父載入器反饋自己無法完成這個載入請求(它的搜尋範圍中沒有找到所需要的類)時,子載入器才會嘗試自己去載入。如ClassLoader類的loadClass方法所示:
以上ClassLoader的loadClass方法的實現就是“雙親委派機制”的原型。除了“雙親委派機制”外,我們還需要知道一點的是:對於任意一個類,都需要由它的類載入器和這個類本身一同確立其在Java虛擬機器中的唯一性。也就是說,同一個class檔案,由不同的載入器去載入,都不相等。所以“雙親委派機制”有一個顯而易見的好處就是Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係,例如java.lang.Object,它存放於rt.jar之中,無論哪一個類載入器要載入這個類,最終都是委派給處於模型最頂端的啟動類去載入,因此Object類在程式的各種類載入器環境中都是同一個類。除此之外,這種設計模式的其它優缺點需要各自腦補了。當然,這種設計預設並不是必須的(後面會提到)。學習以上的知識,那麼就可以自定義動手寫一個屬於自己的類載入器了,
自定義一個類載入器的原因有很多,例如應用需要載入不在ClassPath路徑下的類(重寫findClass方法),又或者不同外掛容器需要不同載入器載入同一個類檔案(重寫loadClass方法)等等。就像以上例子,我自定義了一個MyClassLoader重寫了findClass方法專門去載入我本地G盤的類。其實,只要得到類檔案的二進位制流(甚至可以通過ASM位元組碼操作框架動態生成class二進位制),就可以初始化類物件,所以無論本地還是遠端,都可以通過實現類載入。 注意:這裡打破雙親委派模型就不做講解,主要例子JNDI(必須啟動Tomcat才可以訪問)
總結 類載入確實是Java虛擬機器的一大亮點,在本章也學習了類載入器委託、可見性以及單一性原理特性,許多人可能還是會把類載入器跟“雙親委派機制”緊關聯甚至畫上等號,“雙親委派機制”是一種設計模式(代理模式),這種模式帶來的好處顯而易見,但是不同場景可能會有不同的場景需求而去破壞這種設計模式,例如許多WEB容器都有自己的類載入器,如Tomcat,它的自定義載入器首先會嘗試自己載入應用的類檔案再交給父類載入器嘗試載入,這一點已經打破了雙親委派模型,但有些WEB容器又有自己的自定義規則,例如Websphere,所有本章重點在於理解類載入器原理,才能更好的掌控“格局”。類如何載入到jvm中的大概思路就是這些。希望筆者的部落格可以解決你的煩惱。 ---------------------