Java虛擬機器(六):類載入機制

weixin_34377065發表於2018-12-09

大家都知道,我們編寫的Java類經過編譯器編譯後會生成class檔案,class檔案描述了類的各種資訊,最終都要載入到記憶體中才能執行使用,那虛擬機器是如何載入這些class檔案的呢?載入又有哪些過程呢?是否程式一啟動就把所有的類都載入到記憶體中呢?下面我們就來討論這些問題。

上面說到Java類經過編譯器編譯後會生成class檔案,這個說法在現在可能有些不準確了,更準確的說法應該是“Java類經過前端編譯器編譯後會生成class檔案”,因為現在的Java會有一個JIT機制,JIT屬於後端編譯,JIT編譯器會在Java程式執行期間將“熱點程式碼”編譯成機器碼,以提高執行效率。本文所提到的編譯都是前端編譯,不涉及後端編譯,關於後端編譯,會在下一篇文章中詳細討論。

1 什麼是類載入

編寫好Java程式碼經Java編譯器(Javac)編譯之後會生成class位元組碼檔案,這是我們從剛開始學習Java就知道的事。實際上,這僅僅是Java程式執行的第一步,JVM還必須在執行時將位元組碼載入到內中,然後驗證、分析位元組碼檔案,並執行相應的指令,最後該class檔案對應的Java類才能被使用,這就是類載入機制。

那為什麼會有這個類載入機制呢?學過C++的朋友應該知道,C++程式要執行大體有編譯和連結兩個過程,這樣的好處是執行時效率非常高,不需要在做額外的操作,但大型的C++程式的編譯速度會慢得令人髮指。Java就不這樣幹,它會先編譯原始碼成位元組碼,然後在執行時動態的將位元組碼載入到記憶體中,這樣的效果是大大降低了編譯速度和啟動速度(我們發現即使大型的Java程式,編譯和啟動過程都不會太慢),只有需要用到某個類的時候才會將其位元組碼載入到記憶體中,但執行時效率就會受到影響(不過隨著JIT技術的成熟,這個方面的效能問題已經得到了很大的改善),這是Java程式執行時效能不如C++程式的一方面原因。

2 類載入過程

上圖是Java類的生命週期,從載入到解除安裝。我們主要關注的是載入、驗證、準備、解析和初始化5個階段,使用和解除安裝暫不討論。這些階段都是交叉執行的,例如載入階段可能還沒完成,驗證就已經開始了,這有點像流水線作業一樣。Java虛擬機器沒有明確規定什麼時候應該開始載入一個類,但規定了什麼時候應該開始初始化一個類,而載入的開始必須要發生在初始化開始之前(但初始化開始並不就一定需要等待載入階段結束)。虛擬機器規定了如下5種情況必須立即對類進行初始化:

  1. 遇到new、getstatic、putstatic和invokestatic這四條指令時,如果該類沒有進行過初始化,就必須先觸發其初始化操作。
  2. 使用java.util.reflect包的方法對類進行反射呼叫的時候,如果該類沒有進行過初始化,就必須先觸發其初始化操作。
  3. 當初始化一個類時,如果其父類沒有進行過初始化,就先觸發其父類的初始化。
  4. 當虛擬機器啟動時,會先啟動使用者指定的主類(包含main方法的類)。
  5. 當使用動態代理相關技術時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果是REF_getStatic、REF_pubSttic、REF_invokeStatic的方法控制程式碼,並且這個方法所對應的類沒有進行過初始化,那麼必須先觸發其初始化。

“有且僅有”上述5種情況才會觸發初始化,這5中情況的行為被稱作“主動引用”,其他引用類的方式都不會觸發初始化,被稱作“被動引用”。只有根據這5種情況來判斷類是否初始化才是正確的,根據其他的諸如“經驗法則”等會很容易出錯,所以正確理解5種情況所要表達的意義才是關鍵。

2.1 載入階段

載入階段,主要完成以下3個事情:

  1. 通過一個類的全限定類名來獲取該類對應的二進位制位元組流。
  2. 將這個位元組流轉換成方法區的執行時資料結構。
  3. 在記憶體中生成代表這個類的class物件,作為方法區這個類的各種資料訪問的入口。

這裡的二進位制位元組流並不一定就是class檔案,只要是二進位制位元組流就行,也沒有規定該位元組流從哪獲取,所以其實獲取位元組流的方式有很多,例如從壓縮包中獲取、網路傳輸通道中獲取,執行時生成(動態代理等技術)等。載入階段是類載入整個過程中唯一可以由開發人員掌控的,開發人員可以通過重寫Classloader的loadClass()方法來更改載入的方式,但最好要遵循雙親委派模型。

陣列類和普通類的載入階段有一些差別。陣列類是由虛擬機器直接建立的,而不是由類載入器建立的,但陣列類和載入器仍然有密切的關係。如果陣列的元素型別是引用型別,那麼就根據普通類的載入規則去載入該類,陣列類將與載入該類的載入器建立唯一關係標識,如果陣列元素類似引用型別,那麼陣列類將與bootstrap載入器建立唯一關係標識。

載入完成之後,會在方法區中生成一個代表該類的Class物件,class物件雖然是物件,但確實是儲存在方法區裡,這算是一個特例,主要目的應該是方便直接在方法區裡訪問Class物件。

2.2 驗證

驗證和載入階段是交叉執行的,即載入階段可能剛剛開始載入位元組流,驗證階段就開始對位元組流進行驗證了,但驗證開始時機仍然發生在載入開始之後。

Java號稱是一門安全的語言,所以驗證階段就顯得尤為重要,在驗證階段中,虛擬機器會驗證位元組碼是否符號虛擬機器規範,是否存在惡意的位元組碼指令,邏輯是否符合Java語言規範等。如果驗證失敗,虛擬機器應該丟擲一個java.lang.VerifyError異常或者其子類並停止類載入過程。驗證階段大致分為4個校驗動作:檔案格式驗證、後設資料驗證、位元組碼驗證、符號引用驗證。

2.2.1 檔案格式驗證

檔案格式驗證即驗證位元組流是否符合Class檔案的格式規範,例如開頭的CAFEBABE魔數,版本號、常量池等各種資訊的先後順序、有UTF-8要求的字串是否滿足UTF-8字元編碼等等。這個階段的操作目標是位元組流,只有通過了這個階段的驗證,並將位元組流描述的類儲存到方法區中,才能進行後面的驗證,因為後面的幾個驗證動作都是基於方法區的資料結構來做的。

2.2.2 後設資料驗證

這個階段就是對位元組碼描述的資訊做語義分析,驗證位元組碼是否符合Java語言的規範,例如這個類是否有父類,這個類是否被設定成不可繼承的類,如果該類不是抽象類且實現了介面,是否實現了介面的抽象方法等等。這裡還要說一下,Java語言規範和Java虛擬機器規範是兩碼事,不能一概而論。

2.2.3 位元組碼驗證

這個階段主要進行的是用資料流和控制流分析程式語義是否合法,保證被校驗的類不會做出危害虛擬機器的事情。該階段是很複雜的,也是比較耗時的,所以後期的Java虛擬機器對整個步驟做了一些優化,使用“StackMapTable”屬性來儲存本地變數表和運算元等資訊,當需要進行位元組碼驗證的時候,就直接驗證“StackMapTable”裡的資訊即可,大大減少了驗證時間。

2.2.4 符號引用驗證

這個階段會嚴重符號引用是否正確,符號引用是否正確的判斷依據主要有以下幾個:

  • 是否能通過描述符號引用的全限定類名字串找到對應的類。

  • 在指定的類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位。

  • 符號引用中的類、欄位、方法的訪問性是否可以被當前類訪問。

    .......

只有完成了符號引用驗證,後續在解析階段將符號引用轉換成直接引用的時候才可能成功,如果驗證失敗,將會丟擲java.lang.NoSuchMethodError、java.lang.NoSuchFieldError等異常。

對於類載入機制來說,驗證階段雖然是非常重要的,但並不是必須的,如果要執行的程式碼已經經歷過反覆驗證和使用,那麼就可以省略掉驗證這個階段,從而降低類載入的時間。

2.3 準備

準備階段是為類變數分配記憶體並初始化的階段,這些變數所使用記憶體都是方法區記憶體,需要注意的是,類變數和例項變數是不同的,準備階段僅包括類變數(static修飾的)的記憶體分配和初始化。初始化是給變數賦予對應型別的“零值”,這裡的“零值”並不是特點的數字0,對於數字型別來說確實是0或者0.0,對於布林型變數來說是false,引用型別是null等,下面這個表格給出了各種型別的“零”值:

即使使用者在宣告的時候並同時賦值,也不會馬上按照程式設計師的意願進行操作,如下所示:

public class Main {
    private static int a = 123;   
}

這裡a在僅僅會被賦值成0,而不是123。但有一個例外,就是常量!常量會直接根據程式設計師的意願進行操作:

public class Main {
    private static final int a = 123;
}

a被final修飾了,所以他是一個常量,在準備階段,虛擬機器會根據常量值做賦值操作,即準備階段完成後,a的值是123而不是0。

2.4 解析

解析過程的作用是將符號引用轉換成虛擬機器可以直接使用的直接引用。在之前的文章中,有不少地方提到過符號引用,但一直沒有詳細解釋什麼是符號引用,在此就詳細介紹一下吧:

  • 符號引用。符號引用可以是任何形式的字面值常量,只要能在使用時無歧義的定位到目標即可。在HotSpot虛擬機器中,是以字串的形式存在的,而且往往是一組字串。這組字串所代表的可能是某個類、某個介面等,無論代表的是什麼,只要能唯一的定位到目標,那就是一個正確的符號引用。關於符號引用的更多,推薦看看知乎上這個問題:JVM裡的符號引用如何儲存
  • 直接引用。直接引用可以是指向目標的指標、相對偏移量或者一個能間接定位到目標的控制程式碼等,直接引用和虛擬機器的記憶體佈局是有關的,同一個符號引用在不同的虛擬機器裡翻譯處理的直接引用往往不相同,如果成功將符號引用轉換成直接引用了,那麼直接引用的目標肯定是已經存在於記憶體中的。

解析階段的物件不僅僅是類,還包括介面、方法、欄位等。因為要訪問一個介面或者方法、欄位都需要有一個直接引用,而直接引用又是由符號引用轉換而成的。關於解析更加詳細的內容建議細節看看《深入理解Java虛擬機器》的7.3.4節內容。

2.5 初始化

初始化階段是執行類構造器<clinit>()方法的過程。需要注意的是這裡的<clinit>()方法不包括例項構造器,例項構造器屬於<init>()方法,換句話說,這裡不會執行例項構造器或者初始化程式碼塊裡的內容。這是因為這裡的初始化階段還屬於類載入的過程,沒有涉及到例項化的過程,例項構造器或者初始化程式碼塊的程式碼會在類被例項化成物件的時候執行。

<clinit>()方法由靜態變數的賦值語句和static程式碼塊裡的語句構成,出現在前的語句在合併後仍然出現在前,即順序保持原始碼中的順序。有一個比較奇怪的現象,在我們編寫原始碼的時候,static塊裡能對宣告在後面的變數做賦值操作,只是不能訪問。

<clinit>()方法不需要顯式的呼叫父類的<clinit>()方法(例項構造器需要顯示的呼叫,只是大多數時候,編譯器會幫我們在第一行添上了),虛擬機器會保證在呼叫子類的<clinit>()方法之前呼叫父類的<clinit>()方法。基於這個機制,父類的靜態變數的賦值操作優先於子類的靜態變數賦值。

<clinit>()方法並不是必須的,如果一個類沒有任何靜態變數和靜態塊,那麼虛擬機器就不會為該類生成<clinit>()方法。還有就是雖然介面不能定義靜態塊,但可以定義靜態變數,所以介面也是可能有<clinit>(),但和類的<clinit>()不同,介面執行<clinit>()方法之前不需要執行父介面的<clinit>()方法,只有在父介面中定義的變數被使用時,才會呼叫父介面的<clinit>方法。

虛擬機器還會保證<clinit>方法是執行緒安全的,即多個執行緒同時要去執行<clinit>()方法也僅僅有一個方法能真正執行,其他執行緒會被阻塞,當能執行<clinit>()方法的執行緒執行完畢之後,其他執行緒被喚醒,但不會再去嘗試執行<clinit>方法,這避免了重複執行<clinit>()。我們可以寫一些程式碼嘗試一下:

public class ClinitTest {

    static class Test {
        private static int a = 32;

        static {
            a = 42;
            System.out.println(Thread.currentThread().getName() + "execute static");

        }
    }


    public static void main(String[] args) throws InterruptedException {
        Runnable r = () -> {
            System.out.println(Thread.currentThread().getName() + " started");
            Test test = new Test();
            System.out.println(Thread.currentThread().getName() + "end");
        };
        Thread thread1 = new Thread(r);
        Thread thread2 = new Thread(r);
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

結果如下所示:

Thread-0 started
Thread-1 started
Thread-1execute static
Thread-1end
Thread-0end

可見,<clinit>()方法只被執行了一次,符合我們上面說到的規則。

3 類載入器

類載入器是這麼一個東西:可以通過一個類的全限定類名獲取描述該類的二進位制位元組流的程式碼模組。有了上面的分析,我們知道這其實是“載入”階段的一個步驟,虛擬機器設計團隊之所以單獨將其抽離出來,是為了方便應用程式自己決定如何獲取需要的位元組流。

我們可以通過重寫ClassLoader的loadClass()方法來改變載入類的方式,如下程式碼所示:

public class ClassLoaderTest {
    public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {

        ClassLoader classLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                try {
                    InputStream is = getClass().getResourceAsStream(fileName);
                    if (is == null) {
                        return super.loadClass(name);
                    }
                    byte[] bytes = new byte[is.available()];
                    is.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException();
                }
            }
        };

        Class<?> clz = classLoader.loadClass("top.yeonon.ch11.ClassLoaderTest");
        System.out.println(clz);
        System.out.println(clz.newInstance() instanceof ClassLoaderTest);
    }
}

程式碼重寫了loadClass方法,只是簡單的通過class檔案來獲取。其中最後一行程式碼的返回結果是false,為什麼呢?因為我們用自己重寫了loadClass()方法的classLoader來載入類,這裡獲取到的類和虛擬機器載入類是不一樣的,即使它們是同一個類。那為什麼會不一樣呢?因為類載入器不一樣,現在虛擬機器中有兩個ClassLoaderTest類,一個是由系統的類載入器載入的(更準確的應該是ApplicationClassLoader),一個是由我們自己實現的classloader載入,虛擬機器判斷兩個類是否是同一個類不僅僅是通過他們的全限定類名來判斷,還通過載入他們的類載入器是否一樣來判斷,只有滿足上述兩個條件,虛擬機器才會認為兩個類是相同的。

既然講到了ApplicationClassLoader,接下來就討論一下雙親委派模型。

3.1 雙親委派模型

在JDK8及以下的版本中(JDK9之後有不小的改動),預設的有三種類載入器:

  1. BootStrap ClassLoader(引導類載入器)
  2. Extension ClassLoader(擴充套件類載入器)
  3. Application ClassLoader(應用類載入器)

引導類載入器負責載入$JAVA_HOME/lib下的,或者被-Xbootclasspath引數指定的路徑下的,並且是虛擬機器識別的(有些類即使在上述兩個路徑下,也不會被載入)類。這個類載入器是由C++實現的(HotSpot虛擬機器),所以使用Java程式碼無法獲取,只會返回null,當我們需要將類載入委託給它時,用null代替介面。

擴充套件類載入器負責載入$JAVA_HOME/lib/ext目錄,或者被java.ext.dirs系統變數指定的路徑下的類。這個類載入器是由Java語言實現的,使用者可以直接使用該類載入器。

應用類載入器負責載入classpath中指定的路徑中的類,一般我們編寫的Java類都是由這個類載入的。

有了上述三個概念,我們就可以看看雙親委派模型的定義了:如果一個類載入器收到了類載入請求,它不會自己馬上去嘗試載入這個類,而是將這個請求委託給父類載入器完成,如果父類載入器上面還有父類載入器,那麼會繼續將委託向上提交,直到引導類載入器,如果引導類載入器無法載入這個類,就會將請求往下傳,只要中途有一個類載入器載入成功了,就不會繼續往下走了。

為了理解這個過程,舉個例子。假設我們現在編寫了一個top.yeonon.Test類,當需要載入這個類的時候,如果沒有其他類載入器,預設就先將請求傳送到Application ClassLoader,Application ClassLoader有父類載入器Extension ClassLoader,所以它就將請求傳送到Extension ClassLoader,Extension ClassLoader也同理,最終請求達到最頂層的BootStrap ClassLoader,BootStrap ClassLoader發現top.yeonon.Test這個類自己不能載入,然後將請求原路返回,到Extension ClassLoader的時候,Extension ClassLoader發現自己也不能載入,然後再回到Application ClassLoader,這時候沒地方去了,Application ClassLoader才會嘗試去載入該類,如果載入成功(該類確實在classpath路徑下),那麼就完成了類載入,如果載入失敗,就會丟擲異常。下面是雙親委派模型的示意圖:

那為什麼Java要搞這麼一套雙親委派模型呢?為了保證安全,試想一下,假設我們現在編寫了一個java.lang.String類,在這類里加入了一些惡意程式碼,如果沒有雙親委派模型,這個類就會直接被Application ClassLoader載入,當使用者使用String類的時候,就會用到這個含有惡意程式碼的類,從而造成應用程式崩潰或者重要資訊洩露。

4 小結

本文介紹了什麼是類載入、類載入過程已經類載入器和雙親委派模型,類載入是一個比較獨特的特性,這個機制使得Java程式更加安全、高效,理解類載入過程也有助於解決各種由於類載入導致的問題。

5 參考資料

《深入理解Java虛擬機器》

相關文章