看過這篇文章,大廠面試你「雙親委派模型」,硬氣的說一句,你怕啥? 讀該文章姿勢 1. 打開手頭的 IDE,按照文章內容及思路進行代碼跟蹤與思考 2. 手頭沒有 IDE,先收藏,回頭看 (萬一哪次面試問了呢) 3. 需要查看和拷貝代碼,點擊文章末尾出「閱讀原文」 文章內容相對較長,所以添加了目錄,如果 ...
看過這篇文章,大廠面試你「雙親委派模型」,硬氣的說一句,你怕啥?
讀該文章姿勢
- 打開手頭的 IDE,按照文章內容及思路進行代碼跟蹤與思考
- 手頭沒有 IDE,先收藏,回頭看 (萬一哪次面試問了呢)
- 需要查看和拷貝代碼,點擊文章末尾出「閱讀原文」
文章內容相對較長,所以添加了目錄,如果你希望對 Java 的類載入過程有個更深入的瞭解,同時增加自己的面試技能點,請耐心讀完......
雙親委派模型
在介紹這個Java技術點之前,先試著思考以下幾個問題:
- 為什麼我們不能定義同名的 String 的 java 文件?
- 多線程的情況下,類的載入為什麼不會出現重覆載入的情況?
- 熱部署的原理是什麼?
- 下麵代碼,虛擬機是怎樣初始化註冊 Mysql 連接驅動(Driver)的?
想理解以上幾個問題的前提是瞭解類載入時機與過程, 這篇文章將會以非常詳細的解讀方式來回答以上幾個問題
類載入時機與過程
類從被載入到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中準備、驗證、解析3個部分統稱為連接(Linking)。如圖所示
載入、驗證、準備、初始化和卸載這5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支持Java語言的運行時綁定(也稱為動態綁定或晚期綁定)
載入
在載入階段(可以參考java.lang.ClassLoader的loadClass()方法),虛擬機需要完成以下3件事情:
- 通過一個類的全限定名來獲取定義此類的二進位位元組流(並沒有指明要從一個Class文件中獲取,可以從其他渠道,譬如:網路、動態生成、資料庫等);
- 將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構;
- 在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口;
載入階段和連接階段(Linking)的部分內容(如一部分位元組碼文件格式驗證動作)是交叉進行的,載入階段尚未完成,連接階段可能已經開始,但這些夾在載入階段之中進行的動作,仍然屬於連接階段的內容,這兩個階段的開始時間仍然保持著固定的先後順序。
驗證
驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的位元組流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。
驗證階段大致會完成4個階段的檢驗動作:
- 文件格式驗證:驗證位元組流是否符合Class文件格式的規範;例如:是否以魔術0xCAFEBABE開頭(當class文件以二進位形式打開,會看到這個文件頭,cafebabe)、主次版本號是否在當前虛擬機的處理範圍之內、常量池中的常量是否有不被支持的類型。
- 元數據驗證:對位元組碼描述的信息進行語義分析(註意:對比javac編譯階段的語義分析),以保證其描述的信息符合Java語言規範的要求;例如:這個類是否有父類,除了java.lang.Object之外。
- 位元組碼驗證:通過數據流和控制流分析,確定程式語義是合法的、符合邏輯的。
- 符號引用驗證:確保解析動作能正確執行。
驗證階段是非常重要的,但不是必須的,它對程式運行期沒有影響,如果所引用的類經過反覆驗證,那麼可以考慮採用-Xverifynone參數來關閉大部分的類驗證措施,以縮短虛擬機類載入的時間。
準備
準備階段是正式為類變數分配記憶體並設置類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括實例變數,實例變數將會在對象實例化時隨著對象一起分配在堆中。其次,這裡所說的初始值通常情況下是數據類型的零值,假設一個類變數的定義為:
有通常情況就有特殊情況,這裡的特殊是指:
解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法類型、方法句柄和調用點限定符7類符號引用進行。
初始化
在介紹初始化時,要先介紹兩個方法:<clinit>
和 <init>
:
- 在編譯生成class文件時,會自動產生兩個方法,一個是類的初始化方法
, 另一個是實例的初始化方法 clinit>
:在jvm第一次載入class文件時調用,包括靜態變數初始化語句和靜態塊的執行<init>
: 在實例創建出來的時候調用,包括調用new操作符;調用 Class 或 Java.lang.reflect.Constructor 對象的newInstance()方法;調用任何現有對象的clone()方法;通過 java.io.ObjectInputStream 類的getObject() 方法反序列化。
類初始化階段是類載入過程的最後一步,到了初始化階段,才真正開始執行類中定義的java程式代碼。在準備極端,變數已經付過一次系統要求的初始值,而在初始化階段,則根據程式猿通過程式制定的主管計划去初始化類變數和其他資源,或者說:初始化階段是執行類構造器<clinit>()
方法的過程.
<clinit>()
方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊 static{} 中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問。如下:
那麼去掉報錯的那句,改成下麵:
輸出結果:1
為什麼輸出結果是 1,在準備階段我們知道 i=0,然後類初始化階段按照順序執行,首先執行 static 塊中的 i=0,接著執行 static賦值操作i=1, 最後在 main 方法中獲取 i 的值為1
<clinit>
()方法與實例構造器<init>()
方法不同,它不需要顯示地調用父類構造器,虛擬機會保證在子類<init>()
方法執行之前,父類的<clinit>()
方法方法已經執行完畢- 由於父類的
<clinit>()
方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。 <clinit>()
方法對於類或者介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生產<clinit>()
方法。- 介面中不能使用靜態語句塊,但仍然有變數初始化的賦值操作,因此介面與類一樣都會生成
<clinit>()
方法。但介面與類不同的是,執行介面的<clinit>()
方法不需要先執行父介面的<clinit>()
方法。只有當父介面中定義的變數使用時,父介面才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()
方法。 - 虛擬機會保證一個類的
<clinit>()
方法在多線程環境中被正確的加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()
方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()
方法完畢。如果在一個類的<clinit>()
方法中有耗時很長的操作,就可能造成多個線程阻塞,在實際應用中這種阻塞往往是隱藏的。
讓我們來驗證上面的載入規則
驗證 1: 虛擬機會保證在子類
<init>()
方法執行之前,父類的<clinit>()
方法方法已經執行完畢
輸出結果
SSClass
SuperClass init!
123
驗證 2: 通過數組定義來引用類,不會觸發此類的初始化(我的理解是數組的父類是Object)
輸出結果:無
驗證 3: 常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化
輸出結果:
hello world
驗證小結
虛擬機規範嚴格規定了有且只有5中情況(jdk1.7)必須對類進行“初始化”(而載入、驗證、準備自然需要在此之前開始):
- 遇到 new, getstatic, putstatic, invokestatic 這些位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態欄位(被final修飾、已在編譯器把結果放入常量池的靜態欄位除外)的時候,以及調用一個類的靜態方法的時候。
- 使用 java.lang.reflect 包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
- 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
- 當使用jdk1.7動態語言支持時,如果一個 java.lang.invoke.MethodHandle 實例最後的解析結果REF_getstatic, REF_putstatic, REF_invokeStatic 的方法句柄,並且這個方法句柄所對應的類沒有進行初始化,則需要先出觸發其初始化。
有了這個載入規則的印象,雙親委派模型就很好理解了,彆著急,繼續向下看, 你會發現你的理解層面提高了
雙親委派模型
剛看到這個辭彙的時候我是完全懵懂的狀態,其實就是定義了 JVM 啟動的時候類的載入規則, 大家要按規矩辦事,好辦事,來看下圖:
所謂雙親委派是指每次收到類載入請求時,先將請求委派給父類載入器完成(所有載入請求最終會委派到頂層的Bootstrap ClassLoader載入器中),如果父類載入器無法完成這個載入(該載入器的搜索範圍中沒有找到對應的類),子類嘗試自己載入, 如果都沒載入到,則會拋出 ClassNotFoundException 異常, 看到這裡其實就解釋了文章開頭提出的第一個問題,父載入器已經載入了JDK 中的 String.class 文件,所以我們不能定義同名的 String java 文件。
為什麼會有這樣的規矩設定?
因為這樣可以避免重覆載入,當父親已經載入了該類的時候,就沒有必要 ClassLoader 再載入一次。考慮到安全因素,我們試想一下,如果不使用這種委托模式,那我們就可以隨時使用自定義的String來動態替代java核心api中定義的類型,這樣會存在非常大的安全隱患,而雙親委托的方式,就可以避免這種情況,因為String 已經在啟動時就被引導類載入器(Bootstrcp ClassLoader)載入,所以用戶自定義的ClassLoader永遠也無法載入一個自己寫的String,除非你改變 JDK 中 ClassLoader 搜索類的預設演算法。
我們發現除了啟動類載入器(BootStrap ClassLoader),每個類都有其"父類"載入器
⚠️ 其實這裡的父子關係是組合模式,不是繼承關係來實現
從圖中可以看到類 AppClassLoader 和 ExtClassLoader 都繼承 URLClassLoader, 而 URLClassLoader 又繼承 ClassLoader, 在 ClassLoader 中有一個屬性
在通過構造函數實例化 AppClassLoader 和 ExtClassLoader 的時候都要傳入一個 classloader 作為當前 classloader 的 parent
頂層ClassLoader有幾個函數很關鍵,先有個印象
指定保護域(protectionDomain),把ByteBuffer的內容轉換成 Java 類,這個方法被聲明為final的
把位元組數組 b中的內容轉換成 Java 類,其開始偏移為off,這個方法被聲明為final的
查找指定名稱的類
鏈接指定的類
類載入器責任範圍
上面我們提到每個載入器都有對應的載入搜索範圍
- Bootstrap ClassLoader:這個載入器不是一個Java類,而是由底層的c++實現,負責在虛擬機啟動時載入Jdk核心類庫(如:rt.jar、resources.jar、charsets.jar等)以及載入後兩個類載入器。這個ClassLoader完全是JVM自己控制的,需要載入哪個類,怎麼載入都是由JVM自己控制,別人也訪問不到這個類
- Extension ClassLoader:是一個普通的Java類,繼承自ClassLoader類,負責載入{JAVA_HOME}/jre/lib/ext/目錄下的所有jar包。
- App ClassLoader:是Extension ClassLoader的子對象,負責載入應用程式classpath目錄下的所有jar和class文件。
大家自行運行這個文件,就可以看到每個類載入器載入的文件了
兩種類的載入方式
通常用這兩種方式來動態載入一個 java 類,Class.forName() 與 ClassLoader.loadClass() 但是兩個方法之間也是有一些細微的差別
Class.forName() 方式
查看Class類的具體實現可知,實質上這個方法是調用原生的方法:
形式上類似於Class.forName(name,true,currentLoader)。 綜上所述,Class.forName 如果調用成功會:
- 保證一個Java類被有效得載入到記憶體中;
- 類預設會被初始化,即執行內部的靜態塊代碼以及保證靜態屬性被初始化;
- 預設會使用當前的類載入器來載入對應的類。
ClassLoader.loadClass方式
如果採用這種方式的類載入策略,由於雙親托管模型的存在,最終都會將類的載入任務交付給Bootstrap ClassLoader進行載入。跟蹤源代碼,最終會調用原生方法:
與此同時,與上一種方式的最本質的不同是,類不會被初始化,只有顯式調用才會進行初始化。綜上所述,ClassLoader.loadClass 如果調用成功會:
- 類會被載入到記憶體中;
- 類不會被初始化,只有在之後被第一次調用時類才會被初始化;
- 之所以採用這種方式的類載入,是提供一種靈活度,可以根據自身的需求繼承ClassLoader類實現一個自定義的類載入器實現類的載入。(很多開源Web項目中都有這種情況,比如tomcat,struct2,jboss。原因是根據Java Servlet規範的要求,既要Web應用自己的類的優先順序要高於Web容器提供的類,但同時又要保證Java的核心類不被任意覆蓋,此時重寫一個類載入器就很必要了)
雙親委派模型源碼分析
Launcher
分析類載入器源碼要從 sun.misc.Launcher.class 文件看起, 關鍵代碼已添加註釋,同時可以在此類中看到 ExtClassLoader 和 AppClassLoader 的定義,也驗證了我們上文提到的他們不是繼承關係,而是通過指定 parent 屬性來形成的組合模型
進入上面第25行的 loadClass 方法中
我們看到方法有同步塊(synchronized), 這也就解釋了文章開頭第2個問題,多線程情況不會出現重覆載入的情況。同時會詢問parent classloader是否有載入,如果沒有,自己嘗試載入。
URLClassLoader中的 findClass方法:
借用網友的一個載入時序圖來解釋整個過程更加清晰:
雙親委派模型的破壞
Java本身有一套資源管理服務JNDI,是放置在rt.jar中,由啟動類載入器載入的。以對資料庫管理JDBC為例,java給資料庫操作提供了一個Driver介面:
然後提供了一個DriverManager來管理這些Driver的具體實現:
這裡省略了大部分代碼,可以看到我們使用資料庫驅動前必須先要在DriverManager中使用registerDriver()註冊,然後我們才能正常使用。
不破壞雙親委派模型的情況(不使用JNDI服務)
我們看下mysql的驅動是如何被載入的:
核心就是這句Class.forName()觸發了mysql驅動的載入,我們看下mysql對Driver介面的實現:
可以看到,Class.forName()其實觸發了靜態代碼塊,然後向DriverManager中註冊了一個mysql的Driver實現。這個時候,我們通過DriverManager去獲取connection的時候只要遍歷當前所有Driver實現,然後選擇一個建立連接就可以了。
破壞雙親委派模型的情況
在JDBC4.0以後,開始支持使用spi的方式來註冊這個Driver,具體做法就是在mysql的jar包中的META-INF/services/java.sql.Driver 文件中指明當前使用的Driver是哪個,然後使用的時候就直接這樣就可以了:
可以看到這裡直接獲取連接,省去了上面的Class.forName()註冊過程。
現在,我們分析下看使用了這種spi服務的模式原本的過程是怎樣的:
- 第一,從META-INF/services/java.sql.Driver文件中獲取具體的實現類名“com.mysql.jdbc.Driver”
- 第二,載入這個類,這裡肯定只能用class.forName("com.mysql.jdbc.Driver")來載入
好了,問題來了,Class.forName()載入用的是調用者的Classloader,這個調用者DriverManager是在rt.jar中的,ClassLoader是啟動類載入器,而com.mysql.jdbc.Driver肯定不在
那麼,這個問題如何解決呢?按照目前情況來分析,這個mysql的drvier只有應用類載入器能載入,那麼我們只要在啟動類載入器中有方法獲取應用程式類載入器,然後通過它去載入就可以了。這就是所謂的線程上下文載入器。
文章前半段提到線程上下文類載入器可以通過 Thread.setContextClassLoaser() 方法設置,如果不特殊設置會從父類繼承,一般預設使用的是應用程式類載入器
很明顯,線程上下文類載入器讓父級類載入器能通過調用子級類載入器來載入類,這打破了雙親委派模型的原則
現在我們看下DriverManager是如何使用線程上下文類載入器去載入第三方jar包中的Driver類的,先來看源碼:
使用時,我們直接調用DriverManager.getConnection() 方法自然會觸發靜態代碼塊的執行,開始載入驅動然後我們看下ServiceLoader.load()的具體實現:
繼續向下看構造函數實例化 ServiceLoader 做了哪些事情:
查看 reload() 函數:
繼續查看LazyIterator構造器,該類同樣實現了Iterator介面:
實例化到這裡我們也將上下文得到的類載入器實例化到這裡,來回看ServiceLoader 重寫的 iterator() 方法:
上面next() 方法調用了lookupIterator.next(),這個lookupIterator 就是剛剛實例化的 LazyIterator(); 來看next方法
繼續查看nextService 方法:
終於到這裡了,在上面 nextService函數中第8行調用了c = Class.forName(cn, false, loader) 方法,我們成功的做到了通過線程上下文類載入器拿到了應用程式類載入器(或者自定義的然後塞到線程上下文中的),同時我們也查找到了廠商在子級的jar包中註冊的驅動具體實現類名,這樣我們就可以成功的在rt.jar包中的DriverManager中成功的載入了放在第三方應用程式包中的類了同時在第16行完成Driver的實例化,等同於new Driver(); 文章開頭的問題在理解到這裡也迎刃而解了
JAVA熱部署實現
首先談一下何為熱部署(hotswap),熱部署是在不重啟 Java 虛擬機的前提下,能自動偵測到 class 文件的變化,更新運行時 class 的行為。Java 類是通過 Java 虛擬機載入的,某個類的 class 文件在被 classloader 載入後,會生成對應的 Class 對象,之後就可以創建該類的實例。預設的虛擬機行為只會在啟動時載入類,如果後期有一個類需要更新的話,單純替換編譯的 class 文件,Java 虛擬機是不會更新正在運行的 class。如果要實現熱部署,最根本的方式是修改虛擬機的源代碼,改變 classloader 的載入行為,使虛擬機能監聽 class 文件的更新,重新載入 class 文件,這樣的行為破壞性很大,為後續的 JVM 升級埋下了一個大坑。
另一種友好的方法是創建自己的 classloader 來載入需要監聽的 class,這樣就能控制類載入的時機,從而實現熱部署。
熱部署步驟:
銷毀自定義classloader(被該載入器載入的class也會自動卸載);
更新class
使用新的ClassLoader去載入class
JVM中的Class只有滿足以下三個條件,才能被GC回收,也就是該Class被卸載(unload):
- 該類所有的實例都已經被GC,也就是JVM中不存在該Class的任何實例。
- 載入該類的ClassLoader已經被GC。
- 該類的java.lang.Class 對象沒有在任何地方被引用,如不能在任何地方通過反射訪問該類的方法
自定義類載入器
要創建用戶自己的類載入器,只需要繼承java.lang.ClassLoader類,然後覆蓋它的findClass(String name)方法即可,即指明如何獲取類的位元組碼流。
如果要符合雙親委派規範,則重寫findClass方法(用戶自定義類載入邏輯);要破壞的話,重寫loadClass方法(雙親委派的具體邏輯實現)。
感謝與參考
非常感謝以下博文的作者,通過反覆拜讀來瞭解雙親委派模型的原理
- https://blog.csdn.net/u014634338/article/details/81434327
- https://www.cnblogs.com/aspirant/p/7200523.html
- https://www.cnblogs.com/gdpuzxs/p/7044963.html
- https://www.jianshu.com/p/09f73af48a98
- https://www.cnblogs.com/yahokuma/p/3668138.html
推薦閱讀
- 面試還不知道 BeanFactory 和 ApplicationContext 的區別?
- Spring Bean 生命周期之"我從哪裡來?",懂得這個很重要
- Spring Bean 生命周期之"我要到哪裡去?"
- 如何設計好的RESTful API
- 輕鬆高效玩轉DTO(Data Transfer Object)
後續會出一系列文章點亮上圖,同時進行 Spring 知識點解釋與串聯,在工作中充分利用 Spring 的特性
另外,還會推出 Java 多線程與 ElasticSearch 相關內容
歡迎持續關註公眾號:「日拱一兵」
- 前沿 Java 技術乾貨分享
- 高效工具彙總
- 面試問題分析與解答
- 技術資料領取
持續關註,帶你像讀偵探小說一樣輕鬆趣味學習 Java 技術棧相關知識