【JVM第十三篇】:JVM的類載入機制
一. 前言
我們知道,Java原始碼(.java檔案)需要通過編譯器編譯成位元組碼檔案(.class)後由類裝載子系統(ClassLoader)載入執行時資料區(<jdk1.8之前是載入方法區,>=jdk1.8以後是載入元資料區)才能被後續的Java執行程式(執行緒)正常使用(例項化或引用)。
那麼類裝載的具體機制是什麼樣的呢?下面就讓我們一起進一步來了解下吧!
二. JVM類裝載概述
與C/C++那些需要在編譯器期進行連線工作的語言不同,Java類的載入、連線和初始化都是在程式執行時完成的,只有在類被需要的時候才進行動態載入,這種方式被稱為“Java語言的執行期類載入機制
例如我們在實際使用Java語言進行程式設計時通常會編寫一個面向介面的應用程式,可以等到執行時再指定其實際的實現類。此外,更高階一點的做法是可以通過自定義的類載入器(關於具體的ClassLoader在後面的內容會提到),讓一個本地的應用程式可以在執行時從網路或其他地方載入一個二進位制流作為程式程式碼的一部分(Applet就是這麼幹的),這種組裝應用程式的方式目前已廣泛的應用於Java程式之中。
類(Class)從被載入到虛擬機器記憶體中開始,到卸載出記憶體為止會經歷如下生命週期:
其中驗證、準備、解析3個部分又統稱為連線(Linking)。在以上過程中,除解析外,載入、驗證、準備、初始化、解除安裝這5個階段的順序都是確定的,JVM規定類的載入過程必須按照這種順序按部就班地開始。而解析階段則不一定,為了支援Java語言的執行時繫結,解析過程在某些情況下可以在初始化階段之後再開始。
需要注意的是,這些階段並不是必須等到上一個階段完成才能開始下一個階段,這些階段通常都是互相交叉地混合式進行的,會在一個階段執行的過程中就會呼叫、啟用另外一個階段。
聊到這裡,我們大概瞭解了從一個class位元組碼檔案變成載入到記憶體中能夠被使用的類,按照先後順序需要經過載入、連線、初始化三大主要步驟。連線過程又需要經歷驗證、準備、解析三個階段,完成後類被載入至記憶體,但此時並不能被使用,還需要經過初始化階段。
那麼,在Java中是否所有的型別在類載入的過程中都需要經過這幾個步驟呢?
我們知道Java語言的型別可以分為兩大類:基本型別、引用型別。基本型別是由虛擬機器預先定義好的,所以不會經歷單獨的類載入過程。而引用型別又分為四種:類、介面、陣列類、泛型引數。由於泛型引數會在編譯的過程中被擦除(關於型別擦除的知識,大家可以查下資料),所以在Java中只有類、介面、陣列類
在上述三種類型中陣列類是由JVM直接生成的,類和介面則有對應的位元組流,位元組流最常見的形式就是我們由編譯器生成的class檔案,另外的形式也有在前面說到的通過網路載入的二進位制流(例如網頁中內嵌的小程式 Java applet),這些不同形式的位元組流都會被JVM載入到記憶體中,成為類或介面。
三. JVM類裝載過程
那麼這些過程具體會幹些什麼事呢?接下來我們就詳細瞭解下這些具體步驟的細節。
1. 載入(Loading)
載入”是“類載入”(Class Loading)過程的一個階段,是查詢位元組流並據此建立類的過程。在前面我們提到過說陣列類因為沒有對應的位元組流所以是由JVM直接生成的,而對於類和介面來說則需要藉助類載入器(後面會講到)來完成查詢位元組流的過程。
但是我們在回答上面關於Java中哪些型別需要經歷類載入的階段時,又明確說了陣列型別也是需要JVM對其進行連線和初始化的,這是不是有點矛盾呢?事實上,雖然陣列類本身並不通過類載入器載入(由虛擬機器直接建立),但是陣列類與類載入器仍然有很密切的關係,因為陣列類的元素型別(Element Type)如物件陣列,最終還是要靠類載入器去建立。
關於陣列類的載入建立過程是需要遵循如下規範的:
- 如果陣列的元素型別是引用型別的話,那麼就會遞迴採用前面內容中定義的類載入過程去載入這個元素型別,該陣列本身將會在載入該元素型別的類載入器的類名稱空間上被標識(這一點非常重要,在講述類載入器的時候會介紹到,一個類必須與類載入器一起確定唯一性)。
- 如果陣列的元素型別不是引用型別(例如int[]陣列),JVM則會把該int[]陣列標記為啟動類載入器(Bootstrap Classloader)關聯。
實際上,在載入階段虛擬機器需要完成以下三件事:
- 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
- 將這個位元組流所代表的靜態儲存結構轉換為方法區(JDK1.8以前)或者元資料(JDK1.8以後)的執行時資料結構。
- 在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區或元資料區這個類的各種資料的訪問入口。
載入階段完成後,JVM外部的二進位制位元組流就按照虛擬機器所需要的格式儲存在了方法區或元資料區的記憶體中了。
2.驗證(Verification)
驗證是連線階段的第一步,這一階段的主要目的是為了確保Class檔案的位元組流中所包含的資訊符合當前JVM的要求,並且不會危害JVM自身的安全。虛擬機器如果不檢查輸入的位元組流,對其完全信任的話,很可能會因為載入了有害的位元組流而導致系統崩潰,所以驗證階段是非常重要的,這個階段是否嚴謹,直接決定了JVM是否能承受惡意程式碼的攻擊。
那麼驗證階段具體應當檢查哪些方面?如何檢查?何時檢查呢?
在《Java虛擬機器規範(Java SE 7版)》中大概是有130頁左右的篇幅是來描述驗證的過程的,受篇幅所限,我們無法逐條規則去探討。但從整體上來看,驗證階段大致會完成如下四個階段的檢驗動作:
1)、檔案格式驗證
檔案格式驗證就是要驗證位元組流是否符合Class檔案格式的規範,以及是否能被當前版本的虛擬機器處理。例如,常量池的常量中是否有不被支援的常量型別;Class檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊等等。
這個階段驗證的主要目的就是為了保證輸入的位元組流能正確地解析並存儲於方法區或元資料區之內,格式上符合描述一個Java型別資訊的要求。本階段的驗證是基於二進位制位元組流進行的,只有通過了這個階段的驗證,位元組流才會正常進入記憶體(方法區/元資料區)中進行儲存,所以後面剩下的3個驗證階段全部是基於已經載入記憶體的儲存結構進行的,而不會再直接操作位元組流了。
2)、元資料驗證
語言規範的要求。
例如:
這個類是否有父類(除了java.lang.Object之外,所有的類都應該有父類)?這個類的父類是否繼承了不允許被繼承的類(如被final修飾的類)?如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法?類中的欄位、方法是否與父類產生矛盾?等等。雖然這些邏輯目前編譯器都會在編譯位元組碼檔案時加以校驗,但是作為JVM類載入本身為了確保自身的安全性,也是需要進行嚴格校驗的。
3)、位元組碼驗證
位元組碼驗證是一個更加複雜的階段,主要目的是通過資料流和控制流的分析,確定程式語義是合法的、符合邏輯的。在元資料驗證階段主要是完成了對元資料資訊的型別校驗,而這個階段則是對類的方法體進行校驗分析,確保被校驗類的方法在執行時不會做出危害虛擬機器安全的事件。例如,保證方法體中的型別轉換是有效的,可以把一個子類物件賦值給父類的資料型別(上溯造型),但是不能把父類物件賦值給子類資料型別或者把物件賦值給與與它毫無繼承關係的型別。
4)、符號引用驗證
符號引用驗證可以看做是對類自身以外(主要是常量池中的各種符號引用)的資訊進行匹配性校驗。目的是確保後面進入解析階段後,解析動作能夠正常執行。如果無法通過符號引用驗證,就會丟擲如“java.lang.IllegalAccessError”、“java.lang.NoSuchFileIdError”、“java.lang.NoSuchMethodError”等這樣的異常資訊。
對於JVM的類載入機制來說,驗證階段是一個非常重要,但是不一定必要(對程式的執行期沒有影響)的階段。如果所執行的全部程式碼,包括自己編寫的以及第三方包中的程式碼都已經被反覆使用和驗證過,那麼就可以考慮使用“-Xverify:none”引數來關閉大部分的驗證措施,以縮短虛擬機器類載入的時間。
3.準備(Preparation)
準備階段是正式為類變數(被static修飾的變數)分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區(<Jdk1.8)元資料區(>=Jdk1.8)中進行分配。這時候進行記憶體分配的僅包括類變數,而不包括例項變數,例項變數將會在物件例項化的時候隨物件一起分配在Java堆中。
另外,上面所說的對類變數進行初始值,通常情況下是初始為零值。如int型別的類變數,初始值就是0。
4.解析(Resolution)
在class檔案被載入至JVM之前,這個類是無法知道其他類及方法、欄位所對應的具體地址的,甚至不知道自己方法、欄位的記憶體地址。因此,每當需要引用這些成員時Java編譯器會生成一個符號引用。在執行階段這個符號引用一般都能無歧義地定位在具體目標上。舉個例子,對於一個方法呼叫,編譯器會生成一個包含目標方法所在類的名字、目標方法的名字、接收引數型別以及返回值型別的符號引用,來指代所要呼叫的方法。
解析階段的目的就是將這些符號引用解析成為實際引用。而實際引用就是真正指向記憶體地址的指標、相對偏移量或能間接定位到目標的控制代碼。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符這7類符號引用進行。
在前面我們提到過,解析階段並不一定會在連線過程中完成,因為JVM虛擬機器規範並沒有對此作明確的要求,只是規定了:“如果某些位元組碼使用了符號引用,那麼在執行這些位元組碼之前,需要完成對這些符號引用的解析”。對於這一點大家不要搞錯了。
5.初始化(Intialization)
類初始化是類載入過程的最後一步,是為標記為常量值的欄位賦值,以及執行方法的過程。那麼什麼樣的欄位才會被標記為常量值呢?方法又是什麼呢?
在Java程式碼中如果要初始化一個靜態欄位,我們可以在宣告時直接賦值,也可以在靜態程式碼塊中對其賦值。在這裡,如果直接賦值的靜態欄位被 final 所修飾,並且它的型別是基本型別或字串時,那麼該欄位便會被 Java 編譯器標記成常量值(ConstantValue),其初始化直接由Java 虛擬機器完成。
而除此之外的直接賦值操作,以及所有靜態程式碼塊中的程式碼則都會被Java編譯器置於同一方法中,這個方法就是方法,也稱為類構造器方法。Java 虛擬機器會通過加鎖來確保類的 只會被執行一次。
在我們講述JVM類載入過程的時候,並沒有特別說明什麼情況下需要開始類載入過程的第一個階段:載入?這是因為JVM虛擬機器規範並沒有進行強制約束。但是對於初始化階段,JVM規範則是嚴格規定了發生如下情況時必須立刻對類進行“初始化”,而載入、驗證、準備也自然需要在此之前開始。
這幾種情況如下:
1)、當虛擬機器啟動時,初始化使用者指定的主類(包含main方法的類);
2)、當遇到用以新建目標類例項的 new 指令時,初始化 new 指令的目標類;
3)、當遇到呼叫靜態方法的指令時,初始化該靜態方法所在的類;
4)、當遇到訪問靜態欄位的指令時,初始化該靜態欄位所在的類;
5)、子類的初始化會先觸發父類的初始化(如果父類還沒有進行過初始化的話);
6)、如果一個介面定義了 default 方法,那麼直接實現或者間接實現該介面的類的初始化,會觸發該介面的初始化;
7)、使用反射 API 對某個類進行反射呼叫時,初始化這個類;
8)、當初次呼叫 MethodHandle 例項時(JDK1.7的動態語言支援),初始化該 MethodHandle 指向的方法所在的類;
以上基本上就是類載入機制中初始化的大致過程,只有當初始化完成之後,類才能正式成為可執行的狀態。
四.類載入器
在整個類載入過程中,除了在載入階段使用者應用程式可以通過自定義類載入器參與之外,其餘的動作完全是由虛擬機器主導和控制的。那麼什麼是類載入器呢?
在上述類載入機制的第一個階段:"載入"中,把“通過一個類的全限定名來獲取描述此類的二進位制位元組流”這個動作由JVM外部實現的程式碼模組稱為“類載入器”。
從JVM的角度來看,只存在兩種不同的類載入器:啟動類載入器(Bootstrap ClassLoader)、其他類的載入器。啟動類載入器是由C++語言實現的,屬於JVM自身的一部分,而其他的類載入器則都是獨立於JVM外部,由Java語言實現的繼承java.lang.ClassLoader的型別。
而從Java程式設計師的角度看,類載入器還可以劃分得更加細緻一些。示意圖如下:
在上圖中的類載入器,是有層次關係的,這種關係被稱之為類載入器的“雙親委派模式”,它要求除了頂層啟動類載入器外,其餘所有的類載入器都應當有自己的父類載入器,並且如果一個類載入器在收到類載入的請求之後都要先把這個請求委派給父類載入器去完成(每一個層次的類載入器都是如此,因此所有的載入請求最終都應該會傳送到頂層的啟動類載入器中),只有當父類載入器反饋自己無法完成這個載入請求(在搜尋範圍沒有找到所需的類)時,子載入器才會嘗試自己去載入。
雙親委派模式不是強制性的約束模型,只是Java設計者推薦給開發者的類載入器的實現方式,但是採用這種模式對於保證Java程式的穩定運作確實很重要的,因為它可以避免Java體系中基礎的型別被混亂載入的風險。例如類java.lang.Object,它存放在rt.jar之中,無論那一個類載入器要載入這個類,最終都會委派給啟動類載入器,這樣Object類在程式的各種類載入器環境中都是一個類,否則就會導致系統中出現多個不同的Object類,從而連Java型別體系中最基本的行為都無法保證。
以上就是關於JVM類載入系統的全部內容了,希望本文能夠對你補充知識盲點起到一點作用!