聊聊這道經(jīng)典的大廠面試題
大家好,今天我們來學(xué)習(xí)一道 Java 常見的面試題:類加載機制。
在工程中我們基本無時無刻都在和對象打交道,那么大家有想過這些這些對象是怎么來的嗎,當(dāng) new 一個對象的時候到底發(fā)生了什么?
沒錯,就是類加載機制,了解這個機制很重要,這不僅能讓我們理解 JVM 的運行機制,更重要的是它還能解釋一些我們看起來覺得很奇怪的現(xiàn)象,比如如下懶漢式單例模式
public?class?Singleton?{
??private?Singleton()?{}
??private?static?class?LazyHolder?{
????static?final?Singleton?INSTANCE?=?new?Singleton();
??}
??public?static?Singleton?getInstance()?{
????return?LazyHolder.INSTANCE;
??}
}
乍一看可能會覺得多線程環(huán)境下可能會產(chǎn)生多個 Singleton 實例,實際上由于類初始化是線程安全的,并且僅被執(zhí)行一次,因此程序可以確保多線程環(huán)境下有且僅有一個 Singleton 實例,再問這個線程安全是如何保證的?這就得進(jìn)一步了解類初始化階段的 clinit 方法了,所以你看看了解類加載這些底層的機制有多么重要。
本文思維導(dǎo)圖如下:

類加載機制簡介
類加載整體流程如下圖所示,這也是類的生命周期

我們可以看到,字節(jié)碼文件需要經(jīng)過加載,鏈接(包括驗證,準(zhǔn)備,解析),初始化才能轉(zhuǎn)為類,然后才能根據(jù)類來創(chuàng)建對象
需要注意的是,圖中紅框所代表的加載,驗證,準(zhǔn)備,初始化,卸載這五個階段的順序是確定的,類加載必須嚴(yán)格按照這五個階段的順序來開始,但解析階段則未必,有可能在初始化之后才開始,主要是為了支持 Java 的動態(tài)綁定特性,那么各個階段主要做了哪些事呢
加載
在加載階段,虛擬機需要完成以下三件事
通過一個類的全限定名來獲取此類的二進(jìn)制字節(jié)流
將這個字節(jié)流所代表的靜態(tài)存儲結(jié)構(gòu)轉(zhuǎn)化為方法區(qū)的運行時結(jié)構(gòu)
在內(nèi)存中生成一個代表這個類的 java.lang.Class 對象,作為方法區(qū)這個類的各種數(shù)據(jù)的訪問入口

如上圖所示,加載后生成的類對象與對象間的關(guān)系如上圖所示,什么是類對象呢,比如實例的 getClass() 或 Foo.Class 即為類對象
每個類只有一個對象實例(類對象),多個對象共享類對象,這里有個需要注意的點是類對象是在堆中而不是在方法區(qū)(這里針對的是 Java 7 及以后的版本),所有的對象都是分配在堆中的,類對象也是對象,所以也是分配在堆中,這點網(wǎng)上挺多人混淆了,需要注意一下
有人可能會奇怪,只看上面這張圖,對象和類對象貌似聯(lián)系不起來,實際上在虛擬機底層,比如 Java Hotspot 虛擬機,對象和類是以一種被稱為 oop-klass 的模型來表示的,每個對象或類都有對應(yīng)的 C++ 類表示方式,它的底層其實是如下這樣來表示的,通過下圖可以看到,通過這種方式實例對象和 Class 對象就能聯(lián)系起來了,我們另一篇講述對象模型時再詳述 oop-klass 對象,這里先按下不表

類元信息也就是類的信息主要分配在方法區(qū),在 Java 8 中方法區(qū)是在元空間(metaspace)中實現(xiàn)的,所以類元信息是保存在元空間的。
注意這一階段雖然名曰加載,但其實在加載階段是夾雜著一些驗證工作的,主要有以下驗證
文件格式的驗證:比如驗證字節(jié)碼是否是以魔數(shù) 0xCAFEBABE 開頭,主次版本是否在當(dāng)前虛擬機可接受范圍內(nèi)等安全校驗的操作等,通過這一階段的驗證后加載的字節(jié)流才被允許進(jìn)入 Java 虛擬機內(nèi)存的方法區(qū)中進(jìn)行存儲。元數(shù)據(jù)校驗:這一階段主要是對字節(jié)碼描述的信息進(jìn)行語義分析,如確保每一個加載的類除了 Object 外都有父類,這也就意味著,一旦某個類被加載,那么它的父類,祖先類。。。等也會被加載(但此時還不會被鏈接,初始化)
有人可能會困惑,為啥需要做這些校驗工作呢,字節(jié)碼文件難道不安全?字節(jié)碼文件一般來說是通過正常的 Java 編譯器編譯而成的,但字節(jié)碼文件也是可以編輯修改的,也是有可能被篡改注入惡意的字節(jié)碼的,就會對程序造成不可預(yù)知的風(fēng)險,所以加載階段的驗證是非常有必要的
我們可以在執(zhí)行 java 程序的時候加上?-verbose:class?或?-XX:+TraceClassLoading?這兩個 JVM 參數(shù)來觀察一下類的加載情況,比如我們寫了如下測試類
public?class?Test?{
????public?static?void?main(String[]?args)?{
????}
}
編譯后執(zhí)行?java -XX:+TraceClassLoading Test
可以看到如下加載過程
[Opened?/Library/Internet?Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/lib/rt.jar]
[Loaded?java.lang.Object?from?/Library/Internet?Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/lib/rt.jar]
[Loaded?java.lang.CharSequence?from?/Library/Internet?Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/lib/rt.jar]
[Loaded?java.lang.String?from?/Library/Internet?Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/lib/rt.jar]
...?//?省略號表示加載了很多?lib/rt.jar?下的類
[Loaded?Test?from?file:/Users/ronaldo/practice/]
...
注意看倒數(shù)第二行,可以看到 Test 類被加載了,這可以理解,因為執(zhí)行了 Test 的 main 方法,Test 會被初始化,也就會被加載(之后會講述初始化條件), 但上述有挺多加載 lib/rt.jar 下的類又是怎么回事呢?
要回答這個問題,我們必須得先搞清楚一個問題:我們說的類加載到底是由誰執(zhí)行的?
雙親委派模式
類加載必須由類加載器(classloader)來完成,類加載器+類的全限定名(包名+類名)唯一確定一個類,看到這有人可能會問了,類加載器難道會有多個?
猜得沒錯!類加載器的確會有多個,為啥會有多個呢,主要有兩個目的:安全性和責(zé)任分離
首先說安全性,試想如果只有一個類加載器會出現(xiàn)什么情況,我們可能會定義一個java.lang.virus 的類,這樣的話由于此類與 Java.lang.String 等核心類處于同一個包名下,那么此類就具有訪問這些核心類 package 方法的權(quán)限,此外如果用戶自定義一個 java.lang.String 類,如果類加載器加載了這個類,有可能把原本的 ?String 類給替換掉,這顯然會造成極大的安全隱患
再來說責(zé)任分離,像 rt.jar 包下的核心類等沒有什么特殊的要求顯然可以直接加載,而且由于是核心類,程序一啟動就會被加載,也可以進(jìn)一步優(yōu)化來提升加載速度,而有些字節(jié)碼文件由于反編譯等原因可能需要加密,此時類加載器就需要在加載字節(jié)碼文件時對其進(jìn)行解密,再比如實現(xiàn)熱部署也需要類加載器從指定的目錄中加載文件,這些功能如果都在一個類加載器里實現(xiàn),會導(dǎo)致類加載器的功能很重,所以解決辦法就是定義多個類加載器,各自負(fù)責(zé)加載指定路徑下的字節(jié)碼文件,從而針對指定路徑下的類文件加載做相關(guān)的操作,達(dá)到責(zé)任分離的目的
在 JVM 中有哪些類加載器呢
主要有以下三類加載器
啟動類加載器(
BootstrapClassLoader):,負(fù)責(zé)加載\lib 下的 rt.jar,resources.jar 等核心類庫或者 -Xbootclasspath 指定的文件 擴展類加載器(
Extension ClassLoader):負(fù)責(zé)加載目錄或\lib\ext java.ext.dirs系統(tǒng)變量指定的路徑中的所有類庫。應(yīng)用程序類加載器(
Application ClassLoader)。負(fù)責(zé)加載用戶類路徑(classpath)上的指定類庫,我們可以直接使用這個類加載器。一般情況,如果我們沒有自定義類加載器默認(rèn)就是用這個加載器。
類加載器的主要作用就是負(fù)責(zé)加載字節(jié)碼二進(jìn)制流,將其最終轉(zhuǎn)成方法區(qū)中的類對象
現(xiàn)在我們知道了有以上幾個種類的類加載器,那么這里有三個問題需要回答:
怎么指定類由指定的類加載器加載的呢?
類加載器是如何保證類的一致性的,由以上可知類加載器+類的全限定名唯一確定一個類,那怎么避免一個類被多個類加載器加載呢,畢竟你無法想象工程中有兩個 Object 類,那豈不亂套了
類加載器(java.lang.ClassLoader)是用來加載類的,但其本身也是類,那么類加載器又是被誰加載的呢
為了解決上述問題,類加載器采用了雙親委派模型模式來設(shè)計類加載器的層次結(jié)構(gòu)
什么是雙親委派模式
先來看一下雙親委派模式的整體設(shè)計架構(gòu)圖

可以看到,程序默認(rèn)是由 AppClassLoader 加載的,每個類被相應(yīng)的加載器加載后都會被緩存起來,這樣下次再碰到相關(guān)的類直接從緩存里獲取即可,避免了重復(fù)的加載,同時每個類由于只會被相應(yīng)的類加載器加載,確保了類的唯一性,比如 java.lang.Object 只會被 BootstrapClassLoader 加載,保證了 Object 的唯一性
類加載器是如何加載類的呢?
當(dāng)類首次被加載時(假設(shè)此類為 ArrayList),AppClassLoader 并不會馬上就加載它,而是會向上委托給它的 parent,即 ExtClassLoader,查看是否已加載了這個類,如果沒有則繼續(xù)向上委托給 BootsrapClassLoader 讓其加載,此時 BootsrapClassLoader 就會從 lib/rt.jar 加載此類生成類對象
并緩存起來,然后 BootsrapClassLoader 會把此類對象返回給 ExtClassLoader,ExtClassLoader 再把此類對象返回給 AppClassLoader,然后就可以基于此類對象來創(chuàng)建類的實例對象了當(dāng)再次調(diào)用 new ArrayList() 時,也會觸發(fā) ArrayList 的加載,此時 AppClassLoader 也會首先往上層層委托給 BootsrapClassLoader 給加載,由于其緩存里已經(jīng)有此類對象了,所以直接在緩存里查找后遞歸返回給 AppClassLoader 即可。
再來看上述問題 3,類加載器是被誰加載的?
實際上 AppClassLoader 和 ExClassLoader 都是 java.lang.ClassLoader 的子類,它們都是在應(yīng)用啟動時是由 BootstrapClassLoader 加載的,畢竟其它類要由這三個類加載器加載,所以這三個類加載器必須先存在,那么誰來加載 BootstrapClassLoader 呢,如果還是由另一個類加載器加載,那么還要設(shè)計一個類加載器來加載它,。。。,就陷入了無限循環(huán)之中,所以 BootstrapClassLoader 在 JVM 中本身是以 C++ 形式實現(xiàn)的,是 JVM 的一部分,在應(yīng)用啟動時就存在了,所以它本身可以說是 JVM 自身創(chuàng)建的,不需要由另外的加載器加載,所以它也被稱為根加載器
java.lang 下的一些核心類如 Object,String,Class 等核心類本身非常重要也很常用,所以在應(yīng)用啟動時 BootstrapClassLoader 也會提前把它們加載好,另外在加載 AppClassLoader 和 ExClassLoader 時在這兩個類中也會遇到使用 List 等核心類的情況,所以也會把 rt.jar 中的這些核心類也一起加載了,這就是為什么我們在上文看到 Test 類被加載前也看到了這些核心類被加載的原因
類加載都要遵循雙親委派機制嗎
不是的,一個典型的應(yīng)用場景就是 Tomcat 的類加載,由于 Tomcat 可能會加載多個 web 應(yīng)用,而多個應(yīng)用很有可能出現(xiàn)包名+類名都一樣的類,最典型的比如兩個應(yīng)用采用了同樣的第三方類庫,但是它們的版本不同,這種情況下如果按雙親委派來加載,只會有一個類對象,顯然有問題,這種情況要能區(qū)分各個應(yīng)用的類,就得破壞雙親委派機制,如下:

綠色部分是 java 項目在打 war 包的時候, tomcat 自動生成的類加載器, 也就是說 , 每一個項目打成一個war包, tomcat都會自動生成一個類加載器, 專門用來加載這個 war 包,當(dāng)加載 war 包中的類時,首先由 webappClassLoader 加載,而不是首先委托給上一級類加載器加載,這樣的話由于加載每一個 war 包的 webappClassLoader 都不一樣,每個 war 包被加載的類最終生成的類對象也必然不一樣!就達(dá)到了應(yīng)用程序間類的隔離
最后有一個需要注意的點是并不是所有的類都需要通過類加載器來加載創(chuàng)建,比如數(shù)組類就比較特殊,它是由 Java 虛擬機直接在內(nèi)存中動態(tài)構(gòu)造出來的,但由于類的特性(類加載器+類的全限定名惟一確定一個類),數(shù)組類依然最終會會被標(biāo)識在某個加載器的命名空間下,到底標(biāo)識在哪個類加載器的命名空間下,取決于數(shù)組的組件類型(比如 int[] 數(shù)組組件類型為 int,String[] 數(shù)組組件類型為 String),如果組件類型為 int 等基本類型,會標(biāo)識在啟動類加載器 bootstrapclassloader 下,如果為其它的引用類型(比如自定義的類 Test,數(shù)組為 Test)則標(biāo)識為最終加載此類的類加載器下
花了這么大的筆墨終于把加載階段講完了,這個階段真的很重要,不僅是因為它是類加載的第一個階段,還因為其中涉及到雙親委派等原理,如果沒有搞明白的,建議多看幾遍,應(yīng)該都講得比較清楚了。
接下來我們再來看另外兩個階段:鏈接和初始化,首先需要明白的是,加載階段完成后并不會馬上就做之后的鏈接,初始化的操作,比如如果我有一個類 Test,在方法中定義了一個?Test[] list = new Test[10];?這樣的數(shù)組變量,此時會觸發(fā) Test 類的加載,但并不會觸發(fā) Test 類的鏈接,初始化。那么什么是鏈接和初始化呢
鏈接
鏈接包括三個階段:

驗證,準(zhǔn)備和解析,其中驗證又包括字節(jié)碼驗證和符號引用驗證
這里的驗證主要有兩種字節(jié)碼驗證,符號引用驗證
字節(jié)碼驗證
這個階段主要是對類的方法體(Class 文件中的 Code 屬性)進(jìn)行校驗分析,保證被校驗類的方法不會在運行時做出危害虛擬機安全的行為,比如:
保證任何跳轉(zhuǎn)指令不會跳轉(zhuǎn)到方法體以外的字節(jié)碼指令上
保證類的轉(zhuǎn)換是有效的,比如可以把子類對象賦值給父類變量,反之則不行
…
符號引用驗證
這個驗證其實是在解析階段發(fā)生,符號引用可以看作是對類自身以外(常用池引用中的各種符合引用)的各類信息進(jìn)行匹配性的驗證,我們知道在字節(jié)碼方法中如果調(diào)用了或者說引用了某個類,那么這個類是在字節(jié)碼中是以符號引用的形式存在的,所以就要確保真正用到此類的時候能找到此類,如果找不到就會報錯,舉個簡單的例子,假設(shè)有以下兩個類,顯然編譯時都能通過,但在編譯后如果我把 B.class 刪掉,A.class 保留著 B 類的符號引用,如果執(zhí)行 A 的 main 方法需要加載 B 類,由于 B.class 文件缺失導(dǎo)致無法加載 B 類,就會報錯
//?B.java
public?class?B?{
}
//?A.java
public?class?A?{
????public?static?void?main(String[]?args)?{
????????B?b?=?new?B();
????}
}
符號引用驗證不光驗證類,還會驗證方法,字段等
注意,類的驗證并不是必須的,如果你能確保你的 class 文件是絕對安全的,那么可以開啟 -Xverify:none 來關(guān)閉類的驗證,這樣可以縮短類的加載時間以達(dá)到加快類加載的目的。
準(zhǔn)備
準(zhǔn)備階段的主要目的有兩個
為了給被加載類的靜態(tài)字段分配內(nèi)存,并為其賦默認(rèn)初始值,如 int 類型的靜態(tài)變量默認(rèn)賦值為 0
部分 Java 虛擬機還會在此階段構(gòu)造其他跟類層次相關(guān)的數(shù)據(jù)結(jié)構(gòu),比如說用來實現(xiàn)虛方法的動態(tài)綁定的方法表。
解析
如前所述,這一階段會進(jìn)行符號引用驗證,主要作用是在運行時把字節(jié)碼類中的常量池符號引用解析成為能定位到內(nèi)存方法區(qū)中對應(yīng)類信息的直接引用(內(nèi)存中的具體地址),以上述的代碼為例
//?B.java
public?class?B?{
}
//?A.java
public?class?A?{
????public?static?void?main(String[]?args)?{
????????????B?b?=?new?B();
????}
}
在編譯后,A 類的字節(jié)碼文件 A.class 包括 B 的符號引用,那么在執(zhí)行 main 方法后,由于碰到了?new B(),此時就會將 B 的符號引用轉(zhuǎn)為指向 B 的類對象的直接引用,由于 B 未加載,所以,所以此時也會觸發(fā) B 的加載生成 B 的類對象,這樣符號引用就可以轉(zhuǎn)成直接引用了,這里是以類的解析舉例,但實際上,常量,方法,字段等符號引用也都會被解析
但需要注意的是這一階段有可能發(fā)生在初始化之后,因為只有真正用到了比如需要調(diào)用某個類的方法時才需要去解析,如果在初始化時此方法還沒有被用到,那解析自然也完全沒有必要了
初始化
這一階段主要做兩件事
初始化靜態(tài)變量,為其賦值
執(zhí)行靜態(tài)代碼塊內(nèi)容
無論是初始化靜態(tài)變量還是執(zhí)行靜態(tài)代碼塊,java 編譯器編譯后, 它們都會被一起置于一個被稱為 clinit 的方法中,并且 JVM 會對其加鎖以保證此方法只會被執(zhí)行一次,只有在初始化完成之后,類才真正成為可執(zhí)行狀態(tài),另外需要注意的,在子類的 clinit 完成之前,JVM 會確保父類的 clinit 也已經(jīng)完成了,這從繼承的角度也容易理解,子類畢竟繼承著父類,只有父類初始化可用了,子類才能放心繼承或者說使用父類的方法等。
這里有一個需要注意的點是如果是 final 的靜態(tài)變量,且其類型是基本類型或字符串時,該字段會被標(biāo)記為常量值,其初始化由 JVM 完成,而不會被放入 clinit,比如如下類靜態(tài)變量
public?class?Test?{
????private?static?final?int?field?=?1;
}
這個 field 由于是常量值,所以并不會放入 clinit,而是由 JVM 來完成初始化
那么什么時候會執(zhí)行初始化呢,《Java 虛擬機規(guī)范》規(guī)定了六種情況必須立即對類進(jìn)行初始化
1、 遇到 new、getstatic、putstatic 或 invokestatic 這四條字節(jié)碼指令的時候,如果類沒有進(jìn)行初始化,則需要先觸發(fā)其初始化.
生成這四條指令的最常見的java代碼場景是:
使用 new 關(guān)鍵字實例化對象的時候
讀取或設(shè)置一個類的靜態(tài)字段(被final修飾、已在編譯期把結(jié)果放入常量池的靜態(tài)字段除外)的時候
需要特別強調(diào)這一條,這里針對的是類讀取或設(shè)置本類的靜態(tài)字節(jié),如果子類讀取父類的靜態(tài)字段,父類會初始化,但子類不會,比如有以下代碼
public?class?SuperClass{
??static?{
??????System.out.println("SuperClass?init");
??}
??public?static?int?value?=?10;
}
public?class?SubClass?extends?SuperClass?{
??static?{
??????System.out.println("SubClass?init");
??}
}
public?class?NotInitialization?{
??public?static?void?main(String[]?args)?{
??????System.out.println(SubClass.value);
??}
}則執(zhí)行的輸出為
SuperClass?init
10可以看到子類獲取子父類的靜態(tài)變量會讓父類初始化,但子類自身并不會被初始化
調(diào)用一個類的靜態(tài)方法的時候
2、使用 java.lang.reflect 包的方法對類進(jìn)行反射調(diào)用的時候,如果類沒有進(jìn)行初始化,則需要先觸發(fā)其初始化
3、當(dāng)初始化一個類的時候,如果發(fā)現(xiàn)其父類還沒有進(jìn)行過初始化,則需要先觸發(fā)其父類的初始化
4、當(dāng)虛擬機啟動的時候,用戶需要指定一個要執(zhí)行的主類(包含 main 方法的那個類),虛擬機會先初始化這個主類
5、當(dāng)使用 jdk7 新加入的動態(tài)語言支持的時候,如果一個 java,lang.invoke.MethodHandler 實例的最后解析結(jié)果是REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial 四種類的方法句柄,并且這個方法句柄對應(yīng)的類沒有進(jìn)行過初始化,那么需要先觸發(fā)其初始化.
6、(新)當(dāng)一個接口中定義了 JDK8 新加入的默認(rèn)方法(被default關(guān)鍵字修飾的接口方法)時,如果這個接口的實現(xiàn)類發(fā)生了初始化,那么該接口要在其之前初始化
7、 當(dāng)初次調(diào)用 MethodHandle 實例時,初始化該 MethodHandle 指向的方法所在的類。
這六種場景的行為稱為對一個類型的主動引用.除此之外,所有的引用類型的方式都不會觸發(fā)其初始化,稱為被動引用
看完這些相信你能回答開頭的單例模式為啥是安全可行的
簡單地作個總結(jié)
怎么來更通俗地理解加載,鏈接,初始化這些階段呢,其實我之前常說要理解技術(shù)概念,代入生活中的場景會更容易理解,比如我們要蓋房子,你總要圖紙吧(字節(jié)碼文件),按圖紙建筑加工(加載)后成了一座房子(類對象),但此時的房子還只是毛坯房,還不能住人,如果這個房子蓋了沒人住,那之后的裝修等過程就沒必要做了,這就是為什么上文定義了Test[] list = new Test[10];?這樣的數(shù)組變量只是加載的原因,因為你沒有調(diào)用 Test 相關(guān)的方法等操作,后續(xù)的步驟就沒有必要做了,但如果房子蓋好了之后你要入住,那首先這是個毛坯房,總得找人驗下房(鏈接中的驗證)吧,不然要是出現(xiàn)一些狀況(比如把承重墻敲了成為了危房)這房子根本就不符合驗收標(biāo)準(zhǔn)總得拒收吧,好了,驗收通過之后那就可以開始裝修了,為沙發(fā),電視等預(yù)留好空間(準(zhǔn)備),此時你只是在相應(yīng)的地方標(biāo)記了一下,A 位置留出來給電視,B 位置留出現(xiàn)給沙發(fā),此時就相當(dāng)只是做了一個符號引用,但你真正要看電視的時候,此時沒有,那么你就得去買來裝到對應(yīng)的位置上,這就是解析,當(dāng)把房子裝修完成之后(即初始化完成),此時的房子才是可用狀態(tài)(即類處于可用狀態(tài)),才可以交付給人入住。另外不難看出,解析這一步是可以放到初始化之后的,就就好比,雖然你為電視預(yù)留了位置,但你不看不買電視也照樣能夠入駐。
本文作者:碼海
