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