雙親委派模型,類的加載機制,搞定大廠高頻面試題

看過這篇文章,大廠面試你「雙親委派模型」,硬氣的說一句,你怕啥?html

讀該文章姿式

  1. 打開手頭的 IDE,按照文章內容及思路進行代碼跟蹤與思考
  2. 手頭沒有 IDE,先收藏,回頭看 (萬一哪次面試問了呢)
  3. 須要查看和拷貝代碼,點擊文章末尾出「閱讀原文」

文章內容相對較長,因此添加了目錄,若是你但願對 Java 的類加載過程有個更深刻的瞭解,同時增長本身的面試技能點,請耐心讀完......java

雙親委派模型

在介紹這個Java技術點以前,先試着思考如下幾個問題:mysql

  1. 爲何咱們不能定義同名的 String 的 java 文件?
  2. 多線程的狀況下,類的加載爲何不會出現重複加載的狀況?
  3. 熱部署的原理是什麼?
  4. 下面代碼,虛擬機是怎樣初始化註冊 Mysql 鏈接驅動(Driver)的?

想理解以上幾個問題的前提是瞭解類加載時機與過程, 這篇文章將會以很是詳細的解讀方式來回答以上幾個問題

類加載時機與過程

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中準備、驗證、解析3個部分統稱爲鏈接(Linking)。如圖所示c++

加載、驗證、準備、初始化和卸載這5個階段的順序是肯定的,類的加載過程必須按照這種順序循序漸進地開始,而解析階段則不必定:它在某些狀況下能夠在初始化階段以後再開始,這是爲了支持Java語言的運行時綁定(也稱爲動態綁定或晚期綁定)git

加載

在加載階段(能夠參考java.lang.ClassLoader的loadClass()方法),虛擬機須要完成如下3件事情:github

  1. 經過一個類的全限定名來獲取定義此類的二進制字節流(並無指明要從一個Class文件中獲取,能夠從其餘渠道,譬如:網絡、動態生成、數據庫等);
  2. 將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構;
  3. 在內存中生成一個表明這個類的java.lang.Class對象,做爲方法區這個類的各類數據的訪問入口;

加載階段和鏈接階段(Linking)的部份內容(如一部分字節碼文件格式驗證動做)是交叉進行的,加載階段還沒有完成,鏈接階段可能已經開始,但這些夾在加載階段之中進行的動做,仍然屬於鏈接階段的內容,這兩個階段的開始時間仍然保持着固定的前後順序。面試

驗證

驗證是鏈接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。 驗證階段大體會完成4個階段的檢驗動做:算法

  1. 文件格式驗證:驗證字節流是否符合Class文件格式的規範;例如:是否以魔術0xCAFEBABE開頭(當class文件以二進制形式打開,會看到這個文件頭,cafebabe)、主次版本號是否在當前虛擬機的處理範圍以內、常量池中的常量是否有不被支持的類型。
  2. 元數據驗證:對字節碼描述的信息進行語義分析(注意:對比javac編譯階段的語義分析),以保證其描述的信息符合Java語言規範的要求;例如:這個類是否有父類,除了java.lang.Object以外。
  3. 字節碼驗證:經過數據流和控制流分析,肯定程序語義是合法的、符合邏輯的。
  4. 符號引用驗證:確保解析動做能正確執行。

驗證階段是很是重要的,但不是必須的,它對程序運行期沒有影響,若是所引用的類通過反覆驗證,那麼能夠考慮採用-Xverifynone參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。sql

準備

準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這時候進行內存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一塊兒分配在堆中。其次,這裏所說的初始值一般狀況下是數據類型的零值,假設一個類變量的定義爲:shell

一般狀況就有特殊狀況,這裏的特殊是指:

解析

解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。解析動做主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符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)必須對類進行「初始化」(而加載、驗證、準備天然須要在此以前開始):

  1. 遇到 new, getstatic, putstatic, invokestatic 這些字節碼指令時,若是類沒有進行過初始化,則須要先觸發其初始化。生成這4條指令的最多見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯器把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
  2. 使用 java.lang.reflect 包的方法對類進行反射調用的時候,若是類沒有進行過初始化,則須要先觸發其初始化。
  3. 當初始化一個類的時候,若是發現其父類尚未進行過初始化,則須要先觸發其父類的初始化。
  4. 當虛擬機啓動時,用戶須要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
  5. 當使用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),每一個類都有其"父類"加載器

⚠️ 其實這裏的父子關係是組合模式,不是繼承關係來實現

從圖中能夠看到類 AppClassLoaderExtClassLoader 都繼承 URLClassLoader, 而 URLClassLoader 又繼承 ClassLoader, 在 ClassLoader 中有一個屬性

在經過構造函數實例化 AppClassLoaderExtClassLoader 的時候都要傳入一個 classloader 做爲當前 classloader 的 parent

頂層ClassLoader有幾個函數很關鍵,先有個印象

指定保護域(protectionDomain),把ByteBuffer的內容轉換成 Java 類,這個方法被聲明爲final的

把字節數組 b中的內容轉換成 Java 類,其開始偏移爲off,這個方法被聲明爲final的

查找指定名稱的類

連接指定的類

類加載器責任範圍

上面咱們提到每一個加載器都有對應的加載搜索範圍

  1. Bootstrap ClassLoader:這個加載器不是一個Java類,而是由底層的c++實現,負責在虛擬機啓動時加載Jdk核心類庫(如:rt.jar、resources.jar、charsets.jar等)以及加載後兩個類加載器。這個ClassLoader徹底是JVM本身控制的,須要加載哪一個類,怎麼加載都是由JVM本身控制,別人也訪問不到這個類
  2. Extension ClassLoader:是一個普通的Java類,繼承自ClassLoader類,負責加載{JAVA_HOME}/jre/lib/ext/目錄下的全部jar包。
  3. 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確定不在<JAVA_HOME>/lib下,因此確定是沒法加載mysql中的這個類的。這就是雙親委派模型的侷限性了,父級加載器沒法加載子級類加載器路徑中的類。

那麼,這個問題如何解決呢?按照目前狀況來分析,這個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,這樣就能控制類加載的時機,從而實現熱部署。

熱部署步驟:

  1. 銷燬自定義classloader(被該加載器加載的class也會自動卸載);

  2. 更新class

  3. 使用新的ClassLoader去加載class

JVM中的Class只有知足如下三個條件,才能被GC回收,也就是該Class被卸載(unload):

  • 該類全部的實例都已經被GC,也就是JVM中不存在該Class的任何實例。
  • 加載該類的ClassLoader已經被GC。
  • 該類的java.lang.Class 對象沒有在任何地方被引用,如不能在任何地方經過反射訪問該類的方法

自定義類加載器

要建立用戶本身的類加載器,只須要繼承java.lang.ClassLoader類,而後覆蓋它的findClass(String name)方法便可,即指明如何獲取類的字節碼流。

若是要符合雙親委派規範,則重寫findClass方法(用戶自定義類加載邏輯);要破壞的話,重寫loadClass方法(雙親委派的具體邏輯實現)

感謝與參考

很是感謝如下博文的做者,經過反覆拜讀來了解雙親委派模型的原理

  1. blog.csdn.net/u014634338/…
  2. www.cnblogs.com/aspirant/p/…
  3. www.cnblogs.com/gdpuzxs/p/7…
  4. www.jianshu.com/p/09f73af48…
  5. www.cnblogs.com/yahokuma/p/…

推薦閱讀


後續會出一系列文章點亮上圖,同時進行 Spring 知識點解釋與串聯,在工做中充分利用 Spring 的特性 另外,還會推出 Java 多線程與 ElasticSearch 相關內容

歡迎持續關注公衆號:「日拱一兵」

  • 前沿 Java 技術乾貨分享
  • 高效工具彙總
  • 面試問題分析與解答
  • 技術資料領取

持續關注,帶你像讀偵探小說同樣輕鬆趣味學習 Java 技術棧相關知識

閱讀原文

相關文章
相關標籤/搜索