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 中的文件,優先級更高,可以被最先加載到。
————來自拉勾教育筆記