JVM 通過載入 .class 檔案,能夠將其中的位元組碼解析成作業系統機器碼。那這些檔案是怎麼載入進來的呢?又有哪些約定?接下來我們就詳細介紹 JVM 的類載入機制,同時介紹三個實際的應用場景。
我們首先看幾個面試題。
- 我們能夠通過一定的手段,覆蓋 HashMap 類的實現麼?
- 有哪些地方打破了 Java 的類載入機制?
- 如何載入一個遠端的 .class 檔案?怎樣加密 .class 檔案?
類載入過程
現實中並不是說,我把一個檔案修改成 .class 字尾,就能夠被 JVM 識別。類的載入過程非常複雜,主要有這幾個過程:載入、驗證、準備、解析、初始化。這些術語很多地方都出現過,我們不需要死記硬背,而應該要了解它背後的原理和要做的事情。
如圖所示。大多數情況下,類會按照圖中給出的順序進行載入。下面我們就來分別介紹下這個過程。
1.載入
載入的主要作用是將外部的 .class 檔案,載入到 Java 的方法區內,你可以回顧一下我們在上一課時講的記憶體區域圖。載入階段主要是找到並載入類的二進位制資料,比如從 jar 包裡或者 war 包裡找到它們。
2.驗證
肯定不能任何 .class 檔案都能載入,那樣太不安全了,容易受到惡意程式碼的攻擊。驗證階段在虛擬機器整個類載入過程中佔了很大一部分,不符合規範的將丟擲 java.lang.VerifyError 錯誤。像一些低版本的 JVM,是無法載入一些高版本的類庫的,就是在這個階段完成的。
3.準備
從這部分開始,將為一些類變數分配記憶體,並將其初始化為預設值。此時,例項物件還沒有分配記憶體,所以這些動作是在方法區上進行的。
我們順便看一道面試題。下面兩段程式碼,code-snippet 1 將會輸出 0,而 code-snippet 2 將無法通過編譯。
code-snippet 1: public class A { static int a ; public static void main(String[] args) { System.out.println(a); } } code-snippet 2: public class A { public static void main(String[] args) { int a ; System.out.println(a); } }
為什麼會有這種區別呢?
這是因為區域性變數不像類變數那樣存在準備階段。類變數有兩次賦初始值的過程,一次在準備階段,賦予初始值(也可以是指定值);另外一次在初始化階段,賦予程式設計師定義的值。
因此,即使程式設計師沒有為類變數賦值也沒有關係,它仍然有一個預設的初始值。但區域性變數就不一樣了,如果沒有給它賦初始值,是不能使用的。
4.解析
解析在類載入中是非常非常重要的一環,是將符號引用替換為直接引用的過程。這句話非常的拗口,其實理解起來也非常的簡單。
符號引用是一種定義,可以是任何字面上的含義,而直接引用就是直接指向目標的指標、相對偏移量。
直接引用的物件都存在於記憶體中,你可以把通訊錄裡的女友手機號碼,類比為符號引用,把面對面和你吃飯的人,類比為直接引用。
解析階段負責把整個類啟用,串成一個可以找到彼此的網,過程不可謂不重要。那這個階段都做了哪些工作呢?大體可以分為:
- 類或介面的解析
- 類方法解析
- 介面方法解析
- 欄位解析
我們來看幾個經常發生的異常,就與這個階段有關。
- java.lang.NoSuchFieldError 根據繼承關係從下往上,找不到相關欄位時的報錯。
- java.lang.IllegalAccessError 欄位或者方法,訪問許可權不具備時的錯誤。
-
java.lang.NoSuchMethodError 找不到相關方法時的錯誤。
解析過程保證了相互引用的完整性,把繼承與組合推進到執行時。
5.初始化
如果前面的流程一切順利的話,接下來該初始化成員變數了,到了這一步,才真正開始執行一些位元組碼。
接下來是另一道面試題,你可以猜想一下,下面的程式碼,會輸出什麼?
public class A { static int a = 0 ; static { a = 1; b = 1; } static int b = 0; public static void main(String[] args) { System.out.println(a); System.out.println(b); } }
結果是 1 0。a 和 b 唯一的區別就是它們的 static 程式碼塊的位置。
這就引出一個規則:static 語句塊,只能訪問到定義在 static 語句塊之前的變數。所以下面的程式碼是無法通過編譯的。
static { b = b + 1; } static int b = 0;
我們再來看第二個規則:JVM 會保證在子類的初始化方法執行之前,父類的初始化方法已經執行完畢。
所以,JVM 第一個被執行的類初始化方法一定是 java.lang.Object。另外,也意味著父類中定義的 static 語句塊要優先於子類的。
<cinit>與<init>
說到這裡,不得不再說一個面試題:<cinit> 方法和 <init> 方法有什麼區別?
主要是為了讓你弄明白類的初始化和物件的初始化之間的差別。
public class A { static { System.out.println("1"); } public A(){ System.out.println("2"); } } public class B extends A { static{ System.out.println("a"); } public B(){ System.out.println("b"); } public static void main(String[] args){ A ab = new B(); ab = new B(); } }
輸出結果:
1 a 2 b 2 b
你可以看下這張圖。其中 static 欄位和 static 程式碼塊,是屬於類的,在類的載入的初始化階段就已經被執行。類資訊會被存放在方法區,在同一個類載入器下,這些資訊有一份就夠了,所以上面的 static 程式碼塊只會執行一次,它對應的是 <cinit> 方法。
而物件初始化就不一樣了。通常,我們在 new 一個新物件的時候,都會呼叫它的構造方法,就是 <init>,用來初始化物件的屬性。每次新建物件的時候,都會執行。
所以,上面程式碼的 static 程式碼塊只會執行一次,物件的構造方法執行兩次。再加上繼承關係的先後原則,不難分析出正確結果。
類載入器
整個類載入過程任務非常繁重,雖然這活兒很累,但總得有人幹。類載入器做的就是上面 5 個步驟的事。
如果你在專案程式碼裡,寫一個 java.lang 的包,然後改寫 String 類的一些行為,編譯後,發現並不能生效。JRE 的類當然不能輕易被覆蓋,否則會被別有用心的人利用,這就太危險了。
那類載入器是如何保證這個過程的安全性呢?其實,它是有著嚴格的等級制度的。
幾個類載入器
首先,我們介紹幾個不同等級的類載入器。
1.Bootstrap ClassLoader
這是載入器中的大 Boss,任何類的載入行為,都要經它過問。它的作用是載入核心類庫,也就是 rt.jar、resources.jar、charsets.jar 等。當然這些 jar 包的路徑是可以指定的,-Xbootclasspath 引數可以完成指定操作。
這個載入器是 C++ 編寫的,隨著 JVM 啟動。
2.Extention ClassLoader
擴充套件類載入器,主要用於載入 lib/ext 目錄下的 jar 包和 .class 檔案。同樣的,通過系統變數 java.ext.dirs 可以指定這個目錄。
這個載入器是個 Java 類,繼承自 URLClassLoader。
3.App ClassLoader
這是我們寫的 Java 類的預設載入器,有時候也叫作 System ClassLoader。一般用來載入 classpath 下的其他所有 jar 包和 .class 檔案,我們寫的程式碼,會首先嚐試使用這個類載入器進行載入。
4.Custom ClassLoader
自定義載入器,支援一些個性化的擴充套件功能。
雙親委派機制
關於雙親委派機制的問題面試中經常會被問到,你可能已經倒背如流了。
雙親委派機制的意思是除了頂層的啟動類載入器以外,其餘的類載入器,在載入之前,都會委派給它的父載入器進行載入。這樣一層層向上傳遞,直到祖先們都無法勝任,它才會真正的載入。
打個比方。有一個家族,都是一些聽話的孩子。孫子想要買一塊棒棒糖,最終都要經過爺爺過問,如果力所能及,爺爺就直接幫孫子買了。
但你有沒有想過,“類載入的雙親委派機制,雙親在哪裡?明明都是單親?”
我們還是用一張圖來講解。可以看到,除了啟動類載入器,每一個載入器都有一個parent,並沒有所謂的雙親。但是由於翻譯的問題,這個叫法已經非常普遍了,一定要注意背後的差別。
我們可以翻閱 JDK 程式碼的 ClassLoader#loadClass 方法,來看一下具體的載入過程。和我們描述的一樣,它首先使用 parent 嘗試進行類載入,parent 失敗後才輪到自己。同時,我們也注意到,這個方法是可以被覆蓋的,也就是雙親委派機制並不一定生效。
這個模型的好處在於 Java 類有了一種優先順序的層次劃分關係。比如 Object 類,這個毫無疑問應該交給最上層的載入器進行載入,即使是你覆蓋了它,最終也是由系統預設的載入器進行載入的。
如果沒有雙親委派模型,就會出現很多個不同的 Object 類,應用程式會一片混亂。
那麼,如何替換 JDK 中的類?比如,我們現在就拿 HashMap為例。
當 Java 的原生 API 不能滿足需求時,比如我們要修改 HashMap 類,就必須要使用到 Java 的 endorsed 技術。我們需要將自己的 HashMap 類,打包成一個 jar 包,然後放到 -Djava.endorsed.dirs 指定的目錄中。注意類名和包名,應該和 JDK 自帶的是一樣的。但是,java.lang 包下面的類除外,因為這些都是特殊保護的。
因為我們上面提到的雙親委派機制,是無法直接在應用中替換 JDK 的原生類的。但是,有時候又不得不進行一下增強、替換,比如你想要除錯一段程式碼,或者比 Java 團隊早發現了一個 Bug。所以,Java 提供了 endorsed 技術,用於替換這些類。這個目錄下的 jar 包,會比 rt.jar 中的檔案,優先順序更高,可以被最先載入到。
————來自拉勾教育筆記