深入了解Java中的類加載機制
一、類加載過程
程序員編寫的Java源程序(.java文件)在經(jīng)過編譯器編譯之后被轉(zhuǎn)換成字節(jié)代碼(.class 文件),類加載器將.class文件中的二進制數(shù)據(jù)讀入到內(nèi)存中,將其放在方法區(qū)內(nèi),然后在堆區(qū)創(chuàng)建一個java.lang.Class對象,用來封裝類在方法區(qū)內(nèi)的數(shù)據(jù)結(jié)構(gòu)。
類加載的最終產(chǎn)品是位于堆區(qū)中的Class對象,Class對象封裝了類在方法區(qū)內(nèi)的數(shù)據(jù)結(jié)構(gòu),并且向Java程序員提供了訪問方法區(qū)內(nèi)的數(shù)據(jù)結(jié)構(gòu)的接口。
以下是舉例說明類加載過程:
二、類生命周期
類的生命周期包括:加載、驗證、準(zhǔn)備、解析、初始化、使用、卸載7個階段。其中加載、驗證、準(zhǔn)備、初始化、卸載5個階段是按照這種順序按部就班的開始,而解析階段則不一定:某些情況下,可以在初始化之后再開始,這是為了支持Java語言的運行時綁定(也稱為動態(tài)綁定或晚期綁定,其實就是多態(tài)),例如子類重寫父類方法。
注意:這里寫的是按部就班的開始,而不是按部就班地進行或完成,因為這些階段通常都是互相交叉混合式進行的,通常會在一個階段執(zhí)行過程中調(diào)用、激活另外一個階段。
1、加載
加載階段會做3件事情:
- 通過一個類的全限定名來獲取定義此類的二進制字節(jié)流。
- 將這個字節(jié)流所代表的靜態(tài)存儲結(jié)構(gòu)轉(zhuǎn)化為方法區(qū)的運行時數(shù)據(jù)結(jié)構(gòu)。
- 在Java堆中生成一個代表這個類的java.lang.Class對象,作為對方法區(qū)中這些數(shù)據(jù)的訪問入口。
此處第一點并沒指明要從哪里獲取、怎樣獲取,因此這里給開發(fā)人員預(yù)留了擴展空間。許多Java技術(shù)就建立在此基礎(chǔ)上,例如:
- 從ZIP包讀取,如JAR、WAR。
- 從網(wǎng)絡(luò)中獲取,這種場景最典型應(yīng)用場景應(yīng)用就是Applet。
- 運行時計算生成,使用較多場景是動態(tài)代理技術(shù),如spring AOP。
加載階段完成后,虛擬機外部的二進制字節(jié)流就按照虛擬機所需的格式存儲在方法區(qū)之中,而且在Java堆中也創(chuàng)建一個java.lang.Class類的對象,這樣便可以通過該對象訪問方法區(qū)中的這些數(shù)據(jù)。
2、驗證
確保被加載的類的正確性,分為4個驗證階段:
- 文件格式驗證
- 元數(shù)據(jù)驗證
- 字節(jié)碼驗證
- 符號引用驗證
驗證階段非常重要的,但不是必須的,它對程序運行期沒有影響,如果所引用的類經(jīng)過反復(fù)驗證,那么可以考慮采用-Xverifynone參數(shù)來關(guān)閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
3、準(zhǔn)備
為類的靜態(tài)變量分配內(nèi)存,并初始化默認(rèn)值,這些內(nèi)存是在方法區(qū)中分配,需要注意以下幾點:
- 此處內(nèi)存分配的變量僅包含類變量(static),而不包括實例變量,實例變量會隨著對象實例化被分配在java堆中。
- 這里默認(rèn)值是數(shù)據(jù)類型的默認(rèn)值(如0、0L、null、false),而不是代碼中被顯示的賦予的值。
- 如果類字段的字段屬性表中存在ConstatntValue屬性,即同時被final和static修飾,那么在準(zhǔn)備階段變量value就會被初始化為ConstValue屬性所指定的值。
4、解析
解析階段是虛擬機將常量池內(nèi)的符號引用替換為直接引用的過程,解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調(diào)用點限定符7類符號引用進行。符號引用就是一組符號來描述目標(biāo),可以是任何字面量。
直接引用就是直接指向目標(biāo)的指針、相對偏移量或一個間接定位到目標(biāo)的句柄。
5、初始化
為類的靜態(tài)變量賦予正確的初始值,JVM負(fù)責(zé)對類進行初始化,主要對類變量進行初始化。初始化階段是執(zhí)行類構(gòu)造器<client>()
方法的過程。
<client>()
方法是由編譯器自動收集類中的所有類變量賦值動作和靜態(tài)語句static{}
塊中的語句合并產(chǎn)生的,編譯器收集的順序是由語句在源文件出現(xiàn)的順序所決定的。靜態(tài)語句塊中只能訪問到定義在靜態(tài)語句塊之前的變量,定義在之后的變量可以賦值,但不能訪問。如下所示:
public class Test{ static{ i=0; System.out.print(i); } static int i=1; }
<clinit>()
方法與類構(gòu)造函數(shù)不一樣,不需要顯示調(diào)用父類構(gòu)造函數(shù),虛擬機會保證在子類的<clinit>()
方法執(zhí)行之前,父類的<clinit>()
方法已執(zhí)行完畢。
由于父類的<clinit>()
方法首先執(zhí)行,意味著父類中的靜態(tài)語句塊要優(yōu)先于子類的變量賦值操作,如下所示,最終得出的值是2,而不是1。
public class TestClassLoader { public static int A = 1; static { A = 2; // System.out.println(A); } static class Sub extends TestClassLoader { public static int B = A; } public static void main(String[] args) { System.out.println(Sub.B); } }
<clinit>()
方法對于類和接口來說,并不是必須的,若類沒有靜態(tài)語句塊,也沒有對變量賦值操作,則不會生成<clinit>()
方法。
接口與類不同的是,接口不需要先執(zhí)行父類的<clinit>()
方法,只有父接口定義的變量使用時,父接口才會被初始化。另外接口的實現(xiàn)類也不會先執(zhí)行接口的<clinit>()
方法。
虛擬機保證當(dāng)多線程去初始化類時,只會有一個線程去執(zhí)行<clinit>()
方法,而其他線程則被阻塞。
<clinit>()
方法和<init>()
方法區(qū)別:
執(zhí)行時機不同:init方法是對象構(gòu)造器方法,在new一個對象并調(diào)用該對象的constructor方法時才會執(zhí)行。clinit方法是類構(gòu)造器方法,是在JVM加載期間的初始化階段才會調(diào)用。
執(zhí)行目的不同:init是對非靜態(tài)變量解析初始化,而clinit是對靜態(tài)變量,靜態(tài)代碼塊進行初始化。
三、雙親委派機制
在介紹雙親委派機制前,先來看下類加載器的層次關(guān)系圖,如下:
- 啟動類加載器(Bootstrap ClassLoader),負(fù)責(zé)加載存放在$JAVA_HOME\jre\lib下,或被-Xbootclasspath參數(shù)指定的路徑中的,并且能被虛擬機識別的類庫(如rt.jar,所有的java.*開頭的類均被Bootstrap ClassLoader加載)。啟動類加載器是無法被Java程序直接引用的。
- 擴展類加載器(Extension ClassLoader),該加載器由sun.misc.Launcher$ExtClassLoader實現(xiàn),它負(fù)責(zé)加載$JAVA_HOME\jre\lib\ext目錄中,或者由java.ext.dirs系統(tǒng)變量指定的路徑中的所有類庫(如javax.*開頭的類),開發(fā)者可以直接使用擴展類加載器。
- 應(yīng)用程序類加載器(Application ClassLoader),該類加載器由sun.misc.Launcher$AppClassLoader來實現(xiàn),它負(fù)責(zé)加載用戶類路徑(ClassPath)所指定的類,開發(fā)者可以直接使用該類加載器,如果應(yīng)用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認(rèn)的類加載器。
- 自定義類加載器(User ClassLoader),如果有必要,我們還可以加入自定義的類加載器。因為JVM自帶的ClassLoader只是懂得從本地文件系統(tǒng)加載標(biāo)準(zhǔn)的java class文件。
雙親委派機制是指如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把請求委托給父加載器去完成,依次向上,因此,所有的類加載請求最終都應(yīng)該被傳遞到頂層的啟動類加載器中,只有當(dāng)父加載器在它的搜索范圍中沒有找到所需的類時,即無法完成該加載,子加載器才會嘗試自己去加載該類。
為了更清楚的了解雙親委派機制,我們來看下jdk1.8源碼java.lang.ClassLoader.loadClass()方法實現(xiàn):
public Class<?> loadClass(String name) throws ClassNotFoundException { return loadClass(name, false); } protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
上面代碼注釋寫的很清楚,首先調(diào)用findLoadedClass方法檢查是否已加載過這個類,如果沒有就調(diào)用parent的loadClass方法,從底層一級級往上。如果所有ClassLoader都沒有加載過這個類,就調(diào)用findClass方法查找這個類,然后又從頂層逐級向下調(diào)用findClass方法,最終都沒找到就拋出ClassNotFoundException。這樣設(shè)計的目的是保證安全性,防止系統(tǒng)類被偽造。
為了便于理解,以下是加載邏輯示意圖:
四、自定義類加載器的應(yīng)用
自定義類加載器通常有以下四種應(yīng)用場景:
- 源代碼加密,防止源碼泄露
- 隔離加載類,采用隔離加載,防止依賴沖突。
- 修改類加載的方式。
- 擴展加載源。
1、源代碼加密
源代碼加密的本質(zhì)是對字節(jié)碼文件進行操作。我們可以在打包的時候?qū)lass進行加密操作,然后在加載class文件之前通過自定義classloader先進行解密操作,然后再按照標(biāo)準(zhǔn)的class文件標(biāo)準(zhǔn)進行加載,這樣就完成了class文件正常的加載。因此這個加密的jar包只有能夠?qū)崿F(xiàn)解密方法的classloader才能正常加載。
2、隔離加載類
我們常常遇到頭疼的事情就是jar包版本的依賴沖突,寫代碼五分鐘,排包一整天。
舉個栗子:
工程里面同時引入了 A、B 兩個 jar 包,以及 C 的 v0.1、v0.2 版本,v2 版本的 Log 類比 v1 版本新增了 error 方法,,打包的時候 maven 只能選擇 C 的一個版本,假設(shè)選擇了 v1 版本。到了運行的時候,默認(rèn)情況下一個項目的所有類都是用同一個類加載器加載的,所以不管你依賴了多少個版本的 C,最終只會有一個版本的 C 被加載到 JVM 中。當(dāng) B 要去訪問 Log.error,就會發(fā)現(xiàn) Log 壓根就沒有 error 方法,然后就拋異常 java.lang.NoSuchMethodError。這就是類沖突的一個典型案例。
類隔離技術(shù)就是用來解決這個問題。讓不同模塊的 jar 包用不同的類加載器加載。
JVM 提供了一種非常簡單有效的方式,我把它稱為類加載傳導(dǎo)規(guī)則:JVM 會選擇當(dāng)前類的類加載器來加載所有該類的引用的類。例如我們定義了 TestA 和 TestB 兩個類,TestA 會引用 TestB,只要我們使用自定義的類加載器加載 TestA,那么在運行時,當(dāng) TestA 調(diào)用到 TestB 的時候,TestB 也會被 JVM 使用 TestA 的類加載器加載。依此類推,只要是 TestA 及其引用類關(guān)聯(lián)的所有 jar 包的類都會被自定義類加載器加載。通過這種方式,我們只要讓模塊的 main 方法類使用不同的類加載器加載,那么每個模塊的都會使用 main 方法類的類加載器加載的,這樣就能讓多個模塊分別使用不同類加載器。這也是 OSGi 和 SofaArk 能夠?qū)崿F(xiàn)類隔離的核心原理。
3、熱加載/熱部署
在應(yīng)用運行的時升級軟件,無需重新啟動的方式有兩種,熱部署和熱加載。
對于Java應(yīng)用程序來說,熱部署就是在服務(wù)器運行時重新部署項目,熱加載即在運行時重新加載class,從而升級應(yīng)用。
熱加載可以概括為在容器啟動的時候起一條后臺線程,定時的檢測類文件的時間戳變化,如果類的時間戳變掉了,則將類重新載入。對比反射機制,反射是在運行時獲取類信息,通過動態(tài)的調(diào)用來改變程序行為。而熱加載則是在運行時通過重新加載改變類信息,直接改變程序行為。
熱部署原理類似,但它是直接重新加載整個應(yīng)用,這種方式會釋放內(nèi)存,比熱加載更加干凈徹底,但同時也更費時間。
4、擴展加載源
字節(jié)碼文件可以從數(shù)據(jù)庫、網(wǎng)絡(luò)、移動設(shè)備、甚至是電視機機頂盒進行加載,可以與源代碼加密方式搭配使用。比如部分關(guān)鍵代碼可以通過移動U盤讀取再加載到JVM。
到此這篇關(guān)于深入了解Java中的類加載機制的文章就介紹到這了,更多相關(guān)Java類加載機制內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java kafka如何實現(xiàn)自定義分區(qū)類和攔截器
這篇文章主要介紹了Java kafka如何實現(xiàn)自定義分區(qū)類和攔截器,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2020-06-06Java解除文件占用即Dom4j操作后實現(xiàn)xml關(guān)流
這篇文章主要介紹了Java解除文件占用即Dom4j操作后實現(xiàn)xml關(guān)流,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2020-04-04Java Clone深拷貝與淺拷貝的兩種實現(xiàn)方法
今天小編就為大家分享一篇關(guān)于Java Clone深拷貝與淺拷貝的兩種實現(xiàn)方法,小編覺得內(nèi)容挺不錯的,現(xiàn)在分享給大家,具有很好的參考價值,需要的朋友一起跟隨小編來看看吧2018-10-10SpringBoot定時任務(wù)參數(shù)運行代碼實例解析
這篇文章主要介紹了SpringBoot定時任務(wù)運行代碼實例解析,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友可以參考下2020-06-06