java類載入及雙親委派機制

塵虛緣_KY發表於2017-06-04

目錄

類載入流程 

1、載入

2、驗證

3、準備

4、解析

5、初始化

雙親委派模型

常見異常

NoClassDefFoundError

ClassNotFoundException

ClassCastException

執行緒上下文類載入器

JIT

編譯器

誰被編譯了?

觸發條件


當我們第一次使用該類的時候,如果該類還未被載入到記憶體,則系統會通過載入-連線-初始化來實現這個類的初始化。其中連線又分為:驗證,準備和解析三步。所以一個類被載入到jvm其生命週期包括以下7個階段。類載入包括前5個階段。具體流程如下:

類載入過程
類載入流程圖

類載入流程 

      類載入器的任務就是根據一個類的全限定名來讀取此類的二進位制位元組流到JVM中,然後轉換為一個與目標類對應的java.lang.Class物件例項。具體流程如下:

1、載入

  • 通過一個類的全限定名來獲取此類的二進位制位元組流;
  • 將位元組流代表的靜態儲存結構轉換為方法區的執行時資料結構;
  • 在記憶體中建立一個代表此類的java.lang.Class物件,作為方法區此類的'各種資料的訪問入口;

2、驗證

  • 格式驗證:驗證是否符合class檔案規範
  • 語義驗證:檢查一個被標記為final的型別是否包含子類;檢查一個類中的final方法是否被子類進行重寫;確保父類和子類之間沒有不相容的一些方法宣告(比如方法簽名相同,但方法的返回值不同)
  • 操作驗證:在運算元棧中的資料必須進行正確的操作,對常量池中的各種符號引用執行驗證(通常在解析階段執行,檢查是否可以通過符號引用中描述的全限定名定位到指定型別上,以及類成員資訊的訪問修飾符是否允許訪問等)

3、準備

  • 為類中的所有靜態變數分配記憶體空間,併為其設定一個初始值(由於還沒有產生物件,例項變數不在此操作範圍內)
  • 被final修飾的static變數(常量),會直接賦值;

4、解析

  • 將常量池中的符號引用轉為直接引用(得到類或者欄位、方法在記憶體中的指標或者偏移量,以便直接呼叫該方法),這個可以在初始化之後再執行。
  • 解析需要靜態繫結的內容。靜態繫結包括一些final方法(不可以重寫),static方法(只會屬於當前類),構造器(不會被重寫)

5、初始化

  • 為靜態變數賦值
  • 執行static程式碼塊【static程式碼塊只有jvm能夠呼叫】
  • 如果是多執行緒需要同時初始化一個類,僅僅只能允許其中一個執行緒對其執行初始化操作,其餘執行緒必須等待,只有在活動執行緒執行完對類的初始化操作之後,才會通知正在等待的其他執行緒。

因為子類存在對父類的依賴,所以類的載入順序是先載入父類後載入子類,初始化也一樣。不過,父類初始化時,子類靜態變數的值也是有的,是預設值。

  • 最終,方法區會儲存當前類類資訊,包括類的靜態變數、類初始化程式碼(定義靜態變數時的賦值語句 和 靜態初始化程式碼塊)、例項變數定義、例項初始化程式碼(定義例項變數時的賦值語句例項程式碼塊和構造方法)和例項方法,還有父類的類資訊引用     

類載入(初始化)時機

  1. 建立類的例項
  2. 訪問類的靜態變數或者為靜態變數賦值
  3. 呼叫類的靜態方法
  4. 使用反射方法來強制建立某個類或介面對應的java.lang.Class物件
  5. 初始化某個類的子類
  6. 直接使用java.exe命令來執行某個主類

雙親委派模型

  1. 當前ClassLoader首先從自己已經載入的類中查詢是否此類已經載入,如果已經載入則直接返回原來已經載入的類。每個類載入器都有自己的載入快取,當一個類被載入了以後就會放入快取,等下次載入的時候就可以直接返回了。
  2. 當前classLoader的快取中沒有找到被載入的類的時候,委託父類載入器去載入,父類載入器採用同樣的策略,首先檢視自己的快取,然後委託父類的父類去載入,一直到bootstrp ClassLoader.
  3. 當所有的父類載入器都沒有載入的時候,再由當前的類載入器載入,並將其放入它自己的快取中,以便下次有載入請求的時候直接返回。

流程如下圖所示:

這裡我們可以通過getParent()來返回該類的父類載入器。

public class ClassLoadTest {

    public static void main(String[] args) {
        try {
            System.out.println(ClassLoader.getSystemClassLoader());
            System.out.println(ClassLoader.getSystemClassLoader().getParent());
            System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

得到的結果如下:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@312b1dae
null

Process finished with exit code 0

正常的期望值是:系統類載入器的父類 -> 擴充套件類載入器 -> 啟動類載入器,但是這裡擴充套件類的父類不是啟動類載入器?看了下原始碼:

public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }
 
    protected synchronized Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {
 
        // 首先判斷該型別是否已經被載入
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果沒被載入,就委託給父類載入或者委派給啟動類載入器載入
            try {
                if (parent != null) {
                    //如果存在父類載入器,就委派給父類載入器載入
                    c = parent.loadClass(name, false);
                } else {
                    //如果不存在父類載入器,就通過啟動類載入器載入該類,
                    //通過呼叫本地方法native findBootstrapClass0(String name)
                    c = findBootstrapClass0(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父類載入器和啟動類載入器都不能完成載入任務,才呼叫自身的載入功能
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

通過程式碼可以知道,如果發現某個類沒有被載入,且當前的父載入類為空則會呼叫啟動類載入器去載入,當啟動類載入器也載入失敗的時候,才會呼叫自定義的載入器。所以擴充套件類設定為null和設定啟動類為父類是一樣的,返回為null也就正常類。

雙親委派流程

 “雙親委派”機制只是Java推薦的機制,並不是強制的機制。從虛擬機器的角度來說,只存在兩種類載入器:一種是啟動類載入器(Bootstrap ClassLoader),該類載入器使用C++語言實現,屬於虛擬機器自身的一部分。另外一種就是所有其它的類載入器,這些類載入器是由Java語言實現,獨立於JVM外部,並且全部繼承自抽象類java.lang.ClassLoader。

   我們可以繼承java.lang.ClassLoader類,實現自己的類載入器。如果想保持雙親委派模型,就應該重寫findClass(name)方法;如果想破壞雙親委派模型,可以重寫loadClass(name)方法

雙親委派模型的好處

  • 能夠有效確保一個類的全域性唯一性;【當程式中出現多個限定名相同的類時,類載入器在執行載入時,始終只會載入其中的某一個類】
  • 避免類載入惡意程式碼,信任機制,保證類 Java 核心庫的型別安全。【例如如果自己去編寫一個與rt.jar類庫中已有類重名的Java類(java.lang.Object),將會發現可以正常編譯,但永遠無法被載入執行。】
  • 分層思想

         另外:(1)父載入器中載入的類對於所有子載入器可見 (2)子類之間各自載入的類對於各自是不可間的(達到隔離效果)

常見異常

NoClassDefFoundError

       我們知道類載入器採用的是雙親委派原則,類載入器會首先代理給其它類載入器來嘗試載入某個類。這就意味著真正完成類的載入工作的類載入器和啟動這個載入過程的類載入器,可能不是同一個。真正完成類的載入工作是通過呼叫 defineClass來實現的;而啟動類的載入過程是通過呼叫 loadClass來實現的。前者稱為一個類的定義載入器(defining loader),後者稱為初始載入器(initiating loader)。在 Java 虛擬機器判斷兩個類是否相同的時候,使用的是類的定義載入器。也就是說,哪個類載入器啟動類的載入過程並不重要,重要的是最終定義這個類的載入器。兩種類載入器的關聯之處在於:一個類的定義載入器是它引用的其它類的初始載入器。如類 com.example.Outer引用了類 com.example.Inner,則由類 com.example.Outer的定義載入器負責啟動類 com.example.Inner的載入過程。 java.lang.ClassNotFoundException是被loadClass()丟擲的, java.lang.NoClassDefFoundError是被defineClass()丟擲。

      類載入器在成功載入某個類之後,會把得到的 java.lang.Class類的例項快取起來。下次再請求載入該類的時候,類載入器會直接使用快取的類的例項,而不會嘗試再次載入。也就是說,對於一個類載入器例項來說,相同全名的類只載入一次,即 loadClass方法不會被重複呼叫。

    loadClass()是來啟動類的載入過程的,其原始碼在前面我們已經分析過,即當父載入器不為null同時不能完成載入請求的情況下會丟擲ClassNotFoundException異常,而導致父載入器無法完成載入的一個原因很簡單就是找不到這個類,通常這種情況是傳入的類的字串名稱書寫錯誤,如呼叫class.forName(String name)或者loadClass(String name)時傳入了一個錯誤的類的名稱導致類載入器無法找到這個類。

ClassNotFoundException

defineClass()是用來完成類的載入工作的,此時已經表明類載入的啟動已經完成了,即當前執行的類已經編譯,但執行時找不到它的定義時就會丟擲NoClassDefFoundError異常,這種情況通常出現在建立一個物件例項的時候(creating a new instance),如在類X中定義了一個類Y,如在類X中定義如下語句ClassY y=new ClassY; 程式執行成功之後(此時X與Y的位元組碼檔案已經存在),如果將類Y的位元組碼檔案刪除了重新執行上述程式碼,則會在執行時候丟擲NoClassDefFoundError異常。當然這只是為了說明丟擲這種異常的原因,一般不會出現刪除該類位元組碼情況,實際上是其它原因導致類似刪除的效果導致的,如JAR重複引入,版本不一致導至。因為jar中都是一些已經編譯好的Class檔案,如果存在多個版本那麼在載入的時候就不知道應該呼叫哪一個版本(相當於刪除位元組碼的效果),此種情況一般出現在引入第三方SDK的時候。

總結:

ClassNotFoundException NoClassDefFoundError
從java.lang.Exception繼承,是一個Exception型別 從java.lang.Error繼承,是一個Error型別
當動態載入Class的時候找不到類會丟擲該異常 當編譯成功以後執行過程中Class找不到導致丟擲該錯誤
一般在執行Class.forName()、ClassLoader.loadClass()或ClassLoader.findSystemClass()的時候丟擲 由JVM的執行時系統丟擲

ClassNotFoundException發生在裝入階段。 
當應用程式試圖通過類的字串名稱,使用常規的三種方法裝入類,但卻找不到指定名稱的類定義時就丟擲該異常。

NoClassDefFoundError: 當目前執行的類已經編譯,但是找不到它的定義時,發生連線階段:
也就是說你如果編譯了一個類B,在類A中呼叫,編譯完成以後,你又刪除掉B,執行A的時候那麼就會出現這個錯誤

載入時從外儲存器找不到需要的class就出現ClassNotFoundException 
連線時從記憶體找不到需要的class就出現NoClassDefFoundError

ClassCastException

在jvm的世界裡,確定是否是相同的一個類需要判斷兩個條件:類的全名 + 類載入器。如果一個相同的類,com.test.Sample,在通過不同的載入器載入後,如果相互賦值,則會出現ClassCastException的異常。

打破雙親委派機制

第一次:在雙親委派模型出現之前—–即JDK1.2釋出之前。 

第二次:模型自身缺點導致:

雙親委派模型的第二次“被破壞”是由這個模型自身的缺陷所導致的,雙親委派很好地解決了各個類載入器的基礎類的統一問題(越基礎的類由越上層的載入器進行載入),基礎類之所以稱為“基礎”,是因為它們總是作為被使用者程式碼呼叫的API,但世事往往沒有絕對的完美,如果基礎類又要呼叫回使用者的程式碼,那該怎麼辦?
這並非是不可能的事情,一個典型的例子便是JNDI服務,JNDI現在已經是Java的標準服務,它的程式碼由啟動類載入器去載入(在JDK 1.3時放進去的rt.jar),但JNDI的目的就是對資源進行集中管理和查詢,它需要呼叫由獨立廠商實現並部署在應用程式的ClassPath下的JNDI介面提供者(SPI,Service Provider Interface)的程式碼,但啟動類載入器不可能“認識”這些程式碼啊!那該怎麼辦?
為了解決這個問題,Java設計團隊只好引入了一個不太優雅的設計:執行緒上下文類載入器(Thread Context ClassLoader)。這個類載入器可以通過java.lang.Thread類的setContextClassLoaser()方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是應用程式類載入器。
有了執行緒上下文類載入器,就可以做一些“舞弊”的事情了,JNDI服務使用這個執行緒上下文類載入器去載入所需要的SPI程式碼,也就是父類載入器請求子類載入器去完成類載入的動作,這種行為實際上就是打通了雙親委派模型的層次結構來逆向使用類載入器,實際上已經違背了雙親委派模型的一般性原則,但這也是無可奈何的事情。Java中所有涉及SPI的載入動作基本上都採用這種方式,例如JNDI、JDBC、JCE、JAXB和JBI等。

第三次:為了實現熱插拔,熱部署,模組化,意思是新增一個功能或減去一個功能不用重啟,只需要把這模組連同類載入器一起換掉就實現了程式碼的熱替換。 

執行緒上下文載入器的使用場景

  • 當高層提供了統一介面讓低層去實現,同時又要是在高層載入(或例項化)低層的類時,必須通過執行緒上下文類載入器來幫助高層的ClassLoader找到並載入該類。
  • 當使用本類託管類載入,然而載入本類的ClassLoader未知時,為了隔離不同的呼叫者,可以取呼叫者各自的執行緒上下文類載入器代為託管。
     

JIT

JIT是just in time,即時編譯技術。使用該技術,可以加速java程式的執行速度,是一種優化手段。

對於 Java 程式碼,剛開始都是被編譯器編譯成位元組碼檔案,然後位元組碼檔案會被交由 JVM 解釋執行,所以可以說 Java 本身是一種半編譯半解釋執行的語言。為了進一步提高程式碼的執行速度,HotSpot VM的熱點程式碼探測能力可以通過執行計數器找出最具有編譯價值的程式碼,然後通知JIT編譯器以方法為單位進行編譯,提高優化。

編譯器

HotSpot虛擬機器內建了兩個即時編譯器:

  • Client Compiler 

        C1編譯器是一個簡單快速的三段式編譯器,主要關注“區域性效能優化”,放棄許多耗時較長的全域性優化手段 ;

         過程:class -> 1. 高階中間程式碼 -> 2. 低階中間程式碼 -> 3. 機器程式碼

  • Server Compiler 

       C2是專門面向伺服器應用的編譯器,是一個充分優化過的高階編譯器,幾乎能達到GNU C++編譯器使用-O2引數時的優化強度。

誰被編譯了?

兩類熱點程式碼

  1. 被多次呼叫的方法 
    • 一個方法被多次呼叫,理應稱為熱點程式碼,這種編譯也是虛擬機器中標準的JIT編譯方式
  2. 被多次執行的迴圈體 
    • 編譯動作由迴圈體出發,但編譯物件依然會以整個方法為物件
    • 這種編譯方式由於編譯發生在方法執行過程中,因此形象的稱為:棧上替換(On Stack Replacement- OSR編譯,即方法棧幀還在棧上,方法就被替換了)

觸發條件

判斷一段程式碼是不是熱點程式碼,是不是需要觸發JIT編譯,這樣的行為稱為:熱點探測(Hot Spot Detection),有幾種主流的探測方式:

  1. 基於計數器的熱點探測(Counter Based Hot Spot Detection) 
    虛擬機器會為每個方法(或每個程式碼塊)建立計數器,統計執行次數,如果超過閥值那麼就是熱點程式碼。缺點是維護計數器開銷。

  2. 基於取樣的熱點探測(Sample Based Hot Spot Detection) 
    虛擬機器會週期性檢查各個執行緒的棧頂,如果某個方法經常出現在棧頂,那麼就是熱點程式碼。缺點是不精確。

  3. 基於蹤跡的熱點探測(Trace Based Hot Spot Detection) 
    Dalvik中的JIT編譯器使用這種方式

hotspot預設使用計數器的熱點探測:

  • 方法計數器
  • 回邊計數器

目前主流商用JVM都採用編譯器和直譯器並存的架構,但主流商用虛擬機器,都同時包含這兩部分。

  1. 當程式需要迅速啟動然後執行的時候,直譯器可以首先發揮作用,編譯器不執行從而省去編譯時間,立即執行程式;

  2. 在程式執行後,隨著時間的推移,編譯器逐漸發揮作用,把越來越多的程式碼編譯成原生程式碼之後,可以獲得更高的執行效率。

 

參考資料:

《深入理解jvm虛擬機器》

https://blog.csdn.net/yangcheng33/article/details/52631940
https://blog.csdn.net/moakun/article/details/81257897
https://blog.csdn.net/weixin_39222112/article/details/81316511
https://blog.csdn.net/qq_26963433/article/details/78048561
https://www.cnblogs.com/xiaoxian1369/p/5498817.html
https://www.cnblogs.com/insistence/p/5901457.html
http://www.cnblogs.com/aspirant/p/8991830.html
https://blog.csdn.net/chen364567628/article/details/52561588
https://www.cnblogs.com/tinytiny/p/3200448.html
http://www.cnblogs.com/charlesblc/p/5993804.html

 

相關文章