類加載機制

1.什麼是類加載

      虛擬機把描述類的數據從 Class 文件加載到內存,並對數據進行校驗,轉換解析和初始化,最終形成可以被虛擬機直接使用的 java 類 型,這就是虛擬機的類加載機制.

      與那些在編譯時需要進行連接工作的語言不同,在 java 語言中,類型的加載,連接和初始化過程都是在程序運行期間完成的,這種策 略雖然會令類加載時稍微增加一些性能開銷,但是會爲 java 應用程序提供高度的靈活性,java 裏,天生可以動態擴展的語言特性就是依賴 運行時期動態加載和動態連接的這個特點實現的.

       類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(loading),驗證(verification),準備(preparation), 解析(resolution),初始化(initialization),使用(using),卸載(unloading)七個階段.其中,驗證,準備,解析三個部分統稱爲連接(linking).

注意:加載,驗證,準備,初始化和卸載這五個階段的順序是確定的,但是解析階段則不一定.它在某些情況下可以在初始化階段之後再 開始.主要是爲了支持 java 語言的運行時綁定.

2.初始化時機

       什麼情況下開始類加載過程的第一個階段:加載?

        java 虛擬機規範中並沒有進行強制約束.這點可以交給虛擬機的具體實現來自有 把握.但是對於初始化階段,虛擬機規範則嚴格規定了有且只有五種情況必須立即對類進行"初始化"(加載,驗證,準備自然需要在初始化 之前開始).

        1.遇到 new 指令(使用關鍵字 new 來實例化對象),getstatic,putstatic(讀取或設置一個類的靜態字段的時候,除了 final 修飾,在編 譯時期就已經把結果放在常量池的靜態字段)或 invokestatic(調用類的靜態方法)這 4 條字節碼指令時,如果類沒有進行初始化,則需要先 觸發其初始化.

         2.使用 java.lang.reflect 包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化.

         3.當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化(接口初始化例外,不要求所有父接口 全部都初始化,只有在真正調用到父接口的時候纔會初始化).

         4.當啓動虛擬機時,用戶需要指定一個需要執行的主類,虛擬機先初始化這個主類.

         5.當使用 java7 的動態語言支持時,如果一個 MethodHandle 實例在解析時,該方法對應的類沒有進行初始化,則需要先觸發其初始 化.

         這五種場景中的行爲稱之爲對一個類進行主動引用.除此之外,所有引用類型的方式都不會觸發初始化,叫做被動引用.
被動引用,如:
1.通過子類引用父類的靜態字段,不會導致子類初始化(此時的靜態資源不是屬於子類父類的,底層還是使用的是 SuperClass.value

去訪問的,所以只初始化 SuperClass,而不初始化 SubClass).

 2.通過數組定義來引用類,不會觸發此類的初始化.

3.常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化. 

 

3.類加載過程 

1 加載

在加載階段,虛擬機需要完成以下 3 件事情:  

1.通過一個類的全限定名來獲取定義此類的二進制字節流.

2.將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構.

3.在內存中生成一個代表這個類的 java.lang.Class 對象,作爲方法區這個類的各種數據的訪問入口

注:

      虛擬機規範的這三點要求其實並不算具體,因此虛擬機實現與具體應用的靈活度都是相當大的.比如從哪裏獲取,如何獲取二 進制字節碼流,都沒有嚴格限定.虛擬機設計團隊搭建了一個相當開放的舞臺.許多舉足輕重的 java 技術都建立在這一基礎之上.

1.從 ZIP 包中讀取.(ZIP, JAR, EAR, WAR) 2.從網絡中獲取 3.運行時計算生成(動態代理技術) 4.由其他文件生成(JSP 應用)

...... 加載和連接的部分內容是交叉進行的.加載尚未完成,連接階段可能已經開始,比如部分字節碼文件格式的驗證.

2 連接-驗證

      驗證是連接階段的第一步,這一階段的目的是爲了確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛 擬機自身的安全.

      如果使用純粹的 java 代碼,做到諸如將一個對象轉型爲它並未實現的類型,編譯器將拒絕編譯(編譯報錯).但是在前文中提到,Class 文件並不一定要求是 Java 源碼編譯而來的.虛擬機如果不檢查輸入的字節碼流,對其完全信任的話,很可能會因爲載入了有害的字節碼 流而導致系統崩潰.

       驗證階段是非常重要的,這個階段是否嚴謹,直接決定了 java 虛擬機是否能夠承受惡意代碼的攻擊,從執行性能的角度上講,驗證階 段的工作量在虛擬機的類加載子系統中又佔了相當大的一部分.

       根據 2011 年發佈的<Java 虛擬機規範(Java SE 7 版)>的要求,驗證階段大致需要下面四個階段來驗證.

 1.文件格式驗證.

        驗證字節流是否符合 Class 文件格式的規範,並且能被當前版本的虛擬機處理.比如,是否以魔數 0xCAFEBABE 開頭(字節碼頭四個字節,用來表示一個可以接受的字節碼文件). 主要目的是保證輸入的字節流能正確的解析並存儲於方法區之內,格式上符合描述一個 java 類型信息的要求.

 2.元數據驗證.
        對字節碼描述的信息進行語義分析,以保證其描述的信息符合 Java 語言規範的要求. 比如,是否有父類,是否繼承了不允許被繼承的類等等. 主要目的是對類的元數據信息進行語義校驗,保證不存在不符合 java 語言規範的元數據信息.

3.字節碼驗證.

      在元數據驗證之後,這個階段將對類的方法體進行校驗分析,保證被校驗的類的方法在運行時不會做出危害虛擬機安全的事

件.目的是通過數據流和控制流分析,確定程序語義是合法的,符合邏輯的.

4.符號引用驗證.

     該校驗是發生在虛擬機將符號引用轉化爲直接引用的時候,這個轉化動作將在解析階段中發生. 比如校驗符號引用中的全限定名是否能找到對應的類,是否具備訪問權限等等. 目的是確保解析動作能正常執行.

      對於虛擬機的類加載機制來說,驗證階段是一個非常重要的,但不是一定必要(對程序運行期沒有影響)的階段.如果所運行的全部代 碼都已經被反覆使用和驗證過,那麼在實施階段可以考慮使用 -Xverify:none 參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的 時間.

3 連接-準備

      準備階段是正式爲類變量分配內存並設置類變量初始值的階段.這些變量所使用的內存都將在方法區中進行分配.

      這時候進行內存分配的僅包括類變量(被 static 修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分 配在 Java 堆中;並且這裏所說的初始值“通常情況”是數據類型的零值,例如:public static int value = 123;value 在準備階段過後的初始值爲 0 而不是 123,而給 value 賦值的指令將在初始化階段纔會被執行."特殊情況":類字段的字段屬性表中存在常量屬性.那麼在準備階段就會被初始化爲指定的值.例如: public static final int value = 123; 在準備階段虛擬機就會將 value 賦值爲 123.

4 連接-解析

 

      解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程.

     符號引用(Symbolic References):以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能夠無歧義 的定位到目標即可.符號引用與虛擬機的內存佈局無關,引用的目標並不一定加載到內存中。在 Java 中,一個 java 類將會編譯成一個 class 文件。在編譯時,java 類並不知道所引用的類的實際地址,因此只能使用符號引用來代替.

      直接引用是和虛擬機的佈局相關的,同一個符號引用在不同的虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引 用,那引用的目標必定已經被加載入內存中了。

       虛擬機規範之中並未規定解析階段發生的具體時間,只要求了在執行 anewarray(創建數組),checkcast(檢查類型),getfield(獲取字 段),getstatic(獲取類變量),instanceof(檢查類型),invokedynamic(動態解析方法),invokeinterface(調用接口方法),invokespecial(調 用特殊方法,比如構造方法,初始化方法),invokestatic(調用 static 方法),invokevirtual(調用實例方法),ldc/ldc_w(將 int,float,String 常 量推至棧頂),multianewarray(創建數組),new(創建對象),putfield(設置字段),putstatic(設置類變量)這 16 個用於操作符號引用的字節 碼指令之前,先對它們所使用的符號引用進行解析.

5 初始化

      初始化階段,才真正開始執行類中定義的 java 程序代碼.
      初始化階段是執行類構造器<clinit>()方法的過程.

    1.類構造器<clinit>()方法是有編譯器自動收集類中的所有類變量的賦值動作和 static 語句塊中的語句合併產生的.編譯器收集的順序是由語句在源文件中出現的順序所決定的.靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在其之後的變量,在靜態語 句塊中可以賦值,但是不能訪問.

2.類構造器<clinit>()方法與類的構造器<init>()方法不同,虛擬機會保證在子類的<clinit>()方法之前執行,因此,在虛擬機中第一 個被執行的<clinit>()方法的類肯定是 Object.

3.由於父類的<clinit>()方法先執行,也就意味着父類中定義的靜態語句塊要優於子類的變量賦值.

4.類構造器<clinit>()方法對於類或者接口來說並不是必需的,如果一個類中沒有靜態語句塊,編譯器就不會爲這個類生成 <clinit>()方法.

5.接口中也可以定義 static 變量,生成的<clinit>()方法不需要先執行父接口中的<clinit>()方法,同理,接口的實現類在初始化的時 候也一樣不會執行接口中的<clinit>()方法.

6.虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確的加鎖,同步,如果多線程同時去初始化一個類,只會有一個線程去 初始化,其他線程都阻塞.

5 類加載器

       虛擬機設計團隊把類加載階段中的"通過一個類的全限定名來獲取描述此類的二進制字節流"這個動作放到了 java 虛擬機外部去 實現(意思就是說,如何把字節碼文件變成流的過程,不僅僅屬於虛擬機中的功能).以便讓應用程序自己決定如何去獲取所需要的類.這個 動作的代碼模塊成爲"類加載器".

       類加載器可以說是 java 語言的一項創新.也是 java 語言流行的重要原因之一.它在類層次劃分,OSGi,熱部署,代碼加密等領域大放 異彩.成爲 java 體系中一塊重要的基石.

        類加載器雖然只用於一個類的加載動作,但是它在 java 程序中起到的作用卻遠遠不限於類加載階段.對於任意一個類,都需要由加 載它的類加載器和這個類本身一同確立其在 java 虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間.

5.1 類加載器分類

       從 java 虛擬機的角度來講,只存在兩種不同的類加載器.一種是啓動類加載器(Bootstrap Classloader),這個類加載器使用 c/c++ 語言實現,是虛擬機自身的一部分.另一種就是所有其他的類加載器,這些類加載器都由 java 語言實現,獨立於虛擬機外部,並且都繼承於 java.lang.ClassLoader.

        從 java 開發人員的角度來看,類加載器還可以劃分得更細緻一些.

        啓動類加載器(Bootstrap ClassLoader):負責將存在於<JAVA_HOME>\lib 目錄中,或者配置參數-Xbootclasspath 參數指定的 路徑中,並且能夠被虛擬機識別(名字不符合的,就算放在了 lib 中也不會被夾在)的類庫加載到虛擬機內存中.該類加載器無法被 java 程序 直接引用.

       擴 展 類 加 載 器 (Extension ClassLoader): 這個加 載 器 由 sun.miscLauncher$ExtClassLoader 實現 , 負責加載 <JAVA_HOME>\lib\ext 目錄中,或者被 java.ext.dirs 系統變量所指定的路徑中的所有類庫.開發者可以直接使用擴展類加載器.

       應用程序類加載器(Application ClassLoader):這個加載器由 sun.misc.Launcher$AppClassLoader 實現.也稱之爲系統類加載 器.它負責加載用戶類路徑(classpath)上所指定的類庫,開發者可以直接使用.一般情況下,這個就是程序中默認的類加載器.

我們程序都是由這三種類加載器互相配合進行加載的,如果有必要,還可以加入自定義的類加載器.

TIM截圖20170926111521

5.2 雙親委派模型

        對於類的加載,只需要加載進內存一次就足夠了.爲了避免重複加載,當父 ClassLoader 已經加載了該類的時候,就沒有必要子 ClassLoader 再加載一次。這種加載器之間的層次關係,就叫做雙親委派模型(Parents Delegation Model).

        雙親委派模型要求除了頂層的啓動類加載器外,其餘的類加載器都應該有自己的父(沒有繼承關係,而是使用組合關係來組織他們 的層級關係)類加載器.如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去 完成.每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啓動類加載器中,當父類加載器反饋自己不能完成 這個加載請求(自己負責的範圍內沒有這個類),子類加載器纔會嘗試自己去加載.

       雙親委派模型有一個顯而易見的好處就是 java 類隨着它的類加載器一起具備了一種帶有優先級的層次關係.比如 java.lang.Object 類,無論是哪個類加載器接收到加載的請求,都會委派給啓動類加載器去加載.因此 Object 類在程序中都是同一個類. 相反,如果沒有雙親委派,任何一個類加載器收到請求都自己去加載,那麼系統中將會出現多個不同的 Object 類,java 類型體系的最基本 的行爲就無法保證了.

     大家可以去嘗試,定義一個 java.lang.Object 類,可以正常編譯,但是永遠無法被加載運行.即使自定義了自己的類加載器.強行用 defineClass()方法去加載一個以”java.lang”開頭的類也不會成功

5.3 破壞雙親委派模型

     雙親委派模型,並不是一個強制性的約束模型,而是 java 設計者推薦給開發者的類加載實現方式.在 java 的世界中大部分的類加載 器都遵循這個模型.但是,在一些應用場景下,由於直接或間接的原因,雙親委派模型被破壞.

1.在我們自定義類加載器的時候,可以複寫父類 ClassLoader 的 loadClass 方法,這樣就直接破壞了雙親委派模型.到後面 JDK1.2 之後,爲了解決這個問題以及兼容問題,提供了一個 findClass()方法.

2.如果 API 中的基礎類想要調用用戶的代碼(JNDI/JDBC 等),此時雙親委派模型就不能完成.爲了解決這個問題,java 設計團隊只好 使用一個不優雅的設計方案:Thread 的上下文類加載器,默認就是應用程序的類加載器.

3.由於程序動態性的發展,希望應用程序不用重啓就可以加載最新的字節碼文件.此時就需要破壞雙親委派模型.

雙親委派模型被破壞,並不包含貶義,只要有足夠意義和理由就可以認爲這是一種創新,什麼方式會打破雙親委派模型呢?

1.自定義類加載器,複寫 loadClass 方法.
2.使用線程的上下文類加載器對象.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章