看過這篇文章,大廠面試你「雙親委派模型」,硬氣的說一句,你怕啥?html
讀該文章姿式
- 打開手頭的 IDE,按照文章內容及思路進行代碼跟蹤與思考
- 手頭沒有 IDE,先收藏,回頭看 (萬一哪次面試問了呢)
- 須要查看和拷貝代碼,點擊文章末尾出「閱讀原文」
文章內容相對較長,因此添加了目錄,若是你但願對 Java 的類加載過程有個更深刻的瞭解,同時增長本身的面試技能點,請耐心讀完......java
在介紹這個Java技術點以前,先試着思考如下幾個問題:mysql
想理解以上幾個問題的前提是瞭解類加載時機與過程, 這篇文章將會以很是詳細的解讀方式來回答以上幾個問題c++
類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中準備、驗證、解析3個部分統稱爲鏈接(Linking)。如圖所示git
加載、驗證、準備、初始化和卸載這5個階段的順序是肯定的,類的加載過程必須按照這種順序循序漸進地開始,而解析階段則不必定:它在某些狀況下能夠在初始化階段以後再開始,這是爲了支持Java語言的運行時綁定(也稱爲動態綁定或晚期綁定)github
在加載階段(能夠參考java.lang.ClassLoader的loadClass()方法),虛擬機須要完成如下3件事情:面試
加載階段和鏈接階段(Linking)的部份內容(如一部分字節碼文件格式驗證動做)是交叉進行的,加載階段還沒有完成,鏈接階段可能已經開始,但這些夾在加載階段之中進行的動做,仍然屬於鏈接階段的內容,這兩個階段的開始時間仍然保持着固定的前後順序。算法
驗證是鏈接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。
驗證階段大體會完成4個階段的檢驗動做:sql
驗證階段是很是重要的,但不是必須的,它對程序運行期沒有影響,若是所引用的類通過反覆驗證,那麼能夠考慮採用-Xverifynone參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。shell
準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一塊兒分配在堆中。其次,這裏所說的初始值一般狀況下是數據類型的零值,假設一個類變量的定義爲:
有一般狀況就有特殊狀況,這裏的特殊是指:
解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。解析動做主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行。
在介紹初始化時,要先介紹兩個方法:<clinit>
和 <init>
:
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)必須對類進行「初始化」(而加載、驗證、準備天然須要在此以前開始):
有了這個加載規則的印象,雙親委派模型就很好理解了,彆着急,繼續向下看, 你會發現你的理解層面提升了
剛看到這個詞彙的時候我是徹底懵懂的狀態,其實就是定義了 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的
查找指定名稱的類
連接指定的類
上面咱們提到每一個加載器都有對應的加載搜索範圍
你們自行運行這個文件,就能夠看到每一個類加載器加載的文件了
一般用這兩種方式來動態加載一個 java 類,Class.forName() 與 ClassLoader.loadClass() 可是兩個方法之間也是有一些細微的差異
Class.forName() 方式
查看Class類的具體實現可知,實質上這個方法是調用原生的方法:
形式上相似於Class.forName(name,true,currentLoader)。 綜上所述,Class.forName 若是調用成功會:
ClassLoader.loadClass方式
若是採用這種方式的類加載策略,因爲雙親託管模型的存在,最終都會將類的加載任務交付給Bootstrap ClassLoader進行加載。跟蹤源代碼,最終會調用原生方法:
與此同時,與上一種方式的最本質的不一樣是,類不會被初始化,只有顯式調用纔會進行初始化。綜上所述,ClassLoader.loadClass 若是調用成功會:
分析類加載器源碼要從 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()註冊,而後咱們才能正常使用。
咱們看下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服務的模式本來的過程是怎樣的:
好了,問題來了,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(); 文章開頭的問題在理解到這裏也迎刃而解了
首先談一下何爲熱部署(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):
要建立用戶本身的類加載器,只須要繼承java.lang.ClassLoader類,而後覆蓋它的findClass(String name)方法便可,即指明如何獲取類的字節碼流。
若是要符合雙親委派規範,則重寫findClass方法(用戶自定義類加載邏輯);要破壞的話,重寫loadClass方法(雙親委派的具體邏輯實現)。
很是感謝如下博文的做者,經過反覆拜讀來了解雙親委派模型的原理
後續會出一系列文章點亮上圖,同時進行 Spring 知識點解釋與串聯,在工做中充分利用 Spring 的特性
另外,還會推出 Java 多線程與 ElasticSearch 相關內容
歡迎持續關注公衆號:「日拱一兵」
- 前沿 Java 技術乾貨分享
- 高效工具彙總
- 面試問題分析與解答
- 技術資料領取
持續關注,帶你像讀偵探小說同樣輕鬆趣味學習 Java 技術棧相關知識