1. 程式人生 > >深入JVM類載入機制

深入JVM類載入機制

從ClassLoad開始說起

ClassLoader顧名思義就是我們所常見的類載入器,其作用就是將編譯後的class檔案載入記憶體當中.在應用啟動時,JVM通過ClassLoader載入相關的類到JVM當中.在具體瞭解ClassLoader之前我們先來了解下JVM的類載入機制.

1. 類載入機制

虛擬機器將class檔案載入到記憶體,並對資料校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的java型別。在java中語言中類的載入、連線和初始化過程都是在程式執行期間完成的,因此在類載入時的效率相對編譯型語言較低,除此之外,只有在任何一個類只有在執行期間使用到該類的時候才會將該類加到記憶體中。總之,java依賴於執行期間動態載入和動態連結來實現類的動態使用。其整個流程如下:
類載入生命週期圖


其中載入、檢驗、準備、初始化和解除安裝這個五個階段的順序是固定的,而解析則未必。為了支援動態繫結,解析這個過程可以發生在初始化階段之後。另外,這個過程表示的是按順序開始,不是所謂的第一步、第二步、第三步的關係,而往往是交叉混合進行,在一個階段中可能呼叫或者啟用另一個過程。
java中,對於初始化階段,有且只有以下五種情況才會對要求類立刻“初始化”:

  1. 使用new關鍵字例項化物件、訪問或者設定一個類的靜態欄位(被final修飾、編譯器優化時已經放入常量池的例外)、呼叫類方法,都會初始化該靜態欄位或者靜態方法所在的類。
  2. 初始化類的時候,如果其父類沒有被初始化過,則要先觸發其父類初始化。
  3. 使用java.lang.reflect包的方法進行反射呼叫的時候,如果類沒有被初始化,則要先初始化。
  4. 虛擬機器啟動時,使用者會先初始化要執行的主類(含有main)
  5. jdk 1.7後,如果java.lang.invoke.MethodHandle的例項最後對應的解析結果是 REF_getStatic、REF_putStatic、REF_invokeStatic方法控制代碼,並且這個方法所在類沒有初始化,則先初始化。

2. 類載入過程

2.1 載入

載入過程主要完成三件事情:

  1. 通過類的全限定名來獲取定義此類的二進位制位元組流
  2. 將這個類位元組流代表的靜態儲存結構轉為方法區的執行時資料結構
  3. 在堆中生成一個代表此類的java.lang.Class物件,作為訪問方法區這些資料結構的入口。

這個過程主要就是類載入器完成。(對於HotSpot虛擬而言,Class物件較為特殊,其被放置在方法區而不是堆中)

2.2 驗證

此階段主要確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器的自身安全。主要包括以下四個階段:

  1. 檔案格式驗證:基於位元組流驗證,驗證位元組流符合當前的Class檔案格式的規範,能被當前虛擬機器處理。驗證通過後,位元組流才會進入記憶體的方法區進行儲存。
  2. 元資料驗證:基於方法區的儲存結構驗證,對位元組碼進行語義驗證,確保不存在不符合java語言規範的元資料資訊。
  3. 位元組碼驗證:基於方法區的儲存結構驗證,通過對資料流和控制流的分析,保證被檢驗類的方法在執行時不會做出危害虛擬機器的動作。
  4. 符號引用驗證:基於方法區的儲存結構驗證,發生在解析階段,確保能夠將符號引用成功的解析為直接引用,其目的是確保解析動作正常執行。換句話說就是對類自身以外的資訊進行匹配性校驗。
  5. 5.

2.3 準備

僅僅為類變數(static修飾的變數)分配記憶體空間並且設定該類變數的初始值(這裡的初始值指的是資料型別預設的零值),這裡不包含用final修飾的static,因為用final修飾的類變數在javac執行編譯期間就會分配,同時要注意,這裡不會為例項變數分配初始化。類變數會分配在方法區中,而例項變數會在物件例項化是隨著物件一起被分配在java堆中。舉例:

public static int value=33;

這據程式碼的賦值過程分兩次,一是上面我們提到的階段,此時的value將會被賦值為0;而value=33這個過程發生在類構造器的<clinit>()方法中。

2.4 解析

解析階段主要是將常量池內的符號引用替換為直接引用的過程。符號引用是用一組符號來描述目標,可以是任何字面量,而直接引用則是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制代碼。通常而言一個符號引用在不同虛擬機器例項翻譯出來的直接引用一般不會相同。
和C之類的純編譯型語言不同,Java類檔案在編譯過程中只會生成class檔案,並不會進行連線操作,這意味在編譯階段Java類並不知道引用類的實際地址,因此只能用“符號引用”來代表引用類。舉個例子來說明,在com.sbbic.Person類中引用了com.sbbic.Animal類,在編譯階段,Person類並不知道Animal的實際記憶體地址,因此只能用com.sbbic.Animal來代表Animal真實的記憶體地址。在解析階段,JVM可以通過解析該符號引用,來確定com.sbbic.Animal類的真實記憶體地址(如果該類未被載入過,則先載入)。
主要有以下四種:

  1. 類或介面的解析
  2. 欄位解析
  3. 類方法解析
  4. 介面方法解析

2.5 初始化

類載入過程的最後一步,到該階段才真正開始執行類中定義的java程式碼,同樣該階段也是初始化類變數和其他資源(執行static欄位和靜態程式碼塊),換句話說該階段是執行類構造器<clinit>()方法的過程。
<clinit>()方法是由編譯器自動收集類中所有的類變數的賦值動作和靜態語句塊(static{})中的語句合併而成。<clinit>()方法和例項構造方法不同<init>()不同,它不需要顯示的呼叫父類的<clinit>(),虛擬機器會保證父類的<clinit>()方法在子類的<clinit>()方法之前執行完成,也就是說,父類的靜態語句塊和靜態變數優先於子類中變數賦值操作。
<clinit>()方法對於類或者介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變數賦值的操作,那麼編譯器就不會為這個類生成<clinit>()方法。介面中不能使用靜態語句塊,單仍然有變數賦值的操作,所以仍然可以生成<clinit>()方法,但與類不同的執行介面<clinit>()方法不需要先執行父介面的<clinit>()方法,另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。

3. 類載入器

把類載入階段的“通過一個類的全限定名來獲取描述此類的二進位制位元組流”這個動作交給虛擬機器之外的類載入器來完成。這樣的好處在於,我們可以自行實現類載入器來載入其他格式的類,只要是二進位制位元組流就行,這就大大增強了載入器靈活性。

通常系統自帶的類載入器分為三種:

啟動類載入器(Bootstrap ClassLoader):由C/C++實現,負責載入<JAVA_HOME>\jre\lib目錄下或者是-Xbootclasspath所指定路徑下目錄以及系統屬性sun.boot.class.path制定的目錄中特定名稱的jar包到虛擬機器記憶體中。在JVM啟動時,通過Bootstrap ClassLoader載入rt.jar,並初始化sun.misc.Launcher從而建立Extension ClassLoader和Application ClassLoader的例項.
需要注意的是,Bootstrap ClassLoader智慧載入特定名稱的類庫,比如rt.jar.這意味我們自定義的jar扔到<JAVA_HOME>\jre\lib也不會被載入.
我們可以通過以下程式碼,檢視Bootstrap ClassLoader到底初始化了那些類庫:

 URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
        for (URL urL : urLs) {
            System.out.println(urL.toExternalForm());
        }

擴充套件類載入器(Extension Classloader):只有一個例項,由sun.misc.Launcher$ExtClassLoader實現,負責載入<JAVA_HOME>\lib\ext目錄下或是被系統屬性java.ext.dirs所指定路徑目錄下的所有類庫。

應用程式類載入器(Application ClassLoader):只有一個例項,由sun.misc.Launcher$AppClassLoader實現,負責載入系統環境變數ClassPath或者系統屬性java.class.path制定目錄下的所有類庫,如果應用程式中沒有定義自己的載入器,則該載入器也就是預設的類載入器.該載入器可以通過java.lang.ClassLoader.getSystemClassLoader獲取.

以上三種是我們經常認識最多,除此之外還包括執行緒上下文類載入器(Thread Context ClassLoader)和自定義類載入器.

下來解釋一下執行緒上下文載入器:
每個執行緒都有一個類載入器(jdk 1.2後引入),稱之為Thread Context ClassLoader,如果執行緒建立時沒有設定,則預設從父執行緒中繼承一個,如果在應用全域性內都沒有設定,則所有Thread Context ClassLoader為Application ClassLoader.可通過Thread.currentThread().setContextClassLoader(ClassLoader)來設定,通過Thread.currentThread().getContextClassLoader()來獲取.
我們來想想執行緒上下文載入器有什麼用的?該類載入器容許父類載入器通過子類載入器載入所需要的類庫,也就是打破了我們下文所說的雙親委派模型.
這有什麼好處呢?利用執行緒上下文載入器,我們能夠實現所有的程式碼熱替換,熱部署,Android中的熱更新原理也是借鑑如此的.

至於自定義載入器就更簡單了,JVM執行我們通過自定義的ClassLoader載入相關的類庫.

3.1 類載入器的雙親委派模型

當一個類載入器收到一個類載入的請求,它首先會將該請求委派給父類載入器去載入,每一個層次的類載入器都是如此,因此所有的類載入請求最終都應該被傳入到頂層的啟動類載入器(Bootstrap ClassLoader)中,只有當父類載入器反饋無法完成這個列的載入請求時(它的搜尋範圍內不存在這個類),子類載入器才嘗試載入。其層次結構示意圖如下:
雙親委派模型示意圖

不難發現,該種載入流程的好處在於:

  1. 可以避免重複載入,父類已經載入了,子類就不需要再次載入
  2. 更加安全,很好的解決了各個類載入器的基礎類的統一問題,如果不使用該種方式,那麼使用者可以隨意定義類載入器來載入核心api,會帶來相關隱患。

接下來,我們看看雙親委派模型是如何實現的:

 protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先先檢查該類已經被載入過了
            Class c = findLoadedClass(name);
            if (c == null) {//該類沒有載入過,交給父類載入
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {//交給父類載入
                        c = parent.loadClass(name, false);
                    } else {//父類不存在,則交給啟動類載入器載入
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                   //父類載入器丟擲異常,無法完成類載入請求
                }

                if (c == null) {//
                    long t1 = System.nanoTime();
                    //父類載入器無法完成類載入請求時,呼叫自身的findClass方法來完成類載入
                    c = findClass(name);
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

這裡有些童鞋會問,JVM怎麼知道一個某個類載入器的父載入器呢?如果你有此疑問,請重新再看一遍.

3.2 類載入器的特點

  1. 執行任何一個程式時,總是由Application Loader開始載入指定的類。
  2. 一個類在收到載入類請求時,總是先交給其父類嘗試載入。
  3. Bootstrap Loader是最頂級的類載入器,其父載入器為null。

3.3 類載入的三種方式

  1. 通過命令列啟動應用時由JVM初始化載入含有main()方法的主類。
  2. 通過Class.forName()方法動態載入,會預設執行初始化塊(static{}),但是Class.forName(name,initialize,loader)中的initialze可指定是否要執行初始化塊。
  3. 通過ClassLoader.loadClass()方法動態載入,不會執行初始化塊。

3.4 自定義類載入器的兩種方式

1、遵守雙親委派模型:繼承ClassLoader,重寫findClass()方法。
2、破壞雙親委派模型:繼承ClassLoader,重寫loadClass()方法。
通常我們推薦採用第一種方法自定義類載入器,最大程度上的遵守雙親委派模型。
自定義類載入的目的是想要手動控制類的載入,那除了通過自定義的類載入器來手動載入類這種方式,還有其他的方式麼?

利用現成的類載入器進行載入:

1. 利用當前類載入器
Class.forName();

2. 通過系統類載入器
Classloader.getSystemClassLoader().loadClass();

3. 通過上下文類載入器
Thread.currentThread().getContextClassLoader().loadClass();

l
利用URLClassLoader進行載入:

URLClassLoader loader=new URLClassLoader();
loader.loadClass();

類載入例項演示:
命令列下執行HelloWorld.java

public class HelloWorld{
    public static void main(String[] args){
        System.out.println("Hello world");
    }
}

該段程式碼大體經過了一下步驟:

  1. 尋找jre目錄,尋找jvm.dll,並初始化JVM.
  2. 產生一個Bootstrap ClassLoader;
  3. Bootstrap ClassLoader載入器會載入他指定路徑下的java核心api,並且生成Extended ClassLoader載入器的例項,然後Extended ClassLoader會載入指定路徑下的擴充套件java api,並將其父設定為Bootstrap ClassLoader。
  4. Bootstrap ClassLoader生成Application ClassLoader,並將其父Loader設定為Extended ClassLoader。
  5. 最後由AppClass ClassLoader載入classpath目錄下定義的類——HelloWorld類。

我們上面談到 Extended ClassLoader和Application ClassLoader是通過Launcher來建立,現在我們再看看原始碼:

 public Launcher() {
        Launcher.ExtClassLoader var1;
        try {
            //例項化ExtClassLoader
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError("Could not create extension class loader", var10);
        }

        try {
            //例項化AppClassLoader
            this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
        } catch (IOException var9) {
            throw new InternalError("Could not create application class loader", var9);
        }
        //主執行緒設定預設的Context ClassLoader為AppClassLoader.
        //因此在主執行緒中建立的子執行緒的Context ClassLoader 也是AppClassLoader
        Thread.currentThread().setContextClassLoader(this.loader);
        String var2 = System.getProperty("java.security.manager");
        if(var2 != null) {
            SecurityManager var3 = null;
            if(!"".equals(var2) && !"default".equals(var2)) {
                try {
                    var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
                } catch (IllegalAccessException var5) {
                    ;
                } catch (InstantiationException var6) {
                    ;
                } catch (ClassNotFoundException var7) {
                    ;
                } catch (ClassCastException var8) {
                    ;
                }
            } else {
                var3 = new SecurityManager();
            }

            if(var3 == null) {
                throw new InternalError("Could not create SecurityManager: " + var2);
            }

            System.setSecurityManager(var3);
        }

    }

3.5 非常重要

在這裡呢我們需要注意幾個問題:
1. 我們知道ClassLoader通過一個類的全限定名來獲取二進位制流,那麼如果我們需要通過自定義類載入其來載入一個Jar包的時候,難道要自己遍歷jar中的類,然後依次通過ClassLoader進行載入嗎?或者說我們怎麼來載入一個jar包呢?
2. 如果一個類引用的其他的類,那麼這個其他的類由誰來載入?
3. 既然類可以由不同的載入器載入,那麼如何確定兩個類如何是同一個類?

我們來依次解答這兩個問題:
對於動態載入jar而言,JVM預設會使用第一次載入該jar中指定類的類載入器作為預設的ClassLoader.假設我們現在存在名為sbbic的jar包,該包中存在ClassA和ClassB這兩個類(ClassA中沒有引用ClassB).現在我們通過自定義的ClassLoaderA來載入在ClassA這個類,那麼此時此時ClassLoaderA就成為sbbic.jar中其他類的預設類載入器.也就是,ClassB也預設會通過ClassLoaderA去載入.

那麼如果ClassA中引用了ClassB呢?當類載入器在載入ClassA的時候,發現引用了ClassB,此時類載入如果檢測到ClassB還沒有被載入,則先回去載入.當ClassB載入完成後,繼續回來載入ClassA.換句話說,類會通過自身對應的來載入其載入其他引用的類.

JVM規定,對於任何一個類,都需要由載入它的類載入器和這個類本身一同確立在java虛擬機器中的唯一性,通俗點就是說,在jvm中判斷兩個類是否是同一個類取決於類載入和類本身,也就是同一個類載入器載入的同一份Class檔案生成的Class物件才是相同的,類載入器不同,那麼這兩個類一定不相同.